Add repos-devtools-server and AnythingLLM dev tools panel (0.2.0)
**Motivations:** - Clone or load repos under /home/ncantu/code with AnythingLLM workspace ensure/create from the editor **Root causes:** - N/A (new capability) **Correctifs:** - N/A **Evolutions:** - services/repos-devtools-server: POST /repos-clone, GET /repos-list, POST /repos-load (Bearer REPOS_DEVTOOLS_TOKEN) - Extension: Webview panel, slash commands, workspaceEnsure + POST /api/v1/workspace/new - Docs: feature note and index links **Pages affectées:** - services/repos-devtools-server/* - extensions/anythingllm-workspaces/* - docs/README.md - docs/features/repos-devtools-server-and-dev-panel.md - docs/features/anythingllm-vscode-extension.md
This commit is contained in:
parent
564b9d5576
commit
597f18f758
@ -14,6 +14,8 @@ Operational, architectural, and UX-design notes for the local-AI IDE initiative
|
||||
| [../deploy/nginx/README-ia-enso.md](../deploy/nginx/README-ia-enso.md) | Proxy HTTPS `ia.enso.4nkweb.com` → Ollama / AnythingLLM (Bearer, script SSH, dépannage) |
|
||||
| [../extensions/anythingllm-workspaces/README.md](../extensions/anythingllm-workspaces/README.md) | Extension VS Code / Cursor : lister les workspaces AnythingLLM (API) et ouvrir l’UI |
|
||||
| [features/anythingllm-vscode-extension.md](./features/anythingllm-vscode-extension.md) | Fiche évolution : extension AnythingLLM, impacts, modalités |
|
||||
| [features/repos-devtools-server-and-dev-panel.md](./features/repos-devtools-server-and-dev-panel.md) | API locale repos + panneau dev tools (clone, workspace AnythingLLM) |
|
||||
| [../services/repos-devtools-server/README.md](../services/repos-devtools-server/README.md) | Serveur HTTP local : clone/list/load sous `REPOS_DEVTOOLS_ROOT` |
|
||||
| [fixKnowledge/anythingllm-extension-403-api-key.md](./fixKnowledge/anythingllm-extension-403-api-key.md) | 403 API AnythingLLM : clé nginx Ollama vs clé UI API Keys |
|
||||
| [features/ia-enso-nginx-proxy-ollama-anythingllm.md](./features/ia-enso-nginx-proxy-ollama-anythingllm.md) | Fiche évolution : objectifs, impacts, modalités du reverse proxy ia.enso |
|
||||
| [anythingllm-workspaces.md](./anythingllm-workspaces.md) | One AnythingLLM workspace per project; sync pipeline |
|
||||
|
||||
@ -15,6 +15,7 @@ Fournir un point d’entrée minimal dans l’éditeur pour lister les **workspa
|
||||
|
||||
- `package.json`, `tsconfig.json`, sources TypeScript (`src/extension.ts`, `src/anythingllmClient.ts`, `src/types.ts`).
|
||||
- `README.md` de l’extension : prérequis, configuration, commandes, lien vers `deploy/nginx/README-ia-enso.md`.
|
||||
- Évolutions ultérieures (v0.2.0) : panneau dev tools, client `repos-devtools-server`, `POST /api/v1/workspace/new` — voir [repos-devtools-server-and-dev-panel.md](./repos-devtools-server-and-dev-panel.md).
|
||||
|
||||
## Modalités de déploiement
|
||||
|
||||
|
||||
34
docs/features/repos-devtools-server-and-dev-panel.md
Normal file
34
docs/features/repos-devtools-server-and-dev-panel.md
Normal file
@ -0,0 +1,34 @@
|
||||
# repos-devtools-server + panneau « Dev tools » (extension AnythingLLM)
|
||||
|
||||
**Author:** 4NK
|
||||
|
||||
## Objectif
|
||||
|
||||
Sur l’hôte qui porte les clones (ex. `192.168.1.164`, racine `/home/ncantu/code`) :
|
||||
|
||||
- exposer une **API HTTP locale** (git clone branche `test`, liste des dépôts, résolution de chemin) ;
|
||||
- depuis l’extension **AnythingLLM Workspaces**, fournir un **panneau Webview** pour saisir des commandes texte et afficher la réponse ;
|
||||
- enchaîner avec l’**API développeur AnythingLLM** pour **vérifier / créer** un workspace dont le nom (ou slug) correspond au dépôt.
|
||||
|
||||
## Impacts
|
||||
|
||||
- Nouveau service : `services/repos-devtools-server/` (Node 20+, écoute `127.0.0.1`, Bearer obligatoire via `REPOS_DEVTOOLS_TOKEN`).
|
||||
- Extension version **0.2.0** : réglages `anythingllm.reposApiBaseUrl`, `anythingllm.reposApiToken`, commande **AnythingLLM: Dev tools panel**, fichiers `media/devTools.js`, logique `workspaceEnsure`, `POST /api/v1/workspace/new`.
|
||||
|
||||
## Modifications
|
||||
|
||||
- Serveur : `POST /repos-clone`, `GET /repos-list`, `POST /repos-load`.
|
||||
- Extension : parseur de lignes, client HTTP repos, `ensureWorkspaceForRepoName`, panneau Webview.
|
||||
|
||||
## Modalités de déploiement
|
||||
|
||||
1. Sur la machine des clones : définir `REPOS_DEVTOOLS_TOKEN`, optionnellement `REPOS_DEVTOOLS_ROOT`, `npm run build && npm start` (voir `services/repos-devtools-server/README.md`).
|
||||
2. Dans Cursor / VS Code (même hôte ou tunnel vers `:37140`) : renseigner `anythingllm.reposApiBaseUrl` et `anythingllm.reposApiToken`.
|
||||
3. Recompiler / réinstaller l’extension (`.vsix` ou workspace dev).
|
||||
|
||||
## Modalités d’analyse
|
||||
|
||||
- Erreur **401** sur l’API repos : token extension ≠ `REPOS_DEVTOOLS_TOKEN`.
|
||||
- Erreur **403** AnythingLLM : clé API application (pas secret nginx Ollama).
|
||||
- **409** clone : répertoire cible déjà présent sous `REPOS_DEVTOOLS_ROOT`.
|
||||
- **git clone** échoue si la branche `test` n’existe pas sur le remote (comportement git nominal).
|
||||
@ -1,6 +1,6 @@
|
||||
# AnythingLLM Workspaces (VS Code / Cursor extension)
|
||||
|
||||
Minimal extension to call the **AnythingLLM developer API** and open a workspace in the browser.
|
||||
Extension for the **AnythingLLM developer API**, optional **local repos API** (`repos-devtools-server`), and a **dev tools panel** for slash-style commands.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -9,21 +9,44 @@ Minimal extension to call the **AnythingLLM developer API** and open a workspace
|
||||
|
||||
**Do not** use the **nginx Bearer secret** for `/ollama/` (see `deploy/nginx/README-ia-enso.md`). That value is only for the Ollama reverse proxy. AnythingLLM validates API keys against its **own** database; a wrong secret yields `403` with `{"error":"No valid api key found."}`.
|
||||
|
||||
For **clone / repos-list / repos-load**, run **`services/repos-devtools-server`** on the host that owns the clone root (e.g. `192.168.1.164` with `REPOS_DEVTOOLS_ROOT=/home/ncantu/code`). The extension calls this API over HTTP (default `http://127.0.0.1:37140` when your editor runs on that same host or via port-forward).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Description |
|
||||
|--------|-------------|
|
||||
| `anythingllm.baseUrl` | Base URL without trailing slash (default matches `deploy/nginx/README-ia-enso.md`). |
|
||||
| `anythingllm.apiKey` | Secret from AnythingLLM **Settings → API Keys** (optional leading `Bearer ` is stripped). Use **User** settings to avoid committing secrets. |
|
||||
| `anythingllm.apiKey` | Secret from AnythingLLM **Settings → API Keys** (optional leading `Bearer ` is stripped). Use **User** settings. |
|
||||
| `anythingllm.reposApiBaseUrl` | `repos-devtools-server` base URL (no trailing slash), default `http://127.0.0.1:37140`. |
|
||||
| `anythingllm.reposApiToken` | Same value as `REPOS_DEVTOOLS_TOKEN` on the server. **User** settings. |
|
||||
|
||||
## Commands
|
||||
## Commands (palette)
|
||||
|
||||
- **AnythingLLM: List workspaces** — Fetches workspaces, then opens the selected one in the default browser (`/workspace/<slug>` under your base URL).
|
||||
- **AnythingLLM: List workspaces** — `GET /api/v1/workspaces`, then open the chosen workspace in the browser.
|
||||
- **AnythingLLM: Open web UI** — Opens the AnythingLLM base URL.
|
||||
- **AnythingLLM: Dev tools panel** — Webview: enter commands, **Run**, read JSON/text **Response**.
|
||||
|
||||
## Dev tools panel — command lines
|
||||
|
||||
One command per line:
|
||||
|
||||
| Line | Behaviour |
|
||||
|------|-----------|
|
||||
| `/repos-clone <git-url>` | `POST /repos-clone` — clone into `REPOS_DEVTOOLS_ROOT`, branch **`test`** (override with JSON only via API, not this line). |
|
||||
| `/repos-clone-sync <url>` | Clone + ensure AnythingLLM workspace named like the repo folder + **Open folder** + open workspace in browser. |
|
||||
| `repos-list` or `/repos-list` | `GET /repos-list` — cloned git directories under the root. |
|
||||
| `/repos-load <name>` | Verify repo folder + **Open folder** in the editor. |
|
||||
| `/repos-load-sync <name>` | Open folder + ensure workspace + browser. |
|
||||
| `/workspace-load <name>` | Ensure workspace exists (`GET /workspaces` then `POST /workspace/new` if missing) + browser. |
|
||||
| `help` | Short built-in help text. |
|
||||
|
||||
**Workspace matching:** name or slug must equal the folder/repo name you use (exact string match on `name` or `slug` from AnythingLLM). New workspaces are created with `POST /api/v1/workspace/new` and `{ "name": "<name>" }`.
|
||||
|
||||
**AnythingLLM “user”:** the developer API uses the **API key**; per-user workspace ownership follows AnythingLLM’s own multi-user rules — this extension does not impersonate a browser session.
|
||||
|
||||
## Ollama
|
||||
|
||||
This extension targets **AnythingLLM** only. For OpenAI-compatible Ollama behind the same proxy, use Cursor’s model settings with `https://ia.enso.4nkweb.com/ollama/v1` and the nginx Bearer (see `deploy/nginx/README-ia-enso.md`).
|
||||
This extension does not call Ollama. For `https://ia.enso.4nkweb.com/ollama/v1`, use Cursor model settings and the nginx Bearer (see `deploy/nginx/README-ia-enso.md`).
|
||||
|
||||
## Build
|
||||
|
||||
@ -37,4 +60,5 @@ Load the folder in VS Code / Cursor with **Run Extension** or install the packag
|
||||
|
||||
## API reference
|
||||
|
||||
Upstream routes (mounted under `/api`): `GET /v1/workspaces` — see Mintplex-Labs anything-llm `server/endpoints/api/workspace/index.js`.
|
||||
- AnythingLLM: `server/endpoints/api/workspace/index.js` (e.g. `GET /v1/workspaces`, `POST /v1/workspace/new` under `/api`).
|
||||
- Local repos: `services/repos-devtools-server/README.md`.
|
||||
|
||||
25
extensions/anythingllm-workspaces/media/devTools.js
Normal file
25
extensions/anythingllm-workspaces/media/devTools.js
Normal file
@ -0,0 +1,25 @@
|
||||
(function () {
|
||||
const vscode = acquireVsCodeApi();
|
||||
const input = document.getElementById("cmd");
|
||||
const out = document.getElementById("out");
|
||||
const runBtn = document.getElementById("run");
|
||||
const clearBtn = document.getElementById("clear");
|
||||
|
||||
function run() {
|
||||
const text = input.value;
|
||||
out.textContent = "…";
|
||||
vscode.postMessage({ type: "run", text: text });
|
||||
}
|
||||
|
||||
runBtn.addEventListener("click", run);
|
||||
clearBtn.addEventListener("click", function () {
|
||||
out.textContent = "";
|
||||
});
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
const msg = event.data;
|
||||
if (msg && msg.type === "result") {
|
||||
out.textContent = typeof msg.text === "string" ? msg.text : String(msg.text);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "anythingllm-workspaces",
|
||||
"displayName": "AnythingLLM Workspaces (ia.enso)",
|
||||
"description": "List AnythingLLM workspaces via your proxied instance (e.g. ia.enso.4nkweb.com/anythingllm).",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"publisher": "4nk",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -28,6 +28,16 @@
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "AnythingLLM API key (UI: **Settings → API Keys**). Prefer **User** settings to avoid committing secrets."
|
||||
},
|
||||
"anythingllm.reposApiBaseUrl": {
|
||||
"type": "string",
|
||||
"default": "http://127.0.0.1:37140",
|
||||
"markdownDescription": "Base URL of **repos-devtools-server** (no trailing slash). Must match the machine where `/home/ncantu/code` (or `REPOS_DEVTOOLS_ROOT`) lives."
|
||||
},
|
||||
"anythingllm.reposApiToken": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "Bearer token shared with `REPOS_DEVTOOLS_TOKEN` on the repos-devtools-server. **User** settings only."
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -39,6 +49,10 @@
|
||||
{
|
||||
"command": "anythingllm.openWebUi",
|
||||
"title": "AnythingLLM: Open web UI"
|
||||
},
|
||||
{
|
||||
"command": "anythingllm.openDevToolsPanel",
|
||||
"title": "AnythingLLM: Dev tools panel"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -80,3 +80,47 @@ export const listWorkspaces = async (
|
||||
}
|
||||
return parseListWorkspaces(parseJson(text));
|
||||
};
|
||||
|
||||
const parseWorkspaceEnvelope = (payload: unknown): AnythingWorkspace => {
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("AnythingLLM API: expected object body");
|
||||
}
|
||||
const ws = payload.workspace;
|
||||
if (!isWorkspace(ws)) {
|
||||
throw new Error("AnythingLLM API: missing workspace in response");
|
||||
}
|
||||
return ws;
|
||||
};
|
||||
|
||||
export const createWorkspace = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
name: string,
|
||||
): Promise<AnythingWorkspace> => {
|
||||
const normalized = normalizeAnythingLlmBaseUrl(baseUrl);
|
||||
const key = normalizeApiSecret(apiKey);
|
||||
if (key.length === 0) {
|
||||
throw new Error("anythingllm.apiKey is empty");
|
||||
}
|
||||
const label = name.trim();
|
||||
if (label.length === 0) {
|
||||
throw new Error("workspace name is empty");
|
||||
}
|
||||
const url = `${normalized}/api/v1/workspace/new`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
body: JSON.stringify({ name: label }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`AnythingLLM API ${response.status}: ${text.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
return parseWorkspaceEnvelope(parseJson(text));
|
||||
};
|
||||
|
||||
52
extensions/anythingllm-workspaces/src/commandParser.ts
Normal file
52
extensions/anythingllm-workspaces/src/commandParser.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export type ParsedDevCommand =
|
||||
| { readonly kind: "repos-clone"; readonly url: string; readonly sync: boolean }
|
||||
| { readonly kind: "repos-list" }
|
||||
| { readonly kind: "repos-load"; readonly name: string; readonly sync: boolean }
|
||||
| { readonly kind: "workspace-load"; readonly name: string }
|
||||
| { readonly kind: "help" }
|
||||
| { readonly kind: "unknown"; readonly raw: string };
|
||||
|
||||
export const parseDevCommandLine = (line: string): ParsedDevCommand => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { kind: "unknown", raw: line };
|
||||
}
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const cmd = parts[0];
|
||||
const argRest = parts.slice(1).join(" ").trim();
|
||||
if (cmd === "/repos-clone-sync") {
|
||||
return { kind: "repos-clone", url: argRest, sync: true };
|
||||
}
|
||||
if (cmd === "/repos-clone") {
|
||||
return { kind: "repos-clone", url: argRest, sync: false };
|
||||
}
|
||||
if (cmd === "repos-list" || cmd === "/repos-list") {
|
||||
return { kind: "repos-list" };
|
||||
}
|
||||
if (cmd === "/repos-load-sync") {
|
||||
return { kind: "repos-load", name: argRest, sync: true };
|
||||
}
|
||||
if (cmd === "/repos-load") {
|
||||
return { kind: "repos-load", name: argRest, sync: false };
|
||||
}
|
||||
if (cmd === "/workspace-load") {
|
||||
return { kind: "workspace-load", name: argRest };
|
||||
}
|
||||
if (cmd === "help" || cmd === "/help") {
|
||||
return { kind: "help" };
|
||||
}
|
||||
return { kind: "unknown", raw: trimmed };
|
||||
};
|
||||
|
||||
export const devCommandsHelpText = (): string => {
|
||||
return [
|
||||
"Commands (one per line):",
|
||||
" /repos-clone <git-url> — clone into REPOS_DEVTOOLS_ROOT, branch test (default)",
|
||||
" /repos-clone-sync <url> — clone + ensure AnythingLLM workspace (same name) + open folder + browser",
|
||||
" repos-list — list cloned git repos under root",
|
||||
" /repos-load <name> — verify repo; open folder in editor",
|
||||
" /repos-load-sync <name> — open folder + ensure workspace + browser",
|
||||
" /workspace-load <name> — ensure workspace by name (create via API if missing) + browser",
|
||||
" help — this list",
|
||||
].join("\n");
|
||||
};
|
||||
36
extensions/anythingllm-workspaces/src/config.ts
Normal file
36
extensions/anythingllm-workspaces/src/config.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import * as vscode from "vscode";
|
||||
|
||||
const CONFIG_SECTION = "anythingllm";
|
||||
|
||||
export interface DevToolsConfigSnapshot {
|
||||
readonly anythingBaseUrl: string;
|
||||
readonly anythingApiKey: string;
|
||||
readonly reposApiBaseUrl: string;
|
||||
readonly reposApiToken: string;
|
||||
}
|
||||
|
||||
export const readAnythingConfig = (): { baseUrl: string; apiKey: string } => {
|
||||
const cfg = vscode.workspace.getConfiguration(CONFIG_SECTION);
|
||||
const baseUrl = typeof cfg.get("baseUrl") === "string" ? (cfg.get("baseUrl") as string) : "";
|
||||
const apiKey = typeof cfg.get("apiKey") === "string" ? (cfg.get("apiKey") as string) : "";
|
||||
return { baseUrl, apiKey };
|
||||
};
|
||||
|
||||
export const readDevToolsConfig = (): DevToolsConfigSnapshot => {
|
||||
const cfg = vscode.workspace.getConfiguration(CONFIG_SECTION);
|
||||
const { baseUrl, apiKey } = readAnythingConfig();
|
||||
const reposApiBaseUrl =
|
||||
typeof cfg.get("reposApiBaseUrl") === "string"
|
||||
? (cfg.get("reposApiBaseUrl") as string)
|
||||
: "";
|
||||
const reposApiToken =
|
||||
typeof cfg.get("reposApiToken") === "string"
|
||||
? (cfg.get("reposApiToken") as string)
|
||||
: "";
|
||||
return {
|
||||
anythingBaseUrl: baseUrl,
|
||||
anythingApiKey: apiKey,
|
||||
reposApiBaseUrl,
|
||||
reposApiToken,
|
||||
};
|
||||
};
|
||||
196
extensions/anythingllm-workspaces/src/devToolsExecutor.ts
Normal file
196
extensions/anythingllm-workspaces/src/devToolsExecutor.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import type * as vscode from "vscode";
|
||||
import {
|
||||
devCommandsHelpText,
|
||||
parseDevCommandLine,
|
||||
type ParsedDevCommand,
|
||||
} from "./commandParser";
|
||||
import { normalizeAnythingLlmBaseUrl } from "./anythingllmClient";
|
||||
import { reposApiClone, reposApiList, reposApiLoad } from "./reposApiClient";
|
||||
import { ensureWorkspaceForRepoName } from "./workspaceEnsure";
|
||||
|
||||
const DEFAULT_BRANCH = "test";
|
||||
|
||||
const fmt = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
export interface DevToolsRunnerContext {
|
||||
readonly anythingBaseUrl: string;
|
||||
readonly anythingApiKey: string;
|
||||
readonly reposApiBaseUrl: string;
|
||||
readonly reposApiToken: string;
|
||||
readonly openFolder: (fsPath: string) => Thenable<void>;
|
||||
readonly openAnythingWorkspaceInBrowser: (slug: string) => Thenable<void>;
|
||||
}
|
||||
|
||||
const assertReposConfig = (ctx: DevToolsRunnerContext): void => {
|
||||
if (ctx.reposApiBaseUrl.trim().length === 0) {
|
||||
throw new Error("Set anythingllm.reposApiBaseUrl (repos-devtools-server URL).");
|
||||
}
|
||||
if (ctx.reposApiToken.trim().length === 0) {
|
||||
throw new Error("Set anythingllm.reposApiToken (same value as REPOS_DEVTOOLS_TOKEN).");
|
||||
}
|
||||
};
|
||||
|
||||
const assertAnythingConfig = (ctx: DevToolsRunnerContext): void => {
|
||||
if (ctx.anythingApiKey.trim().length === 0) {
|
||||
throw new Error("Set anythingllm.apiKey for workspace operations.");
|
||||
}
|
||||
};
|
||||
|
||||
const runOne = async (
|
||||
cmd: ParsedDevCommand,
|
||||
ctx: DevToolsRunnerContext,
|
||||
): Promise<string> => {
|
||||
if (cmd.kind === "help") {
|
||||
return devCommandsHelpText();
|
||||
}
|
||||
if (cmd.kind === "unknown") {
|
||||
return `Unknown command: ${cmd.raw}\n${devCommandsHelpText()}`;
|
||||
}
|
||||
if (cmd.kind === "repos-list") {
|
||||
assertReposConfig(ctx);
|
||||
const data = await reposApiList(ctx.reposApiBaseUrl, ctx.reposApiToken);
|
||||
return fmt(data);
|
||||
}
|
||||
if (cmd.kind === "repos-clone") {
|
||||
assertReposConfig(ctx);
|
||||
if (cmd.url.length === 0) {
|
||||
throw new Error("/repos-clone requires a git URL.");
|
||||
}
|
||||
const data = await reposApiClone(
|
||||
ctx.reposApiBaseUrl,
|
||||
ctx.reposApiToken,
|
||||
cmd.url,
|
||||
DEFAULT_BRANCH,
|
||||
);
|
||||
let out = fmt(data);
|
||||
if (cmd.sync) {
|
||||
assertAnythingConfig(ctx);
|
||||
const rec = data as Record<string, unknown>;
|
||||
const name = rec.name;
|
||||
const fsPath = rec.path;
|
||||
if (typeof name !== "string") {
|
||||
throw new Error("clone response missing name");
|
||||
}
|
||||
if (typeof fsPath !== "string" || fsPath.length === 0) {
|
||||
throw new Error("clone response missing path");
|
||||
}
|
||||
const ensured = await ensureWorkspaceForRepoName(
|
||||
ctx.anythingBaseUrl,
|
||||
ctx.anythingApiKey,
|
||||
name,
|
||||
);
|
||||
out += `\n---\nAnythingLLM workspace: ${fmt({
|
||||
slug: ensured.workspace.slug,
|
||||
name: ensured.workspace.name,
|
||||
created: ensured.created,
|
||||
})}`;
|
||||
await ctx.openFolder(fsPath);
|
||||
await ctx.openAnythingWorkspaceInBrowser(ensured.workspace.slug);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (cmd.kind === "repos-load") {
|
||||
assertReposConfig(ctx);
|
||||
if (cmd.name.length === 0) {
|
||||
throw new Error("/repos-load requires a repository folder name.");
|
||||
}
|
||||
const loaded = await reposApiLoad(
|
||||
ctx.reposApiBaseUrl,
|
||||
ctx.reposApiToken,
|
||||
cmd.name,
|
||||
);
|
||||
let out = fmt(loaded);
|
||||
await ctx.openFolder(loaded.path);
|
||||
if (cmd.sync) {
|
||||
assertAnythingConfig(ctx);
|
||||
const ensured = await ensureWorkspaceForRepoName(
|
||||
ctx.anythingBaseUrl,
|
||||
ctx.anythingApiKey,
|
||||
loaded.name,
|
||||
);
|
||||
out += `\n---\nAnythingLLM workspace: ${fmt({
|
||||
slug: ensured.workspace.slug,
|
||||
name: ensured.workspace.name,
|
||||
created: ensured.created,
|
||||
})}`;
|
||||
await ctx.openAnythingWorkspaceInBrowser(ensured.workspace.slug);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (cmd.kind === "workspace-load") {
|
||||
assertAnythingConfig(ctx);
|
||||
if (cmd.name.length === 0) {
|
||||
throw new Error("/workspace-load requires a workspace name.");
|
||||
}
|
||||
const ensured = await ensureWorkspaceForRepoName(
|
||||
ctx.anythingBaseUrl,
|
||||
ctx.anythingApiKey,
|
||||
cmd.name,
|
||||
);
|
||||
await ctx.openAnythingWorkspaceInBrowser(ensured.workspace.slug);
|
||||
return fmt({
|
||||
slug: ensured.workspace.slug,
|
||||
name: ensured.workspace.name,
|
||||
created: ensured.created,
|
||||
});
|
||||
}
|
||||
return `Unhandled: ${JSON.stringify(cmd)}`;
|
||||
};
|
||||
|
||||
export const runDevToolsScript = async (
|
||||
text: string,
|
||||
ctx: DevToolsRunnerContext,
|
||||
): Promise<string> => {
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
if (lines.length === 0) {
|
||||
return devCommandsHelpText();
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const line of lines) {
|
||||
const parsed = parseDevCommandLine(line);
|
||||
try {
|
||||
parts.push(await runOne(parsed, ctx));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
parts.push(`ERROR: ${msg}`);
|
||||
}
|
||||
}
|
||||
return parts.join("\n---\n");
|
||||
};
|
||||
|
||||
export const registerDevToolsOpenFolder = (
|
||||
vscodeApi: typeof vscode,
|
||||
): ((fsPath: string) => Thenable<void>) => {
|
||||
return (fsPath: string) => {
|
||||
if (fsPath.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const uri = vscodeApi.Uri.file(fsPath);
|
||||
return vscodeApi.commands.executeCommand("vscode.openFolder", uri, false);
|
||||
};
|
||||
};
|
||||
|
||||
export const makeOpenAnythingHandler = (
|
||||
vscodeApi: typeof vscode,
|
||||
baseUrl: string,
|
||||
): ((slug: string) => Thenable<void>) => {
|
||||
return (slug: string) => {
|
||||
const root = normalizeAnythingLlmBaseUrl(baseUrl);
|
||||
const uri = vscodeApi.Uri.parse(
|
||||
`${root}/workspace/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
return vscodeApi.env.openExternal(uri).then(() => undefined);
|
||||
};
|
||||
};
|
||||
103
extensions/anythingllm-workspaces/src/devToolsPanel.ts
Normal file
103
extensions/anythingllm-workspaces/src/devToolsPanel.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import * as vscode from "vscode";
|
||||
import type { DevToolsConfigSnapshot } from "./config";
|
||||
import { runDevToolsScript, makeOpenAnythingHandler, registerDevToolsOpenFolder } from "./devToolsExecutor";
|
||||
|
||||
const PANEL_ID = "anythingllmDevTools";
|
||||
|
||||
const panelTitle = "AnythingLLM dev tools";
|
||||
|
||||
const buildHtml = (
|
||||
webview: vscode.Webview,
|
||||
extensionUri: vscode.Uri,
|
||||
): string => {
|
||||
const scriptUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(extensionUri, "media", "devTools.js"),
|
||||
);
|
||||
const csp = webview.cspSource;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${csp} 'unsafe-inline'; script-src ${csp};" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${panelTitle}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0.75rem; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||
label { display: block; margin-bottom: 0.35rem; font-weight: 600; }
|
||||
textarea#cmd { width: 100%; min-height: 7rem; box-sizing: border-box; font-family: monospace; font-size: 12px; }
|
||||
pre#out { white-space: pre-wrap; word-break: break-word; min-height: 4rem; padding: 0.5rem; border: 1px solid var(--vscode-panel-border); }
|
||||
.row { margin-bottom: 0.5rem; }
|
||||
button { margin-right: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="row">
|
||||
<label for="cmd">Commands (one per line)</label>
|
||||
<textarea id="cmd" spellcheck="false" placeholder="/repos-clone https://… repos-list"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" id="run">Run</button>
|
||||
<button type="button" id="clear">Clear output</button>
|
||||
</div>
|
||||
<label for="out">Response</label>
|
||||
<pre id="out"></pre>
|
||||
<script src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
export const showDevToolsPanel = (
|
||||
context: vscode.ExtensionContext,
|
||||
readConfig: () => DevToolsConfigSnapshot,
|
||||
): void => {
|
||||
const column = vscode.ViewColumn.Beside;
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
PANEL_ID,
|
||||
panelTitle,
|
||||
column,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(context.extensionUri, "media"),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
panel.webview.html = buildHtml(panel.webview, context.extensionUri);
|
||||
|
||||
const openFolder = registerDevToolsOpenFolder(vscode);
|
||||
|
||||
panel.webview.onDidReceiveMessage(
|
||||
(msg: unknown) => {
|
||||
void (async () => {
|
||||
if (typeof msg !== "object" || msg === null) {
|
||||
return;
|
||||
}
|
||||
const rec = msg as Record<string, unknown>;
|
||||
if (rec.type !== "run") {
|
||||
return;
|
||||
}
|
||||
const text = typeof rec.text === "string" ? rec.text : "";
|
||||
const use = readConfig();
|
||||
const openBrowser = makeOpenAnythingHandler(vscode, use.anythingBaseUrl);
|
||||
try {
|
||||
const result = await runDevToolsScript(text, {
|
||||
anythingBaseUrl: use.anythingBaseUrl,
|
||||
anythingApiKey: use.anythingApiKey,
|
||||
reposApiBaseUrl: use.reposApiBaseUrl,
|
||||
reposApiToken: use.reposApiToken,
|
||||
openFolder,
|
||||
openAnythingWorkspaceInBrowser: openBrowser,
|
||||
});
|
||||
panel.webview.postMessage({ type: "result", text: result });
|
||||
} catch (e) {
|
||||
const m = e instanceof Error ? e.message : String(e);
|
||||
panel.webview.postMessage({ type: "result", text: `ERROR: ${m}` });
|
||||
}
|
||||
})();
|
||||
},
|
||||
undefined,
|
||||
context.subscriptions,
|
||||
);
|
||||
};
|
||||
@ -1,15 +1,8 @@
|
||||
import * as vscode from "vscode";
|
||||
import { readAnythingConfig, readDevToolsConfig } from "./config";
|
||||
import { listWorkspaces, normalizeAnythingLlmBaseUrl } from "./anythingllmClient";
|
||||
import type { AnythingWorkspace } from "./types";
|
||||
|
||||
const CONFIG_SECTION = "anythingllm";
|
||||
|
||||
const readConfig = (): { baseUrl: string; apiKey: string } => {
|
||||
const cfg = vscode.workspace.getConfiguration(CONFIG_SECTION);
|
||||
const baseUrl = typeof cfg.get("baseUrl") === "string" ? cfg.get("baseUrl") as string : "";
|
||||
const apiKey = typeof cfg.get("apiKey") === "string" ? cfg.get("apiKey") as string : "";
|
||||
return { baseUrl, apiKey };
|
||||
};
|
||||
import { showDevToolsPanel } from "./devToolsPanel";
|
||||
|
||||
const workspaceLabel = (w: AnythingWorkspace): string => `${w.name} (${w.slug})`;
|
||||
|
||||
@ -22,7 +15,7 @@ const openWorkspaceInBrowser = async (baseUrl: string, slug: string): Promise<vo
|
||||
|
||||
export const activate = (context: vscode.ExtensionContext): void => {
|
||||
const listCmd = vscode.commands.registerCommand("anythingllm.listWorkspaces", async () => {
|
||||
const { baseUrl, apiKey } = readConfig();
|
||||
const { baseUrl, apiKey } = readAnythingConfig();
|
||||
try {
|
||||
const workspaces = await listWorkspaces(baseUrl, apiKey);
|
||||
if (workspaces.length === 0) {
|
||||
@ -47,7 +40,7 @@ export const activate = (context: vscode.ExtensionContext): void => {
|
||||
});
|
||||
|
||||
const openUiCmd = vscode.commands.registerCommand("anythingllm.openWebUi", async () => {
|
||||
const { baseUrl } = readConfig();
|
||||
const { baseUrl } = readAnythingConfig();
|
||||
try {
|
||||
const root = normalizeAnythingLlmBaseUrl(baseUrl);
|
||||
await vscode.env.openExternal(vscode.Uri.parse(root));
|
||||
@ -57,7 +50,11 @@ export const activate = (context: vscode.ExtensionContext): void => {
|
||||
}
|
||||
});
|
||||
|
||||
context.subscriptions.push(listCmd, openUiCmd);
|
||||
const devPanelCmd = vscode.commands.registerCommand("anythingllm.openDevToolsPanel", () => {
|
||||
showDevToolsPanel(context, readDevToolsConfig);
|
||||
});
|
||||
|
||||
context.subscriptions.push(listCmd, openUiCmd, devPanelCmd);
|
||||
};
|
||||
|
||||
export const deactivate = (): void => {
|
||||
|
||||
94
extensions/anythingllm-workspaces/src/reposApiClient.ts
Normal file
94
extensions/anythingllm-workspaces/src/reposApiClient.ts
Normal file
@ -0,0 +1,94 @@
|
||||
const trimSlash = (u: string): string => u.replace(/\/+$/, "");
|
||||
|
||||
const normalizeReposToken = (raw: string): string => {
|
||||
const trimmed = raw.trim();
|
||||
const bearerPrefix = /^Bearer\s+/i;
|
||||
return bearerPrefix.test(trimmed) ? trimmed.replace(bearerPrefix, "").trim() : trimmed;
|
||||
};
|
||||
|
||||
const authHeaders = (token: string): Record<string, string> => {
|
||||
const key = normalizeReposToken(token);
|
||||
return {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const reposApiClone = async (
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
url: string,
|
||||
branch: string,
|
||||
): Promise<unknown> => {
|
||||
const root = trimSlash(baseUrl.trim());
|
||||
if (root.length === 0) {
|
||||
throw new Error("anythingllm.reposApiBaseUrl is empty");
|
||||
}
|
||||
const res = await fetch(`${root}/repos-clone`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ url, branch }),
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: unknown = text;
|
||||
try {
|
||||
body = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
/* keep text */
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`repos API ${res.status}: ${typeof body === "string" ? body : JSON.stringify(body)}`);
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
export const reposApiList = async (baseUrl: string, token: string): Promise<unknown> => {
|
||||
const root = trimSlash(baseUrl.trim());
|
||||
if (root.length === 0) {
|
||||
throw new Error("anythingllm.reposApiBaseUrl is empty");
|
||||
}
|
||||
const res = await fetch(`${root}/repos-list`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${normalizeReposToken(token)}`,
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`repos API ${res.status}: ${text}`);
|
||||
}
|
||||
return JSON.parse(text) as unknown;
|
||||
};
|
||||
|
||||
export const reposApiLoad = async (
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
name: string,
|
||||
): Promise<{ path: string; name: string }> => {
|
||||
const root = trimSlash(baseUrl.trim());
|
||||
if (root.length === 0) {
|
||||
throw new Error("anythingllm.reposApiBaseUrl is empty");
|
||||
}
|
||||
const res = await fetch(`${root}/repos-load`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const text = await res.text();
|
||||
const body = JSON.parse(text) as unknown;
|
||||
if (!res.ok) {
|
||||
throw new Error(`repos API ${res.status}: ${text}`);
|
||||
}
|
||||
if (typeof body !== "object" || body === null) {
|
||||
throw new Error("repos-load: invalid response");
|
||||
}
|
||||
const rec = body as Record<string, unknown>;
|
||||
const path = rec.path;
|
||||
const n = rec.name;
|
||||
if (typeof path !== "string" || typeof n !== "string") {
|
||||
throw new Error("repos-load: missing path or name");
|
||||
}
|
||||
return { path, name: n };
|
||||
};
|
||||
27
extensions/anythingllm-workspaces/src/workspaceEnsure.ts
Normal file
27
extensions/anythingllm-workspaces/src/workspaceEnsure.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { createWorkspace, listWorkspaces } from "./anythingllmClient";
|
||||
import type { AnythingWorkspace } from "./types";
|
||||
|
||||
export interface EnsuredWorkspace {
|
||||
readonly workspace: AnythingWorkspace;
|
||||
readonly created: boolean;
|
||||
}
|
||||
|
||||
export const ensureWorkspaceForRepoName = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
repoName: string,
|
||||
): Promise<EnsuredWorkspace> => {
|
||||
const key = repoName.trim();
|
||||
if (key.length === 0) {
|
||||
throw new Error("repo/workspace name is empty");
|
||||
}
|
||||
const all = await listWorkspaces(baseUrl, apiKey);
|
||||
const byName = all.find((w) => w.name === key);
|
||||
const bySlug = all.find((w) => w.slug === key);
|
||||
const found = byName ?? bySlug;
|
||||
if (found) {
|
||||
return { workspace: found, created: false };
|
||||
}
|
||||
const created = await createWorkspace(baseUrl, apiKey, key);
|
||||
return { workspace: created, created: true };
|
||||
};
|
||||
2
services/repos-devtools-server/.gitignore
vendored
Normal file
2
services/repos-devtools-server/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
36
services/repos-devtools-server/README.md
Normal file
36
services/repos-devtools-server/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
# repos-devtools-server
|
||||
|
||||
Local HTTP API bound to **`127.0.0.1`** for git operations under **`REPOS_DEVTOOLS_ROOT`** (default `/home/ncantu/code`).
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `REPOS_DEVTOOLS_TOKEN` | yes | Shared secret; clients send `Authorization: Bearer <token>`. |
|
||||
| `REPOS_DEVTOOLS_ROOT` | no | Absolute root for clones (default `/home/ncantu/code`). |
|
||||
| `REPOS_DEVTOOLS_HOST` | no | Bind address (default `127.0.0.1`). |
|
||||
| `REPOS_DEVTOOLS_PORT` | no | Port (default `37140`). |
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `POST /repos-clone` — JSON `{ "url": "<git url>", "branch": "test" }` (`branch` optional, default `test`).
|
||||
- `GET /repos-list` — Lists immediate subdirectories of the root that contain `.git`.
|
||||
- `POST /repos-load` — JSON `{ "name": "<folder name>" }` — Verifies the repo exists; returns absolute `path`.
|
||||
|
||||
All endpoints require `Authorization: Bearer <REPOS_DEVTOOLS_TOKEN>`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd services/repos-devtools-server
|
||||
npm install
|
||||
npm run build
|
||||
export REPOS_DEVTOOLS_TOKEN='generate-a-long-random-secret'
|
||||
npm start
|
||||
```
|
||||
|
||||
Use the same token in the VS Code / Cursor setting **`anythingllm.reposApiToken`**.
|
||||
|
||||
## Integration
|
||||
|
||||
The **AnythingLLM Workspaces** extension command **AnythingLLM: Dev tools panel** calls this API and the AnythingLLM HTTP API for workspace create/list.
|
||||
51
services/repos-devtools-server/package-lock.json
generated
Normal file
51
services/repos-devtools-server/package-lock.json
generated
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@4nk/repos-devtools-server",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@4nk/repos-devtools-server",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
services/repos-devtools-server/package.json
Normal file
20
services/repos-devtools-server/package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@4nk/repos-devtools-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Local HTTP API: git clone/list under REPOS_DEVTOOLS_ROOT (e.g. /home/ncantu/code).",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p ./",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
21
services/repos-devtools-server/src/auth.ts
Normal file
21
services/repos-devtools-server/src/auth.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
export const readExpectedToken = (): string => {
|
||||
return process.env.REPOS_DEVTOOLS_TOKEN?.trim() ?? "";
|
||||
};
|
||||
|
||||
export const requireBearer = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
expected: string,
|
||||
): boolean => {
|
||||
const h = req.headers.authorization ?? "";
|
||||
const match = /^Bearer\s+(.+)$/i.exec(h);
|
||||
const got = match?.[1]?.trim() ?? "";
|
||||
if (got !== expected) {
|
||||
res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
34
services/repos-devtools-server/src/gitSpawn.ts
Normal file
34
services/repos-devtools-server/src/gitSpawn.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface GitSpawnResult {
|
||||
readonly code: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
export const runGit = (args: readonly string[], cwd: string): Promise<GitSpawnResult> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git", [...args], {
|
||||
cwd,
|
||||
env: process.env,
|
||||
});
|
||||
const out: Buffer[] = [];
|
||||
const err: Buffer[] = [];
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
out.push(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
err.push(chunk);
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
resolve({
|
||||
code: code ?? 1,
|
||||
stdout: Buffer.concat(out).toString("utf8"),
|
||||
stderr: Buffer.concat(err).toString("utf8"),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
130
services/repos-devtools-server/src/handlers.ts
Normal file
130
services/repos-devtools-server/src/handlers.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import * as path from "node:path";
|
||||
import { runGit } from "./gitSpawn.js";
|
||||
import { readJsonBody } from "./httpUtil.js";
|
||||
import {
|
||||
getCodeRoot,
|
||||
isGitRepo,
|
||||
repoDirForName,
|
||||
repoNameFromGitUrl,
|
||||
} from "./paths.js";
|
||||
|
||||
const json = (res: ServerResponse, status: number, body: unknown): void => {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
const readBranch = (body: unknown, fallback: string): string => {
|
||||
if (!isRecord(body)) {
|
||||
return fallback;
|
||||
}
|
||||
const b = body.branch;
|
||||
return typeof b === "string" && b.trim().length > 0 ? b.trim() : fallback;
|
||||
};
|
||||
|
||||
const readUrl = (body: unknown): string => {
|
||||
if (!isRecord(body)) {
|
||||
throw new Error("Expected JSON object with url");
|
||||
}
|
||||
const u = body.url;
|
||||
if (typeof u !== "string" || u.trim().length === 0) {
|
||||
throw new Error("Missing or invalid url");
|
||||
}
|
||||
return u.trim();
|
||||
};
|
||||
|
||||
const readName = (body: unknown): string => {
|
||||
if (!isRecord(body)) {
|
||||
throw new Error("Expected JSON object with name");
|
||||
}
|
||||
const n = body.name;
|
||||
if (typeof n !== "string" || n.trim().length === 0) {
|
||||
throw new Error("Missing or invalid name");
|
||||
}
|
||||
return n.trim();
|
||||
};
|
||||
|
||||
export const handleReposClone = async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> => {
|
||||
const codeRoot = getCodeRoot();
|
||||
const body = await readJsonBody(req);
|
||||
const url = readUrl(body);
|
||||
const branch = readBranch(body, "test");
|
||||
const name = repoNameFromGitUrl(url);
|
||||
const dest = path.join(codeRoot, name);
|
||||
try {
|
||||
await fs.access(dest);
|
||||
json(res, 409, {
|
||||
error: "Target directory already exists",
|
||||
name,
|
||||
path: dest,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
/* absent */
|
||||
}
|
||||
const r = await runGit(
|
||||
["clone", "-b", branch, "--single-branch", url, dest],
|
||||
codeRoot,
|
||||
);
|
||||
if (r.code !== 0) {
|
||||
json(res, 500, {
|
||||
error: "git clone failed",
|
||||
code: r.code,
|
||||
stderr: r.stderr,
|
||||
stdout: r.stdout,
|
||||
});
|
||||
return;
|
||||
}
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
name,
|
||||
path: dest,
|
||||
branch,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
export const handleReposList = async (res: ServerResponse): Promise<void> => {
|
||||
const codeRoot = getCodeRoot();
|
||||
const entries = await fs.readdir(codeRoot, { withFileTypes: true });
|
||||
const repos: { name: string; path: string }[] = [];
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const full = path.join(codeRoot, ent.name);
|
||||
if (await isGitRepo(full)) {
|
||||
repos.push({ name: ent.name, path: full });
|
||||
}
|
||||
}
|
||||
repos.sort((a, b) => a.name.localeCompare(b.name));
|
||||
json(res, 200, { repos, codeRoot });
|
||||
};
|
||||
|
||||
export const handleReposLoad = async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> => {
|
||||
const codeRoot = getCodeRoot();
|
||||
const body = await readJsonBody(req);
|
||||
const name = readName(body);
|
||||
const dest = repoDirForName(codeRoot, name);
|
||||
try {
|
||||
await fs.access(dest);
|
||||
} catch {
|
||||
json(res, 404, { error: "Repository directory not found", name, path: dest });
|
||||
return;
|
||||
}
|
||||
if (!(await isGitRepo(dest))) {
|
||||
json(res, 400, { error: "Directory is not a git repository", name, path: dest });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { ok: true, name, path: dest });
|
||||
};
|
||||
25
services/repos-devtools-server/src/httpUtil.ts
Normal file
25
services/repos-devtools-server/src/httpUtil.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
const MAX_BODY = 1_048_576;
|
||||
|
||||
export const readJsonBody = async (req: IncomingMessage): Promise<unknown> => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of req) {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buf.length;
|
||||
if (total > MAX_BODY) {
|
||||
throw new Error("Request body too large");
|
||||
}
|
||||
chunks.push(buf);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (raw.length === 0) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch (cause) {
|
||||
throw new Error("Invalid JSON body", { cause });
|
||||
}
|
||||
};
|
||||
47
services/repos-devtools-server/src/paths.ts
Normal file
47
services/repos-devtools-server/src/paths.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$/;
|
||||
|
||||
export const assertSafeRepoName = (name: string): string => {
|
||||
const trimmed = name.trim();
|
||||
if (!SAFE_NAME.test(trimmed)) {
|
||||
throw new Error("Invalid repo name: use letters, digits, . _ - only; no path segments.");
|
||||
}
|
||||
if (trimmed.includes("..")) {
|
||||
throw new Error("Invalid repo name: path traversal rejected.");
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const getCodeRoot = (): string => {
|
||||
const raw = process.env.REPOS_DEVTOOLS_ROOT ?? "/home/ncantu/code";
|
||||
const resolved = path.resolve(raw);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const repoDirForName = (codeRoot: string, name: string): string => {
|
||||
const safe = assertSafeRepoName(name);
|
||||
return path.join(codeRoot, safe);
|
||||
};
|
||||
|
||||
export const isGitRepo = async (dir: string): Promise<boolean> => {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(dir, ".git"));
|
||||
return stat.isDirectory() || stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const repoNameFromGitUrl = (rawUrl: string): string => {
|
||||
const trimmed = rawUrl.trim();
|
||||
const noQuery = trimmed.split("?")[0] ?? trimmed;
|
||||
const base = noQuery.replace(/[/]+$/, "");
|
||||
const segment = base.split("/").filter((s) => s.length > 0).pop() ?? "";
|
||||
const withoutGit = segment.replace(/\.git$/i, "");
|
||||
if (withoutGit.length === 0) {
|
||||
throw new Error("Could not derive repository name from URL.");
|
||||
}
|
||||
return assertSafeRepoName(withoutGit);
|
||||
};
|
||||
56
services/repos-devtools-server/src/server.ts
Normal file
56
services/repos-devtools-server/src/server.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import * as http from "node:http";
|
||||
import { requireBearer, readExpectedToken } from "./auth.js";
|
||||
import {
|
||||
handleReposClone,
|
||||
handleReposList,
|
||||
handleReposLoad,
|
||||
} from "./handlers.js";
|
||||
|
||||
const HOST = process.env.REPOS_DEVTOOLS_HOST ?? "127.0.0.1";
|
||||
const PORT = Number(process.env.REPOS_DEVTOOLS_PORT ?? "37140");
|
||||
|
||||
const main = (): void => {
|
||||
const token = readExpectedToken();
|
||||
if (token.length === 0) {
|
||||
console.error("repos-devtools-server: set REPOS_DEVTOOLS_TOKEN (non-empty secret).");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (!requireBearer(req, res, token)) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url ?? "/", `http://${HOST}`);
|
||||
const p = url.pathname;
|
||||
if (req.method === "POST" && p === "/repos-clone") {
|
||||
await handleReposClone(req, res);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && p === "/repos-list") {
|
||||
await handleReposList(res);
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && p === "/repos-load") {
|
||||
await handleReposLoad(req, res);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: msg }));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.error(
|
||||
`repos-devtools-server listening on http://${HOST}:${PORT} (root=${process.env.REPOS_DEVTOOLS_ROOT ?? "/home/ncantu/code"})`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
main();
|
||||
16
services/repos-devtools-server/tsconfig.json
Normal file
16
services/repos-devtools-server/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user