Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df15789982 | |||
| 272a3e7d6e | |||
| 569b68ec98 | |||
| 631ea96f19 | |||
| dce32b96b7 | |||
| a0cd808456 | |||
| e3745d7504 | |||
| 8546748759 | |||
| cc2ece94c3 |
+2
-2
@@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -2557,7 +2557,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
|
|||||||
| **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled |
|
| **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled |
|
||||||
| **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module |
|
| **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module |
|
||||||
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
||||||
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
| **React 19** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||||
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
||||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
|
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
|
||||||
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
||||||
|
|||||||
+67
-69
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
|
|||||||
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
// warning, not a failure: it costs the run console and `workspace shell`, nothing
|
||||||
// else.
|
// else.
|
||||||
try {
|
try {
|
||||||
const { available, upgrade } = await get("/api/run/tmux");
|
const { available, upgrade } = await get("/api/run/tmux");
|
||||||
@@ -1693,7 +1693,7 @@ function cmdOpen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
|
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
|
||||||
*/
|
*/
|
||||||
async function cmdLanesAdd(args) {
|
async function cmdLanesAdd(args) {
|
||||||
const flag = (name) => {
|
const flag = (name) => {
|
||||||
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
|
|||||||
if (base) body.base = base;
|
if (base) body.base = base;
|
||||||
if (slug) body.slug = slug;
|
if (slug) body.slug = slug;
|
||||||
if (tool) body.tool = tool;
|
if (tool) body.tool = tool;
|
||||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
|
||||||
const deadline = Date.now() + 30_000;
|
const deadline = Date.now() + 30_000;
|
||||||
let lane = provisioning;
|
let lane = provisioning;
|
||||||
while (lane.status === "provisioning" && Date.now() < deadline) {
|
while (lane.status === "provisioning" && Date.now() < deadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
({ lane } = await get(`/api/lanes/${provisioning.id}`));
|
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
|
||||||
}
|
}
|
||||||
if (lane.status === "provisioning") {
|
if (lane.status === "provisioning") {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
|
|||||||
const body = { cwd, title };
|
const body = { cwd, title };
|
||||||
if (pipeline) body.pipeline = pipeline;
|
if (pipeline) body.pipeline = pipeline;
|
||||||
if (tool) body.tool = tool;
|
if (tool) body.tool = tool;
|
||||||
const { lane } = await post("/api/lanes", body);
|
const { lane } = await post("/api/workspaces", body);
|
||||||
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||||
* (owning session died) and cap oversized hook logs, across every lane on
|
* (owning session died) and cap oversized hook logs, across every lane on
|
||||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||||
* action, no HTTP round-trip.
|
* action, no HTTP round-trip.
|
||||||
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
|
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
|
||||||
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||||
* talk to the dashboard server at all.
|
* talk to the dashboard server at all.
|
||||||
*/
|
*/
|
||||||
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
|
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
|
||||||
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||||
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
* resolution (unlike every other `workspace` subcommand) — this is meant to run
|
||||||
* against a bare repo right after `profile init`.
|
* against a bare repo right after `profile init`.
|
||||||
*/
|
*/
|
||||||
async function cmdLanesProfileCheck(args) {
|
async function cmdLanesProfileCheck(args) {
|
||||||
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
|
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
|
||||||
async function cmdLanesLifecycle(action, args) {
|
async function cmdLanesLifecycle(action, args) {
|
||||||
const id = args.find((arg) => !arg.startsWith("--"));
|
const id = args.find((arg) => !arg.startsWith("--"));
|
||||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||||
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
|
||||||
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
||||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||||
if (preflight.database) {
|
if (preflight.database) {
|
||||||
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
const body = { confirm: true, expect };
|
const body = { confirm: true, expect };
|
||||||
if (args.includes("--force")) body.force = true;
|
if (args.includes("--force")) body.force = true;
|
||||||
if (keepDb) body.keepDb = true;
|
if (keepDb) body.keepDb = true;
|
||||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
const error = result.data?.error || {};
|
const error = result.data?.error || {};
|
||||||
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||||
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
|
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
|
||||||
* whether the driving session is still breathing.
|
* whether the driving session is still breathing.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cmdLanes() {
|
async function cmdLanes() {
|
||||||
const { lanes, counts } = await get("/api/lanes");
|
const { lanes, counts } = await get("/api/workspaces");
|
||||||
if (!lanes.length) {
|
if (!lanes.length) {
|
||||||
console.log(
|
console.log(
|
||||||
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
||||||
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
|
|||||||
*
|
*
|
||||||
* Only the FIRST positional counts as an id, and only when it is all digits.
|
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||||
* Scanning the whole argv for a number would swallow flag values —
|
* Scanning the whole argv for a number would swallow flag values —
|
||||||
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
|
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
|
||||||
*
|
*
|
||||||
* @param {string[]} args - Arguments after the subcommand.
|
* @param {string[]} args - Arguments after the subcommand.
|
||||||
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||||
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
|
|||||||
return i > -1 ? args[i + 1] : undefined;
|
return i > -1 ? args[i + 1] : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const explicit = flagValue("lane") || flagValue("workspace");
|
const explicit = flagValue("workspace");
|
||||||
if (explicit) return { laneId: explicit, rest: args };
|
if (explicit) return { laneId: explicit, rest: args };
|
||||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||||
|
|
||||||
const tool = flagValue("tool") || toolFromEnv();
|
const tool = flagValue("tool") || toolFromEnv();
|
||||||
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||||
const { lanes } = await get("/api/lanes");
|
const { lanes } = await get("/api/workspaces");
|
||||||
|
|
||||||
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
||||||
// against the live lane list AND the directory: a pane outlives the lane it
|
// against the live lane list AND the directory: a pane outlives the lane it
|
||||||
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||||
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||||
* lane id.
|
* lane id.
|
||||||
*
|
*
|
||||||
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const { laneId, rest: laneArgs } = resolved;
|
const { laneId, rest: laneArgs } = resolved;
|
||||||
|
|
||||||
if (sub === "runtime") {
|
if (sub === "runtime") {
|
||||||
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const body = {};
|
const body = {};
|
||||||
if (laneArgs.includes("--no-build")) body.build = false;
|
if (laneArgs.includes("--no-build")) body.build = false;
|
||||||
if (laneArgs.includes("--qc")) body.qc = true;
|
if (laneArgs.includes("--qc")) body.qc = true;
|
||||||
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
const deadline = Date.now() + 15 * 60 * 1000;
|
const deadline = Date.now() + 15 * 60 * 1000;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
await new Promise((r) => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
|
||||||
if (runtime.healthy) {
|
if (runtime.healthy) {
|
||||||
printRuntime(runtime);
|
printRuntime(runtime);
|
||||||
return;
|
return;
|
||||||
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sub === "down") {
|
if (sub === "down") {
|
||||||
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(
|
console.error(
|
||||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||||
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
}
|
}
|
||||||
const i = laneArgs.indexOf("--tail");
|
const i = laneArgs.indexOf("--tail");
|
||||||
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||||
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||||
if (!log.available) {
|
if (!log.available) {
|
||||||
console.log("no logs for this workspace yet.");
|
console.log("no logs for this workspace yet.");
|
||||||
return;
|
return;
|
||||||
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await post(
|
const result = await post(
|
||||||
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||||
{ args: laneArgs.slice(1) },
|
{ args: laneArgs.slice(1) },
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
|
|||||||
: "merge";
|
: "merge";
|
||||||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||||
const result = await post(
|
const result = await post(
|
||||||
`/api/lanes/${laneId}/sync-base`,
|
`/api/workspaces/${laneId}/sync-base`,
|
||||||
{ mode, branch },
|
{ mode, branch },
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
|
|||||||
|
|
||||||
const resolved = await resolveLaneArg(argsAfterName);
|
const resolved = await resolveLaneArg(argsAfterName);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
|
|||||||
async function cmdFeatureList(args) {
|
async function cmdFeatureList(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
|
||||||
if (!features.length) {
|
if (!features.length) {
|
||||||
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
||||||
return;
|
return;
|
||||||
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
|
|||||||
};
|
};
|
||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
|
||||||
slug,
|
slug,
|
||||||
title: flag("title"),
|
title: flag("title"),
|
||||||
});
|
});
|
||||||
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||||
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||||
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||||
* by anything else in this codebase; a session or hook calls it explicitly. */
|
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||||
async function cmdLanesProofLink(args) {
|
async function cmdLanesProofLink(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
|
||||||
console.log(
|
console.log(
|
||||||
linked
|
linked
|
||||||
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||||
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||||
* profiles. Never run automatically; a session calls it explicitly. */
|
* profiles. Never run automatically; a session calls it explicitly. */
|
||||||
async function cmdLanesMcpSync(args) {
|
async function cmdLanesMcpSync(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes integration <name> [<id>]` — check whether a named
|
/** `agenthub workspace integration <name> [<id>]` — check whether a named
|
||||||
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||||
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||||
async function cmdLanesIntegration(args) {
|
async function cmdLanesIntegration(args) {
|
||||||
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
|
|||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { enabled } = await get(
|
const { enabled } = await get(
|
||||||
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||||
);
|
);
|
||||||
console.log(enabled ? "on" : "off");
|
console.log(enabled ? "on" : "off");
|
||||||
process.exitCode = enabled ? 0 : 1;
|
process.exitCode = enabled ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||||
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
||||||
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
||||||
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
||||||
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
|
|||||||
const resolved = await resolveLaneArg(args.slice(2));
|
const resolved = await resolveLaneArg(args.slice(2));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await get(
|
const result = await get(
|
||||||
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||||
undefined,
|
undefined,
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||||
* lane's pipeline instead of the whole thing.
|
* lane's pipeline instead of the whole thing.
|
||||||
*
|
*
|
||||||
* Every node already carries the one command that drives it (`command`, added
|
* Every node already carries the one command that drives it (`command`, added
|
||||||
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
|
|||||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||||
*
|
*
|
||||||
* - a slash command starts `claude` with it as the prompt;
|
* - a slash command starts `claude` with it as the prompt;
|
||||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
|
||||||
*
|
*
|
||||||
* With no node id it lists the nodes and their commands, which is also the
|
* With no node id it lists the nodes and their commands, which is also the
|
||||||
* error path's answer. `--print` shows the argv and runs nothing.
|
* error path's answer. `--print` shows the argv and runs nothing.
|
||||||
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
|
|||||||
const { flags, positional } = parseArgs(args);
|
const { flags, positional } = parseArgs(args);
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
||||||
// the template does. Merge, so the listing can still mark the current node.
|
// the template does. Merge, so the listing can still mark the current node.
|
||||||
const { pipelines } = await get("/api/lanes/pipelines");
|
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||||
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
||||||
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
||||||
...n,
|
...n,
|
||||||
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||||
* template a lane renders against. `agenthub lanes add --pipeline` could only set
|
* template a lane renders against. `agenthub workspace add --pipeline` could only set
|
||||||
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||||
* was stuck on `default` with no way back to a 16-node template.
|
* was stuck on `default` with no way back to a 16-node template.
|
||||||
*/
|
*/
|
||||||
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
|
|||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
const { pipelines } = await get("/api/lanes/pipelines");
|
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
|
||||||
console.log(
|
console.log(
|
||||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||||
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
|
|||||||
async function cmdLanesShell(args) {
|
async function cmdLanesShell(args) {
|
||||||
const resolved = await resolveLaneArg(args);
|
const resolved = await resolveLaneArg(args);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||||
const sessionName = `agenthub-lane-${lane.id}`;
|
const sessionName = `agenthub-lane-${lane.id}`;
|
||||||
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
|
|||||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const result = await get(
|
const result = await get(
|
||||||
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||||
undefined,
|
undefined,
|
||||||
{ allowError: true }
|
{ allowError: true }
|
||||||
);
|
);
|
||||||
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
|
|||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
const laneId = resolved.laneId;
|
const laneId = resolved.laneId;
|
||||||
|
|
||||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
|
||||||
stage,
|
stage,
|
||||||
status: flag("status"),
|
status: flag("status"),
|
||||||
evidence: flag("evidence"),
|
evidence: flag("evidence"),
|
||||||
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
|
|||||||
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
||||||
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
||||||
|
|
||||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
|
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
|
||||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||||
|
|
||||||
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
||||||
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
||||||
@@ -3512,8 +3512,8 @@ function replHelp() {
|
|||||||
/** Detailed help for one command (or command prefix): every catalog entry
|
/** Detailed help for one command (or command prefix): every catalog entry
|
||||||
* whose invocation starts with the token. Returns false if none matched. */
|
* whose invocation starts with the token. Returns false if none matched. */
|
||||||
function commandHelp(token) {
|
function commandHelp(token) {
|
||||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
|
||||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
// `help workspaces` finds the `workspace …` rows and vice versa.
|
||||||
const canon = (words) => {
|
const canon = (words) => {
|
||||||
const [head, ...tail] = words.split(" ");
|
const [head, ...tail] = words.split(" ");
|
||||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||||
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
|
|||||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||||
// decides whether to fall back to an offline reader or report the refusal.
|
// decides whether to fall back to an offline reader or report the refusal.
|
||||||
|
|
||||||
/** Command names that are another command under the user-facing word.
|
/** Command names that are another command under the user-facing word. */
|
||||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
const COMMAND_ALIASES = { workspaces: "workspace" };
|
||||||
* `--lane` are the OLD names, still working but deprecated. Removing them is
|
|
||||||
* step 4 of docs/plans/workspace-rename/README.md — not before the third
|
|
||||||
* release after the first one that shipped these aliases. */
|
|
||||||
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
|
|
||||||
const canonicalArgv = (argv) =>
|
const canonicalArgv = (argv) =>
|
||||||
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
||||||
|
|
||||||
/** One stderr line when an old name is typed, so people and agents move over
|
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
|
||||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
|
||||||
function warnOldNames(argv) {
|
* — never "Unknown command" or help, so a script written before says why. */
|
||||||
const old = [];
|
function refuseOldNames(argv) {
|
||||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
if (argv[0] === "lanes") {
|
||||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
console.error(c.red("✖ renamed: use agenthub workspace …"));
|
||||||
if (old.length) {
|
process.exit(1);
|
||||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
}
|
||||||
|
if (argv.includes("--lane")) {
|
||||||
|
console.error(c.red("✖ renamed: use --workspace <id>"));
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
|
|||||||
for (const [name] of items) set.add(name.split(" ")[0]);
|
for (const [name] of items) set.add(name.split(" ")[0]);
|
||||||
}
|
}
|
||||||
set.add("help");
|
set.add("help");
|
||||||
// The old names stay typeable (and completable) for scripts written before.
|
|
||||||
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
||||||
return [...set];
|
return [...set];
|
||||||
})();
|
})();
|
||||||
@@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||||
* points, not here, to avoid recursive REPLs. */
|
* points, not here, to avoid recursive REPLs. */
|
||||||
async function runCommand(argv) {
|
async function runCommand(argv) {
|
||||||
warnOldNames(argv);
|
refuseOldNames(argv);
|
||||||
const [cmd, ...rest] = canonicalArgv(argv);
|
const [cmd, ...rest] = canonicalArgv(argv);
|
||||||
const { flags, positional } = parseArgs(rest);
|
const { flags, positional } = parseArgs(rest);
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
@@ -4571,7 +4569,7 @@ async function runCommand(argv) {
|
|||||||
return cmdReinstallHooks();
|
return cmdReinstallHooks();
|
||||||
case "update-check":
|
case "update-check":
|
||||||
return cmdUpdateCheck();
|
return cmdUpdateCheck();
|
||||||
case "lanes":
|
case "workspace":
|
||||||
if (rest[0] === "add") {
|
if (rest[0] === "add") {
|
||||||
return cmdLanesAdd(rest.slice(1));
|
return cmdLanesAdd(rest.slice(1));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
|||||||
|
|
||||||
The client is a single-page application (SPA) built with modern web technologies:
|
The client is a single-page application (SPA) built with modern web technologies:
|
||||||
|
|
||||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
- **React 19** - Component-based UI with hooks and concurrent features
|
||||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
|
||||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||||
|
|
||||||
@@ -1215,7 +1215,7 @@ graph TB
|
|||||||
|
|
||||||
The client is a production-ready React application with:
|
The client is a production-ready React application with:
|
||||||
|
|
||||||
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
|
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
|
||||||
- ⚡ **Real-time** - WebSocket integration for live updates
|
- ⚡ **Real-time** - WebSocket integration for live updates
|
||||||
- 🧪 **Tested** - Vitest + React Testing Library
|
- 🧪 **Tested** - Vitest + React Testing Library
|
||||||
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
||||||
|
|||||||
Generated
+427
-139
@@ -15,18 +15,18 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/maple-mono": "^5.3.0",
|
"@fontsource/maple-mono": "^5.3.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^1.61.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
@@ -34,17 +34,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
@@ -450,10 +450,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/merge": {
|
"node_modules/@codemirror/merge": {
|
||||||
"version": "6.12.2",
|
"version": "6.12.3",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
|
||||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "^6.0.0",
|
"@codemirror/language": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -478,10 +477,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@codemirror/view": {
|
"node_modules/@codemirror/view": {
|
||||||
"version": "6.43.14",
|
"version": "6.43.15",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.7.0",
|
"@codemirror/state": "^6.7.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1661,7 +1659,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
||||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5"
|
"@babel/runtime": "^7.12.5"
|
||||||
},
|
},
|
||||||
@@ -2108,30 +2105,21 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
|
||||||
"version": "15.7.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.31",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.3.7",
|
"version": "19.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^19.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
@@ -2140,6 +2128,326 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||||
@@ -2282,59 +2590,38 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-canvas": {
|
|
||||||
"version": "0.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
|
||||||
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/addon-clipboard": {
|
"node_modules/@xterm/addon-clipboard": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
|
||||||
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
|
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-base64": "^3.7.5"
|
"js-base64": "^3.7.5"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.4.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-fit": {
|
"node_modules/@xterm/addon-fit": {
|
||||||
"version": "0.10.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-search": {
|
"node_modules/@xterm/addon-search": {
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-web-links": {
|
"node_modules/@xterm/addon-web-links": {
|
||||||
"version": "0.11.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
|
||||||
"license": "MIT",
|
},
|
||||||
"peerDependencies": {
|
"node_modules/@xterm/addon-webgl": {
|
||||||
"@xterm/xterm": "^5.0.0"
|
"version": "0.19.0",
|
||||||
}
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||||
|
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/xterm": {
|
"node_modules/@xterm/xterm": {
|
||||||
"version": "5.5.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
@@ -3706,9 +3993,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.4.2",
|
"version": "26.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -3723,7 +4010,6 @@
|
|||||||
"url": "https://www.locize.com"
|
"url": "https://www.locize.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6 || ^7"
|
||||||
},
|
},
|
||||||
@@ -3734,12 +4020,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-browser-languagedetector": {
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
"version": "8.2.1",
|
"version": "8.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
|
||||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.23.2"
|
"@babel/runtime": "^7.29.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
@@ -3924,6 +4209,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
@@ -4022,18 +4308,6 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"loose-envify": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
@@ -4052,12 +4326,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.474.0",
|
"version": "1.61.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||||
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
|
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
@@ -5378,35 +5657,28 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "19.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "19.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"scheduler": "^0.28.0"
|
||||||
"scheduler": "^0.23.2"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.1"
|
"react": "^19.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.16",
|
"version": "17.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.7",
|
"@babel/runtime": "^7.29.7",
|
||||||
"html-parse-stringify": "^4.0.1",
|
"html-parse-stringify": "^4.0.1",
|
||||||
@@ -5755,13 +6027,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
@@ -5774,9 +6042,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
|
||||||
},
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
@@ -6183,17 +6451,37 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc"
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
|
|||||||
+13
-13
@@ -18,18 +18,18 @@
|
|||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/maple-mono": "^5.3.0",
|
"@fontsource/maple-mono": "^5.3.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-search": "^0.16.0",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"i18next": "^26.0.8",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^1.61.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
@@ -37,17 +37,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^19.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^6.1.0",
|
"vite": "^6.1.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
|||||||
moment = null,
|
moment = null,
|
||||||
}: SagiAvatarProps) {
|
}: SagiAvatarProps) {
|
||||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||||
const rafRef = useRef<number>();
|
const rafRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||||
|
|||||||
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||||
|
|
||||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const lastBubbleAt = useRef(0);
|
const lastBubbleAt = useRef(0);
|
||||||
const burst = useRef<Burst | null>(null);
|
const burst = useRef<Burst | null>(null);
|
||||||
const momentKey = useRef(0);
|
const momentKey = useRef(0);
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ function QuotaChips() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Leaving the chips closes the panel only after a beat, and entering the
|
// Leaving the chips closes the panel only after a beat, and entering the
|
||||||
// panel cancels that: the pointer crosses the gap to read it.
|
// panel cancels that: the pointer crosses the gap to read it.
|
||||||
const closeTimer = useRef<number>();
|
const closeTimer = useRef<number | undefined>(undefined);
|
||||||
const show = () => {
|
const show = () => {
|
||||||
window.clearTimeout(closeTimer.current);
|
window.clearTimeout(closeTimer.current);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
|
|||||||
@@ -260,9 +260,8 @@ export function UpdateNotifier() {
|
|||||||
// poll that reloads the page keep running, and a failure re-opens it.
|
// poll that reloads the page keep running, and a failure re-opens it.
|
||||||
const closeForNow = useCallback(() => {
|
const closeForNow = useCallback(() => {
|
||||||
if (!dismissKey) return;
|
if (!dismissKey) return;
|
||||||
if (applying || restarting) toast.info(t("backgroundNote"));
|
|
||||||
setClosedKey(dismissKey);
|
setClosedKey(dismissKey);
|
||||||
}, [applying, restarting, dismissKey, t]);
|
}, [dismissKey]);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
if (busy || !dismissKey) return;
|
if (busy || !dismissKey) return;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
|
|||||||
import type { Agent } from "../../lib/types";
|
import type { Agent } from "../../lib/types";
|
||||||
import { formatModelName, fmtCost } from "../../lib/format";
|
import { formatModelName, fmtCost } from "../../lib/format";
|
||||||
|
|
||||||
function renderCard(element: JSX.Element) {
|
function renderCard(element: React.JSX.Element) {
|
||||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { api } from "../../lib/api";
|
|||||||
import { StatusBar } from "../StatusBar";
|
import { StatusBar } from "../StatusBar";
|
||||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||||
|
|
||||||
const at = (url: string, ui: JSX.Element) =>
|
const at = (url: string, ui: React.JSX.Element) =>
|
||||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||||
|
|
||||||
describe("StatusBar", () => {
|
describe("StatusBar", () => {
|
||||||
|
|||||||
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||||
name: "Tick every file in server/lib",
|
name: "Tick every file in server/lib",
|
||||||
});
|
});
|
||||||
expect(box).toBeChecked();
|
// The default ticks land in an effect after the list renders.
|
||||||
|
await waitFor(() => expect(box).toBeChecked());
|
||||||
await user.click(box);
|
await user.click(box);
|
||||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||||
await user.click(folder);
|
await user.click(folder);
|
||||||
|
|||||||
@@ -765,7 +765,7 @@ export function Divider({
|
|||||||
onRatio,
|
onRatio,
|
||||||
label,
|
label,
|
||||||
}: {
|
}: {
|
||||||
gridRef: React.RefObject<HTMLDivElement>;
|
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||||
dir: SplitDir;
|
dir: SplitDir;
|
||||||
at: number;
|
at: number;
|
||||||
box: Rect;
|
box: Rect;
|
||||||
|
|||||||
@@ -48,7 +48,9 @@
|
|||||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||||
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
||||||
* here takes the lead back (`resize` with `drive: true`).
|
* here takes the lead back (`resize` with `drive: true`). Cells are drawn by
|
||||||
|
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
|
||||||
|
* the word-move keys xterm 5 sent (`altArrowSeq`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -63,7 +65,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
import type { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||||
import type { SearchAddon } from "@xterm/addon-search";
|
import type { SearchAddon } from "@xterm/addon-search";
|
||||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||||
@@ -123,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
|
|||||||
* Workspace chunk. The result is cached, so every later pane (and a test that
|
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||||
* awaits `loadXterm()` first) sets up synchronously. */
|
* awaits `loadXterm()` first) sets up synchronously. */
|
||||||
const importXterm = async () => {
|
const importXterm = async () => {
|
||||||
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
|
const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
|
||||||
import("@xterm/xterm"),
|
import("@xterm/xterm"),
|
||||||
import("@xterm/addon-fit"),
|
import("@xterm/addon-fit"),
|
||||||
import("@xterm/addon-canvas"),
|
import("@xterm/addon-webgl"),
|
||||||
import("@xterm/addon-clipboard"),
|
import("@xterm/addon-clipboard"),
|
||||||
import("@xterm/addon-search"),
|
import("@xterm/addon-search"),
|
||||||
import("@xterm/addon-web-links"),
|
import("@xterm/addon-web-links"),
|
||||||
@@ -134,7 +136,7 @@ const importXterm = async () => {
|
|||||||
return {
|
return {
|
||||||
Terminal: xterm.Terminal,
|
Terminal: xterm.Terminal,
|
||||||
FitAddon: fit.FitAddon,
|
FitAddon: fit.FitAddon,
|
||||||
CanvasAddon: canvas.CanvasAddon,
|
WebglAddon: webgl.WebglAddon,
|
||||||
SearchAddon: search.SearchAddon,
|
SearchAddon: search.SearchAddon,
|
||||||
WebLinksAddon: links.WebLinksAddon,
|
WebLinksAddon: links.WebLinksAddon,
|
||||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||||
@@ -158,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
|
|||||||
return xtermLoading;
|
return xtermLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
|
||||||
|
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
|
||||||
|
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
|
||||||
|
* readline's backward-word / forward-word and whose up/down stay Alt's. */
|
||||||
|
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
|
||||||
|
switch (key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
return mac ? "\x1bb" : "\x1b[1;5D";
|
||||||
|
case "ArrowRight":
|
||||||
|
return mac ? "\x1bf" : "\x1b[1;5C";
|
||||||
|
case "ArrowUp":
|
||||||
|
return mac ? null : "\x1b[1;5A";
|
||||||
|
case "ArrowDown":
|
||||||
|
return mac ? null : "\x1b[1;5B";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
||||||
const RETRY_MIN_MS = 1500;
|
const RETRY_MIN_MS = 1500;
|
||||||
const RETRY_MAX_MS = 30_000;
|
const RETRY_MAX_MS = 30_000;
|
||||||
@@ -369,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
const attach = ({
|
const attach = ({
|
||||||
Terminal,
|
Terminal,
|
||||||
FitAddon,
|
FitAddon,
|
||||||
CanvasAddon,
|
WebglAddon,
|
||||||
SearchAddon,
|
SearchAddon,
|
||||||
WebLinksAddon,
|
WebLinksAddon,
|
||||||
ClipboardWithProvider,
|
ClipboardWithProvider,
|
||||||
@@ -379,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||||
// substitutes those specific glyphs from a different face with a
|
// substitutes those specific glyphs from a different face with a
|
||||||
// mismatched advance width, corrupting the cell it's drawn into even
|
// mismatched advance width, corrupting the cell it's drawn into even
|
||||||
// under the canvas renderer (which only stops a glyph from shoving its
|
// under the WebGL renderer (which only stops a glyph from shoving its
|
||||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||||
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||||
@@ -425,17 +446,30 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// face whose advance width differs, so glyphs shove their neighbours along
|
// face whose advance width differs, so glyphs shove their neighbours along
|
||||||
// and the row's tail spills onto the row below. The buffer is correct
|
// and the row's tail spills onto the row below. The buffer is correct
|
||||||
// throughout (copying the line yields clean text); only the painting drifts,
|
// throughout (copying the line yields clean text); only the painting drifts,
|
||||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
|
||||||
// context keeps the DOM renderer rather than losing the terminal.
|
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
|
||||||
let canvas: CanvasAddon | null = null;
|
// keeps the DOM renderer rather than losing the terminal, and so does a
|
||||||
|
// pane whose context the browser takes back (GPU reset, or past the
|
||||||
|
// browser's cap of live contexts with many panes open) — disposing the
|
||||||
|
// addon hands the drawing back to the DOM renderer.
|
||||||
|
let gpu: WebglAddon | null = null;
|
||||||
try {
|
try {
|
||||||
canvas = new CanvasAddon();
|
const addon = new WebglAddon();
|
||||||
term.loadAddon(canvas);
|
term.loadAddon(addon);
|
||||||
|
gpu = addon;
|
||||||
|
addon.onContextLoss(() => {
|
||||||
|
addon.dispose();
|
||||||
|
if (gpu === addon) gpu = null;
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
canvas = null;
|
gpu = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fit.fit();
|
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||||
|
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||||
|
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||||
|
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||||
|
if (hasBox()) fit.fit();
|
||||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||||
// page here, and having to click it first meant the first thing typed went
|
// page here, and having to click it first meant the first thing typed went
|
||||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||||
@@ -522,6 +556,17 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
term.input("\x1f", true);
|
term.input("\x1f", true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
|
||||||
|
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
|
||||||
|
// ESC b / ESC f for left/right on macOS — the same keys tmux,
|
||||||
|
// bash and claude's prompt have always been handed from this pane.
|
||||||
|
const altArrow =
|
||||||
|
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
|
||||||
|
if (altArrow) {
|
||||||
|
ev.preventDefault();
|
||||||
|
term.input(altArrow, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
@@ -564,7 +609,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
*/
|
*/
|
||||||
const sendSizeNow = (drive = false) => {
|
const sendSizeNow = (drive = false) => {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
if (term.cols < 2 || term.rows < 2) return;
|
if (!hasBox() || term.cols < 2 || term.rows < 2) return;
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
||||||
};
|
};
|
||||||
// This browser is the one in use now: back to its own size, and the
|
// This browser is the one in use now: back to its own size, and the
|
||||||
@@ -601,7 +646,10 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// scrollback as torn fragments along the left margin, visible as soon as
|
// scrollback as torn fragments along the left margin, visible as soon as
|
||||||
// you scroll back (issue #33). Happens on every attach: page load,
|
// you scroll back (issue #33). Happens on every attach: page load,
|
||||||
// lane switch, and each reconnect.
|
// lane switch, and each reconnect.
|
||||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
// A hidden terminal sends none: the server attaches it at the window's
|
||||||
|
// own size, so a pane on a background tab is never resized by it.
|
||||||
|
const size =
|
||||||
|
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||||
ws = sock;
|
ws = sock;
|
||||||
wsRef.current = sock;
|
wsRef.current = sock;
|
||||||
@@ -696,6 +744,9 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||||
if (follow) return scaleToFit(); // the driver's grid, not ours
|
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||||
fit.fit(); // a real change fires onResize, which reports it
|
fit.fit(); // a real change fires onResize, which reports it
|
||||||
|
// A tab shown for the first time may fit to the very size xterm already
|
||||||
|
// had (no onResize), yet tmux has never heard it: report it anyway.
|
||||||
|
sendSize();
|
||||||
};
|
};
|
||||||
const resizeObserver = new ResizeObserver(refit);
|
const resizeObserver = new ResizeObserver(refit);
|
||||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||||
@@ -734,7 +785,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
resizeDisposable.dispose();
|
resizeDisposable.dispose();
|
||||||
dataDisposable.dispose();
|
dataDisposable.dispose();
|
||||||
ws?.close();
|
ws?.close();
|
||||||
canvas?.dispose();
|
gpu?.dispose();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
* included) as outgoing binary frames, and
|
* included) as outgoing binary frames, and
|
||||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||||
* after `exit` or a policy close,
|
* after `exit` or a policy close,
|
||||||
* and recolours live on a dark-theme change without re-attaching.
|
* recolours live on a dark-theme change without re-attaching, draws through
|
||||||
|
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
|
||||||
|
* Alt+arrow word moves.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
cleanup,
|
cleanup,
|
||||||
@@ -19,7 +21,7 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { TerminalView, loadXterm } from "../TerminalView";
|
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||||
|
|
||||||
@@ -109,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
|
|||||||
vi.mock("@xterm/addon-fit", () => ({
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@xterm/addon-canvas", () => ({
|
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||||
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
|
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
|
||||||
|
last: null,
|
||||||
|
};
|
||||||
|
vi.mock("@xterm/addon-webgl", () => ({
|
||||||
|
WebglAddon: vi.fn().mockImplementation(() => {
|
||||||
|
let onLoss = () => {};
|
||||||
|
const addon = {
|
||||||
|
__webgl: true,
|
||||||
|
dispose: vi.fn(),
|
||||||
|
onContextLoss: (fn: () => void) => {
|
||||||
|
onLoss = fn;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
|
||||||
|
return addon;
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
@@ -136,6 +154,17 @@ class MockWebSocket {
|
|||||||
// @ts-expect-error test override
|
// @ts-expect-error test override
|
||||||
global.WebSocket = MockWebSocket;
|
global.WebSocket = MockWebSocket;
|
||||||
|
|
||||||
|
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
|
||||||
|
* tab); returns the restore. */
|
||||||
|
function withBox(width = 800, height = 600) {
|
||||||
|
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
|
||||||
|
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
|
||||||
|
return () => {
|
||||||
|
w.mockRestore();
|
||||||
|
h.mockRestore();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("TerminalView", () => {
|
describe("TerminalView", () => {
|
||||||
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||||
beforeAll(() => loadXterm());
|
beforeAll(() => loadXterm());
|
||||||
@@ -167,11 +196,51 @@ describe("TerminalView", () => {
|
|||||||
// it: tmux resizes the window to the newest client, so attaching at the
|
// it: tmux resizes the window to the newest client, so attaching at the
|
||||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||||
// leaves that width in the scrollback (issue #33).
|
// leaves that width in the scrollback (issue #33).
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
const restore = withBox();
|
||||||
expect(MockWebSocket.instances).toHaveLength(1);
|
try {
|
||||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
expect(MockWebSocket.instances).toHaveLength(1);
|
||||||
);
|
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||||
|
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a terminal on a hidden tab sends no size until it is shown", () => {
|
||||||
|
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
|
||||||
|
// that size resized the real tmux pane under every other client.
|
||||||
|
let observed: (() => void) | null = null;
|
||||||
|
const RO = globalThis.ResizeObserver;
|
||||||
|
// @ts-expect-error test override
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
constructor(cb: () => void) {
|
||||||
|
observed = cb;
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let restore = () => {};
|
||||||
|
try {
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||||
|
ws.onopen?.();
|
||||||
|
expect(ws.sent).toEqual([]);
|
||||||
|
// The tab is shown: the box gets a size and its size reaches tmux.
|
||||||
|
restore = withBox();
|
||||||
|
observed!();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(ws.sent).toEqual([
|
||||||
|
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
globalThis.ResizeObserver = RO;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||||
@@ -397,34 +466,75 @@ describe("TerminalView", () => {
|
|||||||
expect(onExit).toHaveBeenCalledTimes(1);
|
expect(onExit).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
it("renders through the WebGL addon, not xterm's DOM renderer", () => {
|
||||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||||
// fallback face with a different advance, so glyphs shove their neighbours
|
// fallback face with a different advance, so glyphs shove their neighbours
|
||||||
// and the row's tail spills onto the row below — buffer clean, painting
|
// and the row's tail spills onto the row below — buffer clean, painting
|
||||||
// wrong. The canvas renderer draws each cell at a fixed position instead.
|
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||||
|
// (xterm 6 removed the canvas one).
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe(
|
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const addon = gpu.last!;
|
||||||
|
expect(addon.dispose).not.toHaveBeenCalled();
|
||||||
|
addon.lose();
|
||||||
|
expect(addon.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||||
|
});
|
||||||
|
|
||||||
|
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||||
|
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||||
|
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||||
|
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
|
||||||
|
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
|
||||||
|
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
|
||||||
|
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
|
||||||
|
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
|
||||||
|
expect(altArrowSeq("ArrowUp", true)).toBeNull();
|
||||||
|
expect(altArrowSeq("a", false)).toBeNull();
|
||||||
|
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
|
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
|
||||||
|
expect(handler(ev)).toBe(false);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
|
||||||
|
// Another modifier with it is xterm's own sequence.
|
||||||
|
expect(
|
||||||
|
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||||
// next row's left margin, and tmux never repaints a row it believes
|
// next row's left margin, and tmux never repaints a row it believes
|
||||||
// unchanged — the stray characters glued to the left edge after scrolling.
|
// unchanged — the stray characters glued to the left edge after scrolling.
|
||||||
it("reports its size when the socket opens", () => {
|
it("reports its size when the socket opens", () => {
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
const restore = withBox();
|
||||||
const ws = MockWebSocket.instances[0]!;
|
try {
|
||||||
ws.onopen?.();
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(ws.sent).toContain(
|
const ws = MockWebSocket.instances[0]!;
|
||||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
ws.onopen?.();
|
||||||
);
|
expect(ws.sent).toContain(
|
||||||
|
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a size change xterm made on its own, with no container resize", async () => {
|
it("reports a size change xterm made on its own, with no container resize", async () => {
|
||||||
// Webfont load and page zoom both move cols without resizing the element
|
// Webfont load and page zoom both move cols without resizing the element
|
||||||
// ResizeObserver watches, which is why onResize is the hook.
|
// ResizeObserver watches, which is why onResize is the hook.
|
||||||
|
const restore = withBox();
|
||||||
|
onTestFinished(restore);
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
ws.onopen?.();
|
ws.onopen?.();
|
||||||
|
|||||||
@@ -79,14 +79,6 @@ function Step({ n, children }: { n: number; children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Code({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||||
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||||
|
|
||||||
@@ -642,10 +634,6 @@ export function GitIdentitySettings() {
|
|||||||
onRemove={() => removeKey("ed25519")}
|
onRemove={() => removeKey("ed25519")}
|
||||||
>
|
>
|
||||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
<Step n={3}>
|
|
||||||
{t("gitIdentity.gitHubStepClone")}
|
|
||||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
|
||||||
</Step>
|
|
||||||
</KeyCard>
|
</KeyCard>
|
||||||
{namedOf("ed25519").map((k) => {
|
{namedOf("ed25519").map((k) => {
|
||||||
const rows = rowsOf(otherRows, k);
|
const rows = rowsOf(otherRows, k);
|
||||||
@@ -668,11 +656,6 @@ export function GitIdentitySettings() {
|
|||||||
{id.writable && rows.length === 0 && (
|
{id.writable && rows.length === 0 && (
|
||||||
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||||
)}
|
)}
|
||||||
{rows.map((r) => (
|
|
||||||
<Code key={r.alias ?? r.host}>
|
|
||||||
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
|
||||||
</Code>
|
|
||||||
))}
|
|
||||||
</Step>
|
</Step>
|
||||||
</KeyCard>
|
</KeyCard>
|
||||||
);
|
);
|
||||||
@@ -720,16 +703,6 @@ export function GitIdentitySettings() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Step>
|
</Step>
|
||||||
{rows.length > 0 && (
|
|
||||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
|
||||||
{t("gitIdentity.awsReady")}
|
|
||||||
{rows.map((r) => (
|
|
||||||
<Code key={r.alias ?? r.host}>
|
|
||||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
|
||||||
</Code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</KeyCard>
|
</KeyCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* @file NineRouterUpdate.tsx
|
||||||
|
* @description **Update 9router** on the AI Proxy page (an admin's; beside
|
||||||
|
* Config 9router): a button — dotted when npm has a newer 9router — and a
|
||||||
|
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
|
||||||
|
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
|
||||||
|
* again hidden (tray, no browser) — the last only when it was running before.
|
||||||
|
* The dialog shows why an update cannot run right now (the server's `blocker`,
|
||||||
|
* with the command to run by hand when sudo needs a password) and follows the
|
||||||
|
* job by polling until it ends. Closing the dialog does not stop the job.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react";
|
||||||
|
import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
|
function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) {
|
||||||
|
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
|
||||||
|
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
|
||||||
|
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
|
||||||
|
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||||
|
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NineRouterUpdate() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(open, dialogRef, () => setOpen(false));
|
||||||
|
|
||||||
|
const load = useCallback(async (fresh = false) => {
|
||||||
|
try {
|
||||||
|
setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const running = status?.job?.status === "running";
|
||||||
|
// Follow a running job — also one started from another tab.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
const timer = window.setInterval(() => void load(), POLL_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [running, load]);
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setError(null);
|
||||||
|
setOpen(true);
|
||||||
|
void load(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setStarting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const job = await api.aiProxies.nineRouterUpdate();
|
||||||
|
setStatus((s) => (s ? { ...s, job } : s));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
void load(true);
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!status?.installed) return null;
|
||||||
|
const job = status.job;
|
||||||
|
const blocker = status.blocker;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openDialog}
|
||||||
|
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("aiProxy.update9router")}
|
||||||
|
{status.updateAvailable && !running && (
|
||||||
|
<span
|
||||||
|
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
|
||||||
|
aria-label={t("aiProxy.update9routerAvailable")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="nine-router-update-title"
|
||||||
|
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
id="nine-router-update-title"
|
||||||
|
className="mb-3 text-sm font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
|
{t("aiProxy.update9router")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
|
||||||
|
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
|
||||||
|
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
|
||||||
|
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
|
||||||
|
<dd className="text-fg-primary">
|
||||||
|
{status.running
|
||||||
|
? status.managedBy === "systemd"
|
||||||
|
? t("aiProxy.update9routerRunningUnit")
|
||||||
|
: t("aiProxy.update9routerRunning")
|
||||||
|
: t("aiProxy.update9routerStopped")}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="mb-3 text-[11px] text-fg-muted">
|
||||||
|
{status.running
|
||||||
|
? t("aiProxy.update9routerExplain")
|
||||||
|
: t("aiProxy.update9routerExplainStopped")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{job && (
|
||||||
|
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
|
||||||
|
{job.steps.map((s) => (
|
||||||
|
<li key={s.id} className="flex items-start gap-2 text-xs">
|
||||||
|
<span className="mt-0.5">
|
||||||
|
<StepIcon status={s.status} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="text-fg-primary">
|
||||||
|
{t(`aiProxy.update9routerStep.${s.id}`)}
|
||||||
|
</span>
|
||||||
|
{s.message && (
|
||||||
|
<span className="block break-words text-[11px] text-fg-muted">
|
||||||
|
{s.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{job && job.status !== "running" && (
|
||||||
|
<p
|
||||||
|
className={`mb-3 text-xs ${
|
||||||
|
job.status === "done" ? "text-status-success" : "text-status-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status !== "done"
|
||||||
|
? t("aiProxy.update9routerFailed")
|
||||||
|
: job.steps.find((s) => s.id === "relaunch")?.status === "skipped"
|
||||||
|
? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" })
|
||||||
|
: t("aiProxy.update9routerDone", { version: job.to ?? "—" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{job?.log && (
|
||||||
|
<details className="mb-3">
|
||||||
|
<summary className="cursor-pointer text-[11px] text-fg-muted">
|
||||||
|
{t("aiProxy.update9routerLog")}
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
|
||||||
|
{job.log}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blocker && !running && blocker.code !== "EUPTODATE" && (
|
||||||
|
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
|
||||||
|
<p>{blocker.message}</p>
|
||||||
|
{blocker.command && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
|
||||||
|
{blocker.command}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(blocker.command!)}
|
||||||
|
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
|
||||||
|
aria-label={t("aiProxy.update9routerCopy")}
|
||||||
|
title={t("aiProxy.update9routerCopy")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{blocker?.code === "EUPTODATE" && !running && (
|
||||||
|
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t("aiProxy.update9routerClose")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={Boolean(blocker) || running || starting}
|
||||||
|
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{t("aiProxy.update9routerStart")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -156,7 +156,6 @@ describe("GitIdentitySettings", () => {
|
|||||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||||
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||||
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
|
||||||
// A CodeCommit host never shows up again under "other hosts".
|
// A CodeCommit host never shows up again under "other hosts".
|
||||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||||
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||||
@@ -260,7 +259,6 @@ describe("GitIdentitySettings", () => {
|
|||||||
expect(
|
expect(
|
||||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
|
|
||||||
// The default key's card does not list the named key's login.
|
// The default key's card does not list the named key's login.
|
||||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||||
@@ -301,7 +299,7 @@ describe("GitIdentitySettings", () => {
|
|||||||
key: "id_ed25519_git-2",
|
key: "id_ed25519_git-2",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
await within(work).findByText("ssh://git-2/ → git@github.com")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
|
||||||
|
* 9router, a dialog that shows versions and starts the job, the server's
|
||||||
|
* blocker (with the manual command) disabling the start, and the job's steps.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { NineRouterUpdate } from "../NineRouterUpdate";
|
||||||
|
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
|
||||||
|
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
|
||||||
|
|
||||||
|
const base: NineRouterUpdateStatus = {
|
||||||
|
installed: true,
|
||||||
|
version: "0.5.104",
|
||||||
|
latest: "0.5.109",
|
||||||
|
updateAvailable: true,
|
||||||
|
running: true,
|
||||||
|
managedBy: "systemd",
|
||||||
|
blocker: null,
|
||||||
|
job: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const job: NineRouterUpdateJob = {
|
||||||
|
status: "running",
|
||||||
|
startedAt: "2026-10-26T00:00:00Z",
|
||||||
|
finishedAt: null,
|
||||||
|
from: "0.5.104",
|
||||||
|
to: null,
|
||||||
|
managedBy: "systemd",
|
||||||
|
install: "direct",
|
||||||
|
steps: [
|
||||||
|
{ id: "shutdown", status: "done", message: null },
|
||||||
|
{ id: "update", status: "running", message: null },
|
||||||
|
{ id: "relaunch", status: "pending", message: null },
|
||||||
|
],
|
||||||
|
log: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
statusMock.mockReset();
|
||||||
|
startMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NineRouterUpdate", () => {
|
||||||
|
it("renders nothing when 9router is not installed", async () => {
|
||||||
|
statusMock.mockResolvedValue({ ...base, installed: false });
|
||||||
|
const { container } = render(<NineRouterUpdate />);
|
||||||
|
await waitFor(() => expect(statusMock).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens with both versions and starts the three-step job", async () => {
|
||||||
|
statusMock.mockResolvedValue(base);
|
||||||
|
startMock.mockResolvedValue(job);
|
||||||
|
render(<NineRouterUpdate />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||||
|
expect(statusMock).toHaveBeenLastCalledWith(true);
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
expect(dialog).toHaveTextContent("0.5.104");
|
||||||
|
expect(dialog).toHaveTextContent("0.5.109");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
|
||||||
|
expect(startMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the blocker with its manual command and keeps the start disabled", async () => {
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
blocker: {
|
||||||
|
code: "ENOPERM",
|
||||||
|
message: "/usr/local is not writable by this account and sudo needs a password",
|
||||||
|
command: "sudo npm install -g 9router@latest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<NineRouterUpdate />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||||
|
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @file useCcTarget — whose Claude Code config the Config Explorer manages for
|
||||||
|
* this login (GET /api/cc-config/target). Local mode and an admin: the host's,
|
||||||
|
* answered at once without a request (nothing changes for them). A non-admin
|
||||||
|
* in server mode asks the server: `own` when they have their own Linux account
|
||||||
|
* (S3) — Settings then shows "Your Claude Code config" — else `none` and the
|
||||||
|
* page stays hidden. The server's refusal (403 ENOACCOUNT/EADMIN) is the rule;
|
||||||
|
* this only hides what would be refused.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type CcTarget } from "../lib/api";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
|
|
||||||
|
const HOST: CcTarget = { kind: "host", readOnly: false, claudeHome: null };
|
||||||
|
|
||||||
|
/** `null` while a non-admin's answer is on its way. */
|
||||||
|
export function useCcTarget(): CcTarget | null {
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
const [target, setTarget] = useState<CcTarget | null>(isAdmin ? HOST : null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
setTarget(HOST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
api.ccConfig
|
||||||
|
.target()
|
||||||
|
.then((t) => {
|
||||||
|
if (live) setTarget(t);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (live) setTarget({ kind: "none", readOnly: true, claudeHome: null });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [isAdmin]);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
@@ -27,9 +27,9 @@ const FOCUSABLE =
|
|||||||
*/
|
*/
|
||||||
export function useDialog(
|
export function useDialog(
|
||||||
open: boolean,
|
open: boolean,
|
||||||
panelRef: RefObject<HTMLElement>,
|
panelRef: RefObject<HTMLElement | null>,
|
||||||
onClose?: () => void,
|
onClose?: () => void,
|
||||||
initialFocus?: RefObject<HTMLElement>
|
initialFocus?: RefObject<HTMLElement | null>
|
||||||
): void {
|
): void {
|
||||||
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
||||||
// would steal focus back to the first control on every parent render.
|
// would steal focus back to the first control on every parent render.
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ type MessageHandler = (msg: WSMessage) => void;
|
|||||||
* auto-reconnects with capped exponential backoff on close - plus an
|
* auto-reconnects with capped exponential backoff on close - plus an
|
||||||
* immediate reconnect attempt on tab focus/network-online/visibility-change
|
* immediate reconnect attempt on tab focus/network-online/visibility-change
|
||||||
* so the socket recovers quickly after a server restart or laptop sleep.
|
* so the socket recovers quickly after a server restart or laptop sleep.
|
||||||
* Guards against React 18 StrictMode's mount→cleanup→remount cycle opening a
|
* Guards against React StrictMode's mount→cleanup→remount cycle opening a
|
||||||
* duplicate socket (see the inline comment in `connect`).
|
* duplicate socket (see the inline comment in `connect`).
|
||||||
* @param onMessage Called with every message parsed from the socket; the
|
* @param onMessage Called with every message parsed from the socket; the
|
||||||
* latest reference is used even across reconnects (no stale closures).
|
* latest reference is used even across reconnects (no stale closures).
|
||||||
@@ -73,7 +73,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
|||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const handlersRef = useRef<MessageHandler>(onMessage);
|
const handlersRef = useRef<MessageHandler>(onMessage);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const reconnectAttempts = useRef(0);
|
const reconnectAttempts = useRef(0);
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
|||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
// Don't open a second socket if one is already alive or in flight.
|
// Don't open a second socket if one is already alive or in flight.
|
||||||
// Without this, React 18 StrictMode (mount → cleanup → remount in dev)
|
// Without this, React StrictMode (mount → cleanup → remount in dev)
|
||||||
// and the close→reconnect race could leave two sockets connected at the
|
// and the close→reconnect race could leave two sockets connected at the
|
||||||
// same time. Both would receive every server broadcast, producing
|
// same time. Both would receive every server broadcast, producing
|
||||||
// duplicate stream_event deltas (doubled text, duplicate assistant
|
// duplicate stream_event deltas (doubled text, duplicate assistant
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "Claude Config Explorer",
|
"title": "Claude Config Explorer",
|
||||||
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
|
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
|
||||||
|
"own": {
|
||||||
|
"label": "Your Claude Code config — {{home}}",
|
||||||
|
"subtitle": "Only your own account's setup: every change here runs as your account and reaches only your own sessions."
|
||||||
|
},
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"refreshing": "Refreshing…",
|
"refreshing": "Refreshing…",
|
||||||
"lastUpdated": "Last updated {{time}}",
|
"lastUpdated": "Last updated {{time}}",
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
|
"claudeHome": "Your .claude",
|
||||||
"projectClaudeDir": "Project .claude",
|
"projectClaudeDir": "Project .claude",
|
||||||
"projectRoot": "Project root",
|
"projectRoot": "Project root",
|
||||||
"claudeJson": "claude.json",
|
"claudeJson": "claude.json",
|
||||||
|
|||||||
@@ -759,6 +759,29 @@
|
|||||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||||
"add": "Add profile",
|
"add": "Add profile",
|
||||||
"config9router": "Config 9router",
|
"config9router": "Config 9router",
|
||||||
|
"update9router": "Update 9router",
|
||||||
|
"update9routerAvailable": "A newer 9router is available",
|
||||||
|
"update9routerCurrent": "Installed",
|
||||||
|
"update9routerLatest": "Latest",
|
||||||
|
"update9routerState": "State",
|
||||||
|
"update9routerRunning": "Running",
|
||||||
|
"update9routerRunningUnit": "Running (9router.service)",
|
||||||
|
"update9routerStopped": "Not running",
|
||||||
|
"update9routerExplain": "Every session using 9router is disconnected while it updates: 9router is shut down, updated with npm, then started again hidden (tray, no browser).",
|
||||||
|
"update9routerExplainStopped": "9router is not running: it is updated with npm and left off.",
|
||||||
|
"update9routerStep": {
|
||||||
|
"shutdown": "Disconnect and shut down 9router",
|
||||||
|
"update": "Run the update (npm install -g 9router@latest)",
|
||||||
|
"relaunch": "Start 9router again, hidden"
|
||||||
|
},
|
||||||
|
"update9routerDone": "9router {{version}} is running.",
|
||||||
|
"update9routerDoneStopped": "9router {{version}} is installed and left off, as it was.",
|
||||||
|
"update9routerFailed": "The update did not finish — see the steps and the log.",
|
||||||
|
"update9routerLog": "npm output",
|
||||||
|
"update9routerUpToDate": "9router is already the latest version.",
|
||||||
|
"update9routerCopy": "Copy command",
|
||||||
|
"update9routerClose": "Close",
|
||||||
|
"update9routerStart": "Update now",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||||
@@ -884,7 +907,6 @@
|
|||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
"gitHubSub": "ED25519 key — the git@… SSH address",
|
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||||
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
||||||
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
|
|
||||||
"awsTitle": "AWS CodeCommit",
|
"awsTitle": "AWS CodeCommit",
|
||||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||||
"stepCopy": "Copy the public key.",
|
"stepCopy": "Copy the public key.",
|
||||||
@@ -894,7 +916,6 @@
|
|||||||
"awsRegion": "Region",
|
"awsRegion": "Region",
|
||||||
"awsSave": "Save",
|
"awsSave": "Save",
|
||||||
"awsSaved": "SSH key ID saved for {{region}}",
|
"awsSaved": "SSH key ID saved for {{region}}",
|
||||||
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
|
|
||||||
"otherTitle": "Other SSH hosts (advanced)",
|
"otherTitle": "Other SSH hosts (advanced)",
|
||||||
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
||||||
"otherHost": "Host",
|
"otherHost": "Host",
|
||||||
@@ -1035,6 +1056,10 @@
|
|||||||
"title": "Claude Code",
|
"title": "Claude Code",
|
||||||
"description": "Claude Code's own configuration, and what AgentHub reads from it."
|
"description": "Claude Code's own configuration, and what AgentHub reads from it."
|
||||||
},
|
},
|
||||||
|
"claudeCodeOwn": {
|
||||||
|
"title": "Your Claude Code config",
|
||||||
|
"description": "The skills, agents, plugins, MCP servers and settings your own sessions use."
|
||||||
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"title": "Codex",
|
"title": "Codex",
|
||||||
"description": "Codex's own configuration, and what AgentHub reads from it."
|
"description": "Codex's own configuration, and what AgentHub reads from it."
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||||
"autoFixWorkspace": "AgentHub update",
|
"autoFixWorkspace": "AgentHub update",
|
||||||
"autoFixError": "Could not start the Auto fix session",
|
"autoFixError": "Could not start the Auto fix session",
|
||||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
|
|
||||||
"chipTitle": "Updating to {{version}}…",
|
"chipTitle": "Updating to {{version}}…",
|
||||||
"chipHint": "The update runs in the background — click to show it",
|
"chipHint": "The update runs in the background — click to show it",
|
||||||
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "Trình khám phá cấu hình Claude",
|
"title": "Trình khám phá cấu hình Claude",
|
||||||
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
|
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
|
||||||
|
"own": {
|
||||||
|
"label": "Cấu hình Claude Code của bạn — {{home}}",
|
||||||
|
"subtitle": "Chỉ thiết lập của tài khoản bạn: mọi thay đổi ở đây chạy dưới tài khoản của bạn và chỉ áp dụng cho các phiên của chính bạn."
|
||||||
|
},
|
||||||
"refresh": "Làm mới",
|
"refresh": "Làm mới",
|
||||||
"refreshing": "Đang làm mới…",
|
"refreshing": "Đang làm mới…",
|
||||||
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
|
"claudeHome": ".claude của bạn",
|
||||||
"projectClaudeDir": "Dự án .claude",
|
"projectClaudeDir": "Dự án .claude",
|
||||||
"projectRoot": "Thư mục dự án",
|
"projectRoot": "Thư mục dự án",
|
||||||
"claudeJson": "claude.json",
|
"claudeJson": "claude.json",
|
||||||
|
|||||||
@@ -747,6 +747,29 @@
|
|||||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||||
"add": "Thêm profile",
|
"add": "Thêm profile",
|
||||||
"config9router": "Config 9router",
|
"config9router": "Config 9router",
|
||||||
|
"update9router": "Cập nhật 9router",
|
||||||
|
"update9routerAvailable": "Đã có bản 9router mới",
|
||||||
|
"update9routerCurrent": "Đang cài",
|
||||||
|
"update9routerLatest": "Mới nhất",
|
||||||
|
"update9routerState": "Trạng thái",
|
||||||
|
"update9routerRunning": "Đang chạy",
|
||||||
|
"update9routerRunningUnit": "Đang chạy (9router.service)",
|
||||||
|
"update9routerStopped": "Không chạy",
|
||||||
|
"update9routerExplain": "Mọi session đang dùng 9router sẽ bị ngắt kết nối trong lúc cập nhật: 9router được tắt, cập nhật bằng npm rồi mở lại ở chế độ ẩn (khay hệ thống, không mở trình duyệt).",
|
||||||
|
"update9routerExplainStopped": "9router đang không chạy: chỉ cập nhật bằng npm và giữ nguyên trạng thái tắt.",
|
||||||
|
"update9routerStep": {
|
||||||
|
"shutdown": "Ngắt kết nối và tắt 9router",
|
||||||
|
"update": "Chạy cập nhật (npm install -g 9router@latest)",
|
||||||
|
"relaunch": "Mở lại 9router ở chế độ ẩn"
|
||||||
|
},
|
||||||
|
"update9routerDone": "9router {{version}} đang chạy.",
|
||||||
|
"update9routerDoneStopped": "Đã cài 9router {{version}}, giữ nguyên trạng thái tắt như trước.",
|
||||||
|
"update9routerFailed": "Cập nhật chưa hoàn tất — xem các bước và log.",
|
||||||
|
"update9routerLog": "Output của npm",
|
||||||
|
"update9routerUpToDate": "9router đã là bản mới nhất.",
|
||||||
|
"update9routerCopy": "Sao chép lệnh",
|
||||||
|
"update9routerClose": "Đóng",
|
||||||
|
"update9routerStart": "Cập nhật ngay",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"remove": "Xoá",
|
"remove": "Xoá",
|
||||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||||
@@ -872,7 +895,6 @@
|
|||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||||
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
||||||
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
|
|
||||||
"awsTitle": "AWS CodeCommit",
|
"awsTitle": "AWS CodeCommit",
|
||||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||||
"stepCopy": "Copy public key.",
|
"stepCopy": "Copy public key.",
|
||||||
@@ -882,7 +904,6 @@
|
|||||||
"awsRegion": "Region",
|
"awsRegion": "Region",
|
||||||
"awsSave": "Lưu",
|
"awsSave": "Lưu",
|
||||||
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
||||||
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
|
|
||||||
"otherTitle": "Host SSH khác (nâng cao)",
|
"otherTitle": "Host SSH khác (nâng cao)",
|
||||||
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
||||||
"otherHost": "Host",
|
"otherHost": "Host",
|
||||||
@@ -1023,6 +1044,10 @@
|
|||||||
"title": "Claude Code",
|
"title": "Claude Code",
|
||||||
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
|
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
|
||||||
},
|
},
|
||||||
|
"claudeCodeOwn": {
|
||||||
|
"title": "Cấu hình Claude Code của bạn",
|
||||||
|
"description": "Skill, agent, plugin, MCP server và thiết lập mà các phiên của chính bạn dùng."
|
||||||
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"title": "Codex",
|
"title": "Codex",
|
||||||
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
|
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||||
"autoFixError": "Không mở được phiên Tự sửa",
|
"autoFixError": "Không mở được phiên Tự sửa",
|
||||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
|
|
||||||
"chipTitle": "Đang cập nhật lên {{version}}…",
|
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||||
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
||||||
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||||
|
|||||||
@@ -717,6 +717,11 @@
|
|||||||
.xterm-viewport::-webkit-scrollbar {
|
.xterm-viewport::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
/* xterm 6 draws its own scrollbar (VS Code's scrollable element) instead of
|
||||||
|
the viewport's native one: the same dead affordance, hidden the same way. */
|
||||||
|
.xterm .xterm-scrollable-element > .scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
|
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
|
||||||
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
|
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ describe("artifactView", () => {
|
|||||||
expect(isArtifact("server.js", true)).toBe(false);
|
expect(isArtifact("server.js", true)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never takes the project's own HTML pages for artifacts", () => {
|
||||||
|
expect(isArtifact("/tmp/out/report.html", true)).toBe(true);
|
||||||
|
expect(isArtifact("/tmp/out/report.html", false)).toBe(false);
|
||||||
|
expect(isArtifact("/repo/index.html", true)).toBe(false);
|
||||||
|
expect(isArtifact("/repo/client/INDEX.HTM", true)).toBe(false);
|
||||||
|
expect(isArtifact("/repo/public/about.html", true)).toBe(false);
|
||||||
|
expect(isArtifact("C:\\repo\\templates\\mail.html", true)).toBe(false);
|
||||||
|
expect(isArtifact("/repo/docs/chart.html", true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||||
const msgs = [
|
const msgs = [
|
||||||
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ describe("git targets", () => {
|
|||||||
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
|
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
|
||||||
await api.lanes.git(gitTarget(3, null));
|
await api.lanes.git(gitTarget(3, null));
|
||||||
const urls = spy.mock.calls.map((c) => String(c[0]));
|
const urls = spy.mock.calls.map((c) => String(c[0]));
|
||||||
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
|
expect(urls[0]).toMatch(/\/workspaces\/3\/git\/changes\?range=work$/);
|
||||||
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
|
expect(urls[1]).toMatch(/\/workspaces\/3\/git\/changes\?range=work&session=s-1$/);
|
||||||
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
|
expect(urls[2]).toMatch(/\/workspaces\/3\/git\/stage\?session=s-1$/);
|
||||||
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
|
expect(urls[3]).toMatch(/\/workspaces\/3\/git$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function stubFetch(body: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("api.lanes.git", () => {
|
describe("api.lanes.git", () => {
|
||||||
it("requests /api/lanes/<id>/git and returns the parsed facts", async () => {
|
it("requests /api/workspaces/<id>/git and returns the parsed facts", async () => {
|
||||||
const facts = {
|
const facts = {
|
||||||
available: true,
|
available: true,
|
||||||
branch: "feat/x",
|
branch: "feat/x",
|
||||||
@@ -41,7 +41,7 @@ describe("api.lanes.git", () => {
|
|||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
|
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
|
||||||
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
|
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
|
||||||
expect(String(call[0]).endsWith("/api/lanes/3/git")).toBe(true);
|
expect(String(call[0]).endsWith("/api/workspaces/3/git")).toBe(true);
|
||||||
expect(call[1]?.method ?? "GET").toBe("GET");
|
expect(call[1]?.method ?? "GET").toBe("GET");
|
||||||
expect(result).toEqual(facts);
|
expect(result).toEqual(facts);
|
||||||
});
|
});
|
||||||
|
|||||||
+94
-41
@@ -915,7 +915,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
|||||||
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
||||||
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
|
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
return `/workspaces/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
|
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
|
||||||
@@ -929,7 +929,7 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
|||||||
const qs = new URLSearchParams(q).toString();
|
const qs = new URLSearchParams(q).toString();
|
||||||
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
|
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
|
||||||
}
|
}
|
||||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
return gitUrl(place, sub, q).replace(/^(\/workspaces\/\d+)\/git/, "$1/fs");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2010,7 +2010,7 @@ export const api = {
|
|||||||
purged_agents: number;
|
purged_agents: number;
|
||||||
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
|
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
|
||||||
gc: (body: { dryRun?: boolean } = {}) =>
|
gc: (body: { dryRun?: boolean } = {}) =>
|
||||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
|
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/workspaces/gc", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -2297,6 +2297,13 @@ export const api = {
|
|||||||
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
|
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
|
||||||
* script - for the dashboard's "CC Config" explorer/editor pages. */
|
* script - for the dashboard's "CC Config" explorer/editor pages. */
|
||||||
ccConfig: {
|
ccConfig: {
|
||||||
|
/**
|
||||||
|
* GET /api/cc-config/target - whose Claude Code config this login manages
|
||||||
|
* here: `own` (server mode, their own account's), `host` (an admin, local
|
||||||
|
* mode, or an analyst's read-only view), `none` (no account: the page is
|
||||||
|
* not theirs).
|
||||||
|
*/
|
||||||
|
target: () => request<CcTarget>("/cc-config/target"),
|
||||||
/**
|
/**
|
||||||
* GET /api/cc-config/overview - counts of every artifact kind, for the
|
* GET /api/cc-config/overview - counts of every artifact kind, for the
|
||||||
* explorer's landing page.
|
* explorer's landing page.
|
||||||
@@ -3274,6 +3281,12 @@ export const api = {
|
|||||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||||
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||||
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
||||||
|
/** GET /api/ai-proxies/9router/update - versions, whether it runs, why an update would be refused, the job. */
|
||||||
|
nineRouterUpdateStatus: (fresh = false) =>
|
||||||
|
request<NineRouterUpdateStatus>(`/ai-proxies/9router/update${fresh ? "?fresh=1" : ""}`),
|
||||||
|
/** POST /api/ai-proxies/9router/update - shut 9router down, update it, start it again hidden (admin). */
|
||||||
|
nineRouterUpdate: () =>
|
||||||
|
request<NineRouterUpdateJob>("/ai-proxies/9router/update", { method: "POST" }),
|
||||||
/** POST /api/ai-proxies - save a new profile. */
|
/** POST /api/ai-proxies - save a new profile. */
|
||||||
create: (body: AiProxyInput) =>
|
create: (body: AiProxyInput) =>
|
||||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||||
@@ -3790,7 +3803,7 @@ export const api = {
|
|||||||
* GET /api/lanes — list all lanes with pipeline progress and liveness.
|
* GET /api/lanes — list all lanes with pipeline progress and liveness.
|
||||||
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
|
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
|
||||||
*/
|
*/
|
||||||
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
|
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/workspaces"),
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
|
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
|
||||||
* @param body The cwd and optional title. When `cwd` is omitted the server
|
* @param body The cwd and optional title. When `cwd` is omitted the server
|
||||||
@@ -3806,7 +3819,7 @@ export const api = {
|
|||||||
/** Only a lane on THIS directory counts, not one on a parent of it. */
|
/** Only a lane on THIS directory counts, not one on a parent of it. */
|
||||||
exact?: boolean;
|
exact?: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
|
request<{ lane: Lane; created: boolean }>("/workspaces/ensure", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -3829,7 +3842,7 @@ export const api = {
|
|||||||
/** This clone's name for {@link lanes.cloneProgress}. */
|
/** This clone's name for {@link lanes.cloneProgress}. */
|
||||||
progressToken?: string;
|
progressToken?: string;
|
||||||
}) =>
|
}) =>
|
||||||
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -3839,7 +3852,7 @@ export const api = {
|
|||||||
* carrying `progressToken` arrives, and a minute after it ends.
|
* carrying `progressToken` arrives, and a minute after it ends.
|
||||||
*/
|
*/
|
||||||
cloneProgress: (token: string) =>
|
cloneProgress: (token: string) =>
|
||||||
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
|
request<CloneProgressView>(`/workspaces/clone/progress/${encodeURIComponent(token)}`),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/pipelines — every pipeline template the server can render
|
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||||
@@ -3847,7 +3860,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
pipelines: () =>
|
pipelines: () =>
|
||||||
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
|
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
|
||||||
"/lanes/pipelines"
|
"/workspaces/pipelines"
|
||||||
),
|
),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
|
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
|
||||||
@@ -3857,7 +3870,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
pipelineStats: (laneId?: number | null) =>
|
pipelineStats: (laneId?: number | null) =>
|
||||||
request<PipelineStats>(
|
request<PipelineStats>(
|
||||||
`/lanes/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
|
`/workspaces/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
|
||||||
),
|
),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/branches — a candidate source repo's local branches.
|
* GET /api/lanes/branches — a candidate source repo's local branches.
|
||||||
@@ -3869,7 +3882,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
branches: (repo: string) =>
|
branches: (repo: string) =>
|
||||||
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
|
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
|
||||||
`/lanes/branches?repo=${encodeURIComponent(repo)}`
|
`/workspaces/branches?repo=${encodeURIComponent(repo)}`
|
||||||
),
|
),
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as
|
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as
|
||||||
@@ -3887,7 +3900,7 @@ export const api = {
|
|||||||
pipeline?: string;
|
pipeline?: string;
|
||||||
tool?: LaneTool;
|
tool?: LaneTool;
|
||||||
}) =>
|
}) =>
|
||||||
request<{ lane: Lane }>("/lanes/worktree", {
|
request<{ lane: Lane }>("/workspaces/worktree", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -3922,7 +3935,7 @@ export const api = {
|
|||||||
file?: boolean;
|
file?: boolean;
|
||||||
mtime?: number | null;
|
mtime?: number | null;
|
||||||
}[];
|
}[];
|
||||||
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
}>(`/workspaces/browse${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
||||||
@@ -3930,7 +3943,7 @@ export const api = {
|
|||||||
* `EFOLDER` (outside the caller's own folder).
|
* `EFOLDER` (outside the caller's own folder).
|
||||||
*/
|
*/
|
||||||
mkdir: (parent: string, name: string) =>
|
mkdir: (parent: string, name: string) =>
|
||||||
request<{ path: string }>("/lanes/browse/mkdir", {
|
request<{ path: string }>("/workspaces/browse/mkdir", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ parent, name }),
|
body: JSON.stringify({ parent, name }),
|
||||||
}),
|
}),
|
||||||
@@ -3944,7 +3957,7 @@ export const api = {
|
|||||||
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
|
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
|
||||||
*/
|
*/
|
||||||
pickPath: (kind: "dir" | "file", start?: string) =>
|
pickPath: (kind: "dir" | "file", start?: string) =>
|
||||||
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
|
request<{ path?: string; cancelled?: boolean }>("/workspaces/pick-path", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ kind, start }),
|
body: JSON.stringify({ kind, start }),
|
||||||
}),
|
}),
|
||||||
@@ -3953,7 +3966,7 @@ export const api = {
|
|||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
* @returns `{ lane }` — the {@link Lane} record.
|
* @returns `{ lane }` — the {@link Lane} record.
|
||||||
*/
|
*/
|
||||||
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`),
|
get: (id: number) => request<{ lane: Lane }>(`/workspaces/${id}`),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/:id/git — the lane's working-copy facts.
|
* GET /api/lanes/:id/git — the lane's working-copy facts.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4087,7 +4100,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
removeSessionWorktree: (id: number, session: string) =>
|
removeSessionWorktree: (id: number, session: string) =>
|
||||||
request<{ removed: true; branch: string | null; branchKept: boolean }>(
|
request<{ removed: true; branch: string | null; branchKept: boolean }>(
|
||||||
`/lanes/${id}/session-worktree/remove`,
|
`/workspaces/${id}/session-worktree/remove`,
|
||||||
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
|
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
|
||||||
),
|
),
|
||||||
gitBranches: (id: GitTarget) =>
|
gitBranches: (id: GitTarget) =>
|
||||||
@@ -4181,7 +4194,7 @@ export const api = {
|
|||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
* @returns {@link LaneRuntime}
|
* @returns {@link LaneRuntime}
|
||||||
*/
|
*/
|
||||||
runtime: (id: number) => request<LaneRuntime>(`/lanes/${id}/runtime`),
|
runtime: (id: number) => request<LaneRuntime>(`/workspaces/${id}/runtime`),
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/:id/up — boot the lane's stack through its profile hooks.
|
* POST /api/lanes/:id/up — boot the lane's stack through its profile hooks.
|
||||||
* Returns 202: the boot runs in the background and streams progress as
|
* Returns 202: the boot runs in the background and streams progress as
|
||||||
@@ -4190,7 +4203,7 @@ export const api = {
|
|||||||
* @param body `build: false` reuses an existing build.
|
* @param body `build: false` reuses an existing build.
|
||||||
*/
|
*/
|
||||||
up: (id: number, body: { build?: boolean } = {}) =>
|
up: (id: number, body: { build?: boolean } = {}) =>
|
||||||
request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, {
|
request<{ ok: true; laneId: number }>(`/workspaces/${id}/up`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -4201,7 +4214,7 @@ export const api = {
|
|||||||
* @returns The pids stopped and the resulting runtime facts.
|
* @returns The pids stopped and the resulting runtime facts.
|
||||||
*/
|
*/
|
||||||
down: (id: number) =>
|
down: (id: number) =>
|
||||||
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/lanes/${id}/down`, {
|
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/workspaces/${id}/down`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
}),
|
}),
|
||||||
@@ -4211,7 +4224,7 @@ export const api = {
|
|||||||
* @returns `{ lane }` — the created {@link Lane}.
|
* @returns `{ lane }` — the created {@link Lane}.
|
||||||
*/
|
*/
|
||||||
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
|
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
|
||||||
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }),
|
request<{ lane: Lane }>("/workspaces", { method: "POST", body: JSON.stringify(body) }),
|
||||||
/**
|
/**
|
||||||
* PATCH /api/lanes/:id — partial update of a lane.
|
* PATCH /api/lanes/:id — partial update of a lane.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4220,17 +4233,17 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
||||||
* folders and context files, and whether its tool can take them. */
|
* folders and context files, and whether its tool can take them. */
|
||||||
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
|
sources: (id: number) => request<LaneSourcesView>(`/workspaces/${id}/sources`),
|
||||||
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
|
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
|
||||||
* (a parent folder of several repos), each a Changes section read with
|
* (a parent folder of several repos), each a Changes section read with
|
||||||
* `?repo=<path>`; `root` = the folder itself is in a repo. */
|
* `?repo=<path>`; `root` = the folder itself is in a repo. */
|
||||||
repos: (id: number, refresh = false) =>
|
repos: (id: number, refresh = false) =>
|
||||||
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
|
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
|
||||||
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}`
|
`/workspaces/${id}/repos${refresh ? "?refresh=1" : ""}`
|
||||||
),
|
),
|
||||||
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
|
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
|
||||||
setInstructions: (id: number, instructions: string | null) =>
|
setInstructions: (id: number, instructions: string | null) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
|
request<LaneSourcesView>(`/workspaces/${id}/instructions`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ instructions }),
|
body: JSON.stringify({ instructions }),
|
||||||
}),
|
}),
|
||||||
@@ -4248,19 +4261,19 @@ export const api = {
|
|||||||
progressToken?: string;
|
progressToken?: string;
|
||||||
}
|
}
|
||||||
) =>
|
) =>
|
||||||
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
||||||
addFolder: (id: number, path: string) =>
|
addFolder: (id: number, path: string) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
|
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
}),
|
}),
|
||||||
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
|
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
|
||||||
addFile: (id: number, file: File) =>
|
addFile: (id: number, file: File) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
|
request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: file,
|
body: file,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -4271,11 +4284,11 @@ export const api = {
|
|||||||
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
||||||
* uploaded file's AgentHub copy is deleted. */
|
* uploaded file's AgentHub copy is deleted. */
|
||||||
removeSource: (id: number, sourceId: number) =>
|
removeSource: (id: number, sourceId: number) =>
|
||||||
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
||||||
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
|
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
|
||||||
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
|
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
|
||||||
update: (id: number, patch: Partial<Lane>) =>
|
update: (id: number, patch: Partial<Lane>) =>
|
||||||
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
request<{ lane: Lane }>(`/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/:id/stage — advance the pipeline stage.
|
* POST /api/lanes/:id/stage — advance the pipeline stage.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4286,14 +4299,14 @@ export const api = {
|
|||||||
id: number,
|
id: number,
|
||||||
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
|
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
|
||||||
) =>
|
) =>
|
||||||
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
|
request<{ lane: Lane }>(`/workspaces/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
|
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
|
||||||
* action-specific facts that a destructive request must echo as `expect`.
|
* action-specific facts that a destructive request must echo as `expect`.
|
||||||
*/
|
*/
|
||||||
preflight: (id: number, action: "reset" | "remove" | "purge") =>
|
preflight: (id: number, action: "reset" | "remove" | "purge") =>
|
||||||
request<import("./types").LanePreflight>(
|
request<import("./types").LanePreflight>(
|
||||||
`/lanes/${id}/preflight?${new URLSearchParams({ action }).toString()}`
|
`/workspaces/${id}/preflight?${new URLSearchParams({ action }).toString()}`
|
||||||
),
|
),
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
|
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
|
||||||
@@ -4305,7 +4318,7 @@ export const api = {
|
|||||||
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
||||||
// `run_id` answers a `start`: the session it opened, which for a split
|
// `run_id` answers a `start`: the session it opened, which for a split
|
||||||
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
|
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
|
||||||
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
|
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/workspaces/${id}/${action}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
@@ -4316,7 +4329,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
features: {
|
features: {
|
||||||
list: (id: number) =>
|
list: (id: number) =>
|
||||||
request<{ features: import("./types").LaneFeature[] }>(`/lanes/${id}/features`),
|
request<{ features: import("./types").LaneFeature[] }>(`/workspaces/${id}/features`),
|
||||||
/**
|
/**
|
||||||
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
|
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4325,7 +4338,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
show: (id: number, slug: string) =>
|
show: (id: number, slug: string) =>
|
||||||
request<{ feature: import("./types").LaneFeature }>(
|
request<{ feature: import("./types").LaneFeature }>(
|
||||||
`/lanes/${id}/features/${encodeURIComponent(slug)}`
|
`/workspaces/${id}/features/${encodeURIComponent(slug)}`
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -4335,7 +4348,7 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
proof: {
|
proof: {
|
||||||
list: (id: number) =>
|
list: (id: number) =>
|
||||||
request<{ features: import("./types").ProofFeature[] }>(`/lanes/${id}/proof`),
|
request<{ features: import("./types").ProofFeature[] }>(`/workspaces/${id}/proof`),
|
||||||
/**
|
/**
|
||||||
* URL for a proof image, ready to use in an <img src>.
|
* URL for a proof image, ready to use in an <img src>.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4345,7 +4358,7 @@ export const api = {
|
|||||||
* @returns A URL string pointing to the image.
|
* @returns A URL string pointing to the image.
|
||||||
*/
|
*/
|
||||||
imageUrl: (id: number, slug: string, group: string, file: string): string =>
|
imageUrl: (id: number, slug: string, group: string, file: string): string =>
|
||||||
`/api/lanes/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
|
`/api/workspaces/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
|
||||||
/**
|
/**
|
||||||
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
|
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
|
||||||
* @param id The lane id.
|
* @param id The lane id.
|
||||||
@@ -4354,19 +4367,19 @@ export const api = {
|
|||||||
* @returns `{ deleted }` — count of deleted images.
|
* @returns `{ deleted }` — count of deleted images.
|
||||||
*/
|
*/
|
||||||
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
|
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
|
||||||
request<{ deleted: number }>(`/lanes/${id}/proof/${encodeURIComponent(slug)}`, {
|
request<{ deleted: number }>(`/workspaces/${id}/proof/${encodeURIComponent(slug)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
mcpSync: (id: number) =>
|
mcpSync: (id: number) =>
|
||||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
|
request<{ servers: string[]; profilesSeeded: string[] }>(`/workspaces/${id}/mcp/sync`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: "{}",
|
body: "{}",
|
||||||
}),
|
}),
|
||||||
profileInit: (id: number, force = false) =>
|
profileInit: (id: number, force = false) =>
|
||||||
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
|
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
|
||||||
`/lanes/${id}/profile/init`,
|
`/workspaces/${id}/profile/init`,
|
||||||
{ method: "POST", body: JSON.stringify({ force }) }
|
{ method: "POST", body: JSON.stringify({ force }) }
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -4627,6 +4640,13 @@ export interface CcProfileUsage {
|
|||||||
* user + project scope in one response. */
|
* user + project scope in one response. */
|
||||||
export type CcScope = "user" | "project" | "all";
|
export type CcScope = "user" | "project" | "all";
|
||||||
|
|
||||||
|
/** GET /api/cc-config/target — whose Claude Code config the page manages. */
|
||||||
|
export interface CcTarget {
|
||||||
|
kind: "own" | "host" | "none";
|
||||||
|
readOnly: boolean;
|
||||||
|
claudeHome: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
|
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
|
||||||
* var Claude Code reads; a proxy that names its models once fills them all
|
* var Claude Code reads; a proxy that names its models once fills them all
|
||||||
* from the profile's `model`. */
|
* from the profile's `model`. */
|
||||||
@@ -4822,6 +4842,38 @@ export interface AiProxy {
|
|||||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||||
* is its HTTP code (null when nothing answered at all), and `message` is its
|
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||||
* own words — truncated server-side, never the key. */
|
* own words — truncated server-side, never the key. */
|
||||||
|
/** One step of the 9router update job, in order: shutdown → update → relaunch. */
|
||||||
|
export interface NineRouterUpdateStep {
|
||||||
|
id: "shutdown" | "update" | "relaunch";
|
||||||
|
status: "pending" | "running" | "done" | "failed" | "skipped";
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's one 9router update job (in memory; null before the first). */
|
||||||
|
export interface NineRouterUpdateJob {
|
||||||
|
status: "running" | "done" | "failed";
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
managedBy: "systemd" | "process" | null;
|
||||||
|
install: "direct" | "sudo";
|
||||||
|
steps: NineRouterUpdateStep[];
|
||||||
|
log: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/ai-proxies/9router/update. `blocker` is why a start would be refused now. */
|
||||||
|
export interface NineRouterUpdateStatus {
|
||||||
|
installed: boolean;
|
||||||
|
version: string | null;
|
||||||
|
latest: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
running: boolean;
|
||||||
|
managedBy: "systemd" | "process" | null;
|
||||||
|
blocker: { code: string; message: string; command?: string } | null;
|
||||||
|
job: NineRouterUpdateJob | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiProxyProbe {
|
export interface AiProxyProbe {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status: number | null;
|
status: number | null;
|
||||||
@@ -5207,8 +5259,9 @@ export interface CcOverview {
|
|||||||
/** Key filesystem locations the explorer reads from. */
|
/** Key filesystem locations the explorer reads from. */
|
||||||
roots: {
|
roots: {
|
||||||
claudeHome: string;
|
claudeHome: string;
|
||||||
projectClaudeDir: string;
|
/** Null for a member's own config with no workspace named: no project layer. */
|
||||||
projectRoot: string;
|
projectClaudeDir: string | null;
|
||||||
|
projectRoot: string | null;
|
||||||
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
|
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
|
||||||
claudeJson: string;
|
claudeJson: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,13 +43,40 @@ export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
|
|||||||
"image",
|
"image",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** An edit worth a card in the conversation: a page, a document, a diagram, a
|
/** Folders an app keeps its own pages in: an HTML file under one of them is
|
||||||
* table — and a React component the model wrote whole (a `.tsx` it merely
|
* the project's code, never a page the model made to show. */
|
||||||
* edited is project code, not an artifact). */
|
const PROJECT_DIRS = new Set([
|
||||||
|
"src",
|
||||||
|
"public",
|
||||||
|
"templates",
|
||||||
|
"views",
|
||||||
|
"pages",
|
||||||
|
"app",
|
||||||
|
"static",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"components",
|
||||||
|
"layouts",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** An HTML file the project serves itself: an `index.html`, or one under a
|
||||||
|
* folder in {@link PROJECT_DIRS}. */
|
||||||
|
function isProjectPage(path: string): boolean {
|
||||||
|
const parts = path.split(/[\\/]/);
|
||||||
|
const name = (parts.pop() || "").toLowerCase();
|
||||||
|
if (name === "index.html" || name === "index.htm") return true;
|
||||||
|
return parts.some((d) => PROJECT_DIRS.has(d.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An edit worth a card in the conversation: a document, a diagram, a table —
|
||||||
|
* and a page or React component the model wrote whole (a `.tsx` or `.html` it
|
||||||
|
* merely edited, and an HTML page of the project itself, are project code,
|
||||||
|
* not an artifact). */
|
||||||
export function isArtifact(path: string, created: boolean): boolean {
|
export function isArtifact(path: string, created: boolean): boolean {
|
||||||
const k = artifactKind(path);
|
const k = artifactKind(path);
|
||||||
if (k === "react") return created;
|
if (k === "react") return created;
|
||||||
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
if (k === "html") return created && !isProjectPage(path);
|
||||||
|
return k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A code fence language for the Code tab. */
|
/** A code fence language for the Code tab. */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*
|
*
|
||||||
* The order lives in the browser rather than the lanes table on purpose: it is
|
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||||
* a per-viewer preference, and nothing
|
* a per-viewer preference, and nothing
|
||||||
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
|
* server-side (the CLI's `agenthub workspaces`, the pipeline-stats picker) has an
|
||||||
* opinion about it. Ceiling: the order does not follow the user to another
|
* opinion about it. Ceiling: the order does not follow the user to another
|
||||||
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||||
* must.
|
* must.
|
||||||
|
|||||||
@@ -2633,7 +2633,7 @@ export interface LaneNode {
|
|||||||
icon: string;
|
icon: string;
|
||||||
gate: boolean;
|
gate: boolean;
|
||||||
/** The one command that drives this node — a slash command
|
/** The one command that drives this node — a slash command
|
||||||
* (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook ci-gate`),
|
* (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook ci-gate`),
|
||||||
* from the pipeline template. Empty string for a node with no single driver
|
* from the pipeline template. Empty string for a node with no single driver
|
||||||
* (`done`, or a gate another agent declares). Display text only; the
|
* (`done`, or a gate another agent declares). Display text only; the
|
||||||
* dashboard never runs it. Optional in the type only because the archived
|
* dashboard never runs it. Optional in the type only because the archived
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file main.tsx
|
* @file main.tsx
|
||||||
* @description Vite client entry point. Bootstraps React 18, loads global styles,
|
* @description Vite client entry point. Bootstraps React 19, loads global styles,
|
||||||
* registers the service worker, prefetches the Workspace chunk on a `/workspace`
|
* registers the service worker, prefetches the Workspace chunk on a `/workspace`
|
||||||
* deep link, and mounts {@link App} into `#root` once the active locale is loaded.
|
* deep link, and mounts {@link App} into `#root` once the active locale is loaded.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||||
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||||
* It is shown only when the server finds a `9router` binary installed.
|
* It is shown only when the server finds a `9router` binary installed.
|
||||||
|
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||||
|
* down, `npm install -g 9router@latest`, start it again hidden.
|
||||||
*
|
*
|
||||||
* **Test** asks the endpoint itself, once as Claude Code would
|
* **Test** asks the endpoint itself, once as Claude Code would
|
||||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||||
@@ -54,6 +56,7 @@ import { confirmDialog } from "../lib/confirm";
|
|||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import { useAuth } from "../components/AuthGate";
|
import { useAuth } from "../components/AuthGate";
|
||||||
|
import { NineRouterUpdate } from "../components/settings/NineRouterUpdate";
|
||||||
|
|
||||||
const NINE_ROUTER_PORT = 20128;
|
const NINE_ROUTER_PORT = 20128;
|
||||||
|
|
||||||
@@ -271,6 +274,7 @@ export function AiProxyConfig() {
|
|||||||
{t("aiProxy.config9router")}
|
{t("aiProxy.config9router")}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{nineRouter && isAdmin && <NineRouterUpdate />}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startAdd}
|
onClick={startAdd}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
* files, and the `MdItemList` artifacts) also opens a
|
* files, and the `MdItemList` artifacts) also opens a
|
||||||
* right-click menu that repeats those buttons — same handlers, same
|
* right-click menu that repeats those buttons — same handlers, same
|
||||||
* confirm path — plus Copy name/path.
|
* confirm path — plus Copy name/path.
|
||||||
|
* Server mode, a non-admin with their own Linux account (S3): the page is
|
||||||
|
* THEIR config (`useCcTarget`, labelled as such, no project layer unless a
|
||||||
|
* workspace is named), and the host-wide controls — Switch profile, Update
|
||||||
|
* all, AgentHub's own Claude settings — are an admin's and hidden (the server
|
||||||
|
* refuses them anyway).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -126,6 +131,8 @@ import { ScopeBadge } from "../components/config/ScopeBadge";
|
|||||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||||
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
|
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
|
import { useCcTarget } from "../hooks/useCcTarget";
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
SkeletonRows,
|
SkeletonRows,
|
||||||
@@ -516,8 +523,14 @@ export function CcConfig() {
|
|||||||
|
|
||||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||||||
const projectRoot = data.overview?.roots.projectRoot ?? null;
|
const projectRoot = data.overview?.roots.projectRoot ?? null;
|
||||||
// Project files under the plugin cache are wiped by the next plugin update.
|
// Server mode: whose config this is. The host-wide controls are an admin's.
|
||||||
const projectDisabled = isPluginCachePath(projectRoot);
|
const { isAdmin } = usePermissions();
|
||||||
|
const ccTarget = useCcTarget();
|
||||||
|
const ownHome = ccTarget?.kind === "own" ? ccTarget.claudeHome : null;
|
||||||
|
// Project files under the plugin cache are wiped by the next plugin update;
|
||||||
|
// a member's own config has no project layer at all.
|
||||||
|
const projectDisabled =
|
||||||
|
isPluginCachePath(projectRoot) || (!!data.overview && projectRoot === null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectDisabled && scope === "project") setScope("all");
|
if (projectDisabled && scope === "project") setScope("all");
|
||||||
}, [projectDisabled, scope]);
|
}, [projectDisabled, scope]);
|
||||||
@@ -681,6 +694,8 @@ export function CcConfig() {
|
|||||||
}}
|
}}
|
||||||
onOpenBackups={() => setBackupsOpen(true)}
|
onOpenBackups={() => setBackupsOpen(true)}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
|
showProfiles={isAdmin}
|
||||||
|
ownHome={ownHome}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
|
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
|
||||||
@@ -777,7 +792,7 @@ export function CcConfig() {
|
|||||||
|
|
||||||
{/* AgentHub's own Claude Code settings follow the overview, outside the
|
{/* AgentHub's own Claude Code settings follow the overview, outside the
|
||||||
list's search box and scope. */}
|
list's search box and scope. */}
|
||||||
{tab === "overview" && (
|
{tab === "overview" && isAdmin && (
|
||||||
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
|
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -820,10 +835,22 @@ interface HeaderProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onOpenBackups: () => void;
|
onOpenBackups: () => void;
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
|
/** The host's login profiles link: an admin's (and local mode's). */
|
||||||
|
showProfiles?: boolean;
|
||||||
|
/** A member's own Claude home (server mode, S3): the page says whose it is. */
|
||||||
|
ownHome?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
|
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
|
||||||
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
|
function Header({
|
||||||
|
loading,
|
||||||
|
lastUpdated,
|
||||||
|
onRefresh,
|
||||||
|
onOpenBackups,
|
||||||
|
wsConnected,
|
||||||
|
showProfiles = true,
|
||||||
|
ownHome = null,
|
||||||
|
}: HeaderProps) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
const settingsHref = useSettingsHref();
|
const settingsHref = useSettingsHref();
|
||||||
const { t: tCommon } = useTranslation("common");
|
const { t: tCommon } = useTranslation("common");
|
||||||
@@ -848,18 +875,30 @@ function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }:
|
|||||||
{tCommon("offline")}
|
{tCommon("offline")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
|
{ownHome ? (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium text-fg-primary" data-testid="cc-own-config">
|
||||||
|
{t("own.label", { home: ownHome })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-fg-muted max-w-2xl">{t("own.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 justify-end flex-wrap">
|
<div className="flex items-center gap-2 justify-end flex-wrap">
|
||||||
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
|
{/* Every AI tool's login profiles live in one place: Settings → AI tools.
|
||||||
<Link
|
The host's logins: an admin's. */}
|
||||||
to={settingsHref("agents#tool-profiles")}
|
{showProfiles && (
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
<Link
|
||||||
>
|
to={settingsHref("agents#tool-profiles")}
|
||||||
<UserCog className="w-3.5 h-3.5" />
|
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||||
{t("profiles.openButton")}
|
>
|
||||||
</Link>
|
<UserCog className="w-3.5 h-3.5" />
|
||||||
|
{t("profiles.openButton")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenBackups}
|
onClick={onOpenBackups}
|
||||||
@@ -1290,18 +1329,31 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
|
|||||||
{t("overview.rootsTitle")}
|
{t("overview.rootsTitle")}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<RootRow
|
{/* A member's own config has no project layer: their Claude home instead. */}
|
||||||
icon={FolderTree}
|
{roots.projectRoot === null && (
|
||||||
tone="emerald"
|
<RootRow
|
||||||
label={t("overview.projectClaudeDir")}
|
icon={FolderTree}
|
||||||
value={roots.projectClaudeDir}
|
tone="emerald"
|
||||||
/>
|
label={t("overview.claudeHome")}
|
||||||
<RootRow
|
value={roots.claudeHome}
|
||||||
icon={FolderTree}
|
/>
|
||||||
tone="violet"
|
)}
|
||||||
label={t("overview.projectRoot")}
|
{roots.projectClaudeDir !== null && (
|
||||||
value={roots.projectRoot}
|
<RootRow
|
||||||
/>
|
icon={FolderTree}
|
||||||
|
tone="emerald"
|
||||||
|
label={t("overview.projectClaudeDir")}
|
||||||
|
value={roots.projectClaudeDir}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{roots.projectRoot !== null && (
|
||||||
|
<RootRow
|
||||||
|
icon={FolderTree}
|
||||||
|
tone="violet"
|
||||||
|
label={t("overview.projectRoot")}
|
||||||
|
value={roots.projectRoot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<RootRow
|
<RootRow
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
tone="amber"
|
tone="amber"
|
||||||
@@ -1718,6 +1770,7 @@ function PluginsPanel({
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const { busy, run } = useConfigAction(onToast, onChanged);
|
const { busy, run } = useConfigAction(onToast, onChanged);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
|
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
|
||||||
@@ -1785,7 +1838,8 @@ function PluginsPanel({
|
|||||||
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
|
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UpdateAllCard onToast={onToast} onChanged={onChanged} />
|
{/* Update all runs on the host's plugins (AgentHub's own included): an admin's. */}
|
||||||
|
{isAdmin && <UpdateAllCard onToast={onToast} onChanged={onChanged} />}
|
||||||
|
|
||||||
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
|
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
|
||||||
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
|
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
|
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
|
||||||
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
|
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
|
||||||
* while the tab is hidden behind a session.
|
* while the tab is hidden behind a session.
|
||||||
|
* Server mode hides every tool page from a non-admin, except Claude Code for a
|
||||||
|
* non-admin with their own Linux account (`useCcTarget`): "Your Claude Code
|
||||||
|
* config", which they manage as themselves.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -75,6 +78,7 @@ import { PricingSettings } from "../components/settings/PricingSettings";
|
|||||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||||
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
|
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
|
import { useCcTarget } from "../hooks/useCcTarget";
|
||||||
import {
|
import {
|
||||||
rankSettings,
|
rankSettings,
|
||||||
rankSettingsEntries,
|
rankSettingsEntries,
|
||||||
@@ -248,10 +252,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
};
|
};
|
||||||
}, [location.key, location.hash]);
|
}, [location.key, location.hash]);
|
||||||
|
|
||||||
const { isAdmin, local } = usePermissions();
|
const { isAdmin, local, seesAll } = usePermissions();
|
||||||
const authState = useAuth().state;
|
const authState = useAuth().state;
|
||||||
const signedIn = authState.mode === "server" && !!authState.user;
|
const signedIn = authState.mode === "server" && !!authState.user;
|
||||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||||
|
// Server mode: a non-admin with their own Linux account (S3) manages their
|
||||||
|
// OWN Claude Code config here; the host's stays an admin's.
|
||||||
|
const ccTarget = useCcTarget();
|
||||||
|
const ownClaude = !isAdmin && ccTarget?.kind === "own";
|
||||||
const s = (key: string) => ({
|
const s = (key: string) => ({
|
||||||
title: t(`settingsNav.section.${key}.title`),
|
title: t(`settingsNav.section.${key}.title`),
|
||||||
description: t(`settingsNav.section.${key}.description`),
|
description: t(`settingsNav.section.${key}.description`),
|
||||||
@@ -299,8 +307,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
<AgentInstructionsSettings />
|
<AgentInstructionsSettings />
|
||||||
<SessionDefaultsSettings />
|
<SessionDefaultsSettings />
|
||||||
<AiToolsSettings settingsHref={toolHref} />
|
<AiToolsSettings settingsHref={toolHref} />
|
||||||
{/* Every AI tool's login profiles, linked to the account pool. */}
|
{/* Every AI tool's login profiles, linked to the account pool: the
|
||||||
<ToolProfilesSettings />
|
host's logins, a staff-only read (a developer would get 403s). */}
|
||||||
|
{seesAll && <ToolProfilesSettings />}
|
||||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||||
<ArtifactsHubSettings />
|
<ArtifactsHubSettings />
|
||||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||||
@@ -315,7 +324,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
group: "agents" as const,
|
group: "agents" as const,
|
||||||
tool: "claude",
|
tool: "claude",
|
||||||
Icon: toolNavIcon("claude", "Claude Code"),
|
Icon: toolNavIcon("claude", "Claude Code"),
|
||||||
...s("claudeCode"),
|
...s(ownClaude ? "claudeCodeOwn" : "claudeCode"),
|
||||||
render: () => <CcConfig />,
|
render: () => <CcConfig />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -439,7 +448,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
|
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
|
||||||
const sections = isAdmin
|
const sections = isAdmin
|
||||||
? allSections
|
? allSections
|
||||||
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
|
: allSections.filter(
|
||||||
|
(x) =>
|
||||||
|
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
|
||||||
|
);
|
||||||
const navSections = sections.filter((x) => !x.tool);
|
const navSections = sections.filter((x) => !x.tool);
|
||||||
const toolTabs = sections.filter((x) => x.tool);
|
const toolTabs = sections.filter((x) => x.tool);
|
||||||
function toolHref(id: string) {
|
function toolHref(id: string) {
|
||||||
|
|||||||
@@ -1251,7 +1251,7 @@ export function Workspace() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
// Mirrors `agenthub workspace pipeline <template> <id>`: same PATCH, same
|
||||||
// stage-mismatch warning when the current declared stage matches no node
|
// stage-mismatch warning when the current declared stage matches no node
|
||||||
// in the newly chosen template.
|
// in the newly chosen template.
|
||||||
const handlePipelineChange = async (id: number, pipeline: string) => {
|
const handlePipelineChange = async (id: number, pipeline: string) => {
|
||||||
@@ -1420,7 +1420,7 @@ export function Workspace() {
|
|||||||
<div data-testid="proof-gallery" className="mt-2">
|
<div data-testid="proof-gallery" className="mt-2">
|
||||||
{proofFeature.ticket_report && (
|
{proofFeature.ticket_report && (
|
||||||
<a
|
<a
|
||||||
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-xs text-fg-muted"
|
className="text-xs text-fg-muted"
|
||||||
|
|||||||
@@ -238,12 +238,17 @@ describe("Artifacts", () => {
|
|||||||
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
|
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
await user.keyboard("{Escape}");
|
await user.keyboard("{Escape}");
|
||||||
// One merged list by default; Hub is a filter over it.
|
// One merged list by default; Hub is a filter over it. The Suggested
|
||||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
|
// count's own `unowned` list runs whenever settings land, so it is left
|
||||||
|
// out: only the order of the LIST loads is under test.
|
||||||
|
const listScopes = () =>
|
||||||
|
vi
|
||||||
|
.mocked(api.artifacts.list)
|
||||||
|
.mock.calls.map(([opts]) => opts?.scope)
|
||||||
|
.filter((s) => s !== "unowned");
|
||||||
|
expect(listScopes().pop()).toBe("all");
|
||||||
await user.click(screen.getByRole("button", { name: "Hub" }));
|
await user.click(screen.getByRole("button", { name: "Hub" }));
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(listScopes().pop()).toBe("hub"));
|
||||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("splits the Hub and My library into yours and others'", async () => {
|
it("splits the Hub and My library into yours and others'", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* @file Covers the Claude Code config page as a MEMBER's own config (server
|
||||||
|
* mode, a non-admin with their own Linux account): it says whose config it is,
|
||||||
|
* and the host-wide controls — Switch profile, Update all, AgentHub's own
|
||||||
|
* Claude settings — are not shown (the server refuses them anyway). An admin
|
||||||
|
* still sees all three.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
const perms = vi.hoisted(() => ({ isAdmin: false }));
|
||||||
|
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
||||||
|
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
usePermissions: () => ({ ...m.usePermissions(), local: false, isAdmin: perms.isAdmin }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../../components/settings/ClaudeAgentHubSettings", () => ({
|
||||||
|
ClaudeAgentHubSettings: () => <div data-testid="agenthub-claude-settings" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const HOME = "/srv/users/mia/.claude";
|
||||||
|
const ccConfigMock = vi.hoisted(() => {
|
||||||
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||||
|
return {
|
||||||
|
target: r({ kind: "own", readOnly: false, claudeHome: "/srv/users/mia/.claude" }),
|
||||||
|
overview: r({
|
||||||
|
roots: {
|
||||||
|
claudeHome: "/srv/users/mia/.claude",
|
||||||
|
projectClaudeDir: null,
|
||||||
|
projectRoot: null,
|
||||||
|
claudeJson: "/srv/users/mia/.claude.json",
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
skills: { user: 0, project: 0 },
|
||||||
|
agents: { user: 0, project: 0 },
|
||||||
|
commands: { user: 0, project: 0 },
|
||||||
|
outputStyles: { user: 0, project: 0 },
|
||||||
|
mcpServers: { user: 0, project: 0 },
|
||||||
|
hooks: {},
|
||||||
|
plugins: 0,
|
||||||
|
marketplaces: 0,
|
||||||
|
keybindings: 0,
|
||||||
|
settingsFiles: 0,
|
||||||
|
memory: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
skills: r({ items: [] }),
|
||||||
|
agents: r({ items: [] }),
|
||||||
|
commands: r({ items: [] }),
|
||||||
|
outputStyles: r({ items: [] }),
|
||||||
|
memory: r({ items: [] }),
|
||||||
|
plugins: r({ manifestPath: null, manifestExists: false, plugins: [] }),
|
||||||
|
marketplaces: r({ items: [] }),
|
||||||
|
mcp: r({ user: [], projectScoped: [] }),
|
||||||
|
hooks: r({ items: [] }),
|
||||||
|
settings: r({ items: [] }),
|
||||||
|
keybindings: r({ groups: [] }),
|
||||||
|
statusline: r({}),
|
||||||
|
hookScripts: r({ items: [] }),
|
||||||
|
backups: r({ items: [] }),
|
||||||
|
pluginsAvailable: r({ installed: [], available: [] }),
|
||||||
|
updateAllStatus: r({ batch: null }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/eventBus", () => ({
|
||||||
|
eventBus: {
|
||||||
|
subscribe: () => () => {},
|
||||||
|
publish: () => {},
|
||||||
|
onConnection: () => () => {},
|
||||||
|
connected: true,
|
||||||
|
setConnected: () => {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CcConfig } from "../CcConfig";
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CcConfig />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
perms.isAdmin = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CcConfig — a member's own config", () => {
|
||||||
|
it("says whose config it is and hides the host-wide controls", async () => {
|
||||||
|
await open();
|
||||||
|
expect(screen.getByTestId("cc-own-config")).toHaveTextContent(HOME);
|
||||||
|
expect(screen.queryByRole("link", { name: /switch profile/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("agenthub-claude-settings")).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||||
|
await settle();
|
||||||
|
expect(screen.queryByRole("button", { name: /^update all$/i })).not.toBeInTheDocument();
|
||||||
|
expect(ccConfigMock.updateAllStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps all three for an admin", async () => {
|
||||||
|
perms.isAdmin = true;
|
||||||
|
await open();
|
||||||
|
expect(screen.queryByTestId("cc-own-config")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: /switch profile/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("agenthub-claude-settings")).toBeInTheDocument();
|
||||||
|
expect(ccConfigMock.target).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,10 +21,15 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
|||||||
import type { AiTool, AiToolsView } from "../../lib/api";
|
import type { AiTool, AiToolsView } from "../../lib/api";
|
||||||
|
|
||||||
// Local mode unless a test says otherwise; still an admin either way.
|
// Local mode unless a test says otherwise; still an admin either way.
|
||||||
const perms = vi.hoisted(() => ({ local: true }));
|
const perms = vi.hoisted(() => ({ local: true, isAdmin: true }));
|
||||||
|
// What GET /api/cc-config/target answers a non-admin.
|
||||||
|
const ccTarget = vi.hoisted(() => ({ kind: "none" as "own" | "host" | "none" }));
|
||||||
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
||||||
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
||||||
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
return {
|
||||||
|
...m,
|
||||||
|
usePermissions: () => ({ ...m.usePermissions(), local: perms.local, isAdmin: perms.isAdmin }),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
vi.mock("../../components/settings/GitIdentitySettings", () => ({
|
vi.mock("../../components/settings/GitIdentitySettings", () => ({
|
||||||
GitIdentitySettings: () => <div data-testid="git-identity" />,
|
GitIdentitySettings: () => <div data-testid="git-identity" />,
|
||||||
@@ -88,7 +93,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||||
// AI tools → Profiles.
|
// AI tools → Profiles.
|
||||||
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
||||||
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
|
ccConfig: {
|
||||||
|
profiles: vi.fn(async () => ({ items: [], active: null })),
|
||||||
|
target: vi.fn(async () => ({
|
||||||
|
kind: ccTarget.kind,
|
||||||
|
readOnly: ccTarget.kind !== "own",
|
||||||
|
claudeHome: ccTarget.kind === "own" ? "/srv/users/mia/.claude" : null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -124,6 +136,8 @@ const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
perms.local = true;
|
perms.local = true;
|
||||||
|
perms.isAdmin = true;
|
||||||
|
ccTarget.kind = "none";
|
||||||
__resetAiTools();
|
__resetAiTools();
|
||||||
view = {
|
view = {
|
||||||
default: "claude",
|
default: "claude",
|
||||||
@@ -202,6 +216,29 @@ describe("SettingsHub", () => {
|
|||||||
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
|
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives a non-admin with their own account their own Claude Code page, and no other tool's", async () => {
|
||||||
|
perms.local = false;
|
||||||
|
perms.isAdmin = false;
|
||||||
|
ccTarget.kind = "own";
|
||||||
|
renderAt("/settings/agents/claude-code");
|
||||||
|
expect(await screen.findByTestId("cc-config")).toBeInTheDocument();
|
||||||
|
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
|
||||||
|
.getAllByRole("tab")
|
||||||
|
.map((a) => a.textContent);
|
||||||
|
expect(tabs).toEqual(["All tools", "Your Claude Code config"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Claude Code page from a non-admin with no account of their own", async () => {
|
||||||
|
perms.local = false;
|
||||||
|
perms.isAdmin = false;
|
||||||
|
ccTarget.kind = "none";
|
||||||
|
renderAt("/settings/agents/claude-code");
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("cc-config")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("navigates from the mobile select", async () => {
|
it("navigates from the mobile select", async () => {
|
||||||
renderAt("/settings/data");
|
renderAt("/settings/data");
|
||||||
await screen.findByTestId("settings-data");
|
await screen.findByTestId("settings-data");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
|
// ES2021 or newer, never Vite's es2020 default: esbuild 0.25 lowers
|
||||||
|
// xterm 6's `let r; …(r||={})` (requestMode, DECRQM) to `void 0||(i={})`
|
||||||
|
// and drops the `let`, so every DECRQM query tmux sends threw
|
||||||
|
// "i is not defined" inside the terminal's parser. React 19 needs these
|
||||||
|
// browsers anyway.
|
||||||
|
target: ["es2021", "chrome100", "edge100", "firefox100", "safari15"],
|
||||||
// Maps are still written (for debugging a build), but no bundle points at
|
// Maps are still written (for debugging a build), but no bundle points at
|
||||||
// them, so a browser never downloads them.
|
// them, so a browser never downloads them.
|
||||||
sourcemap: "hidden",
|
sourcemap: "hidden",
|
||||||
|
|||||||
+14
-4
@@ -1345,7 +1345,7 @@ POST /api/lanes/:id/mcp/sync
|
|||||||
GET /api/lanes/:id/integrations/:name
|
GET /api/lanes/:id/integrations/:name
|
||||||
```
|
```
|
||||||
|
|
||||||
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub lanes profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
|
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub workspace profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
|
||||||
|
|
||||||
#### CI status and base sync
|
#### CI status and base sync
|
||||||
|
|
||||||
@@ -2820,6 +2820,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
|
|||||||
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings/agents/claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
|
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings/agents/claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
GET /api/cc-config/target
|
||||||
GET /api/cc-config/overview
|
GET /api/cc-config/overview
|
||||||
GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
|
GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
|
||||||
GET /api/cc-config/agents
|
GET /api/cc-config/agents
|
||||||
@@ -2861,6 +2862,8 @@ POST /api/cc-config/mcp/remove Body: { name, scope? }
|
|||||||
|
|
||||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
||||||
|
|
||||||
|
**Whose config (server mode).** The router gates itself (`server/lib/cc-target.js`): a non-admin reaches only the reads listed above and the writes that act on their own config (`PUT/DELETE /file`, `PATCH /settings`, `PUT /keybindings`, `POST /project-memory`, `/plugins/install|action`, `/marketplaces/add|action`, `/mcp/add|remove`, `/reload`); `/profiles*`, `/plugins/update-all` and any unlisted endpoint answer `403 EADMIN` (an analyst may still `GET /profiles` and `GET /plugins/update-all`). `GET /target` answers `{kind: "own"|"host"|"none", readOnly, claudeHome}`. Every other handler acts on one target: the host's Claude home for an admin and in local mode (unchanged); under S3 a non-admin's OWN `<home>/.claude` + `<home>/.claude.json` — reads through the guarded reader, writes and backups as them through `write-as`, `claude plugin|mcp` as `sudo -n -u ah-<name> HOME=… DISABLE_AUTOUPDATER=1 /opt/agenthub/tools/bin/claude …` (scope `user` only, never `-y`, no MCP `env`/`headers`: `400 EBADARG`; `503 ENOTSHARED` without a shared `claude`); `403 ENOACCOUNT` for a non-admin with no account (an analyst's reads fall back to the host's, read-only, as before; `?view=host` gives an analyst with an account that view). A member's project layer exists only with `?cwd=` naming one of their own workspaces in their own account (writes: `403 EFOLDER` otherwise; no `cwd` = no project layer, `roots.projectRoot: null`). `POST /project-memory` from a member takes only such a folder; under S3 the file is written as the folder's account, an admin's create in a member's folder included. A member's `cc_config_changed` carries `ownerId` and reaches only that user; their `/reload` (and a plugin action's `reload`) types only into panes that run as them.
|
||||||
|
|
||||||
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
|
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
|
||||||
|
|
||||||
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
|
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
|
||||||
@@ -2960,7 +2963,7 @@ POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 M
|
|||||||
POST /api/run/clipboard-files → {files:[{path,size,dir}]}: the files a file manager's Copy put on THIS machine's clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux text/uri-list via wl-paste/xclip), each that exists here (`size` null for a folder) — local mode + loopback only, else 403 `EREMOTE`; a pasted file whose name and size match is attached by that path, no upload and no size limit
|
POST /api/run/clipboard-files → {files:[{path,size,dir}]}: the files a file manager's Copy put on THIS machine's clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux text/uri-list via wl-paste/xclip), each that exists here (`size` null for a folder) — local mode + loopback only, else 403 `EREMOTE`; a pasted file whose name and size match is attached by that path, no upload and no size limit
|
||||||
POST /api/run/selected-files → {files:[{path,size,dir}]}: the same for a DROP — the items selected in THIS machine's file manager, which is what a drag from it carries (Windows Explorer windows and the desktop via Shell.Application, macOS Finder's selection — macOS asks once to let AgentHub control Finder; Linux file managers publish none, so a Linux drop uses the browser's own `file://` list or uploads). Nothing is searched for on disk
|
POST /api/run/selected-files → {files:[{path,size,dir}]}: the same for a DROP — the items selected in THIS machine's file manager, which is what a drag from it carries (Windows Explorer windows and the desktop via Shell.Application, macOS Finder's selection — macOS asks once to let AgentHub control Finder; Linux file managers publish none, so a Linux drop uses the browser's own `file://` list or uploads). Nothing is searched for on disk
|
||||||
GET /api/run/native-paste → {native, key}: whether a `claude` pane can read this machine's clipboard itself (see drop-file below), and `key` — `ctrl+v`, or `alt+v` on Windows — the chord Claude Code binds its image paste to there, which the terminal sends
|
GET /api/run/native-paste → {native, key}: whether a `claude` pane can read this machine's clipboard itself (see drop-file below), and `key` — `ctrl+v`, or `alt+v` on Windows — the chord Claude Code binds its image paste to there, which the terminal sends
|
||||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V (Alt+V on Windows, Claude Code's binding there) so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel) and only while `GET /api/run/native-paste` answers `{native:true}`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` (Wayland) or `xclip` (X11) — without one the `^V` pasted nothing, so the image is uploaded instead. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
|
POST /api/run/drop-file?name=&laneId= Any raw file body, streamed to disk as it arrives (S3: through `write-as` as the member) — ≤500 MB, like a Files upload; from a loopback browser outside server mode no cap, only the data dir's free space less a 1 GB reserve (Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V (Alt+V on Windows, Claude Code's binding there) so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel) and only while `GET /api/run/native-paste` answers `{native:true}`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` (Wayland) or `xclip` (X11) — without one the `^V` pasted nothing, so the image is uploaded instead. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body (a loopback drop: when it would not fit on the disk), and close the connection; of several files only the refused ones are lost
|
||||||
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
|
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
|
||||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
||||||
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||||
@@ -3030,6 +3033,8 @@ The login profiles of the AI tools other than Claude Code (`server/lib/tool-prof
|
|||||||
```
|
```
|
||||||
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
||||||
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
|
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
|
||||||
|
GET /api/ai-proxies/9router/update versions, running state, `blocker`, current job (`?fresh=1` re-asks npm)
|
||||||
|
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (admin)
|
||||||
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
||||||
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||||
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
||||||
@@ -3052,6 +3057,11 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex
|
|||||||
|
|
||||||
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
|
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
|
||||||
|
|
||||||
|
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, an admin's only — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
|
||||||
|
1. **shutdown** — a running `9router.service` user unit is `systemctl --user stop`ped; otherwise this account's processes whose argv names the install's `cli.js` get SIGTERM, then they and their descendants SIGKILL after 5 s; the step waits until the port is free. Skipped when nothing ran.
|
||||||
|
2. **update** — `npm install -g --prefix <the install's own prefix> --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n` with `--cache /var/cache/agenthub-9router-npm` (root's HOME is inaccessible inside the AgentHub unit; the sudoers rule must name that exact argv), only when `sudo -n <npm> --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`).
|
||||||
|
3. **relaunch** — only when 9router was running before: `systemctl --user start 9router.service`, or `node cli.js --tray --no-browser --skip-update -p <old port> [-H <old host>]` detached in its own `systemd-run --user --scope` (plain detached spawn without one); done once the port answers. Runs even after a failed update.
|
||||||
|
|
||||||
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
|
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
|
||||||
|
|
||||||
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
||||||
@@ -3450,11 +3460,11 @@ Sent when a notification is created.
|
|||||||
|
|
||||||
#### /ws-pty/:runId — PTY frames
|
#### /ws-pty/:runId — PTY frames
|
||||||
|
|
||||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||||
|
|
||||||
#### cc_config_changed
|
#### cc_config_changed
|
||||||
|
|
||||||
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically.
|
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically. In server mode a change to the host's config reaches a developer as the bare type (`data: {}`); a change to a member's OWN config carries `ownerId` and reaches that user alone, admins and analysts included (`scope.exactAudience`).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } }
|
{ "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } }
|
||||||
|
|||||||
+2
-2
@@ -239,7 +239,7 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
|
|||||||
|
|
||||||
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
|
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
|
||||||
|
|
||||||
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. `agenthub lanes …` and `--lane <id>` are the old names and keep working — the same commands as `agenthub workspace …` and `--workspace <id>` — so scripts and skills written before the rename need no change.
|
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. The old names `agenthub lanes …` and `--lane <id>` were removed (step 4 of the rename): they exit 1 with `renamed: use agenthub workspace …` / `renamed: use --workspace <id>`. Your workspaces, sessions and history are unchanged, and the API still answers under both `/api/workspaces` and `/api/lanes`.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------- | ----------- |
|
| ------- | ----------- |
|
||||||
@@ -271,7 +271,7 @@ A workspace is a durable unit of parallel agent work — one working directory,
|
|||||||
|
|
||||||
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
|
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
|
||||||
|
|
||||||
Resolution order, shared by every cwd-resolving command: `--workspace` (old name `--lane`)/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
|
Resolution order, shared by every cwd-resolving command: `--workspace`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
|
||||||
|
|
||||||
Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack.
|
Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack.
|
||||||
|
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -374,7 +374,7 @@ both full suites.
|
|||||||
| Path | What |
|
| Path | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
| `client/` | React 19 + Vite + Tailwind dashboard |
|
||||||
| `bin/agenthub.js` | CLI |
|
| `bin/agenthub.js` | CLI |
|
||||||
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
||||||
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
||||||
|
|||||||
+1
-1
@@ -293,7 +293,7 @@ chạy cả hai bộ đầy đủ.
|
|||||||
| Đường dẫn | Là gì |
|
| Đường dẫn | Là gì |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
|
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
|
||||||
| `client/` | Dashboard React 18 + Vite + Tailwind |
|
| `client/` | Dashboard React 19 + Vite + Tailwind |
|
||||||
| `bin/agenthub.js` | CLI |
|
| `bin/agenthub.js` | CLI |
|
||||||
| `docs/` | Architecture, API, workspaces, database |
|
| `docs/` | Architecture, API, workspaces, database |
|
||||||
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
|
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
|
||||||
|
|||||||
+2
-2
@@ -194,8 +194,8 @@ graph TB
|
|||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|------------|
|
|-------|------------|
|
||||||
| **Frontend** | React 18, TypeScript 5.7, Vite 6, Tailwind CSS |
|
| **Frontend** | React 19, TypeScript 7, Vite 6, Tailwind CSS |
|
||||||
| **Backend** | Node.js 20+, Express 4.21, WebSocket |
|
| **Backend** | Node.js 22.5+, Express 5, WebSocket |
|
||||||
| **Database** | SQLite 3 (better-sqlite3 or node:sqlite) |
|
| **Database** | SQLite 3 (better-sqlite3 or node:sqlite) |
|
||||||
| **Integration** | Claude Code Hooks, MCP Server |
|
| **Integration** | Claude Code Hooks, MCP Server |
|
||||||
|
|
||||||
|
|||||||
+53
-4
@@ -65,7 +65,7 @@ The proxy has to:
|
|||||||
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
|
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
|
||||||
- set `X-Forwarded-For` and `X-Forwarded-Proto`;
|
- set `X-Forwarded-For` and `X-Forwarded-Proto`;
|
||||||
- accept request bodies as large as AgentHub's own limits (Files uploads up to
|
- accept request bodies as large as AgentHub's own limits (Files uploads up to
|
||||||
500 MB, prompt drops 20 MB). nginx's default `client_max_body_size` is 1 MB,
|
500 MB, files dropped on a terminal or a prompt box 500 MB, dropped images 20 MB). nginx's default `client_max_body_size` is 1 MB,
|
||||||
and its own 413 is an HTML page the UI can only report as a failed upload.
|
and its own 413 is an HTML page the UI can only report as a failed upload.
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -106,11 +106,13 @@ The login cookie gets `Secure` when the request came over HTTPS
|
|||||||
start (`server/db.js`), and `member` is no longer accepted.
|
start (`server/db.js`), and `member` is no longer accepted.
|
||||||
|
|
||||||
"The host's configuration" means a write (anything but GET/HEAD) under
|
"The host's configuration" means a write (anything but GET/HEAD) under
|
||||||
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`,
|
`/api/settings`, `/updates`, `/codex-config`,
|
||||||
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
||||||
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
||||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)). A
|
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)) and the
|
||||||
|
host-wide parts of `/cc-config` (see
|
||||||
|
[Your own Claude Code config](#your-own-claude-code-config)). A
|
||||||
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
|
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
|
||||||
an admin can do this". An analyst's other writes are scoped like a
|
an admin can do this". An analyst's other writes are scoped like a
|
||||||
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
||||||
@@ -120,7 +122,8 @@ exactly like a developer's.
|
|||||||
|
|
||||||
The UI hides what a role cannot use (`usePermissions` in
|
The UI hides what a role cannot use (`usePermissions` in
|
||||||
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
|
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
|
||||||
no Data, Pricing or per-tool pages, no scheduled reports or git credentials,
|
no Data, Pricing or per-tool pages (except their own Claude Code config,
|
||||||
|
for a user with their own Linux account), no scheduled reports or git credentials,
|
||||||
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
|
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
|
||||||
no install/update/enable on AI tools, no pet upload or delete, and no update
|
no install/update/enable on AI tools, no pet upload or delete, and no update
|
||||||
check; the AI proxy page shows them only their personal profiles' Edit. An
|
check; the AI proxy page shows them only their personal profiles' Edit. An
|
||||||
@@ -774,6 +777,52 @@ folder under it), and the dark/light mode picked in AgentHub becomes Claude's
|
|||||||
theme. Accepting Bypass Permissions mode stays the member's own choice, made
|
theme. Accepting Bypass Permissions mode stays the member's own choice, made
|
||||||
in their terminal.
|
in their terminal.
|
||||||
|
|
||||||
|
### Your own Claude Code config
|
||||||
|
|
||||||
|
With S3, a developer or analyst who has their own Linux account manages THEIR
|
||||||
|
OWN Claude Code configuration in Settings → AI tools → **Your Claude Code
|
||||||
|
config** — skills, subagents, commands, output styles, `CLAUDE.md` and
|
||||||
|
auto-memory, plugins, marketplaces, MCP servers, `settings.json` (hooks
|
||||||
|
included) and `keybindings.json` — the same Config Explorer an admin uses for
|
||||||
|
the host's, pointed at `<USERS_ROOT>/<name>/.claude` and
|
||||||
|
`<USERS_ROOT>/<name>/.claude.json`. How it runs:
|
||||||
|
|
||||||
|
- `/api/cc-config` gates itself (`server/lib/cc-target.js`): a non-admin
|
||||||
|
reaches only an allowlist of reads and of the writes that run as them.
|
||||||
|
`/profiles*` (the host's login switching), `/plugins/update-all` (the host's
|
||||||
|
Update all, AgentHub's own plugin included) and any endpoint not on the list
|
||||||
|
stay an admin's (`403 EADMIN`; an analyst may still READ the profiles and the
|
||||||
|
Update-all journal, as before). Every handler then resolves ONE target: the
|
||||||
|
host's Claude home for an admin (and in local mode) exactly as before, the
|
||||||
|
member's own home otherwise, and `403 ENOACCOUNT` for a non-admin with no
|
||||||
|
enabled, provisioned account — never the host's. An analyst with no account
|
||||||
|
keeps the read-only view of the host's config they had; with one, `?view=host`
|
||||||
|
gives them that view, and the file reader serves either.
|
||||||
|
- Every file write is the member's own: `write-as` (content on stdin), the
|
||||||
|
timestamped backup a `write-as copy` inside their home; their files are read
|
||||||
|
only through the guarded reader (a regular file they own, size capped), and
|
||||||
|
a directory, plugin `installPath` or marketplace `installLocation` that leads
|
||||||
|
out of their home is not followed.
|
||||||
|
- `claude plugin …`, `claude plugin marketplace …` and `claude mcp …` run as
|
||||||
|
them: `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
|
||||||
|
/opt/agenthub/tools/bin/claude …` in their home, with a timeout and the same
|
||||||
|
argument checks. User scope only, never `-y` (a plugin whose marketplace
|
||||||
|
declares an install command fails; install it from your own terminal), and
|
||||||
|
no MCP `env` values or `headers` (sudo logs its argv: add such a server with
|
||||||
|
`claude mcp add` in your own terminal). `503 ENOTSHARED` until `claude` is
|
||||||
|
in the shared tools.
|
||||||
|
- A project layer only in one of their own workspaces, named with `?cwd=`
|
||||||
|
(`403 EFOLDER` otherwise); with none named there is no project layer at all,
|
||||||
|
never the dashboard's own folder. An admin's "new CLAUDE.md in a project"
|
||||||
|
in a member's folder is written as that member too.
|
||||||
|
- A change is broadcast (`cc_config_changed`) to that member alone, and their
|
||||||
|
**Reload** (`POST /api/cc-config/reload`, or a plugin update with reload)
|
||||||
|
types `/reload-plugins` / `/reload-skills` only into the panes that run as
|
||||||
|
them. Their own config is theirs, so these writes are not audited (like
|
||||||
|
their own File Explorer writes).
|
||||||
|
|
||||||
|
Not verified on a live S3 machine yet.
|
||||||
|
|
||||||
### Plugin baseline (opt-in)
|
### Plugin baseline (opt-in)
|
||||||
|
|
||||||
Write `/etc/agenthub/plugin-baseline.json` (as
|
Write `/etc/agenthub/plugin-baseline.json` (as
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- [NEVER tmux kill-server](never-tmux-kill-server.md) — $TMUX beats TMUX_TMPDIR; it killed the real server + this session twice
|
- [NEVER tmux kill-server](never-tmux-kill-server.md) — $TMUX beats TMUX_TMPDIR; it killed the real server + this session twice
|
||||||
- [Analytics parity plan](analytics-parity-plan.md) — phases 2–7 shipped in v2.4.0; ../agenthub-analytics now redundant; OpenAPI for new routes still missing
|
- [Analytics parity plan](analytics-parity-plan.md) — phases 2–7 shipped in v2.4.0; ../agenthub-analytics now redundant; OpenAPI for new routes still missing
|
||||||
- [Dev/prod ports share one DB](agenthub-dev-prod-ports.md) — 4820 prod (systemd), 4830 user's dev in Warp; same dashboard.db, dev migrations hit prod
|
- [Dev/prod ports share one DB](agenthub-dev-prod-ports.md) — 4820 prod (systemd), 4830 user's dev in Warp; same dashboard.db, dev migrations hit prod
|
||||||
- [Workspace rename step 4](workspace-rename-step4.md) — remove `agenthub lanes`/`--lane` only ≥3 releases after aliases ship; never rename stored data
|
- [Workspace rename step 4](workspace-rename-step4.md) — DONE 10-26: old CLI names refused, clients call /api/workspaces, /api/lanes kept; release needs EN+VI note
|
||||||
- [No git stash here](no-git-stash-shared-checkout.md) — parallel sessions share this checkout; use a worktree to test clean HEAD
|
- [No git stash here](no-git-stash-shared-checkout.md) — parallel sessions share this checkout; use a worktree to test clean HEAD
|
||||||
- [Update never kills panes](update-never-kills-panes.md) — updates/restarts keep tmux lane panes; only Quit AgentHub (#65) closes them; KillMode=process
|
- [Update never kills panes](update-never-kills-panes.md) — updates/restarts keep tmux lane panes; only Quit AgentHub (#65) closes them; KillMode=process
|
||||||
- [Claude Code changelog ≤2.1.295](claude-code-changelog-2-1-293.md) — 2.1.285–295 (to 10-08): hooks onFailure:block (keep AgentHub fail-open), OSC 7501 tested (no use), Workflow scriptPath rules
|
- [Claude Code changelog ≤2.1.295](claude-code-changelog-2-1-293.md) — 2.1.285–295 (to 10-08): hooks onFailure:block (keep AgentHub fail-open), OSC 7501 tested (no use), Workflow scriptPath rules
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
---
|
---
|
||||||
name: workspace-rename-step4
|
name: workspace-rename-step4
|
||||||
description: "Removing the old `agenthub lanes`/`--lane` CLI names is deferred to ≥3 releases after the aliases ship; never touch stored data"
|
description: "Rename step 4 DONE 2026-10-26: `agenthub lanes`/`--lane` now exit 1 'renamed'; client/CLI/MCP call /api/workspaces; /api/lanes kept; never touch stored data"
|
||||||
metadata:
|
metadata:
|
||||||
node_type: memory
|
node_type: memory
|
||||||
type: project
|
type: project
|
||||||
originSessionId: 47dc0201-8e49-4fce-9407-f087d3107588
|
originSessionId: 47dc0201-8e49-4fce-9407-f087d3107588
|
||||||
modified: 2026-10-06T04:07:56.796Z
|
modified: 2026-10-26T00:00:00.000Z
|
||||||
---
|
---
|
||||||
|
|
||||||
On 2026-10-06 the user had steps 1–3 of the lane→workspace rename built (aliases `/api/workspaces`, `agenthub workspace`, `--workspace`; help/docs on the new name; a stderr deprecation note for `agenthub lanes`/`--lane`). Step 4 (removing the old CLI names, moving client+CLI requests to `/api/workspaces`) is deferred until the THIRD release after the first one that ships the aliases (the first release after v2.0.1). The plan, checklist and "never touch" list are in `docs/plans/workspace-rename/README.md`.
|
Step 4 of the lane→workspace rename was built on 2026-10-26 (after v2.8.25): `agenthub lanes …` and `--lane` exit 1 with `renamed: use …`; client `api.ts`, `bin/agenthub.js` and `mcp/server.js` request `/api/workspaces`; the `/api/lanes` mount is KEPT (4c default). Plan: `docs/plans/workspace-rename/README.md`.
|
||||||
|
|
||||||
**Why:** the user wants existing installs to update gradually and NOT lose any data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys must never be renamed.
|
**Why:** existing installs update gradually and must not lose data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys are never renamed.
|
||||||
|
|
||||||
**How to apply:** when cutting a release, check whether it is ≥ R+3 (R is recorded in that plan file — fill it in when R is cut) and remind the user that step 4 is due; keep `/api/lanes` unless the user decides otherwise (step 4c, default keep).
|
**How to apply:** the release that ships this needs the EN+VI note from the plan's release checklist ("`agenthub lanes` and `--lane` are removed — use `agenthub workspace` and `--workspace`…"). Do not remove `/api/lanes` unless the user asks (then 410 ERENAMED for one release first).
|
||||||
|
|
||||||
R = v2.1.0 (released 2026-10-06), so step 4 is allowed from the 3rd release after v2.1.0.
|
|
||||||
|
|||||||
@@ -32,5 +32,5 @@ Decisions (user, 2026-10-08):
|
|||||||
- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
|
- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
|
||||||
- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
|
- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
|
||||||
- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
|
- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
|
||||||
- [ ] prod/dev share one DB: restart prod on the new build together with the first run (migration)
|
- [x] prod/dev share one DB: restart prod on the new build together with the first run (migration) — done with the v2.5.x rollout
|
||||||
- [ ] `agenthub stop` in server mode answers EAUTH (fix was blocked; user to decide)
|
- [x] `agenthub stop` in server mode answers EAUTH — resolved: `cmdStop` (`bin/agenthub.js`) turns EAUTH/EADMIN into a clear message naming the admin's Quit and the `systemctl` stop commands; a host shell carries no login, so that is the intended answer
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Decided: own Linux user per account; skills/plugins = admin baseline + user's ow
|
|||||||
- [x] Per-user AI Proxy store (+ admin shared) — S3-PLAN P1/P1b
|
- [x] Per-user AI Proxy store (+ admin shared) — S3-PLAN P1/P1b
|
||||||
- [x] S3 provisioning + spawn/attach as the account — S3-PLAN P2/P3
|
- [x] S3 provisioning + spawn/attach as the account — S3-PLAN P2/P3
|
||||||
- [x] S4 shared tools in /opt — S3-PLAN P5
|
- [x] S4 shared tools in /opt — S3-PLAN P5
|
||||||
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike not run, member Settings not built
|
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike run 2026-10-09 (works from the first interactive session, S3-PLAN P6); member Settings built 2026-10-26 ("Your Claude Code config", writes as the member) — open until verified live on the S3 box
|
||||||
- [x] Codex account pool — S3-PLAN P7 (fake accounts only; no real Codex login tried)
|
- [x] Codex account pool — S3-PLAN P7 (fake accounts only; no real Codex login tried)
|
||||||
- [x] Security review, docs, suites — P8 F1–F11, second review F12–F20 (2026-10-09), docs/SERVER-MODE.md runbook
|
- [x] Security review, docs, suites — P8 F1–F11, second review F12–F20 (2026-10-09), docs/SERVER-MODE.md runbook
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ Branch: `server-s3`. Commits happen only when the user asks.
|
|||||||
- Not checked: skills actually listed inside a logged-in session (no login on the test user).
|
- Not checked: skills actually listed inside a logged-in session (no login on the test user).
|
||||||
- [x] Fallback (built, opt-in): with `/etc/agenthub/plugin-baseline.json` (`{"marketplaces": ["owner/repo", …], "plugins": ["name@marketplace", …]}`, root's, the admin edits it), creating a user runs `claude plugin marketplace add <source>` for each marketplace, then `claude plugin install --scope user <plugin>` for each plugin, AS the new member (`sudo -n -u ah-<name> HOME=… /opt/agenthub/tools/bin/claude …`, in the background; `agenthub users provision` runs it too, in the foreground, and is the retry). No file = nothing runs. An entry starting with `-` is skipped; `-y` is never passed. Sudoers: `/opt/agenthub/tools/bin/claude` joins the run-as-member list (no new power: the host already runs any command as a member through tmux). `server/lib/member-claude.js`, test `server-s3-provision.test.js` (fake sudo). Not verified live.
|
- [x] Fallback (built, opt-in): with `/etc/agenthub/plugin-baseline.json` (`{"marketplaces": ["owner/repo", …], "plugins": ["name@marketplace", …]}`, root's, the admin edits it), creating a user runs `claude plugin marketplace add <source>` for each marketplace, then `claude plugin install --scope user <plugin>` for each plugin, AS the new member (`sudo -n -u ah-<name> HOME=… /opt/agenthub/tools/bin/claude …`, in the background; `agenthub users provision` runs it too, in the foreground, and is the retry). No file = nothing runs. An entry starting with `-` is skipped; `-y` is never passed. Sudoers: `/opt/agenthub/tools/bin/claude` joins the run-as-member list (no new power: the host already runs any command as a member through tmux). `server/lib/member-claude.js`, test `server-s3-provision.test.js` (fake sudo). Not verified live.
|
||||||
- [ ] A member's own plugins work from their terminal (`/plugin`) as soon as P3 lands, with no code.
|
- [ ] A member's own plugins work from their terminal (`/plugin`) as soon as P3 lands, with no code.
|
||||||
- [ ] Later, optional: Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`.
|
- [x] Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`. Done: one target resolver + the router's own allowlist gate (`server/lib/cc-target.js`; `/cc-config` left `STAFF_ONLY_PREFIXES`/`ADMIN_WRITE_PREFIXES`), the readers/writers take the member's home (`cc-discovery`/`cc-mutate` `home`, `noProject`, `ioFor` → write-as; backups via `write-as copy`), `claude plugin|mcp` as the member's sudo argv (user scope, no `-y`, no env/headers), project writes only in their own workspaces, an admin's project-memory create in a member's folder written as the member, `cc_config_changed` to the owner only (`scope.exactAudience`), `/reload` into their own panes; UI "Your Claude Code config" (`useCcTarget`). Test `server-s3-cc-config.test.js` (fake sudo, fake `claude` spawn). Not verified live.
|
||||||
|
|
||||||
#### P6 spike: how to run it (a human, on the S3 box)
|
#### P6 spike: how to run it (a human, on the S3 box)
|
||||||
`scripts/server/spike-managed-plugins.sh` (never run by AgentHub). It changes `/etc/claude-code/managed-settings.json`, which EVERY Claude Code on the machine reads, the host's own sessions included, so run it in a quiet moment and undo right after.
|
`scripts/server/spike-managed-plugins.sh` (never run by AgentHub). It changes `/etc/claude-code/managed-settings.json`, which EVERY Claude Code on the machine reads, the host's own sessions included, so run it in a quiet moment and undo right after.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Workspace redesign — overview and phase plan
|
# Workspace redesign — overview and phase plan
|
||||||
|
|
||||||
_Status: specs written 2026-10-01, nothing implemented yet. Each phase is one PR, executed in order; a later phase assumes the earlier ones are merged._
|
_Status: specs written 2026-10-01; all four phases are built (sidebar tree + status bar + workbench `ba2c4e1`, grouped Settings with search `eb90244`, Orca themes + notification sounds `b71024f`, the lane → workspace copy rename). Kept as the design record. Each phase was one PR, executed in order._
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ version here when it is cut: R = `v2.1.0`).
|
|||||||
| 1 | Aliases: `/api/workspaces` = `/api/lanes` (same two routers mounted twice, `server/index.js` `LANE_MOUNTS`); `agenthub workspace`/`workspaces` = `agenthub lanes` (`COMMAND_ALIASES`/`canonicalArgv` in `bin/agenthub.js`); `--workspace <id>` = `--lane <id>` (`resolveLaneArg`). | R | done |
|
| 1 | Aliases: `/api/workspaces` = `/api/lanes` (same two routers mounted twice, `server/index.js` `LANE_MOUNTS`); `agenthub workspace`/`workspaces` = `agenthub lanes` (`COMMAND_ALIASES`/`canonicalArgv` in `bin/agenthub.js`); `--workspace <id>` = `--lane <id>` (`resolveLaneArg`). | R | done |
|
||||||
| 2 | Help, usage and hint text, the UI's command hints and the user docs say `agenthub workspace …` / `--workspace`; `docs/API.md` documents both paths. | R | done |
|
| 2 | Help, usage and hint text, the UI's command hints and the user docs say `agenthub workspace …` / `--workspace`; `docs/API.md` documents both paths. | R | done |
|
||||||
| 3 | Deprecation: `agenthub lanes` or `--lane` prints ONE stderr line naming the new spelling (`warnOldNames`), stdout unchanged; the help footer and `docs/CLI.md` call them the old names. | R | done |
|
| 3 | Deprecation: `agenthub lanes` or `--lane` prints ONE stderr line naming the new spelling (`warnOldNames`), stdout unchanged; the help footer and `docs/CLI.md` call them the old names. | R | done |
|
||||||
| 4 | Remove the old CLI names (below). | **not before R+3** | open |
|
| 4 | Remove the old CLI names (below). | **not before R+3** | done 2026-10-26 (after v2.8.25): 4a + 4b built, 4c kept `/api/lanes` |
|
||||||
|
|
||||||
Three releases is the window for an install to update gradually: anyone who
|
Three releases is the window for an install to update gradually: anyone who
|
||||||
runs the CLI in that time sees the step-3 note on every old-name call, and a
|
runs the CLI in that time sees the step-3 note on every old-name call, and a
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ const TOOLS = {
|
|||||||
list_lanes: {
|
list_lanes: {
|
||||||
description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.",
|
description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.",
|
||||||
properties: {},
|
properties: {},
|
||||||
path: () => "/api/lanes",
|
path: () => "/api/workspaces",
|
||||||
},
|
},
|
||||||
list_pricing: {
|
list_pricing: {
|
||||||
description: "Model pricing rules (USD per million tokens) the cost estimates use.",
|
description: "Model pricing rules (USD per million tokens) the cost estimates use.",
|
||||||
|
|||||||
+182
-15
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.8.24
|
version: 2.8.27
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -2178,7 +2178,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Command arguments — present only for `stdio` servers.
|
description: Command arguments — present only for `stdio` servers.
|
||||||
example:
|
example:
|
||||||
- '-y'
|
- -y
|
||||||
- '@modelcontextprotocol/server-github'
|
- '@modelcontextprotocol/server-github'
|
||||||
envNames:
|
envNames:
|
||||||
type: array
|
type: array
|
||||||
@@ -2340,7 +2340,7 @@ components:
|
|||||||
project:
|
project:
|
||||||
type: string
|
type: string
|
||||||
description: 'Auto-memory only: the ~/.claude/projects/<slug> directory name the file belongs to.'
|
description: 'Auto-memory only: the ~/.claude/projects/<slug> directory name the file belongs to.'
|
||||||
example: '-Users-son-repo'
|
example: -Users-son-repo
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: 'Auto-memory only: the markdown filename.'
|
description: 'Auto-memory only: the markdown filename.'
|
||||||
@@ -2868,7 +2868,7 @@ components:
|
|||||||
project:
|
project:
|
||||||
type: string
|
type: string
|
||||||
description: 'Required for `auto-memory`: the ~/.claude/projects/<slug> directory name. Validated against /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/ and rejected if it contains `..`.'
|
description: 'Required for `auto-memory`: the ~/.claude/projects/<slug> directory name. Validated against /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/ and rejected if it contains `..`.'
|
||||||
example: '-Users-son-repo'
|
example: -Users-son-repo
|
||||||
CcConfigFileWriteResponse:
|
CcConfigFileWriteResponse:
|
||||||
type: object
|
type: object
|
||||||
description: Result of a successful create-or-overwrite.
|
description: Result of a successful create-or-overwrite.
|
||||||
@@ -2933,7 +2933,7 @@ components:
|
|||||||
project:
|
project:
|
||||||
type: string
|
type: string
|
||||||
description: 'Required for `auto-memory`: the projects/<slug> directory name.'
|
description: 'Required for `auto-memory`: the projects/<slug> directory name.'
|
||||||
example: '-Users-son-repo'
|
example: -Users-son-repo
|
||||||
CcConfigFileDeleteResponse:
|
CcConfigFileDeleteResponse:
|
||||||
type: object
|
type: object
|
||||||
description: Result of a successful delete (a mandatory backup is created first).
|
description: Result of a successful delete (a mandatory backup is created first).
|
||||||
@@ -2984,7 +2984,7 @@ components:
|
|||||||
project:
|
project:
|
||||||
type: string
|
type: string
|
||||||
description: 'Auto-memory only: the projects/<slug> directory name.'
|
description: 'Auto-memory only: the projects/<slug> directory name.'
|
||||||
example: '-Users-son-repo'
|
example: -Users-son-repo
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -4362,8 +4362,8 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
example:
|
example:
|
||||||
- '-Users-son-code-foo'
|
- -Users-son-code-foo
|
||||||
- '-Users-son-code-bar'
|
- -Users-son-code-bar
|
||||||
RemoteSourceSyncResponse:
|
RemoteSourceSyncResponse:
|
||||||
type: object
|
type: object
|
||||||
description: Counters from a pull-now sync against the remote source.
|
description: Counters from a pull-now sync against the remote source.
|
||||||
@@ -8432,6 +8432,52 @@ paths:
|
|||||||
text/html:
|
text/html:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
/api/redoc/redoc.standalone.js:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Documentation
|
||||||
|
summary: ReDoc bundle
|
||||||
|
description: The ReDoc standalone script the `/api/redoc` page loads, sent from the server's own vendored copy (no CDN).
|
||||||
|
operationId: getRedocBundle
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: JavaScript bundle
|
||||||
|
content:
|
||||||
|
application/javascript:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'500':
|
||||||
|
description: The bundle could not be read
|
||||||
|
/api/cc-config/target:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- CcConfig
|
||||||
|
summary: Whose Claude Code config this login manages
|
||||||
|
description: 'Server mode: `own` for a non-admin with their own Linux account (S3) — every other endpoint then reads and writes THEIR `~/.claude`, writes running as them; `host` for an admin (and in local mode), or an analyst''s read-only view of the host''s; `none` for a non-admin with no account (the other endpoints answer 403 ENOACCOUNT). Never an error.'
|
||||||
|
operationId: ccConfigGetTarget
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The target.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- own
|
||||||
|
- host
|
||||||
|
- none
|
||||||
|
readOnly:
|
||||||
|
type: boolean
|
||||||
|
claudeHome:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example:
|
||||||
|
kind: own
|
||||||
|
readOnly: false
|
||||||
|
claudeHome: /srv/agenthub/mia/.claude
|
||||||
/api/cc-config/overview:
|
/api/cc-config/overview:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -8833,7 +8879,7 @@ paths:
|
|||||||
kind: stdio
|
kind: stdio
|
||||||
command: npx
|
command: npx
|
||||||
args:
|
args:
|
||||||
- '-y'
|
- -y
|
||||||
- '@modelcontextprotocol/server-github'
|
- '@modelcontextprotocol/server-github'
|
||||||
envNames:
|
envNames:
|
||||||
- GITHUB_TOKEN
|
- GITHUB_TOKEN
|
||||||
@@ -8892,7 +8938,7 @@ paths:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
example:
|
example:
|
||||||
- '-y'
|
- -y
|
||||||
- server-github
|
- server-github
|
||||||
env:
|
env:
|
||||||
type: object
|
type: object
|
||||||
@@ -9116,7 +9162,7 @@ paths:
|
|||||||
truncated: false
|
truncated: false
|
||||||
preview: '# Workspace & Productivity System...'
|
preview: '# Workspace & Productivity System...'
|
||||||
- scope: auto-memory
|
- scope: auto-memory
|
||||||
project: '-Users-son-repo'
|
project: -Users-son-repo
|
||||||
name: MEMORY.md
|
name: MEMORY.md
|
||||||
isIndex: true
|
isIndex: true
|
||||||
file: /Users/son/.claude/projects/-Users-son-repo/memory/MEMORY.md
|
file: /Users/son/.claude/projects/-Users-son-repo/memory/MEMORY.md
|
||||||
@@ -13040,8 +13086,8 @@ paths:
|
|||||||
ok: true
|
ok: true
|
||||||
message: Connected; found 24 project directories under ~/.claude/projects.
|
message: Connected; found 24 project directories under ~/.claude/projects.
|
||||||
remoteProjects:
|
remoteProjects:
|
||||||
- '-Users-son-code-foo'
|
- -Users-son-code-foo
|
||||||
- '-Users-son-code-bar'
|
- -Users-son-code-bar
|
||||||
failure:
|
failure:
|
||||||
summary: Unreachable / auth failed
|
summary: Unreachable / auth failed
|
||||||
value:
|
value:
|
||||||
@@ -15088,6 +15134,74 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: deleteServerUser
|
||||||
|
summary: 'Admin (Delete user): the account for good — its logins, panes, workspaces'' records and prefs, and under S3 its Linux account and whole home, removed FIRST so a refusal leaves every row. Session history and the audit log stay. Never yourself (ESELF) or the last enabled admin (ELASTADMIN)'
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- currentPassword
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Deleted — the account as it was
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/AuthUser'
|
||||||
|
'400':
|
||||||
|
description: EBADPASSWORD / EBADROLE / EBADFIELD
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOUSER / ENOLANE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: ESELF — yourself / ELASTADMIN — the last enabled admin
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'502':
|
||||||
|
description: EPROVISION — the Linux account or home could not be removed; nothing deleted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/users/{id}/password:
|
/api/users/{id}/password:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -16979,16 +17093,17 @@ paths:
|
|||||||
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
|
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
|
||||||
'400':
|
'400':
|
||||||
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
|
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
|
||||||
/api/me/git/ssh-user/{from}:
|
/api/me/git/ssh-user/{host}:
|
||||||
delete:
|
delete:
|
||||||
tags:
|
tags:
|
||||||
- Git identity
|
- Git identity
|
||||||
summary: Drop the SSH login of a host or alias (and its key link)
|
summary: Drop the SSH login of a host or alias (and its key link)
|
||||||
operationId: deleteSshUser
|
operationId: deleteSshUser
|
||||||
parameters:
|
parameters:
|
||||||
- name: from
|
- name: host
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
|
description: The host or alias the login was saved under.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
@@ -18988,6 +19103,58 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
installed:
|
installed:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
/api/ai-proxies/9router/update:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- AI Proxies
|
||||||
|
summary: 9router update status
|
||||||
|
description: 'Installed vs latest npm version of the host''s 9router, whether it runs (`managedBy`: the `9router.service` user unit or a plain process), `blocker` — why a start would be refused now (`ENOTINSTALLED`, `EUPTODATE`, `ENOLATEST`, `ENONPM`, `ENOPERM` with the `command` to run by hand, `EUNSUPPORTED`) — and the current/last job. `?fresh=1` re-asks the registry (cached 10 min).'
|
||||||
|
operationId: getAiProxy9routerUpdate
|
||||||
|
parameters:
|
||||||
|
- name: fresh
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- '1'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Status.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- AI Proxies
|
||||||
|
summary: Update 9router
|
||||||
|
description: 'Starts the one in-memory job: shut 9router down (stop the unit, or SIGTERM→SIGKILL its processes until the port is free), `npm install -g --prefix <its prefix> 9router@latest` (under `sudo -n` only when the prefix is not writable and sudo needs no password), then — only if it was running — start it again hidden (`--tray --no-browser --skip-update`). A failed update still relaunches. Admin only; same-origin guarded. Poll the GET for the steps.'
|
||||||
|
operationId: updateAiProxy9router
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: The job, running.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
'403':
|
||||||
|
description: '`EADMIN`.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: '`EBUSY` or the status `blocker` code.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'502':
|
||||||
|
description: '`ENOLATEST` — the npm registry did not answer.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/ai-proxies/test:
|
/api/ai-proxies/test:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Generated
+429
-788
File diff suppressed because it is too large
Load Diff
+6
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.25",
|
"version": "2.8.27",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
@@ -71,21 +71,20 @@
|
|||||||
"link-cli": "npm link || node -e \"console.warn('agenthub CLI not linked globally (no permission?). Link manually with: npm link')\""
|
"link-cli": "npm link || node -e \"console.warn('agenthub CLI not linked globally (no permission?). Link manually with: npm link')\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
"@lydell/node-pty": "^1.2.0-beta.18",
|
||||||
"adm-zip": "^0.6.1",
|
"adm-zip": "^0.6.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.2",
|
"express": "^5.3.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^5.5.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.5.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"systray2": "^2.1.4",
|
"systray2": "^2.1.4",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"better-sqlite3": "^12.0.0"
|
"better-sqlite3": "^13.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ function systemdStatus() {
|
|||||||
/**
|
/**
|
||||||
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
|
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
|
||||||
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
|
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
|
||||||
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
|
* `agenthub workspace shell` and the session-reload keystrokes all fail — while
|
||||||
* sessions, analytics, lanes and the CLI keep working. A partial feature
|
* sessions, analytics, lanes and the CLI keep working. A partial feature
|
||||||
* loss must not fail a doctor run.
|
* loss must not fail a doctor run.
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { v4: uuidv4 } = require("uuid");
|
const { randomUUID: uuidv4 } = require("crypto");
|
||||||
const { db, stmts } = require("../server/db");
|
const { db, stmts } = require("../server/db");
|
||||||
|
|
||||||
// ── Stable fixture IDs ─────────────────────────────────────────────────────
|
// ── Stable fixture IDs ─────────────────────────────────────────────────────
|
||||||
|
|||||||
+3
-2
@@ -441,7 +441,7 @@ All endpoints return JSON unless noted. Error responses use:
|
|||||||
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
|
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
|
||||||
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
|
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
|
||||||
|
|
||||||
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It now documents every backend route (75 path entries). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
|
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It documents every `/api` route (332 paths, 399 operations); `server/__tests__/openapi-coverage.test.js` fails when a registered route has no operation in the spec, or an operation has no route (the `/api/workspaces` and `/api/ai-routers` alias mounts are documented once, under `/api/lanes` and `/api/ai-proxies`). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
|
||||||
|
|
||||||
### Core Endpoints
|
### Core Endpoints
|
||||||
|
|
||||||
@@ -747,10 +747,11 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
|||||||
|
|
||||||
### Claude Config Explorer (`/api/cc-config`)
|
### Claude Config Explorer (`/api/cc-config`)
|
||||||
|
|
||||||
Reads — and carefully gated mutations for low-risk text-file artifacts — for every Claude Code configuration surface. Mutations always create timestamped backups under `<root>/cc-config-backups/<type>/` before writing.
|
Reads — and carefully gated mutations for low-risk text-file artifacts — for every Claude Code configuration surface. Mutations always create timestamped backups under `<root>/cc-config-backups/<type>/` before writing. In server mode the router gates itself (`lib/cc-target.js`): an admin works on the host's Claude home, a non-admin with their own Linux account (S3) on THEIR `~/.claude` (writes as them through `write-as` / sudo `claude`), anyone else gets `403 ENOACCOUNT`; `/profiles*` and `/plugins/update-all` stay an admin's. See `docs/SERVER-MODE.md` → "Your own Claude Code config".
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| -------- | ------------------------------------- | ----------- |
|
| -------- | ------------------------------------- | ----------- |
|
||||||
|
| `GET` | `/api/cc-config/target` | Whose config this login manages: `own` / `host` / `none` |
|
||||||
| `GET` | `/api/cc-config/overview` | Roots + counts for every surface (used by the Overview tab) |
|
| `GET` | `/api/cc-config/overview` | Roots + counts for every surface (used by the Overview tab) |
|
||||||
| `GET` | `/api/cc-config/skills` | Skills with parsed frontmatter, `?scope=user\|project\|all` |
|
| `GET` | `/api/cc-config/skills` | Skills with parsed frontmatter, `?scope=user\|project\|all` |
|
||||||
| `GET` | `/api/cc-config/agents` | Subagents under `<scope>/.claude/agents/*.md` |
|
| `GET` | `/api/cc-config/agents` | Subagents under `<scope>/.claude/agents/*.md` |
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ describe("reloadableLanes", () => {
|
|||||||
stubTmux([]);
|
stubTmux([]);
|
||||||
assert.deepEqual(reloadableLanes(), []);
|
assert.deepEqual(reloadableLanes(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the host's panes and one member's apart (S3)", (t) => {
|
||||||
|
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"]);
|
||||||
|
const owners = { "agenthub-lane-2": { name: "mia" }, "agenthub-lane-3": { name: "bob" } };
|
||||||
|
t.mock.method(require("../lib/run-as"), "accountForSession", (id) => owners[id] || null);
|
||||||
|
const ids = (list) => list.map((l) => l.id);
|
||||||
|
// The host's reload never types into a member's pane…
|
||||||
|
assert.deepEqual(ids(reloadableLanes()), ["agenthub-lane-1"]);
|
||||||
|
// …and a member's reaches their own panes only.
|
||||||
|
assert.deepEqual(ids(reloadableLanes({ name: "mia" })), ["agenthub-lane-2"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("reloadOpenSessions", () => {
|
describe("reloadOpenSessions", () => {
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* @file fake-sudo.js
|
* @file fake-sudo.js
|
||||||
* @description Test helper for the S3 suites: an `execImpl` for
|
* @description Test helper for the S3 suites: an `execImpl` for
|
||||||
* `run-as.__setExecImpl` that plays `sudo -n -u <login> [VAR=v…] <bin> …`
|
* `run-as.__setExecImpl` (and a `spawn` for `__setSpawnImpl`) that plays
|
||||||
* without root — it records the argv (so a test can assert nothing secret
|
* `sudo -n -u <login> [VAR=v…] <bin> …` without root — it records the argv (so a test can assert nothing secret
|
||||||
* crossed sudo) and runs the real `scripts/server/write-as.sh` for
|
* crossed sudo) and runs the real `scripts/server/write-as.sh` for
|
||||||
* `/opt/agenthub/libexec/write-as`, as this account.
|
* `/opt/agenthub/libexec/write-as`, as this account.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { execFileSync } = require("node:child_process");
|
const { execFileSync, spawn: spawnChild } = require("node:child_process");
|
||||||
|
|
||||||
const WRITE_AS = path.join(__dirname, "..", "..", "..", "scripts", "server", "write-as.sh");
|
const WRITE_AS = path.join(__dirname, "..", "..", "..", "scripts", "server", "write-as.sh");
|
||||||
|
|
||||||
/** `{ exec, calls }`: pass `exec` to `runAs.__setExecImpl`; `calls` = every sudo argv. */
|
/** `{ exec, spawn, calls }`: pass `exec` to `runAs.__setExecImpl` and `spawn`
|
||||||
|
* to `runAs.__setSpawnImpl`; `calls` = every sudo argv. */
|
||||||
function fakeSudo() {
|
function fakeSudo() {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const exec = (cmd, argv, opts = {}) => {
|
// The command sudo would run, as [file, args].
|
||||||
|
const unwrap = (cmd, argv) => {
|
||||||
calls.push([cmd, ...argv]);
|
calls.push([cmd, ...argv]);
|
||||||
if (cmd !== "sudo") return execFileSync(cmd, argv, opts);
|
if (cmd !== "sudo") return [cmd, argv];
|
||||||
let i = argv.indexOf("-u") + 2; // past the login
|
let i = argv.indexOf("-u") + 2; // past the login
|
||||||
while (/^[A-Z_][A-Z0-9_]*=/.test(argv[i])) i++;
|
while (/^[A-Z_][A-Z0-9_]*=/.test(argv[i])) i++;
|
||||||
const [bin, ...rest] = argv.slice(i);
|
const [bin, ...rest] = argv.slice(i);
|
||||||
if (bin === "/opt/agenthub/libexec/write-as")
|
return bin === "/opt/agenthub/libexec/write-as" ? ["sh", [WRITE_AS, ...rest]] : [bin, rest];
|
||||||
return execFileSync("sh", [WRITE_AS, ...rest], opts);
|
|
||||||
return execFileSync(bin, rest, opts);
|
|
||||||
};
|
};
|
||||||
return { exec, calls };
|
const exec = (cmd, argv, opts = {}) => execFileSync(...unwrap(cmd, argv), opts);
|
||||||
|
const spawn = (cmd, argv, opts = {}) => spawnChild(...unwrap(cmd, argv), opts);
|
||||||
|
return { exec, spawn, calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { fakeSudo };
|
module.exports = { fakeSudo };
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
"label": "CI gates + preflight",
|
"label": "CI gates + preflight",
|
||||||
"icon": "🧪",
|
"icon": "🧪",
|
||||||
"gate": true,
|
"gate": true,
|
||||||
"command": "agenthub lanes hook ci-gate",
|
"command": "agenthub workspace hook ci-gate",
|
||||||
"aliases": ["pre-push-gate", "tests", "migration-collision", "sync-conflict"],
|
"aliases": ["pre-push-gate", "tests", "migration-collision", "sync-conflict"],
|
||||||
"detect": [
|
"detect": [
|
||||||
{
|
{
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"label": "E2e on feature branch",
|
"label": "E2e on feature branch",
|
||||||
"icon": "🧪",
|
"icon": "🧪",
|
||||||
"gate": false,
|
"gate": false,
|
||||||
"command": "agenthub lanes up --qc",
|
"command": "agenthub workspace up --qc",
|
||||||
"aliases": ["e2e", "e2e-scoped", "booting", "live"],
|
"aliases": ["e2e", "e2e-scoped", "booting", "live"],
|
||||||
"detect": [
|
"detect": [
|
||||||
{
|
{
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
"label": "Browser QC",
|
"label": "Browser QC",
|
||||||
"icon": "🔍",
|
"icon": "🔍",
|
||||||
"gate": true,
|
"gate": true,
|
||||||
"command": "agenthub lanes proof-link",
|
"command": "agenthub workspace proof-link",
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"detect": [
|
"detect": [
|
||||||
{
|
{
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tool": "Bash",
|
"tool": "Bash",
|
||||||
"match": "\\bagenthub\\b[^;&|]*\\blanes proof-link\\b"
|
"match": "\\bagenthub\\b[^;&|]*\\b(?:lanes|workspace) proof-link\\b"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
"label": "Watching PR",
|
"label": "Watching PR",
|
||||||
"icon": "👁",
|
"icon": "👁",
|
||||||
"gate": false,
|
"gate": false,
|
||||||
"command": "agenthub lanes ci status",
|
"command": "agenthub workspace ci status",
|
||||||
"aliases": ["pr-comment-fix"],
|
"aliases": ["pr-comment-fix"],
|
||||||
"detect": [
|
"detect": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tests for the `agenthub stage` / `agenthub lanes` CLI subcommands: lane
|
* @file Tests for the `agenthub stage` / `agenthub workspace` CLI subcommands: lane
|
||||||
* resolution from the current directory, the stage round-trip through the HTTP
|
* resolution from the current directory, the stage round-trip through the HTTP
|
||||||
* API, and the non-zero exit when the cwd belongs to no lane.
|
* API, and the non-zero exit when the cwd belongs to no lane.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -194,20 +194,26 @@ after(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("agenthub workspaces", () => {
|
describe("agenthub workspaces", () => {
|
||||||
it("is the lanes command under the UI's word", async () => {
|
it("is the workspace command under its plural", async () => {
|
||||||
const [lanes, workspaces, workspace] = await Promise.all([
|
const [workspaces, workspace] = await Promise.all([
|
||||||
cli(["lanes"], LANE_DIR),
|
|
||||||
cli(["workspaces"], LANE_DIR),
|
cli(["workspaces"], LANE_DIR),
|
||||||
cli(["workspace"], LANE_DIR),
|
cli(["workspace"], LANE_DIR),
|
||||||
]);
|
]);
|
||||||
assert.equal(workspaces.status, 0, workspaces.stderr);
|
assert.equal(workspaces.status, 0, workspaces.stderr);
|
||||||
assert.match(workspaces.stdout, /CLI lane/);
|
assert.match(workspaces.stdout, /CLI lane/);
|
||||||
assert.equal(workspaces.stdout, lanes.stdout);
|
assert.equal(workspace.stdout, workspaces.stdout);
|
||||||
assert.equal(workspace.stdout, lanes.stdout);
|
});
|
||||||
// The old name still works but says so — on stderr, never in stdout.
|
|
||||||
assert.match(lanes.stderr, /old name.*agenthub workspace/);
|
it("refuses the removed old names, naming the new spelling", async () => {
|
||||||
assert.doesNotMatch(workspaces.stderr, /old name/);
|
const [lanes, flag] = await Promise.all([
|
||||||
assert.doesNotMatch(lanes.stdout, /old name/);
|
cli(["lanes"], LANE_DIR),
|
||||||
|
cli(["stage", "review", "--lane", "1"], LANE_DIR),
|
||||||
|
]);
|
||||||
|
assert.equal(lanes.status, 1);
|
||||||
|
assert.match(lanes.stderr, /renamed: use agenthub workspace/);
|
||||||
|
assert.equal(lanes.stdout, "");
|
||||||
|
assert.equal(flag.status, 1);
|
||||||
|
assert.match(flag.stderr, /renamed: use --workspace <id>/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +222,7 @@ describe("agenthub stage", () => {
|
|||||||
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
|
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, /review/);
|
assert.match(r.stdout, /review/);
|
||||||
const list = await cli(["lanes"], LANE_DIR);
|
const list = await cli(["workspace"], LANE_DIR);
|
||||||
assert.match(list.stdout, /review/);
|
assert.match(list.stdout, /review/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,7 +304,7 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
|||||||
assert.notEqual(await stageOf(codexLane.id), "implement");
|
assert.notEqual(await stageOf(codexLane.id), "implement");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("--workspace names the lane like --lane", async () => {
|
it("--workspace names the lane", async () => {
|
||||||
const r = await cli(
|
const r = await cli(
|
||||||
["stage", "review", "--workspace", String(codexLane.id)],
|
["stage", "review", "--workspace", String(codexLane.id)],
|
||||||
DUAL_DIR,
|
DUAL_DIR,
|
||||||
@@ -328,18 +334,18 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
|||||||
assert.match(`${r.stdout}${r.stderr}`, /no codex workspace/i);
|
assert.match(`${r.stdout}${r.stderr}`, /no codex workspace/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
|
it("--workspace flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
|
||||||
// Even with two lanes at the same cwd, --lane <id> bypasses all resolution
|
// Even with two lanes at the same cwd, --workspace <id> bypasses all resolution
|
||||||
const r = await cli(["stage", "review", "--lane", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
const r = await cli(["stage", "review", "--workspace", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.equal(await stageOf(codexLane.id), "review");
|
assert.equal(await stageOf(codexLane.id), "review");
|
||||||
assert.notEqual(await stageOf(claudeLane.id), "review");
|
assert.notEqual(await stageOf(claudeLane.id), "review");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("positional numeric ID acts like --lane for commands that support it (e.g. agenthub lanes pipeline)", async () => {
|
it("positional numeric ID acts like --workspace for commands that support it (e.g. agenthub workspace pipeline)", async () => {
|
||||||
// Commands like `agenthub lanes pipeline` treat a leading numeric arg as the lane ID.
|
// Commands like `agenthub workspace pipeline` treat a leading numeric arg as the lane ID.
|
||||||
// Other commands like `agenthub stage` have their own positional argument and don't support it.
|
// Other commands like `agenthub stage` have their own positional argument and don't support it.
|
||||||
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
const r = await cli(["workspace", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, new RegExp(`workspace #${codexLane.id}`));
|
assert.match(r.stdout, new RegExp(`workspace #${codexLane.id}`));
|
||||||
});
|
});
|
||||||
@@ -386,15 +392,15 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agenthub lanes add", () => {
|
describe("agenthub workspace add", () => {
|
||||||
it("creates a lane and it appears in lanes list", async () => {
|
it("creates a lane and it appears in lanes list", async () => {
|
||||||
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-add-"));
|
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-add-"));
|
||||||
try {
|
try {
|
||||||
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
|
const r = await cli(["workspace", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, /Created workspace/i);
|
assert.match(r.stdout, /Created workspace/i);
|
||||||
|
|
||||||
const list = await cli(["lanes"], addDir);
|
const list = await cli(["workspace"], addDir);
|
||||||
assert.equal(list.status, 0, list.stderr);
|
assert.equal(list.status, 0, list.stderr);
|
||||||
assert.match(list.stdout, /CLI added/);
|
assert.match(list.stdout, /CLI added/);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -405,7 +411,7 @@ describe("agenthub lanes add", () => {
|
|||||||
it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
|
it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
|
||||||
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-no-pipeline-"));
|
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-no-pipeline-"));
|
||||||
try {
|
try {
|
||||||
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
|
const r = await cli(["workspace", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, /Created workspace/i);
|
assert.match(r.stdout, /Created workspace/i);
|
||||||
|
|
||||||
@@ -423,7 +429,7 @@ describe("agenthub lanes add", () => {
|
|||||||
try {
|
try {
|
||||||
const r = await cli(
|
const r = await cli(
|
||||||
[
|
[
|
||||||
"lanes",
|
"workspace",
|
||||||
"add",
|
"add",
|
||||||
"--cwd",
|
"--cwd",
|
||||||
addDir,
|
addDir,
|
||||||
@@ -449,17 +455,17 @@ describe("agenthub lanes add", () => {
|
|||||||
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
|
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
|
||||||
// `--pipeline` was creation-only, so every lane added from the dashboard
|
// `--pipeline` was creation-only, so every lane added from the dashboard
|
||||||
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
|
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
|
||||||
const show = await cli(["lanes", "pipeline"], LANE_DIR);
|
const show = await cli(["workspace", "pipeline"], LANE_DIR);
|
||||||
assert.equal(show.status, 0, show.stderr);
|
assert.equal(show.status, 0, show.stderr);
|
||||||
assert.match(show.stdout, /default/);
|
assert.match(show.stdout, /default/);
|
||||||
assert.match(show.stdout, /available:.*ship-feature/);
|
assert.match(show.stdout, /available:.*ship-feature/);
|
||||||
|
|
||||||
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
const set = await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
|
||||||
assert.equal(set.status, 0, set.stderr);
|
assert.equal(set.status, 0, set.stderr);
|
||||||
assert.match(set.stdout, /ship-feature/);
|
assert.match(set.stdout, /ship-feature/);
|
||||||
assert.match(set.stdout, /16 nodes/);
|
assert.match(set.stdout, /16 nodes/);
|
||||||
|
|
||||||
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
const back = await cli(["workspace", "pipeline", "default"], LANE_DIR);
|
||||||
assert.equal(back.status, 0, back.stderr);
|
assert.equal(back.status, 0, back.stderr);
|
||||||
assert.match(back.stdout, /8 nodes/);
|
assert.match(back.stdout, /8 nodes/);
|
||||||
});
|
});
|
||||||
@@ -467,17 +473,17 @@ describe("agenthub lanes add", () => {
|
|||||||
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
|
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
|
||||||
// getPipeline() returns the default template for an unknown id, so without
|
// getPipeline() returns the default template for an unknown id, so without
|
||||||
// a write-side check a typo would store, render `default`, and look fine.
|
// a write-side check a typo would store, render `default`, and look fine.
|
||||||
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
|
const r = await cli(["workspace", "pipeline", "no-such-pipeline"], LANE_DIR);
|
||||||
assert.notEqual(r.status, 0);
|
assert.notEqual(r.status, 0);
|
||||||
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
|
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
|
||||||
|
|
||||||
const after = await cli(["lanes", "pipeline"], LANE_DIR);
|
const after = await cli(["workspace", "pipeline"], LANE_DIR);
|
||||||
assert.match(after.stdout, /default/);
|
assert.match(after.stdout, /default/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provisions a managed worktree lane and reports it ready", async () => {
|
it("provisions a managed worktree lane and reports it ready", async () => {
|
||||||
const r = await cli(
|
const r = await cli(
|
||||||
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
|
["workspace", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
|
||||||
SOURCE_REPO
|
SOURCE_REPO
|
||||||
);
|
);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
@@ -504,7 +510,7 @@ describe("agenthub lanes add", () => {
|
|||||||
);
|
);
|
||||||
await git(["branch", "feat"], emptyRepo);
|
await git(["branch", "feat"], emptyRepo);
|
||||||
const r = await cli(
|
const r = await cli(
|
||||||
["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
|
["workspace", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
|
||||||
emptyRepo
|
emptyRepo
|
||||||
);
|
);
|
||||||
assert.notEqual(r.status, 0);
|
assert.notEqual(r.status, 0);
|
||||||
@@ -517,7 +523,7 @@ describe("agenthub lanes add", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agenthub lanes destructive lifecycle", () => {
|
describe("agenthub workspace destructive lifecycle", () => {
|
||||||
// Every test provisions its OWN lane. These tests used to share one mutable
|
// Every test provisions its OWN lane. These tests used to share one mutable
|
||||||
// `managedLane` across describe blocks, which meant they only ever exercised
|
// `managedLane` across describe blocks, which meant they only ever exercised
|
||||||
// whatever state the previous test happened to leave behind — the same
|
// whatever state the previous test happened to leave behind — the same
|
||||||
@@ -526,7 +532,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
|||||||
async function ownLane(title) {
|
async function ownLane(title) {
|
||||||
const slug = `cli-destructive-${(laneSeq += 1)}`;
|
const slug = `cli-destructive-${(laneSeq += 1)}`;
|
||||||
const added = await cli(
|
const added = await cli(
|
||||||
["lanes", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
|
["workspace", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
|
||||||
SOURCE_REPO
|
SOURCE_REPO
|
||||||
);
|
);
|
||||||
assert.equal(added.status, 0, added.stderr);
|
assert.equal(added.status, 0, added.stderr);
|
||||||
@@ -542,7 +548,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
|||||||
const dirtyFile = path.join(lane.cwd, "dirty.txt");
|
const dirtyFile = path.join(lane.cwd, "dirty.txt");
|
||||||
fs.writeFileSync(dirtyFile, "must survive\n");
|
fs.writeFileSync(dirtyFile, "must survive\n");
|
||||||
|
|
||||||
const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
|
const r = await cli(["workspace", "reset", String(lane.id)], SOURCE_REPO);
|
||||||
assert.notEqual(r.status, 0);
|
assert.notEqual(r.status, 0);
|
||||||
assert.match(r.stdout, /Preflight for reset workspace #\d+:/);
|
assert.match(r.stdout, /Preflight for reset workspace #\d+:/);
|
||||||
assert.match(r.stdout, /dirty\s+0/);
|
assert.match(r.stdout, /dirty\s+0/);
|
||||||
@@ -555,7 +561,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
|||||||
const lane = await ownLane("CLI reset applied");
|
const lane = await ownLane("CLI reset applied");
|
||||||
fs.writeFileSync(path.join(lane.cwd, "dirty.txt"), "to be cleaned\n");
|
fs.writeFileSync(path.join(lane.cwd, "dirty.txt"), "to be cleaned\n");
|
||||||
|
|
||||||
const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
|
const r = await cli(["workspace", "reset", String(lane.id), "--yes"], SOURCE_REPO);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, new RegExp(`Reset workspace #${lane.id}`));
|
assert.match(r.stdout, new RegExp(`Reset workspace #${lane.id}`));
|
||||||
assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
|
assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
|
||||||
@@ -577,8 +583,8 @@ describe("agenthub lanes destructive lifecycle", () => {
|
|||||||
const lane = await ownLane("CLI reset race");
|
const lane = await ownLane("CLI reset race");
|
||||||
fs.writeFileSync(path.join(lane.cwd, "race.txt"), "one more untracked file\n");
|
fs.writeFileSync(path.join(lane.cwd, "race.txt"), "one more untracked file\n");
|
||||||
const [a, b] = await Promise.all([
|
const [a, b] = await Promise.all([
|
||||||
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
|
cli(["workspace", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
|
||||||
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
|
cli(["workspace", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
|
||||||
]);
|
]);
|
||||||
const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
|
const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
|
||||||
assert.equal(winner.status, 0, winner.stderr);
|
assert.equal(winner.status, 0, winner.stderr);
|
||||||
@@ -591,7 +597,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
|||||||
it("removes a managed lane's record with --yes, leaving its directory on disk", async () => {
|
it("removes a managed lane's record with --yes, leaving its directory on disk", async () => {
|
||||||
const lane = await ownLane("CLI remove managed");
|
const lane = await ownLane("CLI remove managed");
|
||||||
assert.equal(fs.existsSync(lane.cwd), true);
|
assert.equal(fs.existsSync(lane.cwd), true);
|
||||||
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
|
const r = await cli(["workspace", "remove", String(lane.id), "--yes"], SOURCE_REPO);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, new RegExp(`Removed workspace #${lane.id}`));
|
assert.match(r.stdout, new RegExp(`Removed workspace #${lane.id}`));
|
||||||
assert.match(r.stdout, /left for you to delete yourself/);
|
assert.match(r.stdout, /left for you to delete yourself/);
|
||||||
@@ -610,14 +616,14 @@ describe("agenthub lanes destructive lifecycle", () => {
|
|||||||
});
|
});
|
||||||
const id = created.body.lane.id;
|
const id = created.body.lane.id;
|
||||||
|
|
||||||
const reset = await cli(["lanes", "reset", String(id), "--yes"], adoptedDir);
|
const reset = await cli(["workspace", "reset", String(id), "--yes"], adoptedDir);
|
||||||
assert.notEqual(reset.status, 0);
|
assert.notEqual(reset.status, 0);
|
||||||
assert.match(`${reset.stdout}${reset.stderr}`, /points at a directory you own/i);
|
assert.match(`${reset.stdout}${reset.stderr}`, /points at a directory you own/i);
|
||||||
|
|
||||||
// The server permits `remove` for an adopted lane — it drops the dashboard
|
// The server permits `remove` for an adopted lane — it drops the dashboard
|
||||||
// record only — so the CLI must not refuse it, or the capability is
|
// record only — so the CLI must not refuse it, or the capability is
|
||||||
// unreachable from the terminal.
|
// unreachable from the terminal.
|
||||||
const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
|
const removed = await cli(["workspace", "remove", String(id), "--yes"], adoptedDir);
|
||||||
assert.equal(removed.status, 0, removed.stderr);
|
assert.equal(removed.status, 0, removed.stderr);
|
||||||
assert.match(removed.stdout, new RegExp(`Removed workspace #${id}`));
|
assert.match(removed.stdout, new RegExp(`Removed workspace #${id}`));
|
||||||
assert.equal((await get(`/api/lanes/${id}`)).status, 404);
|
assert.equal((await get(`/api/lanes/${id}`)).status, 404);
|
||||||
@@ -634,7 +640,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
|||||||
assert.equal(preflight.status, 200);
|
assert.equal(preflight.status, 200);
|
||||||
const { sessions, events, tokenRows } = preflight.body;
|
const { sessions, events, tokenRows } = preflight.body;
|
||||||
|
|
||||||
const r = await cli(["lanes", "purge", String(lane.id), "--yes"], SOURCE_REPO);
|
const r = await cli(["workspace", "purge", String(lane.id), "--yes"], SOURCE_REPO);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(
|
assert.match(
|
||||||
r.stdout,
|
r.stdout,
|
||||||
@@ -645,7 +651,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agenthub lanes — inferred stage detection", () => {
|
describe("agenthub workspace — inferred stage detection", () => {
|
||||||
it("prints the inferred stage when detection leads the declared stage", async () => {
|
it("prints the inferred stage when detection leads the declared stage", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-lead-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-lead-"));
|
||||||
try {
|
try {
|
||||||
@@ -666,10 +672,10 @@ describe("agenthub lanes — inferred stage detection", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const r = await cli(["lanes"], dir);
|
const r = await cli(["workspace"], dir);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
|
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
|
||||||
assert.ok(line, "lane row not found in agenthub lanes output");
|
assert.ok(line, "lane row not found in agenthub workspaces output");
|
||||||
assert.match(line, /⇢ detected:tests/);
|
assert.match(line, /⇢ detected:tests/);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
@@ -685,7 +691,7 @@ describe("agenthub lanes — inferred stage detection", () => {
|
|||||||
// Declare 'ship' (past 'tests' in the default pipeline) first, so the
|
// Declare 'ship' (past 'tests' in the default pipeline) first, so the
|
||||||
// detection below is behind the declaration and recordDetection's
|
// detection below is behind the declaration and recordDetection's
|
||||||
// declared-wins guard refuses to write it.
|
// declared-wins guard refuses to write it.
|
||||||
const staged = await cli(["stage", "ship", "--lane", String(id)], dir);
|
const staged = await cli(["stage", "ship", "--workspace", String(id)], dir);
|
||||||
assert.equal(staged.status, 0, staged.stderr);
|
assert.equal(staged.status, 0, staged.stderr);
|
||||||
|
|
||||||
await post("/api/hooks/event", {
|
await post("/api/hooks/event", {
|
||||||
@@ -698,10 +704,10 @@ describe("agenthub lanes — inferred stage detection", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const r = await cli(["lanes"], dir);
|
const r = await cli(["workspace"], dir);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
|
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
|
||||||
assert.ok(line, "lane row not found in agenthub lanes output");
|
assert.ok(line, "lane row not found in agenthub workspaces output");
|
||||||
assert.doesNotMatch(line, /detected:/);
|
assert.doesNotMatch(line, /detected:/);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
@@ -709,9 +715,9 @@ describe("agenthub lanes — inferred stage detection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agenthub lanes node", () => {
|
describe("agenthub workspace node", () => {
|
||||||
it("with no node id, lists every node and its command, and exits non-zero with usage", async () => {
|
it("with no node id, lists every node and its command, and exits non-zero with usage", async () => {
|
||||||
const r = await cli(["lanes", "node"], LANE_DIR);
|
const r = await cli(["workspace", "node"], LANE_DIR);
|
||||||
assert.notEqual(r.status, 0);
|
assert.notEqual(r.status, 0);
|
||||||
assert.match(r.stdout, /\bintake\b/);
|
assert.match(r.stdout, /\bintake\b/);
|
||||||
assert.match(r.stdout, /\bdone\b/);
|
assert.match(r.stdout, /\bdone\b/);
|
||||||
@@ -719,17 +725,17 @@ describe("agenthub lanes node", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
|
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
|
||||||
await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
await cli(["workspace", "pipeline", "default"], LANE_DIR);
|
||||||
const r = await cli(["lanes", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
|
const r = await cli(["workspace", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, /"claude" "\/superpowers:writing-plans the export button"/);
|
assert.match(r.stdout, /"claude" "\/superpowers:writing-plans the export button"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("--print shows a shell node split into argv, never a shell string", async () => {
|
it("--print shows a shell node split into argv, never a shell string", async () => {
|
||||||
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
|
||||||
const r = await cli(["lanes", "node", "gates", "--print"], LANE_DIR);
|
const r = await cli(["workspace", "node", "gates", "--print"], LANE_DIR);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, /"agenthub" "lanes" "hook" "ci-gate"/);
|
assert.match(r.stdout, /"agenthub" "workspace" "hook" "ci-gate"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("every node of both shipped templates declares a runnable command", async () => {
|
it("every node of both shipped templates declares a runnable command", async () => {
|
||||||
@@ -744,26 +750,26 @@ describe("agenthub lanes node", () => {
|
|||||||
it("matches a node by alias, so one slash command works on either template", async () => {
|
it("matches a node by alias, so one slash command works on either template", async () => {
|
||||||
// `default` calls the step `implement`, `ship-feature` calls it
|
// `default` calls the step `implement`, `ship-feature` calls it
|
||||||
// `implementing` — /agenthub:implement must not depend on which one is active.
|
// `implementing` — /agenthub:implement must not depend on which one is active.
|
||||||
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
|
||||||
const ship = await cli(["lanes", "node", "implement", "--print"], LANE_DIR);
|
const ship = await cli(["workspace", "node", "implement", "--print"], LANE_DIR);
|
||||||
assert.equal(ship.status, 0, ship.stderr);
|
assert.equal(ship.status, 0, ship.stderr);
|
||||||
assert.match(ship.stdout, /test-driven-development/);
|
assert.match(ship.stdout, /test-driven-development/);
|
||||||
|
|
||||||
await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
await cli(["workspace", "pipeline", "default"], LANE_DIR);
|
||||||
const def = await cli(["lanes", "node", "implementing", "--print"], LANE_DIR);
|
const def = await cli(["workspace", "node", "implementing", "--print"], LANE_DIR);
|
||||||
assert.equal(def.status, 0, def.stderr);
|
assert.equal(def.status, 0, def.stderr);
|
||||||
assert.match(def.stdout, /test-driven-development/);
|
assert.match(def.stdout, /test-driven-development/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the pipeline's nodes when asked for one that is not in it", async () => {
|
it("names the pipeline's nodes when asked for one that is not in it", async () => {
|
||||||
const r = await cli(["lanes", "node", "not-a-node", "--print"], LANE_DIR);
|
const r = await cli(["workspace", "node", "not-a-node", "--print"], LANE_DIR);
|
||||||
assert.notEqual(r.status, 0);
|
assert.notEqual(r.status, 0);
|
||||||
assert.match(r.stderr, /no node "not-a-node"/);
|
assert.match(r.stderr, /no node "not-a-node"/);
|
||||||
assert.match(r.stdout, /\bintake\b/);
|
assert.match(r.stdout, /\bintake\b/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agenthub lanes ci status", () => {
|
describe("agenthub workspace ci status", () => {
|
||||||
let restorePath;
|
let restorePath;
|
||||||
let bin;
|
let bin;
|
||||||
|
|
||||||
@@ -807,7 +813,7 @@ exit 1
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exits 0 and prints success state", async () => {
|
it("exits 0 and prints success state", async () => {
|
||||||
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR);
|
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR);
|
||||||
assert.equal(r.status, 0);
|
assert.equal(r.status, 0);
|
||||||
assert.match(r.stdout, /"state": "success"/);
|
assert.match(r.stdout, /"state": "success"/);
|
||||||
});
|
});
|
||||||
@@ -816,7 +822,7 @@ exit 1
|
|||||||
const orig = process.env.CI_STATUS_FIXTURE;
|
const orig = process.env.CI_STATUS_FIXTURE;
|
||||||
try {
|
try {
|
||||||
process.env.CI_STATUS_FIXTURE = "failure";
|
process.env.CI_STATUS_FIXTURE = "failure";
|
||||||
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
|
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR, {
|
||||||
CI_STATUS_FIXTURE: "failure",
|
CI_STATUS_FIXTURE: "failure",
|
||||||
});
|
});
|
||||||
assert.equal(r.status, 1);
|
assert.equal(r.status, 1);
|
||||||
@@ -830,7 +836,7 @@ exit 1
|
|||||||
const orig = process.env.CI_STATUS_FIXTURE;
|
const orig = process.env.CI_STATUS_FIXTURE;
|
||||||
try {
|
try {
|
||||||
process.env.CI_STATUS_FIXTURE = "pending";
|
process.env.CI_STATUS_FIXTURE = "pending";
|
||||||
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
|
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR, {
|
||||||
CI_STATUS_FIXTURE: "pending",
|
CI_STATUS_FIXTURE: "pending",
|
||||||
});
|
});
|
||||||
assert.equal(r.status, 3);
|
assert.equal(r.status, 3);
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ describe("per-node driver command", () => {
|
|||||||
|
|
||||||
assert.equal(byId.intake, "/superpowers:brainstorming");
|
assert.equal(byId.intake, "/superpowers:brainstorming");
|
||||||
assert.equal(byId.implementing, "/superpowers:test-driven-development");
|
assert.equal(byId.implementing, "/superpowers:test-driven-development");
|
||||||
assert.equal(byId.gates, "agenthub lanes hook ci-gate");
|
assert.equal(byId.gates, "agenthub workspace hook ci-gate");
|
||||||
// A terminal or gate node has no work of its own to run: reaching it IS
|
// A terminal or gate node has no work of its own to run: reaching it IS
|
||||||
// the declaration, so its command is the declaration - which keeps
|
// the declaration, so its command is the declaration - which keeps
|
||||||
// `agenthub lanes node <id>` runnable for every node in the template.
|
// `agenthub lanes node <id>` runnable for every node in the template.
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("mcp/server.js", () => {
|
|||||||
}
|
}
|
||||||
if (p === "/api/pricing/cost") return send(200, { total_cost: 1.5, daily_costs: [] });
|
if (p === "/api/pricing/cost") return send(200, { total_cost: 1.5, daily_costs: [] });
|
||||||
if (p === "/api/analytics") return send(500, { error: { message: "boom" } });
|
if (p === "/api/analytics") return send(500, { error: { message: "boom" } });
|
||||||
if (p === "/api/lanes") return send(200, "<!doctype html><html></html>", "text/html");
|
if (p === "/api/workspaces") return send(200, "<!doctype html><html></html>", "text/html");
|
||||||
return send(404, "Cannot GET " + p, "text/html");
|
return send(404, "Cannot GET " + p, "text/html");
|
||||||
});
|
});
|
||||||
await new Promise((r) => api.listen(0, "127.0.0.1", r));
|
await new Promise((r) => api.listen(0, "127.0.0.1", r));
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* @file nine-router-update.test.js
|
||||||
|
* @description The **Update 9router** job (`server/lib/nine-router-update.js`)
|
||||||
|
* and its routes, against a FAKE 9router: a throwaway npm prefix whose `cli.js`
|
||||||
|
* listens on a free port and keeps a renamed child, and a fake `npm` that bumps
|
||||||
|
* its version. PATH is cut down so the machine's real 9router can never be
|
||||||
|
* found, let alone stopped — every test first checks the located install is
|
||||||
|
* the fake one. No systemd: the unit path is a seam turned off here.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const net = require("node:net");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "nine-router-update-"));
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
const PREFIX = path.join(TMP, "prefix");
|
||||||
|
const PKG_DIR = path.join(PREFIX, "lib", "node_modules", "9router");
|
||||||
|
const BIN_DIR = path.join(PREFIX, "bin");
|
||||||
|
const NPM_DIR = path.join(TMP, "npmbin");
|
||||||
|
const ORIGINAL_PATH = process.env.PATH;
|
||||||
|
|
||||||
|
const update = require("../lib/nine-router-update");
|
||||||
|
const express = require("express");
|
||||||
|
const router = require("../routes/ai-proxies");
|
||||||
|
|
||||||
|
const CLI = `#!/usr/bin/env node
|
||||||
|
const net = require("net");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const i = process.argv.indexOf("-p");
|
||||||
|
const port = Number(process.argv[i + 1]);
|
||||||
|
// A child that renames itself, like 9router's next-server.
|
||||||
|
const child = spawn(process.execPath, ["-e", "process.title='fake-next-server'; setInterval(()=>{},1e6)"], { stdio: "ignore" });
|
||||||
|
const srv = net.createServer((s) => s.end()).listen(port, "127.0.0.1");
|
||||||
|
process.on("SIGTERM", () => { try { child.kill("SIGKILL"); } catch {} srv.close(); process.exit(0); });
|
||||||
|
setInterval(() => {}, 1e6);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NPM = `#!/bin/sh
|
||||||
|
# fake npm: --version, or install -g --prefix P ... 9router@latest
|
||||||
|
if [ "$1" = "--version" ]; then echo 10.0.0; exit 0; fi
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
if [ "$1" = "--prefix" ]; then P="$2"; shift; fi
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[ -f "${TMP}/npm-fail" ] && { echo "boom" >&2; exit 1; }
|
||||||
|
echo '{"name":"9router","version":"9.9.9"}' > "$P/lib/node_modules/9router/package.json"
|
||||||
|
echo installed
|
||||||
|
`;
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const s = net.createServer().listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = s.address();
|
||||||
|
s.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const portOpen = (port) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const s = net.connect({ host: "127.0.0.1", port });
|
||||||
|
s.once("connect", () => (s.destroy(), resolve(true)));
|
||||||
|
s.once("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function until(fn, ms = 20000) {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
if (await fn()) return true;
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pids whose argv names the fake install — what a test must clean up. */
|
||||||
|
function fakePids() {
|
||||||
|
const out = [];
|
||||||
|
for (const d of fs.readdirSync("/proc")) {
|
||||||
|
if (!/^\d+$/.test(d)) continue;
|
||||||
|
try {
|
||||||
|
const argv = fs.readFileSync(`/proc/${d}/cmdline`, "utf8");
|
||||||
|
if (argv.includes(TMP) || argv.includes("fake-next-server")) out.push(Number(d));
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.filter((p) => p !== process.pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function killFakes() {
|
||||||
|
for (const pid of fakePids()) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFake(port) {
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(PKG_DIR, "cli.js"), "--tray", "-p", String(port)],
|
||||||
|
{
|
||||||
|
stdio: "ignore",
|
||||||
|
detached: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
child.unref();
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finished() {
|
||||||
|
await until(() => update.current()?.status !== "running", 60000);
|
||||||
|
return update.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(PKG_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(NPM_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(PKG_DIR, "cli.js"), CLI, { mode: 0o755 });
|
||||||
|
fs.symlinkSync("../lib/node_modules/9router/cli.js", path.join(BIN_DIR, "9router"));
|
||||||
|
fs.writeFileSync(path.join(NPM_DIR, "npm"), NPM, { mode: 0o755 });
|
||||||
|
// Only the fake bins, node's own dir and the basics — never /usr/local/bin.
|
||||||
|
process.env.PATH = [BIN_DIR, NPM_DIR, path.dirname(process.execPath), "/bin", "/usr/bin"].join(
|
||||||
|
":"
|
||||||
|
);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api/ai-proxies", router);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server = app.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
killFakes();
|
||||||
|
update.__reset();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(PKG_DIR, "package.json"),
|
||||||
|
JSON.stringify({ name: "9router", version: "1.0.0" })
|
||||||
|
);
|
||||||
|
fs.rmSync(path.join(TMP, "npm-fail"), { force: true });
|
||||||
|
update.__setSeams({
|
||||||
|
useSystemd: false,
|
||||||
|
fetchLatest: async () => "2.0.0",
|
||||||
|
timing: { termGraceMs: 2000, portFreeMs: 5000, portUpMs: 15000 },
|
||||||
|
});
|
||||||
|
const loc = update.locate();
|
||||||
|
assert.ok(loc && loc.pkgDir.startsWith(TMP), `refusing to run against ${loc?.pkgDir}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
killFakes();
|
||||||
|
process.env.PATH = ORIGINAL_PATH;
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nine-router-update", () => {
|
||||||
|
it("reports the install, the latest version and that nothing runs", async () => {
|
||||||
|
const s = await update.status();
|
||||||
|
assert.equal(s.installed, true);
|
||||||
|
assert.equal(s.version, "1.0.0");
|
||||||
|
assert.equal(s.latest, "2.0.0");
|
||||||
|
assert.equal(s.updateAvailable, true);
|
||||||
|
assert.equal(s.running, false);
|
||||||
|
assert.equal(s.blocker, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when already on the latest version", async () => {
|
||||||
|
update.__setSeams({ fetchLatest: async () => "1.0.0" });
|
||||||
|
const s = await update.status();
|
||||||
|
assert.equal(s.blocker.code, "EUPTODATE");
|
||||||
|
await assert.rejects(update.start(), { code: "EUPTODATE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses with a manual command when the prefix is not writable and sudo asks", async () => {
|
||||||
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555);
|
||||||
|
update.__setSeams({
|
||||||
|
exec: async (file, args) =>
|
||||||
|
file === "sudo"
|
||||||
|
? { code: 1, stdout: "", stderr: "a password is required" }
|
||||||
|
: { code: 0, stdout: "", stderr: "" },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (process.getuid?.() === 0) return; // root writes anyway
|
||||||
|
await assert.rejects(
|
||||||
|
update.start(),
|
||||||
|
(e) => e.code === "ENOPERM" && /sudo npm install -g 9router@latest/.test(e.command)
|
||||||
|
);
|
||||||
|
assert.equal(update.current(), null, "no job, so 9router was never stopped");
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a sudo update gives root's npm its own cache, never root's HOME", async () => {
|
||||||
|
if (process.getuid?.() === 0) return; // root writes the prefix directly
|
||||||
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555);
|
||||||
|
// A fake sudo first on PATH: drops `-n --` and runs the rest as us.
|
||||||
|
fs.writeFileSync(path.join(NPM_DIR, "sudo"), '#!/bin/sh\nshift; shift; exec "$@"\n', {
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
update.__setSeams({ exec: async () => ({ code: 0, stdout: "", stderr: "" }) });
|
||||||
|
try {
|
||||||
|
await update.start();
|
||||||
|
const done = await finished();
|
||||||
|
assert.match(
|
||||||
|
done.log,
|
||||||
|
/\$ sudo -n -- \S+ install -g --prefix \S+ --prefer-online --cache \/var\/cache\/agenthub-9router-npm 9router@latest/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755);
|
||||||
|
fs.rmSync(path.join(NPM_DIR, "sudo"), { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops a running 9router (child included), updates it, and starts it again hidden", async () => {
|
||||||
|
const port = await freePort();
|
||||||
|
startFake(port);
|
||||||
|
assert.ok(await until(() => portOpen(port)), "fake 9router came up");
|
||||||
|
assert.ok(await until(() => fakePids().length >= 2), "with its child");
|
||||||
|
const before = fakePids();
|
||||||
|
|
||||||
|
const job = await update.start();
|
||||||
|
assert.equal(job.status, "running");
|
||||||
|
assert.equal(job.managedBy, "process");
|
||||||
|
const done = await finished();
|
||||||
|
assert.equal(done.status, "done", done.log + JSON.stringify(done.steps));
|
||||||
|
assert.deepEqual(
|
||||||
|
done.steps.map((s) => s.status),
|
||||||
|
["done", "done", "done"]
|
||||||
|
);
|
||||||
|
assert.equal(done.from, "1.0.0");
|
||||||
|
assert.equal(done.to, "9.9.9");
|
||||||
|
for (const pid of before) assert.ok(!fakePids().includes(pid), `old pid ${pid} is gone`);
|
||||||
|
assert.ok(await portOpen(port), "relaunched on the same port");
|
||||||
|
// Relaunched hidden: tray mode, no browser, no self-update.
|
||||||
|
const argv = fakePids()
|
||||||
|
.map((p) => fs.readFileSync(`/proc/${p}/cmdline`, "utf8").split("\0"))
|
||||||
|
.find((a) => a.includes(path.join(PKG_DIR, "cli.js")));
|
||||||
|
for (const flag of ["--tray", "--no-browser", "--skip-update"])
|
||||||
|
assert.ok(argv.includes(flag), flag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves 9router off when it was not running", async () => {
|
||||||
|
await update.start();
|
||||||
|
const done = await finished();
|
||||||
|
assert.equal(done.status, "done");
|
||||||
|
assert.deepEqual(
|
||||||
|
done.steps.map((s) => s.status),
|
||||||
|
["skipped", "done", "skipped"]
|
||||||
|
);
|
||||||
|
assert.equal(fakePids().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still relaunches the old version when npm fails", async () => {
|
||||||
|
fs.writeFileSync(path.join(TMP, "npm-fail"), "");
|
||||||
|
const port = await freePort();
|
||||||
|
startFake(port);
|
||||||
|
assert.ok(await until(() => portOpen(port)));
|
||||||
|
await update.start();
|
||||||
|
const done = await finished();
|
||||||
|
assert.equal(done.status, "failed");
|
||||||
|
assert.deepEqual(
|
||||||
|
done.steps.map((s) => s.status),
|
||||||
|
["done", "failed", "done"]
|
||||||
|
);
|
||||||
|
assert.match(done.log, /boom/);
|
||||||
|
assert.ok(await portOpen(port));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second start while one runs", async () => {
|
||||||
|
const port = await freePort();
|
||||||
|
startFake(port);
|
||||||
|
assert.ok(await until(() => portOpen(port)));
|
||||||
|
await update.start();
|
||||||
|
await assert.rejects(update.start(), { code: "EBUSY" });
|
||||||
|
await finished();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routes /api/ai-proxies/9router/update", () => {
|
||||||
|
it("GET reports status, POST starts and answers 202, a refusal is a 409", async () => {
|
||||||
|
const s = await (await fetch(`${BASE}/api/ai-proxies/9router/update`)).json();
|
||||||
|
assert.equal(s.version, "1.0.0");
|
||||||
|
const r = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
|
||||||
|
assert.equal(r.status, 202);
|
||||||
|
await finished();
|
||||||
|
update.__setSeams({ fetchLatest: async () => "9.9.9" });
|
||||||
|
const again = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" });
|
||||||
|
assert.equal(again.status, 409);
|
||||||
|
assert.equal((await again.json()).error.code, "EUPTODATE");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @file Fails when an Express route under /api has no operation in the OpenAPI
|
||||||
|
* document. Builds the app with `createApp()`, walks every router layer with
|
||||||
|
* its mount prefix (recorded by wrapping `express.application.use` before the
|
||||||
|
* server loads), and checks each method + path against `createOpenApiSpec()`.
|
||||||
|
* Path parameters are compared by position only (`:token` and `{ref}` match),
|
||||||
|
* and the two alias mounts that the spec documents by their tag description
|
||||||
|
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
|
||||||
|
* are allowlisted. It also fails on a spec operation no route answers.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("path");
|
||||||
|
const os = require("os");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const TEST_DB = path.join(os.tmpdir(), `dashboard-openapi-cov-${Date.now()}-${process.pid}.db`);
|
||||||
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
// Record each app.use mount path on the layer it creates — Express keeps only
|
||||||
|
// a compiled matcher, which cannot be turned back into the prefix.
|
||||||
|
const express = require("express");
|
||||||
|
const originalUse = express.application.use;
|
||||||
|
express.application.use = function (mount, ...rest) {
|
||||||
|
const out = originalUse.call(this, mount, ...rest);
|
||||||
|
if (typeof mount === "string" || Array.isArray(mount)) {
|
||||||
|
const stack = (this.router || this._router).stack;
|
||||||
|
stack[stack.length - 1].__mount = mount;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { createApp } = require("../index");
|
||||||
|
const { createOpenApiSpec } = require("../openapi");
|
||||||
|
|
||||||
|
/** Mounts that repeat another, documented mount by design (see the tags). */
|
||||||
|
const ALIAS_PREFIXES = ["/api/workspaces", "/api/ai-routers"];
|
||||||
|
/** Spec paths served by middleware rather than a route (Swagger UI). */
|
||||||
|
const MIDDLEWARE_PATHS = ["get /api/docs"];
|
||||||
|
const METHODS = new Set(["get", "post", "put", "patch", "delete"]);
|
||||||
|
|
||||||
|
/** `:id`, `:id(\\d+)`, `{id}` all become `{}`; a trailing slash is dropped. */
|
||||||
|
function normalize(p) {
|
||||||
|
return (
|
||||||
|
p
|
||||||
|
.replace(/:[A-Za-z_]\w*(\([^)]*\))?\??/g, "{}")
|
||||||
|
.replace(/\{[^}]+\}/g, "{}")
|
||||||
|
.replace(/\/$/, "") || "/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRoutes(app) {
|
||||||
|
const out = new Set();
|
||||||
|
const walk = (stack, prefixes) => {
|
||||||
|
for (const layer of stack) {
|
||||||
|
if (layer.route) {
|
||||||
|
const routePaths = [].concat(layer.route.path);
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
for (const rp of routePaths) {
|
||||||
|
for (const m of Object.keys(layer.route.methods)) {
|
||||||
|
if (METHODS.has(m)) out.add(`${m} ${normalize(prefix + rp)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (layer.handle && Array.isArray(layer.handle.stack)) {
|
||||||
|
const mounts = layer.__mount === undefined ? [""] : [].concat(layer.__mount);
|
||||||
|
walk(
|
||||||
|
layer.handle.stack,
|
||||||
|
prefixes.flatMap((a) => mounts.map((b) => a + b))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk((app.router || app._router).stack, [""]);
|
||||||
|
return [...out].filter((k) => k.split(" ")[1].startsWith("/api"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OpenAPI coverage", () => {
|
||||||
|
let routes;
|
||||||
|
let documented;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
routes = collectRoutes(createApp());
|
||||||
|
documented = new Set();
|
||||||
|
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
|
||||||
|
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the routes it is meant to check", () => {
|
||||||
|
assert.ok(routes.length > 300, `only ${routes.length} routes found — the walk is broken`);
|
||||||
|
assert.ok(routes.includes("get /api/lanes/{}"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documents every registered /api route", () => {
|
||||||
|
const missing = routes.filter(
|
||||||
|
(k) =>
|
||||||
|
!documented.has(k) &&
|
||||||
|
!ALIAS_PREFIXES.some((a) => {
|
||||||
|
const p = k.split(" ")[1];
|
||||||
|
return p === a || p.startsWith(a + "/");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.deepEqual(missing, [], `routes missing from the OpenAPI spec:\n${missing.join("\n")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documents no operation that no route answers", () => {
|
||||||
|
const have = new Set(routes);
|
||||||
|
const stale = [...documented].filter((k) => !have.has(k) && !MIDDLEWARE_PATHS.includes(k));
|
||||||
|
assert.deepEqual(stale, [], `spec operations with no route:\n${stale.join("\n")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("exit", () => {
|
||||||
|
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`]) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(f);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -235,6 +235,26 @@ describe("pty-attach", () => {
|
|||||||
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
|
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches a terminal that names no size at the window's own size", () => {
|
||||||
|
// A terminal on a hidden tab is 0×0 and sends no size; spawning its attach
|
||||||
|
// at a default resized the pane under every other client.
|
||||||
|
const opts = [];
|
||||||
|
ptyAttach.__setSpawnImpl((_f, _a, o) => {
|
||||||
|
opts.push(o);
|
||||||
|
return makeFakePty();
|
||||||
|
});
|
||||||
|
tmux.__setExecImpl((args) => (args.includes("#{window_width} #{window_height}") ? "141 48\n" : ""));
|
||||||
|
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: undefined, rows: undefined });
|
||||||
|
assert.deepEqual([opts[0].cols, opts[0].rows], [141, 48]);
|
||||||
|
// tmux cannot answer: the old default, never a crash.
|
||||||
|
ptyAttach.__resetGroups();
|
||||||
|
tmux.__setExecImpl(() => {
|
||||||
|
throw new Error("no server");
|
||||||
|
});
|
||||||
|
ptyAttach.attach(makeFakeWs(), "agenthub-lane-2", { cols: undefined, rows: undefined });
|
||||||
|
assert.deepEqual([opts[1].cols, opts[1].rows], [80, 24]);
|
||||||
|
});
|
||||||
|
|
||||||
it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => {
|
it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => {
|
||||||
// Windows node-pty re-throws a read-pipe error nobody listens for, and its
|
// Windows node-pty re-throws a read-pipe error nobody listens for, and its
|
||||||
// write pipe has no listener at all: Stop (kill-session) ended the attach
|
// write pipe has no listener at all: Stop (kill-session) ended the attach
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|||||||
const { db } = require("../db");
|
const { db } = require("../db");
|
||||||
const { runRead } = require("../lib/read-pool");
|
const { runRead } = require("../lib/read-pool");
|
||||||
|
|
||||||
|
// The pool unrefs its worker (a server must be able to exit while one idles),
|
||||||
|
// so nothing else would keep this file's event loop alive while a job runs:
|
||||||
|
// node:test then cancels the awaiting test ("Promise resolution is still
|
||||||
|
// pending but the event loop has already resolved"). A server never sees this.
|
||||||
|
let keepAlive;
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
|
keepAlive = setInterval(() => {}, 1000);
|
||||||
db.prepare("INSERT INTO sessions (id, status) VALUES ('rp-1', 'completed')").run();
|
db.prepare("INSERT INTO sessions (id, status) VALUES ('rp-1', 'completed')").run();
|
||||||
const ins = db.prepare(
|
const ins = db.prepare(
|
||||||
"INSERT INTO events (session_id, event_type, tool_name) VALUES ('rp-1', 'PreToolUse', ?)"
|
"INSERT INTO events (session_id, event_type, tool_name) VALUES ('rp-1', 'PreToolUse', ?)"
|
||||||
@@ -34,6 +41,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
|
clearInterval(keepAlive);
|
||||||
try {
|
try {
|
||||||
db.close();
|
db.close();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -334,12 +334,12 @@ describe("POST /api/run/drop-image", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a file declared over the limit before its body arrives", async () => {
|
it("refuses a file declared over the limit before its body arrives", async () => {
|
||||||
// 4 GB announced, 1 KB sent: the answer must come without the rest.
|
// A petabyte announced, 1 KB sent: the answer must come without the rest.
|
||||||
const { status, body } = await new Promise((resolve, reject) => {
|
const { status, body } = await new Promise((resolve, reject) => {
|
||||||
const url = new URL("/api/run/drop-file?name=big.mp4", BASE);
|
const url = new URL("/api/run/drop-file?name=big.mp4", BASE);
|
||||||
const req = http.request(url, {
|
const req = http.request(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "video/mp4", "Content-Length": 4 * 1024 ** 3 },
|
headers: { "Content-Type": "video/mp4", "Content-Length": 1024 ** 5 },
|
||||||
});
|
});
|
||||||
req.on("response", (res) => {
|
req.on("response", (res) => {
|
||||||
let text = "";
|
let text = "";
|
||||||
@@ -354,8 +354,10 @@ describe("POST /api/run/drop-image", () => {
|
|||||||
});
|
});
|
||||||
assert.equal(status, 413);
|
assert.equal(status, 413);
|
||||||
assert.equal(body.error.code, "ETOOBIG");
|
assert.equal(body.error.code, "ETOOBIG");
|
||||||
// The UI shows the server's limit: a loopback browser's, here.
|
// A loopback browser has no cap, only the disk's free space (less a
|
||||||
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
|
// reserve) — and that is the limit the UI shows.
|
||||||
|
const st = fs.statfsSync(process.env.DASHBOARD_DATA_DIR);
|
||||||
|
assert.ok(body.error.max_bytes <= st.bavail * st.bsize, String(body.error.max_bytes));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams a loopback drop over the remote limit to disk", async () => {
|
it("streams a loopback drop over the remote limit to disk", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for server/lib/send-file.js: under Express 5 a server-chosen
|
||||||
|
* absolute path inside a dotted directory (`~/.claude/…`) is still served, a
|
||||||
|
* dotfile itself is still refused (Express 4's rule), and a download keeps its
|
||||||
|
* attachment name.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("fs");
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const express = require("express");
|
||||||
|
const { sendFile, download } = require("../lib/send-file");
|
||||||
|
|
||||||
|
describe("send-file", () => {
|
||||||
|
let dir;
|
||||||
|
let server;
|
||||||
|
let base;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "send-file-"));
|
||||||
|
fs.mkdirSync(path.join(dir, ".claude"));
|
||||||
|
fs.writeFileSync(path.join(dir, ".claude", "a.txt"), "hi");
|
||||||
|
fs.writeFileSync(path.join(dir, ".claude", ".secret"), "no");
|
||||||
|
const app = express();
|
||||||
|
app.get("/file", (_req, res) => sendFile(res, path.join(dir, ".claude", "a.txt")));
|
||||||
|
app.get("/dot", (_req, res) => sendFile(res, path.join(dir, ".claude", ".secret")));
|
||||||
|
app.get("/dl", (_req, res) => download(res, path.join(dir, ".claude", "a.txt"), "r.txt"));
|
||||||
|
await new Promise((r) => (server = app.listen(0, "127.0.0.1", r)));
|
||||||
|
base = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a file whose parent directory is a dot-directory", async () => {
|
||||||
|
const res = await fetch(`${base}/file`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(await res.text(), "hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses a dotfile", async () => {
|
||||||
|
const res = await fetch(`${base}/dot`);
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloads under the given name", async () => {
|
||||||
|
const res = await fetch(`${base}/dl`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.match(res.headers.get("content-disposition"), /^attachment; filename="?r\.txt"?$/);
|
||||||
|
assert.equal(await res.text(), "hi");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
/**
|
||||||
|
* @file server-s3-cc-config.test.js
|
||||||
|
* @description Settings for members (S3 plan, P6): a non-admin manages THEIR
|
||||||
|
* OWN Claude Code configuration through `/api/cc-config`. Covers the router's
|
||||||
|
* own allowlist gate (`cc-target.js` — no `/profiles*`, no
|
||||||
|
* `/plugins/update-all`, nothing unlisted), the one target resolver (a
|
||||||
|
* member's `~/.claude`, an admin's host home unchanged, 403 ENOACCOUNT for a
|
||||||
|
* developer with no account — never the host's), every file write running AS
|
||||||
|
* the member through `write-as` with the content on stdin (skills, settings,
|
||||||
|
* keybindings, backups), `claude plugin|mcp …` run as the member's sudo argv
|
||||||
|
* (no `-y`, user scope only, no env/headers across sudo), project writes only
|
||||||
|
* in the member's own workspaces, an admin's project-memory create in a
|
||||||
|
* member's folder written as that member, and a member's change broadcast to
|
||||||
|
* that member alone. A fake passwd (every member = this test's uid),
|
||||||
|
* server.conf, fake sudo (run-as) and a fake `claude` spawn make it run
|
||||||
|
* without root.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-cc-config-")));
|
||||||
|
const USERS = path.join(TMP, "users");
|
||||||
|
const MIA = path.join(USERS, "mia");
|
||||||
|
const BOB = path.join(USERS, "bob");
|
||||||
|
const ANA = path.join(USERS, "ana");
|
||||||
|
const HOST = path.join(TMP, "host-claude");
|
||||||
|
const TOOLS = path.join(TMP, "tools");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "data", "dashboard.db");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||||
|
process.env.CLAUDE_HOME = HOST;
|
||||||
|
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
||||||
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||||
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||||
|
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||||
|
process.env.AGENTHUB_TOOLS_ROOT = TOOLS;
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
const linux = require("../lib/linux-accounts");
|
||||||
|
const runAs = require("../lib/run-as");
|
||||||
|
const tmux = require("../lib/tmux");
|
||||||
|
const ccCli = require("../lib/cc-plugin-cli");
|
||||||
|
const scope = require("../lib/scope");
|
||||||
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||||
|
|
||||||
|
const WRITE_AS = "/opt/agenthub/libexec/write-as";
|
||||||
|
const CLAUDE = path.join(TOOLS, "bin", "claude");
|
||||||
|
const UID = process.getuid();
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const cookies = {};
|
||||||
|
let sudo;
|
||||||
|
let cliCalls = [];
|
||||||
|
|
||||||
|
const call = (who, method, p, body) =>
|
||||||
|
fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", cookie: cookies[who] },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||||
|
|
||||||
|
const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
|
||||||
|
const addLane = (cwd, owner) =>
|
||||||
|
db
|
||||||
|
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')")
|
||||||
|
.run(cwd, idOf(owner));
|
||||||
|
|
||||||
|
/** Every sudo argv recorded since the last reset, as one string each. */
|
||||||
|
const sudoLines = () => sudo.calls.map((c) => c.join(" "));
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
for (const d of [path.join(MIA, "proj"), path.join(MIA, "proj2"), path.join(BOB, "proj"), ANA])
|
||||||
|
fs.mkdirSync(d, { recursive: true });
|
||||||
|
fs.mkdirSync(HOST, { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(CLAUDE), { recursive: true });
|
||||||
|
fs.writeFileSync(CLAUDE, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||||
|
fs.writeFileSync(
|
||||||
|
process.env.AGENTHUB_PASSWD,
|
||||||
|
["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n"
|
||||||
|
);
|
||||||
|
runAs.__resetCache();
|
||||||
|
fs.writeFileSync(
|
||||||
|
process.env.AGENTHUB_SERVER_CONF,
|
||||||
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||||
|
);
|
||||||
|
linux.__resetConfCache();
|
||||||
|
linux.__setRunImpl(() => "ok");
|
||||||
|
sudo = fakeSudo();
|
||||||
|
runAs.__setExecImpl(sudo.exec);
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "-V") return "tmux 3.4";
|
||||||
|
if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 });
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
// The `claude` CLI never really runs: every spawn is recorded and answers ok.
|
||||||
|
ccCli.__setExecFileImpl((cmd, argv, opts, cb) => {
|
||||||
|
cliCalls.push({ cmd, argv, opts });
|
||||||
|
const listing = argv.includes("list")
|
||||||
|
? JSON.stringify({ installed: [], available: [{ pluginId: "p@m" }] })
|
||||||
|
: "done\n";
|
||||||
|
setImmediate(() => cb(null, listing, ""));
|
||||||
|
return { stdin: { end() {} } };
|
||||||
|
});
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
for (const [n, role] of [
|
||||||
|
["mia", "developer"],
|
||||||
|
["bob", "developer"],
|
||||||
|
["eve", "developer"], // no Linux account
|
||||||
|
["ana", "analyst"],
|
||||||
|
["boss", "admin"],
|
||||||
|
]) {
|
||||||
|
await auth.createUser(n, `${n}-pass-123`, role);
|
||||||
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
|
||||||
|
});
|
||||||
|
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
||||||
|
}
|
||||||
|
addLane(path.join(MIA, "proj"), "mia");
|
||||||
|
addLane(path.join(BOB, "proj"), "bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sudo.calls.length = 0;
|
||||||
|
cliCalls = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
ccCli.__setExecFileImpl(null);
|
||||||
|
tmux.__reset();
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
delete process.env.AGENTHUB_USERS_ROOT;
|
||||||
|
delete process.env.AGENTHUB_TOOLS_ROOT;
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the gate: what a non-admin may reach under /api/cc-config", () => {
|
||||||
|
it("keeps the host's login profiles and Update all an admin's", async () => {
|
||||||
|
for (const [method, p] of [
|
||||||
|
["GET", "/api/cc-config/profiles"],
|
||||||
|
["POST", "/api/cc-config/profiles"],
|
||||||
|
["POST", "/api/cc-config/profiles/activate"],
|
||||||
|
["POST", "/api/cc-config/profiles/usage"],
|
||||||
|
["DELETE", "/api/cc-config/profiles"],
|
||||||
|
["GET", "/api/cc-config/Profiles"],
|
||||||
|
["GET", "/api/cc-config/%70rofiles"],
|
||||||
|
["GET", "/api/cc-config/plugins/update-all"],
|
||||||
|
["POST", "/api/cc-config/plugins/update-all"],
|
||||||
|
["GET", "/api/cc-config/no-such-route"],
|
||||||
|
["POST", "/api/cc-config/no-such-route"],
|
||||||
|
]) {
|
||||||
|
const r = await call("mia", method, p, method === "GET" ? undefined : {});
|
||||||
|
assert.equal(r.status, 403, `${method} ${p}`);
|
||||||
|
assert.equal(r.body.error.code, "EADMIN", `${method} ${p}`);
|
||||||
|
}
|
||||||
|
assert.equal(cliCalls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an analyst still read the host-wide views, never change them", async () => {
|
||||||
|
assert.equal((await call("ana", "GET", "/api/cc-config/profiles")).status, 200);
|
||||||
|
const w = await call("ana", "POST", "/api/cc-config/profiles", { name: "x" });
|
||||||
|
assert.equal(w.status, 403);
|
||||||
|
assert.equal(w.body.error.code, "EADMIN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an admin every endpoint, the host's as before", async () => {
|
||||||
|
assert.equal((await call("boss", "GET", "/api/cc-config/profiles")).status, 200);
|
||||||
|
const t = await call("boss", "GET", "/api/cc-config/target");
|
||||||
|
assert.deepEqual(t.body, { kind: "host", readOnly: false, claudeHome: HOST });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a developer with no Linux account", () => {
|
||||||
|
it("is refused — never served or written the host's config", async () => {
|
||||||
|
const t = await call("eve", "GET", "/api/cc-config/target");
|
||||||
|
assert.equal(t.body.kind, "none");
|
||||||
|
for (const [method, p, body] of [
|
||||||
|
["GET", "/api/cc-config/overview"],
|
||||||
|
["GET", "/api/cc-config/settings"],
|
||||||
|
["PUT", "/api/cc-config/file", { scope: "user", type: "skills", name: "x", content: "y" }],
|
||||||
|
["PATCH", "/api/cc-config/settings", { scope: "user", set: { theme: "dark" } }],
|
||||||
|
["POST", "/api/cc-config/plugins/install", { spec: "p@m" }],
|
||||||
|
]) {
|
||||||
|
const r = await call("eve", method, p, body);
|
||||||
|
assert.equal(r.status, 403, `${method} ${p}`);
|
||||||
|
assert.equal(r.body.error.code, "ENOACCOUNT", `${method} ${p}`);
|
||||||
|
}
|
||||||
|
assert.ok(!fs.existsSync(path.join(HOST, "skills", "x")));
|
||||||
|
assert.ok(!fs.existsSync(path.join(HOST, "settings.json")));
|
||||||
|
assert.equal(cliCalls.length, 0);
|
||||||
|
assert.equal(sudo.calls.length, 0);
|
||||||
|
// The run form's own lists still answer (empty), as before.
|
||||||
|
const skills = await call("eve", "GET", "/api/cc-config/skills");
|
||||||
|
assert.equal(skills.status, 200);
|
||||||
|
assert.deepEqual(skills.body.items, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a member's own config", () => {
|
||||||
|
const own = path.join(MIA, ".claude");
|
||||||
|
|
||||||
|
it("is the one the page shows", async () => {
|
||||||
|
const t = await call("mia", "GET", "/api/cc-config/target");
|
||||||
|
assert.deepEqual(t.body, { kind: "own", readOnly: false, claudeHome: own });
|
||||||
|
const o = await call("mia", "GET", "/api/cc-config/overview");
|
||||||
|
assert.equal(o.status, 200);
|
||||||
|
assert.equal(o.body.roots.claudeHome, own);
|
||||||
|
assert.equal(o.body.roots.claudeJson, path.join(MIA, ".claude.json"));
|
||||||
|
// No workspace named: no project layer — never the dashboard's own folder.
|
||||||
|
assert.equal(o.body.roots.projectRoot, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a skill AS the member through write-as, content on stdin, with a backup", async () => {
|
||||||
|
const body = {
|
||||||
|
scope: "user",
|
||||||
|
type: "skills",
|
||||||
|
name: "mine",
|
||||||
|
content: "---\nname: mine\n---\nv1",
|
||||||
|
};
|
||||||
|
const r1 = await call("mia", "PUT", "/api/cc-config/file", body);
|
||||||
|
assert.equal(r1.status, 200, JSON.stringify(r1.body));
|
||||||
|
const file = path.join(own, "skills", "mine", "SKILL.md");
|
||||||
|
assert.equal(fs.readFileSync(file, "utf8"), body.content);
|
||||||
|
assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`)));
|
||||||
|
assert.ok(!sudoLines().some((l) => l.includes("v1")), "content crossed sudo's argv");
|
||||||
|
assert.ok(!fs.existsSync(path.join(HOST, "skills", "mine")));
|
||||||
|
|
||||||
|
sudo.calls.length = 0;
|
||||||
|
const r2 = await call("mia", "PUT", "/api/cc-config/file", { ...body, content: "v2" });
|
||||||
|
assert.equal(r2.status, 200);
|
||||||
|
assert.ok(r2.body.backupPath.startsWith(path.join(own, "cc-config-backups", "skills")));
|
||||||
|
assert.equal(fs.readFileSync(path.join(r2.body.backupPath, "SKILL.md"), "utf8"), body.content);
|
||||||
|
assert.ok(sudoLines().some((l) => l.includes(` copy ${r2.body.backupPath}`)));
|
||||||
|
|
||||||
|
const list = await call("mia", "GET", "/api/cc-config/skills?scope=user");
|
||||||
|
assert.deepEqual(
|
||||||
|
list.body.items.map((i) => i.name),
|
||||||
|
["mine"]
|
||||||
|
);
|
||||||
|
|
||||||
|
sudo.calls.length = 0;
|
||||||
|
const del = await call("mia", "DELETE", "/api/cc-config/file", {
|
||||||
|
scope: "user",
|
||||||
|
type: "skills",
|
||||||
|
name: "mine",
|
||||||
|
});
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
assert.ok(!fs.existsSync(path.join(own, "skills", "mine")));
|
||||||
|
assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "skills", "mine")} remove`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges settings.json and writes keybindings.json as the member", async () => {
|
||||||
|
const r = await call("mia", "PATCH", "/api/cc-config/settings", {
|
||||||
|
scope: "user",
|
||||||
|
set: { "env.SECRETISH": "s3cr3t-value", theme: "dark" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||||
|
const settings = JSON.parse(fs.readFileSync(path.join(own, "settings.json"), "utf8"));
|
||||||
|
assert.equal(settings.theme, "dark");
|
||||||
|
assert.ok(!sudoLines().some((l) => l.includes("s3cr3t-value")));
|
||||||
|
assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "settings.json")} replace`)));
|
||||||
|
|
||||||
|
const again = await call("mia", "PATCH", "/api/cc-config/settings", {
|
||||||
|
scope: "user",
|
||||||
|
set: { theme: "light" },
|
||||||
|
});
|
||||||
|
assert.ok(again.body.backupPath.startsWith(path.join(own, ".cc-config-backups")));
|
||||||
|
|
||||||
|
const kb = await call("mia", "PUT", "/api/cc-config/keybindings", {
|
||||||
|
groups: [{ context: "Chat", bindings: [{ key: "ctrl+t", action: "toggleTodos" }] }],
|
||||||
|
});
|
||||||
|
assert.equal(kb.status, 200, JSON.stringify(kb.body));
|
||||||
|
assert.equal(kb.body.file, path.join(own, "keybindings.json"));
|
||||||
|
const read = await call("mia", "GET", "/api/cc-config/keybindings");
|
||||||
|
assert.equal(read.body.groups[0].bindings[0].action, "toggleTodos");
|
||||||
|
// Project layers need a workspace named.
|
||||||
|
const proj = await call("mia", "PATCH", "/api/cc-config/settings", {
|
||||||
|
scope: "project",
|
||||||
|
set: { theme: "dark" },
|
||||||
|
});
|
||||||
|
assert.equal(proj.status, 400);
|
||||||
|
assert.equal(proj.body.error.code, "EBADSCOPE");
|
||||||
|
assert.ok(!fs.existsSync(path.join(HOST, "settings.json")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes into a project only in a workspace of the member's own", async () => {
|
||||||
|
const q = (cwd) => `/api/cc-config/file?cwd=${encodeURIComponent(cwd)}`;
|
||||||
|
const body = { scope: "project", type: "agents", name: "helper", content: "agent" };
|
||||||
|
const mine = await call("mia", "PUT", q(path.join(MIA, "proj")), body);
|
||||||
|
assert.equal(mine.status, 200, JSON.stringify(mine.body));
|
||||||
|
const file = path.join(MIA, "proj", ".claude", "agents", "helper.md");
|
||||||
|
assert.equal(fs.readFileSync(file, "utf8"), "agent");
|
||||||
|
assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`)));
|
||||||
|
|
||||||
|
for (const cwd of [path.join(BOB, "proj"), path.join(MIA, "proj2"), MIA]) {
|
||||||
|
const r = await call("mia", "PUT", q(cwd), body);
|
||||||
|
assert.equal(r.status, 403, cwd);
|
||||||
|
assert.equal(r.body.error.code, "EFOLDER", cwd);
|
||||||
|
}
|
||||||
|
assert.ok(!fs.existsSync(path.join(BOB, "proj", ".claude")));
|
||||||
|
const pm = await call("mia", "POST", "/api/cc-config/project-memory", {
|
||||||
|
dir: path.join(BOB, "proj"),
|
||||||
|
content: "x",
|
||||||
|
});
|
||||||
|
assert.equal(pm.status, 403);
|
||||||
|
assert.ok(!fs.existsSync(path.join(BOB, "proj", "CLAUDE.md")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs `claude plugin` and `claude mcp` as the member — no -y, user scope only", async () => {
|
||||||
|
const inst = await call("mia", "POST", "/api/cc-config/plugins/install", { spec: "p@m" });
|
||||||
|
assert.equal(inst.status, 200, JSON.stringify(inst.body));
|
||||||
|
const add = await call("mia", "POST", "/api/cc-config/marketplaces/add", {
|
||||||
|
source: "owner/repo",
|
||||||
|
});
|
||||||
|
assert.equal(add.status, 200);
|
||||||
|
const mcp = await call("mia", "POST", "/api/cc-config/mcp/add", {
|
||||||
|
name: "srv",
|
||||||
|
scope: "user",
|
||||||
|
command: "npx",
|
||||||
|
args: ["srv"],
|
||||||
|
});
|
||||||
|
assert.equal(mcp.status, 200, JSON.stringify(mcp.body));
|
||||||
|
const avail = await call("mia", "GET", "/api/cc-config/plugins/available");
|
||||||
|
assert.equal(avail.status, 200);
|
||||||
|
const prefix = ["-n", "-u", "ah-mia", `HOME=${MIA}`, "DISABLE_AUTOUPDATER=1", CLAUDE];
|
||||||
|
assert.deepEqual(
|
||||||
|
cliCalls.map((c) => [c.cmd, ...c.argv]),
|
||||||
|
[
|
||||||
|
["sudo", ...prefix, "plugin", "install", "p@m", "--scope", "user"],
|
||||||
|
["sudo", ...prefix, "plugin", "marketplace", "add", "owner/repo", "--scope", "user"],
|
||||||
|
["sudo", ...prefix, "mcp", "add", "--scope", "user", "srv", "--", "npx", "srv"],
|
||||||
|
["sudo", ...prefix, "plugin", "list", "--available", "--json"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.ok(cliCalls.every((c) => c.opts.cwd === MIA));
|
||||||
|
assert.ok(!cliCalls.some((c) => c.argv.some((a) => a === "-y" || a.startsWith("--danger"))));
|
||||||
|
|
||||||
|
cliCalls = [];
|
||||||
|
for (const [p, body] of [
|
||||||
|
["/api/cc-config/plugins/install", { spec: "p@m", scope: "project" }],
|
||||||
|
["/api/cc-config/marketplaces/add", { source: "owner/repo", scope: "local" }],
|
||||||
|
["/api/cc-config/mcp/add", { name: "s", scope: "local", command: "x" }],
|
||||||
|
["/api/cc-config/mcp/add", { name: "s", scope: "user", command: "x", env: { K: "v" } }],
|
||||||
|
[
|
||||||
|
"/api/cc-config/mcp/add",
|
||||||
|
{ name: "s", scope: "user", url: "https://x", headers: { a: "b" } },
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const r = await call("mia", "POST", p, body);
|
||||||
|
assert.equal(r.status, 400, `${p} ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
assert.equal(cliCalls.length, 0, "a refused call never spawned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers ENOTSHARED while claude is not in the shared tools", async () => {
|
||||||
|
fs.renameSync(CLAUDE, `${CLAUDE}.off`);
|
||||||
|
try {
|
||||||
|
const r = await call("mia", "POST", "/api/cc-config/plugins/action", {
|
||||||
|
key: "p@m",
|
||||||
|
action: "enable",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 503);
|
||||||
|
assert.equal(r.body.error.code, "ENOTSHARED");
|
||||||
|
assert.equal(cliCalls.length, 0);
|
||||||
|
} finally {
|
||||||
|
fs.renameSync(`${CLAUDE}.off`, CLAUDE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("an admin", () => {
|
||||||
|
it("still writes the host's own config, as the host", async () => {
|
||||||
|
const r = await call("boss", "PUT", "/api/cc-config/file", {
|
||||||
|
scope: "user",
|
||||||
|
type: "commands",
|
||||||
|
name: "hosty",
|
||||||
|
content: "host cmd",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(fs.readFileSync(path.join(HOST, "commands", "hosty.md"), "utf8"), "host cmd");
|
||||||
|
assert.equal(sudo.calls.length, 0);
|
||||||
|
const inst = await call("boss", "POST", "/api/cc-config/plugins/install", { spec: "p@m" });
|
||||||
|
assert.equal(inst.status, 200);
|
||||||
|
assert.deepEqual(
|
||||||
|
cliCalls.map((c) => [c.cmd, ...c.argv]),
|
||||||
|
[["claude", "plugin", "install", "p@m", "--scope", "user", "-y"]]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates CLAUDE.md in a member's folder as that member, never as the host", async () => {
|
||||||
|
const dir = path.join(MIA, "proj2");
|
||||||
|
const r = await call("boss", "POST", "/api/cc-config/project-memory", {
|
||||||
|
dir,
|
||||||
|
content: "# memo",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||||
|
assert.equal(fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"), "# memo");
|
||||||
|
assert.ok(
|
||||||
|
sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${path.join(dir, "CLAUDE.md")}`))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a member's config change on the WebSocket", () => {
|
||||||
|
it("names the owner, so it reaches that member alone", () => {
|
||||||
|
assert.deepEqual([...scope.exactAudience("cc_config_changed", { ownerId: 7, name: "x" })], [7]);
|
||||||
|
assert.equal(scope.exactAudience("cc_config_changed", { name: "x" }), null);
|
||||||
|
assert.equal(scope.exactAudience("lane_update", { ownerId: 7 }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is delivered to the member and not to an admin; the host's reaches both", async () => {
|
||||||
|
const WebSocket = require("ws");
|
||||||
|
const open = (who) => {
|
||||||
|
const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", {
|
||||||
|
headers: { cookie: cookies[who] },
|
||||||
|
});
|
||||||
|
const seen = [];
|
||||||
|
ws.on("message", (m) => {
|
||||||
|
const msg = JSON.parse(String(m));
|
||||||
|
if (msg.type === "cc_config_changed") seen.push(msg.data);
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
ws.on("open", () => resolve({ ws, seen })).on("error", reject)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const mia = await open("mia");
|
||||||
|
const boss = await open("boss");
|
||||||
|
try {
|
||||||
|
await call("mia", "PUT", "/api/cc-config/file", {
|
||||||
|
scope: "user",
|
||||||
|
type: "commands",
|
||||||
|
name: "secret-plan",
|
||||||
|
content: "x",
|
||||||
|
});
|
||||||
|
await call("boss", "PUT", "/api/cc-config/file", {
|
||||||
|
scope: "user",
|
||||||
|
type: "commands",
|
||||||
|
name: "host-cmd",
|
||||||
|
content: "x",
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
assert.ok(
|
||||||
|
mia.seen.some((d) => d.name === "secret-plan"),
|
||||||
|
JSON.stringify(mia.seen)
|
||||||
|
);
|
||||||
|
assert.ok(!JSON.stringify(boss.seen).includes("secret-plan"), JSON.stringify(boss.seen));
|
||||||
|
assert.ok(boss.seen.some((d) => d.name === "host-cmd"));
|
||||||
|
// The host's change reaches the member as the bare type (as before).
|
||||||
|
assert.ok(!JSON.stringify(mia.seen).includes("host-cmd"));
|
||||||
|
assert.ok(mia.seen.some((d) => d && Object.keys(d).length === 0));
|
||||||
|
} finally {
|
||||||
|
mia.ws.close();
|
||||||
|
boss.ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,7 +97,9 @@ before(async () => {
|
|||||||
);
|
);
|
||||||
linux.__resetConfCache();
|
linux.__resetConfCache();
|
||||||
linux.__setRunImpl(() => "ok");
|
linux.__setRunImpl(() => "ok");
|
||||||
runAs.__setExecImpl(fakeSudo().exec);
|
const sudo = fakeSudo();
|
||||||
|
runAs.__setExecImpl(sudo.exec);
|
||||||
|
runAs.__setSpawnImpl(sudo.spawn);
|
||||||
tmux.__setExecImpl(fakeTmux);
|
tmux.__setExecImpl(fakeTmux);
|
||||||
server = await startServer(createApp(), 0);
|
server = await startServer(createApp(), 0);
|
||||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
@@ -708,6 +710,8 @@ describe("a dropped file", () => {
|
|||||||
byAdmin.body.path.startsWith(path.join(MIA, ".agenthub", "handoff", "drops") + path.sep),
|
byAdmin.body.path.startsWith(path.join(MIA, ".agenthub", "handoff", "drops") + path.sep),
|
||||||
byAdmin.body.path
|
byAdmin.body.path
|
||||||
);
|
);
|
||||||
|
// Streamed to the member's file through write-as, not held whole.
|
||||||
|
assert.equal(fs.readFileSync(byAdmin.body.path, "utf8"), "hello");
|
||||||
// A host workspace's pane runs as the host: the host's own 0700 drops dir.
|
// A host workspace's pane runs as the host: the host's own 0700 drops dir.
|
||||||
const hostLane = addLane(path.join(TMP, "hostdir", "d"), "boss");
|
const hostLane = addLane(path.join(TMP, "hostdir", "d"), "boss");
|
||||||
const host = await drop("boss", hostLane);
|
const host = await drop("boss", hostLane);
|
||||||
|
|||||||
@@ -571,6 +571,8 @@ test.describe("ship-feature pipeline template", () => {
|
|||||||
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
|
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
|
||||||
["qc", "Agent", { subagent_type: "qc-local" }],
|
["qc", "Agent", { subagent_type: "qc-local" }],
|
||||||
["qc", "Bash", { command: "agenthub lanes proof-link" }],
|
["qc", "Bash", { command: "agenthub lanes proof-link" }],
|
||||||
|
["qc", "Bash", { command: "agenthub workspace proof-link" }],
|
||||||
|
["gates", "Bash", { command: "agenthub workspace hook ci-gate" }],
|
||||||
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
|
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
|
||||||
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
|
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
|
||||||
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
|
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ describe("sizeFromQuery — the attach carries the client's real size", () => {
|
|||||||
assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 });
|
assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to 80x24 when the client named no size", () => {
|
// No size (a terminal on a hidden tab) stays no size: the attach then takes
|
||||||
assert.deepEqual(sizeFromQuery(query("")), { cols: 80, rows: 24 });
|
// the window's own, never a default that would resize the pane.
|
||||||
|
const none = { cols: undefined, rows: undefined };
|
||||||
|
|
||||||
|
it("names no size when the client named none", () => {
|
||||||
|
assert.deepEqual(sizeFromQuery(query("")), none);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses anything out of bounds — this number reaches an ioctl", () => {
|
it("refuses anything out of bounds — this number reaches an ioctl", () => {
|
||||||
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), { cols: 80, rows: 24 });
|
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), none);
|
||||||
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), { cols: 80, rows: 24 });
|
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), none);
|
||||||
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12.5")), { cols: 80, rows: 12 });
|
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12")), none);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-3
@@ -159,6 +159,10 @@ const APP_VERSION = (() => {
|
|||||||
|
|
||||||
function createApp() {
|
function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
// Express 5 parses a query string with node's `querystring` by default;
|
||||||
|
// keep Express 4's `qs` parsing, which every route and guard (`namedIds`
|
||||||
|
// refusing `?lane[]=`/`?lane[x]=`) was written against.
|
||||||
|
app.set("query parser", "extended");
|
||||||
const openApiSpec = createOpenApiSpec();
|
const openApiSpec = createOpenApiSpec();
|
||||||
|
|
||||||
// Security hardening (GHSA-gr74-4xfh-6jw9): loopback-only CORS, a Host-header
|
// Security hardening (GHSA-gr74-4xfh-6jw9): loopback-only CORS, a Host-header
|
||||||
@@ -181,6 +185,12 @@ function createApp() {
|
|||||||
);
|
);
|
||||||
app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway);
|
app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway);
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
// Express 4's parser left `req.body` as `{}` when there was no JSON body;
|
||||||
|
// Express 5's leaves it undefined, and handlers destructure it.
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
if (req.body === undefined) req.body = {};
|
||||||
|
next();
|
||||||
|
});
|
||||||
app.use("/api", tokenGuard);
|
app.use("/api", tokenGuard);
|
||||||
// Server mode's login gate (server/lib/auth.js); a no-op in local mode.
|
// Server mode's login gate (server/lib/auth.js); a no-op in local mode.
|
||||||
app.use("/api", authGuard);
|
app.use("/api", authGuard);
|
||||||
@@ -251,7 +261,7 @@ function createApp() {
|
|||||||
// (complements Swagger UI's interactive console at /api/docs). The bundle is
|
// (complements Swagger UI's interactive console at /api/docs). The bundle is
|
||||||
// served from node_modules, never a CDN, so the reference works offline.
|
// served from node_modules, never a CDN, so the reference works offline.
|
||||||
app.get("/api/redoc/redoc.standalone.js", (_req, res) => {
|
app.get("/api/redoc/redoc.standalone.js", (_req, res) => {
|
||||||
res.sendFile(redocBundlePath(), (err) => {
|
require("./lib/send-file").sendFile(res, redocBundlePath(), (err) => {
|
||||||
if (err && !res.headersSent) res.status(500).end();
|
if (err && !res.headersSent) res.status(500).end();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -341,9 +351,9 @@ function startServer(app, port) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
app.get("*", (_req, res) => {
|
app.get("/{*splat}", (_req, res) => {
|
||||||
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
||||||
res.sendFile(path.join(clientDist, "index.html"));
|
require("./lib/send-file").sendFile(res, path.join(clientDist, "index.html"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -56,12 +56,13 @@ const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|||||||
/**
|
/**
|
||||||
* API prefixes whose writes change the host account's own setup. (AI Proxy
|
* API prefixes whose writes change the host account's own setup. (AI Proxy
|
||||||
* profiles are not here: a member writes their own personal ones, and
|
* profiles are not here: a member writes their own personal ones, and
|
||||||
* `routes/ai-proxies.js` keeps the shared ones an admin's.)
|
* `routes/ai-proxies.js` keeps the shared ones an admin's. Nor is
|
||||||
|
* `/cc-config`: its router gates itself — `cc-target.js`'s allowlist — since a
|
||||||
|
* member there manages their OWN Claude Code config.)
|
||||||
*/
|
*/
|
||||||
const ADMIN_WRITE_PREFIXES = [
|
const ADMIN_WRITE_PREFIXES = [
|
||||||
"/settings",
|
"/settings",
|
||||||
"/updates",
|
"/updates",
|
||||||
"/cc-config",
|
|
||||||
"/codex-config",
|
"/codex-config",
|
||||||
// The AI tools' login profiles: switching one changes the host's own login.
|
// The AI tools' login profiles: switching one changes the host's own login.
|
||||||
"/tool-profiles",
|
"/tool-profiles",
|
||||||
@@ -79,16 +80,18 @@ const ADMIN_WRITE_PREFIXES = [
|
|||||||
"/accounts",
|
"/accounts",
|
||||||
// Alert rules and budgets fire for every user's sessions.
|
// Alert rules and budgets fire for every user's sessions.
|
||||||
"/alerts/rules",
|
"/alerts/rules",
|
||||||
|
// Stopping, updating and restarting the host's 9router (both mounts).
|
||||||
|
"/ai-proxies/9router",
|
||||||
|
"/ai-routers/9router",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API prefixes a developer may not reach at all, reads included: the host's own
|
* API prefixes a developer may not reach at all, reads included: the host's own
|
||||||
* configuration and logs, and every view across all users (export, metrics,
|
* configuration and logs, and every view across all users (export, metrics,
|
||||||
* reports, webhook deliveries, remote machines, imports). Admin and analyst
|
* reports, webhook deliveries, remote machines, imports). Admin and analyst
|
||||||
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a developer's own work needs.
|
* pass. (`/cc-config` gates itself: see ADMIN_WRITE_PREFIXES.)
|
||||||
*/
|
*/
|
||||||
const STAFF_ONLY_PREFIXES = [
|
const STAFF_ONLY_PREFIXES = [
|
||||||
"/cc-config",
|
|
||||||
"/codex-config",
|
"/codex-config",
|
||||||
"/tool-profiles",
|
"/tool-profiles",
|
||||||
"/git-credentials",
|
"/git-credentials",
|
||||||
@@ -104,8 +107,6 @@ const STAFF_ONLY_PREFIXES = [
|
|||||||
// The machine's hardware and every user's CPU/memory share.
|
// The machine's hardware and every user's CPU/memory share.
|
||||||
"/host",
|
"/host",
|
||||||
];
|
];
|
||||||
// The run form's `/` autocomplete lists commands and skills.
|
|
||||||
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
|
|
||||||
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
||||||
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
||||||
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
|
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
|
||||||
@@ -723,8 +724,7 @@ function authGuard(req, res, next) {
|
|||||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||||
if (
|
if (
|
||||||
user.role === "developer" &&
|
user.role === "developer" &&
|
||||||
((STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under))) ||
|
(STAFF_ONLY_PREFIXES.some(under) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
|
||||||
STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
|
|
||||||
) {
|
) {
|
||||||
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-80
@@ -6,10 +6,20 @@
|
|||||||
* scripts). Powers the Claude Config Explorer page. All operations are pure
|
* scripts). Powers the Claude Config Explorer page. All operations are pure
|
||||||
* file reads — never writes.
|
* file reads — never writes.
|
||||||
*
|
*
|
||||||
* Path containment: every read resolves under getClaudeHome(),
|
* Path containment: every read resolves under the Claude home being read
|
||||||
* getProjectClaudeDir(), or getProjectRoot() (for CLAUDE.md). Reads outside
|
* (`opts.home`, else getClaudeHome()), getProjectClaudeDir(), or
|
||||||
* those roots return null. Settings are redacted of secret-like keys before
|
* getProjectRoot() (for CLAUDE.md). Reads outside those roots return null.
|
||||||
* returning.
|
* Settings are redacted of secret-like keys before returning.
|
||||||
|
*
|
||||||
|
* Whose config: every reader takes `opts.home` (a Claude dir) and
|
||||||
|
* `opts.claudeJson` — a member's own (`<home>/.claude`, `<home>/.claude.json`)
|
||||||
|
* when the Config Explorer serves a member under S3 — and `opts.noProject`,
|
||||||
|
* which reads no project layer at all (a member who named no workspace: never
|
||||||
|
* the dashboard's own folder). A path inside a member's home is read only
|
||||||
|
* through run-as's guarded reader (a regular file that member owns, size
|
||||||
|
* capped) and listed only when it really lies in that home and is theirs
|
||||||
|
* (`memberPathOk`), and a plugin's or marketplace's install path that the
|
||||||
|
* member's own JSON names is followed only inside their home.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
@@ -28,6 +38,9 @@ function getProjectClaudeDir(cwd) {
|
|||||||
return path.join(getProjectRoot(cwd), ".claude");
|
return path.join(getProjectRoot(cwd), ".claude");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Claude dir a read is about: the caller's `opts.home`, else the host's. */
|
||||||
|
const homeOf = (opts) => (opts && opts.home) || getClaudeHome();
|
||||||
|
|
||||||
function getClaudeJsonPath() {
|
function getClaudeJsonPath() {
|
||||||
// NOT path.join(getClaudeHome(), ".claude.json") — verified against the
|
// NOT path.join(getClaudeHome(), ".claude.json") — verified against the
|
||||||
// real `claude` binary, the two CLAUDE_HOME branches behave differently:
|
// real `claude` binary, the two CLAUDE_HOME branches behave differently:
|
||||||
@@ -57,6 +70,52 @@ function isUnder(root, target) {
|
|||||||
return t.startsWith(r + path.sep);
|
return t.startsWith(r + path.sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3: the member whose home `p` lies in (`run-as.ownerForRead`), or null for a
|
||||||
|
* host path / outside S3.
|
||||||
|
*/
|
||||||
|
function memberOf(p) {
|
||||||
|
try {
|
||||||
|
return require("./run-as").ownerForRead(p);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a path may be listed or stat'ed for what it holds: always outside a
|
||||||
|
* member's home; inside one only when its real path stays in that home and
|
||||||
|
* the member owns it. The dashboard reads with CAP_DAC_READ_SEARCH, so a link
|
||||||
|
* a member planted (`~/.claude/hooks -> /root`) would otherwise list another
|
||||||
|
* account's file names.
|
||||||
|
*/
|
||||||
|
function memberPathOk(p) {
|
||||||
|
const acct = memberOf(p);
|
||||||
|
if (!acct) return true;
|
||||||
|
try {
|
||||||
|
const real = fs.realpathSync(p);
|
||||||
|
if (!isUnder(fs.realpathSync(acct.home), real)) return false;
|
||||||
|
return fs.statSync(real).uid === acct.uid;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A path a member's OWN JSON names (a plugin's `installPath`, a marketplace's
|
||||||
|
* `installLocation`) is followed only inside that member's home; any path is
|
||||||
|
* followed for the host, as before. `claudeDir` is the home being read.
|
||||||
|
*/
|
||||||
|
function followable(claudeDir, p) {
|
||||||
|
const acct = memberOf(claudeDir);
|
||||||
|
if (!acct) return true;
|
||||||
|
try {
|
||||||
|
return isUnder(fs.realpathSync(acct.home), fs.realpathSync(p)) && memberPathOk(p);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readJson(absPath) {
|
function readJson(absPath) {
|
||||||
try {
|
try {
|
||||||
const raw = require("./run-as").readFileSafe(absPath, "utf8");
|
const raw = require("./run-as").readFileSafe(absPath, "utf8");
|
||||||
@@ -122,6 +181,8 @@ function parseFrontmatter(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function safeReadText(absPath) {
|
function safeReadText(absPath) {
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
if (runAs.isMemberFile(absPath)) return memberReadText(runAs, absPath);
|
||||||
try {
|
try {
|
||||||
const stat = fs.statSync(absPath);
|
const stat = fs.statSync(absPath);
|
||||||
if (!stat.isFile()) return null;
|
if (!stat.isFile()) return null;
|
||||||
@@ -144,7 +205,36 @@ function safeReadText(absPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `safeReadText` for a member's file: opened through run-as's guard (a regular
|
||||||
|
* file that member owns, never a FIFO or another account's file through a
|
||||||
|
* link), and only the first MAX_FILE_BYTES read — never the whole of a file
|
||||||
|
* the member made huge.
|
||||||
|
*/
|
||||||
|
function memberReadText(runAs, absPath) {
|
||||||
|
let fd;
|
||||||
|
try {
|
||||||
|
fd = runAs.openSafe(absPath);
|
||||||
|
const st = fs.fstatSync(fd);
|
||||||
|
const want = Math.min(st.size, MAX_FILE_BYTES);
|
||||||
|
const buf = Buffer.alloc(want);
|
||||||
|
let off = 0;
|
||||||
|
for (let n; off < want && (n = fs.readSync(fd, buf, off, want - off, off)) > 0;) off += n;
|
||||||
|
return {
|
||||||
|
truncated: st.size > MAX_FILE_BYTES,
|
||||||
|
size: st.size,
|
||||||
|
text: buf.toString("utf8", 0, off),
|
||||||
|
mtime: st.mtimeMs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (fd != null) fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function listDir(absPath) {
|
function listDir(absPath) {
|
||||||
|
if (!memberPathOk(absPath)) return [];
|
||||||
try {
|
try {
|
||||||
return fs.readdirSync(absPath, { withFileTypes: true });
|
return fs.readdirSync(absPath, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -223,9 +313,9 @@ function readSkillsAt(scope, claudeDir) {
|
|||||||
function readSkills(opts = {}) {
|
function readSkills(opts = {}) {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (opts.scope !== "project") {
|
if (opts.scope !== "project") {
|
||||||
out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome()));
|
out.push(...readSkillsAt("user", opts.userClaudeDir || homeOf(opts)));
|
||||||
}
|
}
|
||||||
if (opts.scope !== "user") {
|
if (opts.scope !== "user" && !opts.noProject) {
|
||||||
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
|
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -262,9 +352,9 @@ function readSimpleMdSurface(subdir) {
|
|||||||
return (opts = {}) => {
|
return (opts = {}) => {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (opts.scope !== "project") {
|
if (opts.scope !== "project") {
|
||||||
out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir));
|
out.push(...readMdFilesAt("user", opts.userClaudeDir || homeOf(opts), subdir));
|
||||||
}
|
}
|
||||||
if (opts.scope !== "user") {
|
if (opts.scope !== "user" && !opts.noProject) {
|
||||||
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
|
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -396,7 +486,11 @@ function readPluginContributions(installPath) {
|
|||||||
if (!installPath) return null;
|
if (!installPath) return null;
|
||||||
let pluginJson = null;
|
let pluginJson = null;
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(path.join(installPath, ".claude-plugin", "plugin.json"), "utf8");
|
const raw = require("./run-as").readFileSafe(
|
||||||
|
path.join(installPath, ".claude-plugin", "plugin.json"),
|
||||||
|
"utf8",
|
||||||
|
{ max: MAX_FILE_BYTES }
|
||||||
|
);
|
||||||
pluginJson = JSON.parse(raw);
|
pluginJson = JSON.parse(raw);
|
||||||
} catch {
|
} catch {
|
||||||
pluginJson = null;
|
pluginJson = null;
|
||||||
@@ -416,13 +510,9 @@ function readPluginContributions(installPath) {
|
|||||||
outputStyles: items.outputStyles.length,
|
outputStyles: items.outputStyles.length,
|
||||||
items,
|
items,
|
||||||
hooks: (() => {
|
hooks: (() => {
|
||||||
try {
|
return listDir(path.join(installPath, "hooks")).filter((e) =>
|
||||||
return fs
|
isFileLike(e, path.join(installPath, "hooks", e.name))
|
||||||
.readdirSync(path.join(installPath, "hooks"), { withFileTypes: true })
|
).length;
|
||||||
.filter((e) => isFileLike(e, path.join(installPath, "hooks", e.name))).length;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
})(),
|
})(),
|
||||||
pluginJson,
|
pluginJson,
|
||||||
};
|
};
|
||||||
@@ -448,7 +538,10 @@ function readPlugins(home = getClaudeHome()) {
|
|||||||
const installPath = inst.installPath;
|
const installPath = inst.installPath;
|
||||||
let exists = false;
|
let exists = false;
|
||||||
try {
|
try {
|
||||||
exists = installPath ? fs.statSync(installPath).isDirectory() : false;
|
exists =
|
||||||
|
installPath && followable(home, installPath)
|
||||||
|
? fs.statSync(installPath).isDirectory()
|
||||||
|
: false;
|
||||||
} catch {
|
} catch {
|
||||||
exists = false;
|
exists = false;
|
||||||
}
|
}
|
||||||
@@ -492,7 +585,7 @@ function readMcpServers(opts = {}) {
|
|||||||
const out = { user: [], projectScoped: [] };
|
const out = { user: [], projectScoped: [] };
|
||||||
// ~/.claude.json is the primary CLI state file; mcpServers can live at the
|
// ~/.claude.json is the primary CLI state file; mcpServers can live at the
|
||||||
// top level (legacy) or inside projects[<cwd>].mcpServers (per-project).
|
// top level (legacy) or inside projects[<cwd>].mcpServers (per-project).
|
||||||
const claudeJson = readJson(getClaudeJsonPath());
|
const claudeJson = readJson(opts.claudeJson || getClaudeJsonPath());
|
||||||
if (claudeJson.ok && claudeJson.data) {
|
if (claudeJson.ok && claudeJson.data) {
|
||||||
const top = claudeJson.data.mcpServers;
|
const top = claudeJson.data.mcpServers;
|
||||||
if (top && typeof top === "object") {
|
if (top && typeof top === "object") {
|
||||||
@@ -505,7 +598,7 @@ function readMcpServers(opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const projects = claudeJson.data.projects;
|
const projects = claudeJson.data.projects;
|
||||||
if (projects && typeof projects === "object") {
|
if (projects && typeof projects === "object" && !opts.noProject) {
|
||||||
const projectRoot = getProjectRoot(opts.cwd);
|
const projectRoot = getProjectRoot(opts.cwd);
|
||||||
const projectEntry = projects[projectRoot];
|
const projectEntry = projects[projectRoot];
|
||||||
if (projectEntry && projectEntry.mcpServers) {
|
if (projectEntry && projectEntry.mcpServers) {
|
||||||
@@ -520,7 +613,7 @@ function readMcpServers(opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also sniff settings.json for an mcpServers key (rare but supported).
|
// Also sniff settings.json for an mcpServers key (rare but supported).
|
||||||
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
|
const userSettings = readJson(path.join(homeOf(opts), "settings.json"));
|
||||||
if (userSettings.ok && userSettings.data && userSettings.data.mcpServers) {
|
if (userSettings.ok && userSettings.data && userSettings.data.mcpServers) {
|
||||||
for (const [name, def] of Object.entries(userSettings.data.mcpServers)) {
|
for (const [name, def] of Object.entries(userSettings.data.mcpServers)) {
|
||||||
out.user.push({
|
out.user.push({
|
||||||
@@ -567,9 +660,12 @@ const HOOK_EVENT_TYPES = [
|
|||||||
"PreCompact",
|
"PreCompact",
|
||||||
];
|
];
|
||||||
|
|
||||||
function readHooks(opts = {}) {
|
/** The settings layers a read covers: user, plus the project's two unless `noProject`. */
|
||||||
const sources = [
|
function settingsSources(opts = {}) {
|
||||||
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
|
const user = [{ scope: "user", file: path.join(homeOf(opts), "settings.json") }];
|
||||||
|
if (opts.noProject) return user;
|
||||||
|
return [
|
||||||
|
...user,
|
||||||
{
|
{
|
||||||
scope: "project",
|
scope: "project",
|
||||||
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
|
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
|
||||||
@@ -579,6 +675,10 @@ function readHooks(opts = {}) {
|
|||||||
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
|
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHooks(opts = {}) {
|
||||||
|
const sources = settingsSources(opts);
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const { scope, file } of sources) {
|
for (const { scope, file } of sources) {
|
||||||
const j = readJson(file);
|
const j = readJson(file);
|
||||||
@@ -617,17 +717,7 @@ function readHooks(opts = {}) {
|
|||||||
// ── Settings ───────────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function readSettings(opts = {}) {
|
function readSettings(opts = {}) {
|
||||||
const sources = [
|
const sources = settingsSources(opts);
|
||||||
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
|
|
||||||
{
|
|
||||||
scope: "project",
|
|
||||||
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scope: "project-local",
|
|
||||||
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return sources.map(({ scope, file }) => {
|
return sources.map(({ scope, file }) => {
|
||||||
const j = readJson(file);
|
const j = readJson(file);
|
||||||
if (!j.ok) return { scope, file, exists: false };
|
if (!j.ok) return { scope, file, exists: false };
|
||||||
@@ -643,8 +733,8 @@ function readSettings(opts = {}) {
|
|||||||
|
|
||||||
// ── Marketplaces ───────────────────────────────────────────────────────
|
// ── Marketplaces ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function readMarketplaces() {
|
function readMarketplaces(opts = {}) {
|
||||||
const home = getClaudeHome();
|
const home = homeOf(opts);
|
||||||
const knownPath = path.join(home, "plugins", "known_marketplaces.json");
|
const knownPath = path.join(home, "plugins", "known_marketplaces.json");
|
||||||
const known = readJson(knownPath);
|
const known = readJson(knownPath);
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -654,10 +744,10 @@ function readMarketplaces() {
|
|||||||
const sourceDef = def && def.source;
|
const sourceDef = def && def.source;
|
||||||
let pluginCount = null;
|
let pluginCount = null;
|
||||||
let marketplaceJson = null;
|
let marketplaceJson = null;
|
||||||
if (installLocation) {
|
if (installLocation && followable(home, installLocation)) {
|
||||||
try {
|
try {
|
||||||
const mfPath = path.join(installLocation, ".claude-plugin", "marketplace.json");
|
const mfPath = path.join(installLocation, ".claude-plugin", "marketplace.json");
|
||||||
const raw = fs.readFileSync(mfPath, "utf8");
|
const raw = require("./run-as").readFileSafe(mfPath, "utf8", { max: MAX_FILE_BYTES });
|
||||||
marketplaceJson = JSON.parse(raw);
|
marketplaceJson = JSON.parse(raw);
|
||||||
pluginCount = Array.isArray(marketplaceJson.plugins)
|
pluginCount = Array.isArray(marketplaceJson.plugins)
|
||||||
? marketplaceJson.plugins.length
|
? marketplaceJson.plugins.length
|
||||||
@@ -687,8 +777,8 @@ function readMarketplaces() {
|
|||||||
|
|
||||||
// ── Keybindings ────────────────────────────────────────────────────────
|
// ── Keybindings ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function readKeybindings() {
|
function readKeybindings(opts = {}) {
|
||||||
const file = path.join(getClaudeHome(), "keybindings.json");
|
const file = path.join(homeOf(opts), "keybindings.json");
|
||||||
const j = readJson(file);
|
const j = readJson(file);
|
||||||
if (!j.ok) return { file, exists: false };
|
if (!j.ok) return { file, exists: false };
|
||||||
const data = j.data && typeof j.data === "object" ? j.data : {};
|
const data = j.data && typeof j.data === "object" ? j.data : {};
|
||||||
@@ -710,13 +800,13 @@ function readKeybindings() {
|
|||||||
|
|
||||||
// ── Statusline (config + script content) ──────────────────────────────
|
// ── Statusline (config + script content) ──────────────────────────────
|
||||||
|
|
||||||
function readStatusline() {
|
function readStatusline(opts = {}) {
|
||||||
const userSettingsPath = path.join(getClaudeHome(), "settings.json");
|
const userSettingsPath = path.join(homeOf(opts), "settings.json");
|
||||||
const j = readJson(userSettingsPath);
|
const j = readJson(userSettingsPath);
|
||||||
const config = j.ok && j.data && j.data.statusLine ? j.data.statusLine : null;
|
const config = j.ok && j.data && j.data.statusLine ? j.data.statusLine : null;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(getClaudeHome(), "statusline.py"),
|
path.join(homeOf(opts), "statusline.py"),
|
||||||
path.join(getClaudeHome(), "statusline-command.sh"),
|
path.join(homeOf(opts), "statusline-command.sh"),
|
||||||
];
|
];
|
||||||
const scripts = [];
|
const scripts = [];
|
||||||
for (const file of candidates) {
|
for (const file of candidates) {
|
||||||
@@ -736,8 +826,8 @@ function readStatusline() {
|
|||||||
|
|
||||||
// ── Hook handler scripts dir (~/.claude/hooks/) ───────────────────────
|
// ── Hook handler scripts dir (~/.claude/hooks/) ───────────────────────
|
||||||
|
|
||||||
function readHookScripts() {
|
function readHookScripts(opts = {}) {
|
||||||
const dir = path.join(getClaudeHome(), "hooks");
|
const dir = path.join(homeOf(opts), "hooks");
|
||||||
const entries = listDir(dir);
|
const entries = listDir(dir);
|
||||||
return {
|
return {
|
||||||
dir,
|
dir,
|
||||||
@@ -747,6 +837,7 @@ function readHookScripts() {
|
|||||||
const file = path.join(dir, e.name);
|
const file = path.join(dir, e.name);
|
||||||
let stat;
|
let stat;
|
||||||
try {
|
try {
|
||||||
|
if (!memberPathOk(file)) return null;
|
||||||
stat = fs.statSync(file);
|
stat = fs.statSync(file);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -774,10 +865,10 @@ const MEMORY_INDEX_RE = /^(MEMORY|INDEX)\b/i;
|
|||||||
* mutable via cc-mutate's "auto-memory" type (create/edit/delete + backup).
|
* mutable via cc-mutate's "auto-memory" type (create/edit/delete + backup).
|
||||||
*/
|
*/
|
||||||
function readMemory(opts = {}) {
|
function readMemory(opts = {}) {
|
||||||
const sources = [
|
const sources = [{ scope: "user", file: path.join(homeOf(opts), "CLAUDE.md") }];
|
||||||
{ scope: "user", file: path.join(getClaudeHome(), "CLAUDE.md") },
|
if (!opts.noProject) {
|
||||||
{ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") },
|
sources.push({ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") });
|
||||||
];
|
}
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const { scope, file } of sources) {
|
for (const { scope, file } of sources) {
|
||||||
const r = safeReadText(file);
|
const r = safeReadText(file);
|
||||||
@@ -796,16 +887,11 @@ function readMemory(opts = {}) {
|
|||||||
// root, an unreadable memory dir, or a single bad file must never break
|
// root, an unreadable memory dir, or a single bad file must never break
|
||||||
// the memory tab — every layer is wrapped so we degrade to "fewer files".
|
// the memory tab — every layer is wrapped so we degrade to "fewer files".
|
||||||
try {
|
try {
|
||||||
const projectsRoot = path.join(getClaudeHome(), "projects");
|
const projectsRoot = path.join(homeOf(opts), "projects");
|
||||||
for (const proj of fs.readdirSync(projectsRoot)) {
|
for (const { name: proj } of listDir(projectsRoot)) {
|
||||||
const memDir = path.join(projectsRoot, proj, "memory");
|
const memDir = path.join(projectsRoot, proj, "memory");
|
||||||
let files;
|
const files = listDir(memDir)
|
||||||
try {
|
.map((e) => e.name)
|
||||||
files = fs.readdirSync(memDir);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
files = files
|
|
||||||
.filter((f) => f.endsWith(".md"))
|
.filter((f) => f.endsWith(".md"))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const rank = (f) => (MEMORY_INDEX_RE.test(f) ? 0 : 1);
|
const rank = (f) => (MEMORY_INDEX_RE.test(f) ? 0 : 1);
|
||||||
@@ -848,16 +934,18 @@ function readMemory(opts = {}) {
|
|||||||
* secret, and AgentHub's own stores under CLAUDE_HOME (`agenthub-proxies`,
|
* secret, and AgentHub's own stores under CLAUDE_HOME (`agenthub-proxies`,
|
||||||
* `agenthub-git-credentials`, the pool accounts…) — no route returns a key.
|
* `agenthub-git-credentials`, the pool accounts…) — no route returns a key.
|
||||||
*/
|
*/
|
||||||
function isSecretConfigPath(resolved) {
|
function isSecretConfigPath(resolved, home) {
|
||||||
const base = path.basename(resolved);
|
const base = path.basename(resolved);
|
||||||
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
||||||
const rel = path.relative(getClaudeHome(), resolved);
|
for (const h of new Set([getClaudeHome(), home].filter(Boolean))) {
|
||||||
if (
|
const rel = path.relative(h, resolved);
|
||||||
!rel.startsWith("..") &&
|
if (
|
||||||
!path.isAbsolute(rel) &&
|
!rel.startsWith("..") &&
|
||||||
rel.split(path.sep)[0].startsWith("agenthub-")
|
!path.isAbsolute(rel) &&
|
||||||
)
|
rel.split(path.sep)[0].startsWith("agenthub-")
|
||||||
return true;
|
)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// AgentHub's data dir is no Claude config: it holds the database, the push
|
// AgentHub's data dir is no Claude config: it holds the database, the push
|
||||||
// keys and every gateway pane's settings file WITH its live pane token.
|
// keys and every gateway pane's settings file WITH its live pane token.
|
||||||
const { getDataDir, dataDirCandidates } = require("./claude-home");
|
const { getDataDir, dataDirCandidates } = require("./claude-home");
|
||||||
@@ -872,24 +960,28 @@ function isSecretConfigPath(resolved) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readFileSafe(absPath, opts = {}) {
|
function readFileSafe(absPath, opts = {}) {
|
||||||
const allowedRoots = [
|
const allowedRoots = [homeOf(opts)];
|
||||||
getClaudeHome(),
|
if (!opts.noProject) {
|
||||||
getProjectClaudeDir(opts.cwd),
|
allowedRoots.push(
|
||||||
getProjectRoot(opts.cwd), // for CLAUDE.md only — caller must pass exact name
|
getProjectClaudeDir(opts.cwd),
|
||||||
];
|
getProjectRoot(opts.cwd) // for CLAUDE.md only — caller must pass exact name
|
||||||
|
);
|
||||||
|
}
|
||||||
const resolved = path.resolve(absPath);
|
const resolved = path.resolve(absPath);
|
||||||
const inside = allowedRoots.some((root) => isUnder(root, resolved));
|
const inside = allowedRoots.some((root) => isUnder(root, resolved));
|
||||||
if (!inside) return { error: "path is outside allowed roots" };
|
if (!inside) return { error: "path is outside allowed roots" };
|
||||||
// Extra guard: under project root we only allow CLAUDE.md (avoid leaking
|
// Extra guard: under project root we only allow CLAUDE.md (avoid leaking
|
||||||
// arbitrary repo files via this endpoint).
|
// arbitrary repo files via this endpoint).
|
||||||
if (
|
if (
|
||||||
|
!opts.noProject &&
|
||||||
isUnder(getProjectRoot(opts.cwd), resolved) &&
|
isUnder(getProjectRoot(opts.cwd), resolved) &&
|
||||||
!isUnder(getProjectClaudeDir(opts.cwd), resolved) &&
|
!isUnder(getProjectClaudeDir(opts.cwd), resolved) &&
|
||||||
path.basename(resolved) !== "CLAUDE.md"
|
path.basename(resolved) !== "CLAUDE.md"
|
||||||
) {
|
) {
|
||||||
return { error: "only CLAUDE.md is readable from project root" };
|
return { error: "only CLAUDE.md is readable from project root" };
|
||||||
}
|
}
|
||||||
if (isSecretConfigPath(resolved)) return { error: "a credential file is never served" };
|
if (isSecretConfigPath(resolved, homeOf(opts)))
|
||||||
|
return { error: "a credential file is never served" };
|
||||||
const r = safeReadText(resolved);
|
const r = safeReadText(resolved);
|
||||||
if (!r) return { error: "file not readable" };
|
if (!r) return { error: "file not readable" };
|
||||||
return { ok: true, file: resolved, ...r };
|
return { ok: true, file: resolved, ...r };
|
||||||
@@ -902,13 +994,13 @@ function readOverview(opts = {}) {
|
|||||||
const agents = readAgents(opts);
|
const agents = readAgents(opts);
|
||||||
const commands = readCommands(opts);
|
const commands = readCommands(opts);
|
||||||
const outputStyles = readOutputStyles(opts);
|
const outputStyles = readOutputStyles(opts);
|
||||||
const plugins = readPlugins();
|
const plugins = readPlugins(homeOf(opts));
|
||||||
const mcp = readMcpServers(opts);
|
const mcp = readMcpServers(opts);
|
||||||
const hooks = readHooks(opts);
|
const hooks = readHooks(opts);
|
||||||
const settings = readSettings(opts);
|
const settings = readSettings(opts);
|
||||||
const memory = readMemory(opts);
|
const memory = readMemory(opts);
|
||||||
const marketplaces = readMarketplaces();
|
const marketplaces = readMarketplaces(opts);
|
||||||
const keybindings = readKeybindings();
|
const keybindings = readKeybindings(opts);
|
||||||
|
|
||||||
const countByScope = (arr) => ({
|
const countByScope = (arr) => ({
|
||||||
user: arr.filter((x) => x.scope === "user").length,
|
user: arr.filter((x) => x.scope === "user").length,
|
||||||
@@ -923,10 +1015,10 @@ function readOverview(opts = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
roots: {
|
roots: {
|
||||||
claudeHome: getClaudeHome(),
|
claudeHome: homeOf(opts),
|
||||||
projectClaudeDir: getProjectClaudeDir(opts.cwd),
|
projectClaudeDir: opts.noProject ? null : getProjectClaudeDir(opts.cwd),
|
||||||
projectRoot: getProjectRoot(opts.cwd),
|
projectRoot: opts.noProject ? null : getProjectRoot(opts.cwd),
|
||||||
claudeJson: getClaudeJsonPath(),
|
claudeJson: opts.claudeJson || getClaudeJsonPath(),
|
||||||
},
|
},
|
||||||
counts: {
|
counts: {
|
||||||
skills: countByScope(skills),
|
skills: countByScope(skills),
|
||||||
@@ -983,6 +1075,7 @@ module.exports = {
|
|||||||
isUnder,
|
isUnder,
|
||||||
isDirLike,
|
isDirLike,
|
||||||
isFileLike,
|
isFileLike,
|
||||||
|
memberPathOk,
|
||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
HOOK_EVENT_TYPES,
|
HOOK_EVENT_TYPES,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,12 @@
|
|||||||
* route can map it to HTTP 422. Only a bad argument or a missing binary
|
* route can map it to HTTP 422. Only a bad argument or a missing binary
|
||||||
* throws — those are programming/environment errors, not CLI outcomes.
|
* throws — those are programming/environment errors, not CLI outcomes.
|
||||||
*
|
*
|
||||||
|
* S3, a member's own config (`acct`): the CLI runs AS the member through
|
||||||
|
* sudo (cc-plugin-cli's `runClaude`), `user` scope only (their CLI runs in
|
||||||
|
* their home, no project), and with no `-e`/`--header` at all — those carry
|
||||||
|
* keys and tokens, and sudo logs its whole argv. Such a server is added from
|
||||||
|
* the member's own terminal instead.
|
||||||
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -174,15 +180,34 @@ function headerFlags(headers) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3, a member's own config: `user` scope only, and no env values or headers
|
||||||
|
* — they would cross sudo's argv, which sudo logs.
|
||||||
|
*/
|
||||||
|
function assertMemberMcp({ scope, headers, env, acct }) {
|
||||||
|
if (!acct) return;
|
||||||
|
if (scope && scope !== "user") {
|
||||||
|
throw makeError("EBADARG", "only scope user can be changed for your own config");
|
||||||
|
}
|
||||||
|
const has = (o) => o && typeof o === "object" && Object.keys(o).length > 0;
|
||||||
|
if (has(headers) || has(env)) {
|
||||||
|
throw makeError(
|
||||||
|
"EBADARG",
|
||||||
|
"env values and headers would be logged by sudo: add this server with `claude mcp add` in your own terminal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `claude mcp add` has two mutually exclusive forms, matching the real CLI
|
* `claude mcp add` has two mutually exclusive forms, matching the real CLI
|
||||||
* (not a `--url` flag — see the file header): an http/sse server (`name`,
|
* (not a `--url` flag — see the file header): an http/sse server (`name`,
|
||||||
* positional `url`, optional `--header`) or a stdio server (`name`, optional
|
* positional `url`, optional `--header`) or a stdio server (`name`, optional
|
||||||
* `-e`, `-- command args…`).
|
* `-e`, `-- command args…`).
|
||||||
*/
|
*/
|
||||||
async function mcpAdd({ name, scope, transport, command, args = [], url, headers, env }) {
|
async function mcpAdd({ name, scope, transport, command, args = [], url, headers, env, acct }) {
|
||||||
const n = assertName(name);
|
const n = assertName(name);
|
||||||
const sc = assertMcpScope(scope, { required: true });
|
const sc = assertMcpScope(scope, { required: true });
|
||||||
|
assertMemberMcp({ scope: sc, headers, env, acct });
|
||||||
if (url && command) {
|
if (url && command) {
|
||||||
throw makeError("EBADARG", "mcpAdd accepts either url or command, not both");
|
throw makeError("EBADARG", "mcpAdd accepts either url or command, not both");
|
||||||
}
|
}
|
||||||
@@ -201,8 +226,9 @@ async function mcpAdd({ name, scope, transport, command, args = [], url, headers
|
|||||||
}
|
}
|
||||||
const t = transport === "sse" ? "sse" : "http";
|
const t = transport === "sse" ? "sse" : "http";
|
||||||
const hFlags = headerFlags(headers);
|
const hFlags = headerFlags(headers);
|
||||||
return serialize(() =>
|
return serialize(
|
||||||
runClaude(["mcp", "add", "--scope", sc, "--transport", t, n, u, ...hFlags])
|
() => runClaude(["mcp", "add", "--scope", sc, "--transport", t, n, u, ...hFlags], { acct }),
|
||||||
|
acct
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,16 +242,18 @@ async function mcpAdd({ name, scope, transport, command, args = [], url, headers
|
|||||||
// env/header values, applied because of position rather than KEY=VALUE.
|
// env/header values, applied because of position rather than KEY=VALUE.
|
||||||
const cmdArgs = args.map((a, i) => assertEmbeddedValue(a, `args[${i}]`));
|
const cmdArgs = args.map((a, i) => assertEmbeddedValue(a, `args[${i}]`));
|
||||||
const eFlags = envFlags(env);
|
const eFlags = envFlags(env);
|
||||||
return serialize(() =>
|
return serialize(
|
||||||
runClaude(["mcp", "add", "--scope", sc, n, ...eFlags, "--", cmd, ...cmdArgs])
|
() => runClaude(["mcp", "add", "--scope", sc, n, ...eFlags, "--", cmd, ...cmdArgs], { acct }),
|
||||||
|
acct
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mcpRemove({ name, scope }) {
|
async function mcpRemove({ name, scope, acct }) {
|
||||||
const n = assertName(name);
|
const n = assertName(name);
|
||||||
const sc = assertMcpScope(scope);
|
const sc = assertMcpScope(scope);
|
||||||
|
assertMemberMcp({ scope: sc, acct });
|
||||||
const scopeFlags = sc ? ["--scope", sc] : [];
|
const scopeFlags = sc ? ["--scope", sc] : [];
|
||||||
return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags]));
|
return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags], { acct }), acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
+188
-57
@@ -24,13 +24,23 @@
|
|||||||
* any failure path.
|
* any failure path.
|
||||||
* - Names are validated against a strict allowlist regex; resolved paths
|
* - Names are validated against a strict allowlist regex; resolved paths
|
||||||
* are double-checked to live under the expected root before any I/O.
|
* are double-checked to live under the expected root before any I/O.
|
||||||
|
*
|
||||||
|
* Whose config: every entry point takes `home` (a Claude dir; the host's
|
||||||
|
* getClaudeHome() when absent) and `noProject` (refuse the project layers —
|
||||||
|
* a member who named no workspace of theirs; never the dashboard's own
|
||||||
|
* folder). Every file operation goes through `ioFor(root)`: under S3 a root
|
||||||
|
* inside a member's home — their own config, or an admin's write into a
|
||||||
|
* member's workspace — is written, backed up, created and deleted AS that
|
||||||
|
* member through `write-as` (content on stdin), and read through run-as's
|
||||||
|
* guarded reader; the host never writes inside a member's home itself. Any
|
||||||
|
* other root is plain `fs`, exactly as before.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { getClaudeHome } = require("./claude-home");
|
const { getClaudeHome } = require("./claude-home");
|
||||||
const { isUnder, MAX_FILE_BYTES } = require("./cc-discovery");
|
const { isUnder, MAX_FILE_BYTES, memberPathOk } = require("./cc-discovery");
|
||||||
const {
|
const {
|
||||||
timestamp,
|
timestamp,
|
||||||
createBackup,
|
createBackup,
|
||||||
@@ -66,28 +76,135 @@ function getProjectClaudeDir(cwd) {
|
|||||||
return path.join(getProjectRoot(cwd), ".claude");
|
return path.join(getProjectRoot(cwd), ".claude");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Claude dir a write is about: the caller's `home`, else the host's. */
|
||||||
|
const homeOf = (opts) => (opts && opts.home) || getClaudeHome();
|
||||||
|
|
||||||
|
/** No project layer for this caller (a member who named no workspace of theirs). */
|
||||||
|
function assertProject(opts) {
|
||||||
|
if (opts && opts.noProject) {
|
||||||
|
throw makeError("EBADSCOPE", "a project write needs ?cwd= naming one of your workspaces");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function rootForScope(scope, opts = {}) {
|
function rootForScope(scope, opts = {}) {
|
||||||
if (scope === "user") return getClaudeHome();
|
if (scope === "user") return homeOf(opts);
|
||||||
if (scope === "project") return getProjectClaudeDir(opts.cwd);
|
if (scope === "project") {
|
||||||
|
assertProject(opts);
|
||||||
|
return getProjectClaudeDir(opts.cwd);
|
||||||
|
}
|
||||||
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
|
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function memoryPathForScope(scope, opts = {}) {
|
function memoryPathForScope(scope, opts = {}) {
|
||||||
if (scope === "user") return path.join(getClaudeHome(), "CLAUDE.md");
|
if (scope === "user") return path.join(homeOf(opts), "CLAUDE.md");
|
||||||
if (scope === "project") return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
|
if (scope === "project") {
|
||||||
|
assertProject(opts);
|
||||||
|
return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
|
||||||
|
}
|
||||||
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
|
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── File operations: as the host, or AS the member whose home it is ──────
|
||||||
|
|
||||||
|
/** Plain `fs`: the host's own config, as it always was. */
|
||||||
|
const HOST_IO = {
|
||||||
|
acct: null,
|
||||||
|
exists: (p) => fs.existsSync(p),
|
||||||
|
readText: (file) => fs.readFileSync(file, "utf8"),
|
||||||
|
mkdir: (dir) => fs.mkdirSync(dir, { recursive: true }),
|
||||||
|
write: (file, content) => atomicWriteFile(file, content),
|
||||||
|
backup: (args) => createBackup(args),
|
||||||
|
copyFile: (src, dst) => {
|
||||||
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dst);
|
||||||
|
},
|
||||||
|
remove: (p) => fs.rmSync(p, { recursive: true, force: true }),
|
||||||
|
trustedRootReady: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3: every operation AS `acct`, through `write-as` (content on stdin, never
|
||||||
|
* sudo's argv). The member can swap any name in their own home, and a write
|
||||||
|
* by the host would follow the swap — as the member, it can only do what the
|
||||||
|
* member could. Reads go through run-as's guarded reader (their own regular
|
||||||
|
* file, size capped).
|
||||||
|
*/
|
||||||
|
function memberIo(acct) {
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
const lexists = (p) => {
|
||||||
|
try {
|
||||||
|
fs.lstatSync(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const copy = (src, dst) => {
|
||||||
|
runAs.mkdirAs(acct, path.dirname(dst));
|
||||||
|
runAs.copyAs(acct, src, dst);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
acct,
|
||||||
|
exists: (p) => fs.existsSync(p),
|
||||||
|
readText: (file) => {
|
||||||
|
try {
|
||||||
|
return runAs.readFileSafe(file, "utf8", { max: MAX_FILE_BYTES });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ETOOBIG") throw makeError("ETOOLARGE", `${file} is too large`);
|
||||||
|
if (err.code === "EFOREIGNFILE") {
|
||||||
|
throw makeError("EOUTOFROOT", `${file} is not a file of this account's`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mkdir: (dir) => runAs.mkdirAs(acct, dir),
|
||||||
|
write: (file, content) => {
|
||||||
|
runAs.mkdirAs(acct, path.dirname(file));
|
||||||
|
runAs.writeAs(acct, file, "replace", content);
|
||||||
|
},
|
||||||
|
backup: ({ source, backupRoot, name }) => {
|
||||||
|
if (!lexists(source)) return null;
|
||||||
|
const dst = path.join(backupRoot, `${name}.${timestamp()}.bak`);
|
||||||
|
copy(source, dst);
|
||||||
|
return dst;
|
||||||
|
},
|
||||||
|
copyFile: copy,
|
||||||
|
remove: (p) => runAs.removeAs(acct, p),
|
||||||
|
// The host may not create a member's directory itself (assertNoSymlinkEscape
|
||||||
|
// would): the member does, and the check runs with `create: false`.
|
||||||
|
trustedRootReady: (root) => runAs.mkdirAs(acct, root),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to touch files under `root`: AS the member whose home it is (S3), else
|
||||||
|
* as the host. Throws EMEMBERGONE for a member's folder with no usable
|
||||||
|
* account — never "the host then".
|
||||||
|
*/
|
||||||
|
function ioFor(root) {
|
||||||
|
const acct = require("./run-as").accountForPath(path.resolve(root));
|
||||||
|
return acct ? memberIo(acct) : HOST_IO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `assertNoSymlinkEscape` without the host ever creating a member's directory. */
|
||||||
|
function guardTarget(io, trustedRoot, target) {
|
||||||
|
io.trustedRootReady(trustedRoot);
|
||||||
|
assertNoSymlinkEscape(trustedRoot, target, {
|
||||||
|
label: "Claude",
|
||||||
|
create: io.acct ? false : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve (and validate) the memory dir for a per-project file-based memory
|
* Resolve (and validate) the memory dir for a per-project file-based memory
|
||||||
* store: ~/.claude/projects/<project>/memory/. Rejects slugs that could
|
* store: ~/.claude/projects/<project>/memory/. Rejects slugs that could
|
||||||
* traverse out of the projects root.
|
* traverse out of the projects root.
|
||||||
*/
|
*/
|
||||||
function autoMemoryDir(project) {
|
function autoMemoryDir(project, opts = {}) {
|
||||||
if (typeof project !== "string" || !PROJECT_SLUG_RE.test(project) || project.includes("..")) {
|
if (typeof project !== "string" || !PROJECT_SLUG_RE.test(project) || project.includes("..")) {
|
||||||
throw makeError("EBADPROJECT", `invalid project slug: ${project}`);
|
throw makeError("EBADPROJECT", `invalid project slug: ${project}`);
|
||||||
}
|
}
|
||||||
const projectsRoot = path.join(getClaudeHome(), "projects");
|
const projectsRoot = path.join(homeOf(opts), "projects");
|
||||||
const dir = path.join(projectsRoot, project, "memory");
|
const dir = path.join(projectsRoot, project, "memory");
|
||||||
if (!isUnder(projectsRoot, dir)) {
|
if (!isUnder(projectsRoot, dir)) {
|
||||||
throw makeError("EOUTOFROOT", "project escapes the projects root");
|
throw makeError("EOUTOFROOT", "project escapes the projects root");
|
||||||
@@ -128,7 +245,7 @@ function resolveTarget(scope, type, name, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (spec.kind === "auto-memory") {
|
if (spec.kind === "auto-memory") {
|
||||||
const memDir = autoMemoryDir(opts.project);
|
const memDir = autoMemoryDir(opts.project, opts);
|
||||||
if (typeof name !== "string" || !MEMORY_FILE_RE.test(name) || name.includes("..")) {
|
if (typeof name !== "string" || !MEMORY_FILE_RE.test(name) || name.includes("..")) {
|
||||||
throw makeError("EBADNAME", `auto-memory name must be a flat *.md filename`);
|
throw makeError("EBADNAME", `auto-memory name must be a flat *.md filename`);
|
||||||
}
|
}
|
||||||
@@ -140,7 +257,7 @@ function resolveTarget(scope, type, name, opts = {}) {
|
|||||||
target,
|
target,
|
||||||
filePath: target,
|
filePath: target,
|
||||||
containmentRoot: memDir,
|
containmentRoot: memDir,
|
||||||
trustedRoot: path.join(getClaudeHome(), "projects"),
|
trustedRoot: path.join(homeOf(opts), "projects"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,25 +307,21 @@ function autoMemoryBackupRoot(memDir) {
|
|||||||
return path.join(memDir, ".cc-config-backups", "auto-memory");
|
return path.join(memDir, ".cc-config-backups", "auto-memory");
|
||||||
}
|
}
|
||||||
|
|
||||||
function rmTreeSync(p) {
|
|
||||||
fs.rmSync(p, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always-on backup, routed to the bucket the (scope, type) pair backs up
|
* Always-on backup, routed to the bucket the (scope, type) pair backs up
|
||||||
* into. For files, copies to <bucket>/<name>.<ts>.bak. For dirs (skills),
|
* into. For files, copies to <bucket>/<name>.<ts>.bak. For dirs (skills),
|
||||||
* copies the whole tree. Returns the backup path (or null if there was
|
* copies the whole tree. Returns the backup path (or null if there was
|
||||||
* nothing to back up — e.g. brand-new file).
|
* nothing to back up — e.g. brand-new file).
|
||||||
*/
|
*/
|
||||||
function backupTarget({ scope, type, target, opts, trustedRoot }) {
|
function backupTarget({ scope, type, target, opts, trustedRoot, io = HOST_IO }) {
|
||||||
let root;
|
let root;
|
||||||
if (type === "memory") root = memoryBackupRoot(scope, opts);
|
if (type === "memory") root = memoryBackupRoot(scope, opts);
|
||||||
else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target));
|
else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target));
|
||||||
else root = backupRoot(scope, type, opts);
|
else root = backupRoot(scope, type, opts);
|
||||||
// The backup DESTINATION needs the same guard as the artifact: a link at
|
// The backup DESTINATION needs the same guard as the artifact: a link at
|
||||||
// `<root>/cc-config-backups` sends the copied contents outside the root.
|
// `<root>/cc-config-backups` sends the copied contents outside the root.
|
||||||
if (trustedRoot) assertNoSymlinkEscape(trustedRoot, root, { label: "Claude" });
|
if (trustedRoot) guardTarget(io, trustedRoot, root);
|
||||||
return createBackup({ source: target, backupRoot: root, type, name: path.basename(target) });
|
return io.backup({ source: target, backupRoot: root, type, name: path.basename(target) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ─────────────────────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────────────────────
|
||||||
@@ -220,12 +333,14 @@ function backupTarget({ scope, type, target, opts, trustedRoot }) {
|
|||||||
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
|
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
|
||||||
*/
|
*/
|
||||||
function writeArtifact(args) {
|
function writeArtifact(args) {
|
||||||
const { scope, type, name, content, cwd, project } = args;
|
const { scope, type, name, content, cwd, project, home, noProject } = args;
|
||||||
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
|
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
|
||||||
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
|
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
|
||||||
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
|
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
|
||||||
}
|
}
|
||||||
const r = resolveTarget(scope, type, name, { cwd, project });
|
const opts = { cwd, home, noProject };
|
||||||
|
const r = resolveTarget(scope, type, name, { ...opts, project });
|
||||||
|
const io = ioFor(r.trustedRoot);
|
||||||
|
|
||||||
// Containment guard: even after our regex, double-check that the resolved
|
// Containment guard: even after our regex, double-check that the resolved
|
||||||
// path actually lives under the expected root. Defends against quirks like
|
// path actually lives under the expected root. Defends against quirks like
|
||||||
@@ -235,23 +350,24 @@ function writeArtifact(args) {
|
|||||||
}
|
}
|
||||||
// The lexical check above is defeated by a symlink at the target or at any
|
// The lexical check above is defeated by a symlink at the target or at any
|
||||||
// ancestor of it — see config-write.js. Resolve before writing.
|
// ancestor of it — see config-write.js. Resolve before writing.
|
||||||
assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" });
|
guardTarget(io, r.trustedRoot, r.target);
|
||||||
|
|
||||||
const existedBefore = fs.existsSync(r.filePath);
|
const existedBefore = io.exists(r.filePath);
|
||||||
const backupPath = existedBefore
|
const backupPath = existedBefore
|
||||||
? backupTarget({
|
? backupTarget({
|
||||||
scope,
|
scope,
|
||||||
type,
|
type,
|
||||||
target: r.kind === "dir" ? r.target : r.filePath,
|
target: r.kind === "dir" ? r.target : r.filePath,
|
||||||
opts: { cwd },
|
opts,
|
||||||
trustedRoot: r.trustedRoot,
|
trustedRoot: r.trustedRoot,
|
||||||
|
io,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (r.kind === "dir") {
|
if (r.kind === "dir") {
|
||||||
fs.mkdirSync(r.target, { recursive: true });
|
io.mkdir(r.target);
|
||||||
}
|
}
|
||||||
atomicWriteFile(r.filePath, content);
|
io.write(r.filePath, content);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -267,17 +383,19 @@ function writeArtifact(args) {
|
|||||||
* the backup fails, the original is left intact.
|
* the backup fails, the original is left intact.
|
||||||
*/
|
*/
|
||||||
function deleteArtifact(args) {
|
function deleteArtifact(args) {
|
||||||
const { scope, type, name, cwd, project } = args;
|
const { scope, type, name, cwd, project, home, noProject } = args;
|
||||||
const r = resolveTarget(scope, type, name, { cwd, project });
|
const opts = { cwd, home, noProject };
|
||||||
|
const r = resolveTarget(scope, type, name, { ...opts, project });
|
||||||
|
const io = ioFor(r.trustedRoot);
|
||||||
|
|
||||||
if (!isUnder(r.containmentRoot, r.target)) {
|
if (!isUnder(r.containmentRoot, r.target)) {
|
||||||
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
|
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
|
||||||
}
|
}
|
||||||
// rmSync/unlinkSync only unlink the link itself, but the backup that runs
|
// rmSync/unlinkSync only unlink the link itself, but the backup that runs
|
||||||
// first copies THROUGH it — so the same guard has to cover delete.
|
// first copies THROUGH it — so the same guard has to cover delete.
|
||||||
assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" });
|
guardTarget(io, r.trustedRoot, r.target);
|
||||||
|
|
||||||
if (!fs.existsSync(r.target)) {
|
if (!io.exists(r.target)) {
|
||||||
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
|
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,15 +403,14 @@ function deleteArtifact(args) {
|
|||||||
scope,
|
scope,
|
||||||
type,
|
type,
|
||||||
target: r.target,
|
target: r.target,
|
||||||
opts: { cwd },
|
opts,
|
||||||
trustedRoot: r.trustedRoot,
|
trustedRoot: r.trustedRoot,
|
||||||
|
io,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (r.kind === "dir") {
|
if (io.acct) io.remove(r.target);
|
||||||
rmTreeSync(r.target);
|
else if (r.kind === "dir") HOST_IO.remove(r.target);
|
||||||
} else {
|
else fs.unlinkSync(r.target);
|
||||||
fs.unlinkSync(r.target);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, file: r.filePath, target: r.target, backupPath };
|
return { ok: true, file: r.filePath, target: r.target, backupPath };
|
||||||
}
|
}
|
||||||
@@ -307,12 +424,12 @@ function deleteArtifact(args) {
|
|||||||
// preserved and only the `bindings` array is replaced, so metadata is never
|
// preserved and only the `bindings` array is replaced, so metadata is never
|
||||||
// dropped. Backups land under CLAUDE_HOME/cc-config-backups/keybindings/.
|
// dropped. Backups land under CLAUDE_HOME/cc-config-backups/keybindings/.
|
||||||
|
|
||||||
function keybindingsFile() {
|
function keybindingsFile(opts) {
|
||||||
return path.join(getClaudeHome(), "keybindings.json");
|
return path.join(homeOf(opts), "keybindings.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
function keybindingsBackupRoot() {
|
function keybindingsBackupRoot(opts) {
|
||||||
return path.join(getClaudeHome(), "cc-config-backups", "keybindings");
|
return path.join(homeOf(opts), "cc-config-backups", "keybindings");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A keybinding key ("ctrl+t", "escape", "shift+ctrl+f") or action id
|
// A keybinding key ("ctrl+t", "escape", "shift+ctrl+f") or action id
|
||||||
@@ -331,7 +448,7 @@ function validKbString(s, max) {
|
|||||||
* @param {{ groups: Array<{context:string, bindings:Array<{key:string,action:string}>}> }} args
|
* @param {{ groups: Array<{context:string, bindings:Array<{key:string,action:string}>}> }} args
|
||||||
*/
|
*/
|
||||||
function writeKeybindings(args = {}) {
|
function writeKeybindings(args = {}) {
|
||||||
const { groups } = args;
|
const { groups, home } = args;
|
||||||
if (!Array.isArray(groups)) {
|
if (!Array.isArray(groups)) {
|
||||||
throw makeError("EBADCONTENT", "groups must be an array");
|
throw makeError("EBADCONTENT", "groups must be an array");
|
||||||
}
|
}
|
||||||
@@ -379,12 +496,13 @@ function writeKeybindings(args = {}) {
|
|||||||
outBindings.push({ context, bindings: map });
|
outBindings.push({ context, bindings: map });
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = keybindingsFile();
|
const file = keybindingsFile({ home });
|
||||||
|
const io = ioFor(homeOf({ home }));
|
||||||
|
|
||||||
// Preserve any existing top-level metadata ($schema, $docs, unknown keys).
|
// Preserve any existing top-level metadata ($schema, $docs, unknown keys).
|
||||||
let base = {};
|
let base = {};
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
const parsed = JSON.parse(io.readText(file));
|
||||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed;
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed;
|
||||||
} catch {
|
} catch {
|
||||||
base = {};
|
base = {};
|
||||||
@@ -396,16 +514,15 @@ function writeKeybindings(args = {}) {
|
|||||||
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
|
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existedBefore = fs.existsSync(file);
|
const existedBefore = io.exists(file);
|
||||||
let backupPath = null;
|
let backupPath = null;
|
||||||
if (existedBefore) {
|
if (existedBefore) {
|
||||||
const root = keybindingsBackupRoot();
|
const root = keybindingsBackupRoot({ home });
|
||||||
fs.mkdirSync(root, { recursive: true });
|
|
||||||
backupPath = path.join(root, `keybindings.json.${timestamp()}.bak`);
|
backupPath = path.join(root, `keybindings.json.${timestamp()}.bak`);
|
||||||
fs.copyFileSync(file, backupPath);
|
io.copyFile(file, backupPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
atomicWriteFile(file, content);
|
io.write(file, content);
|
||||||
|
|
||||||
return { ok: true, file, target: file, backupPath, created: !existedBefore };
|
return { ok: true, file, target: file, backupPath, created: !existedBefore };
|
||||||
}
|
}
|
||||||
@@ -417,9 +534,15 @@ function writeKeybindings(args = {}) {
|
|||||||
// layer `local` (see cc-plugin-cli.js) — the two vocabularies are separate on
|
// layer `local` (see cc-plugin-cli.js) — the two vocabularies are separate on
|
||||||
// purpose and never share a validator.
|
// purpose and never share a validator.
|
||||||
const SETTINGS_SCOPES = {
|
const SETTINGS_SCOPES = {
|
||||||
user: (opts) => path.join(getClaudeHome(), "settings.json"),
|
user: (opts) => path.join(homeOf(opts), "settings.json"),
|
||||||
project: (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
|
project: (opts) => {
|
||||||
"project-local": (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
|
assertProject(opts);
|
||||||
|
return path.join(getProjectClaudeDir(opts.cwd), "settings.json");
|
||||||
|
},
|
||||||
|
"project-local": (opts) => {
|
||||||
|
assertProject(opts);
|
||||||
|
return path.join(getProjectClaudeDir(opts.cwd), "settings.local.json");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A single dotted-path segment. `$` is allowed for `$schema`-style metadata. */
|
/** A single dotted-path segment. `$` is allowed for `$schema`-style metadata. */
|
||||||
@@ -512,8 +635,8 @@ function unsetDeep(obj, parts) {
|
|||||||
* @returns {{path:string, backupPath:string|null, created:boolean, changed:string[]}}
|
* @returns {{path:string, backupPath:string|null, created:boolean, changed:string[]}}
|
||||||
*/
|
*/
|
||||||
function patchSettings(args) {
|
function patchSettings(args) {
|
||||||
const { scope, cwd, set, unset } = args;
|
const { scope, cwd, set, unset, home, noProject } = args;
|
||||||
const opts = { cwd };
|
const opts = { cwd, home, noProject };
|
||||||
const file = settingsFileFor(scope, opts);
|
const file = settingsFileFor(scope, opts);
|
||||||
|
|
||||||
const setEntries =
|
const setEntries =
|
||||||
@@ -531,13 +654,17 @@ function patchSettings(args) {
|
|||||||
});
|
});
|
||||||
const parsedUnset = unsetPaths.map((key) => ({ parts: parseSettingsPath(key), key }));
|
const parsedUnset = unsetPaths.map((key) => ({ parts: parseSettingsPath(key), key }));
|
||||||
|
|
||||||
|
const io = ioFor(path.dirname(file));
|
||||||
let existed = false;
|
let existed = false;
|
||||||
let data = {};
|
let data = {};
|
||||||
let raw = null;
|
let raw = null;
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(file, "utf8");
|
raw = io.readText(file);
|
||||||
existed = true;
|
existed = true;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// Too big, or not this account's own file: never "fresh" — that would
|
||||||
|
// overwrite it.
|
||||||
|
if (err.code === "ETOOLARGE" || err.code === "EOUTOFROOT") throw err;
|
||||||
/* fresh file */
|
/* fresh file */
|
||||||
}
|
}
|
||||||
if (existed) {
|
if (existed) {
|
||||||
@@ -585,12 +712,11 @@ function patchSettings(args) {
|
|||||||
let backupPath = null;
|
let backupPath = null;
|
||||||
if (existed) {
|
if (existed) {
|
||||||
const root = settingsBackupRoot(scope, opts);
|
const root = settingsBackupRoot(scope, opts);
|
||||||
fs.mkdirSync(root, { recursive: true });
|
|
||||||
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
|
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
|
||||||
fs.copyFileSync(file, backupPath);
|
io.copyFile(file, backupPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
atomicWriteFile(file, output);
|
io.write(file, output);
|
||||||
return { path: file, backupPath, created: !existed, changed };
|
return { path: file, backupPath, created: !existed, changed };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +726,9 @@ function patchSettings(args) {
|
|||||||
*/
|
*/
|
||||||
function listBackups(opts = {}) {
|
function listBackups(opts = {}) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
|
const scopes = (opts.scope ? [opts.scope] : ["user", "project"]).filter(
|
||||||
|
(s) => !(opts.noProject && s === "project")
|
||||||
|
);
|
||||||
// auto-memory backups live per-project, not under a user/project root — they
|
// auto-memory backups live per-project, not under a user/project root — they
|
||||||
// are scanned separately below.
|
// are scanned separately below.
|
||||||
const types = (opts.type ? [opts.type] : Object.keys(TYPES)).filter((t) => t !== "auto-memory");
|
const types = (opts.type ? [opts.type] : Object.keys(TYPES)).filter((t) => t !== "auto-memory");
|
||||||
@@ -611,6 +739,7 @@ function listBackups(opts = {}) {
|
|||||||
type === "memory" ? memoryBackupRoot(scope, opts) : backupRoot(scope, type, opts);
|
type === "memory" ? memoryBackupRoot(scope, opts) : backupRoot(scope, type, opts);
|
||||||
let entries = [];
|
let entries = [];
|
||||||
try {
|
try {
|
||||||
|
if (!memberPathOk(root)) continue;
|
||||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
@@ -642,11 +771,13 @@ function listBackups(opts = {}) {
|
|||||||
(!opts.type || opts.type === "auto-memory") && (!opts.scope || opts.scope === "auto-memory");
|
(!opts.type || opts.type === "auto-memory") && (!opts.scope || opts.scope === "auto-memory");
|
||||||
if (wantAuto) {
|
if (wantAuto) {
|
||||||
try {
|
try {
|
||||||
const projectsRoot = path.join(getClaudeHome(), "projects");
|
const projectsRoot = path.join(homeOf(opts), "projects");
|
||||||
|
if (!memberPathOk(projectsRoot)) throw new Error("not listable");
|
||||||
for (const proj of fs.readdirSync(projectsRoot)) {
|
for (const proj of fs.readdirSync(projectsRoot)) {
|
||||||
const root = autoMemoryBackupRoot(path.join(projectsRoot, proj, "memory"));
|
const root = autoMemoryBackupRoot(path.join(projectsRoot, proj, "memory"));
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
|
if (!memberPathOk(root)) continue;
|
||||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
+123
-62
@@ -21,11 +21,23 @@
|
|||||||
* Left unset it stays unset, because the CLI's own defaults already write
|
* Left unset it stays unset, because the CLI's own defaults already write
|
||||||
* where `cc-discovery` looks — pinning it unconditionally is what once
|
* where `cc-discovery` looks — pinning it unconditionally is what once
|
||||||
* made every MCP add/remove write a file nothing reads.
|
* made every MCP add/remove write a file nothing reads.
|
||||||
|
* - S3, a member's own config (`acct` on any operation): the shared `claude`
|
||||||
|
* (`shared-tools.sharedPath`, ENOTSHARED when it is not published) runs
|
||||||
|
* AS the member — `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
|
||||||
|
* <claude> …` in their home, with their own default config dir. Only
|
||||||
|
* `--scope user`, and never `-y`: a plugin whose marketplace declares an
|
||||||
|
* install command fails instead of running unseen as them. Each account
|
||||||
|
* has its own queue, so one member's slow clone never holds up another's.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { execFile } = require("node:child_process");
|
const { execFile: realExecFile } = require("node:child_process");
|
||||||
|
// Test seam: how `claude` (or `sudo … claude`) is spawned.
|
||||||
|
let execFile = realExecFile;
|
||||||
|
function __setExecFileImpl(fn) {
|
||||||
|
execFile = fn || realExecFile;
|
||||||
|
}
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { getClaudeHome } = require("./claude-home");
|
const { getClaudeHome } = require("./claude-home");
|
||||||
|
|
||||||
@@ -98,57 +110,48 @@ function assertMarketplaceName(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertCliScope(value) {
|
function assertCliScope(value, acct) {
|
||||||
if (value === undefined || value === null || value === "") return "user";
|
if (value === undefined || value === null || value === "") return "user";
|
||||||
if (!CLI_SCOPES.includes(value)) {
|
if (!CLI_SCOPES.includes(value)) {
|
||||||
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
|
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
// A member's CLI runs in their home, which is no project: only their user
|
||||||
|
// layer is theirs to change from here.
|
||||||
|
if (acct && value !== "user") {
|
||||||
|
throw makeError("EBADARG", "only scope user can be changed for your own config");
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One in-process queue for every mutating CLI call. The dashboard is a single
|
// One in-process queue per account (the host's is "host") for every mutating
|
||||||
// Node process, so a promise chain is the whole locking story — no file lock,
|
// CLI call. The dashboard is a single Node process, so a promise chain is the
|
||||||
// no cross-process coordination. It does NOT protect against a human running
|
// whole locking story — no file lock, no cross-process coordination. It does
|
||||||
// `claude plugin …` in a terminal at the same moment; the CLI owns that race.
|
// NOT protect against a human running `claude plugin …` in a terminal at the
|
||||||
let mutationQueue = Promise.resolve();
|
// same moment; the CLI owns that race.
|
||||||
|
const mutationQueues = new Map();
|
||||||
|
|
||||||
function serialize(fn) {
|
function serialize(fn, acct) {
|
||||||
const run = mutationQueue.then(fn, fn);
|
const key = acct ? acct.login : "host";
|
||||||
mutationQueue = run.then(
|
const queue = mutationQueues.get(key) || Promise.resolve();
|
||||||
() => undefined,
|
const run = queue.then(fn, fn);
|
||||||
() => undefined
|
mutationQueues.set(
|
||||||
|
key,
|
||||||
|
run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
);
|
);
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
|
* `[command, argv, options]` for `claude <args>`: the host's own `claude` on
|
||||||
* normal result (`ok:false`), not a rejection — callers map it to a response
|
* PATH, or — for `acct` (S3) — the shared one AS the member through sudo,
|
||||||
* body. Only a spawn failure (binary missing) rejects.
|
* with only HOME and DISABLE_AUTOUPDATER set (sudo resets the rest; their
|
||||||
*
|
* default config dir is their own `~/.claude`), in their home.
|
||||||
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
|
|
||||||
* @param {{timeoutMs?:number}} [opts]
|
|
||||||
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
|
|
||||||
*/
|
*/
|
||||||
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
|
function claudeCommand(args, acct) {
|
||||||
// It also predates the confirmation prompt the flag answers, so the same
|
if (!acct) {
|
||||||
// command without it is the correct retry, not a weaker one.
|
|
||||||
const NO_YES_FLAG = /unknown option '-y'/;
|
|
||||||
|
|
||||||
async function runClaude(args, opts = {}) {
|
|
||||||
const result = await runClaudeOnce(args, opts);
|
|
||||||
if (!result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
|
|
||||||
return runClaudeOnce(
|
|
||||||
args.filter((a) => a !== "-y"),
|
|
||||||
opts
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runClaudeOnce(args, opts = {}) {
|
|
||||||
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME
|
// Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME
|
||||||
// (a non-default install). Left unset, the CLI's own default config dir
|
// (a non-default install). Left unset, the CLI's own default config dir
|
||||||
// is `~/.claude` and it writes `.claude.json` NEXT TO it (`~/.claude.json`)
|
// is `~/.claude` and it writes `.claude.json` NEXT TO it (`~/.claude.json`)
|
||||||
@@ -161,13 +164,62 @@ function runClaudeOnce(args, opts = {}) {
|
|||||||
// that branch, so both cases line up. Keep the two in step.
|
// that branch, so both cases line up. Keep the two in step.
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
||||||
|
return ["claude", args, { env }];
|
||||||
|
}
|
||||||
|
const claude = require("./shared-tools").sharedPath("claude");
|
||||||
|
if (!claude) throw makeError("ENOTSHARED", "claude is not in the shared tools yet");
|
||||||
|
// Never -y for a member, and never a --dangerously-* flag (none is built
|
||||||
|
// here; the validators refuse any value starting with "-").
|
||||||
|
const safe = args.filter((a) => a !== "-y");
|
||||||
|
const [cmd, argv] = require("./run-as").sudoArgv(acct, claude, safe, {
|
||||||
|
HOME: acct.home,
|
||||||
|
DISABLE_AUTOUPDATER: "1",
|
||||||
|
});
|
||||||
|
return [cmd, argv, { env: process.env, cwd: acct.home }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
|
||||||
|
* normal result (`ok:false`), not a rejection — callers map it to a response
|
||||||
|
* body. Only a spawn failure (binary missing) rejects.
|
||||||
|
*
|
||||||
|
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
|
||||||
|
* @param {{timeoutMs?:number, acct?:object}} [opts] `acct`: run AS that member (S3).
|
||||||
|
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
|
||||||
|
*/
|
||||||
|
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
|
||||||
|
// It also predates the confirmation prompt the flag answers, so the same
|
||||||
|
// command without it is the correct retry, not a weaker one.
|
||||||
|
const NO_YES_FLAG = /unknown option '-y'/;
|
||||||
|
|
||||||
|
async function runClaude(args, opts = {}) {
|
||||||
|
const result = await runClaudeOnce(args, opts);
|
||||||
|
if (!opts.acct && !result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
|
||||||
|
return runClaudeOnce(
|
||||||
|
args.filter((a) => a !== "-y"),
|
||||||
|
opts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runClaudeOnce(args, opts = {}) {
|
||||||
|
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let cmd, argv, spawnOpts;
|
||||||
|
try {
|
||||||
|
[cmd, argv, spawnOpts] = claudeCommand(args, opts.acct);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const child = execFile(
|
const child = execFile(
|
||||||
"claude",
|
cmd,
|
||||||
args,
|
argv,
|
||||||
{
|
{
|
||||||
timeout,
|
timeout,
|
||||||
maxBuffer: MAX_OUTPUT_BYTES,
|
maxBuffer: MAX_OUTPUT_BYTES,
|
||||||
env,
|
...spawnOpts,
|
||||||
},
|
},
|
||||||
(err, stdout, stderr) => {
|
(err, stdout, stderr) => {
|
||||||
const out = String(stdout || "");
|
const out = String(stdout || "");
|
||||||
@@ -201,51 +253,55 @@ function runClaudeOnce(args, opts = {}) {
|
|||||||
// Each validates first, then queues. Validation errors throw synchronously
|
// Each validates first, then queues. Validation errors throw synchronously
|
||||||
// from the returned promise, which is what the routes turn into a 400.
|
// from the returned promise, which is what the routes turn into a 400.
|
||||||
|
|
||||||
async function marketplaceAdd({ source, scope }) {
|
// `acct` (S3): run AS that member, on their own config — see the file header.
|
||||||
|
const queued = (args, acct) => serialize(() => runClaude(args, { acct }), acct);
|
||||||
|
|
||||||
|
async function marketplaceAdd({ source, scope, acct }) {
|
||||||
const src = assertSource(source);
|
const src = assertSource(source);
|
||||||
const sc = assertCliScope(scope);
|
const sc = assertCliScope(scope, acct);
|
||||||
return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc]));
|
return queued(["plugin", "marketplace", "add", src, "--scope", sc], acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function marketplaceRemove({ name }) {
|
async function marketplaceRemove({ name, acct }) {
|
||||||
const n = assertMarketplaceName(name);
|
const n = assertMarketplaceName(name);
|
||||||
return serialize(() => runClaude(["plugin", "marketplace", "remove", n]));
|
return queued(["plugin", "marketplace", "remove", n], acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function marketplaceUpdate({ name }) {
|
async function marketplaceUpdate({ name, acct }) {
|
||||||
const n = assertMarketplaceName(name);
|
const n = assertMarketplaceName(name);
|
||||||
return serialize(() => runClaude(["plugin", "marketplace", "update", n]));
|
return queued(["plugin", "marketplace", "update", n], acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pluginInstall({ spec, scope }) {
|
async function pluginInstall({ spec, scope, acct }) {
|
||||||
const s = assertPluginSpec(spec);
|
const s = assertPluginSpec(spec);
|
||||||
const sc = assertCliScope(scope);
|
const sc = assertCliScope(scope, acct);
|
||||||
// `-y` accepts the marketplace-declared install command without a prompt;
|
// `-y` accepts the marketplace-declared install command without a prompt;
|
||||||
// required because stdin is not a TTY here.
|
// required because stdin is not a TTY here. Never for a member (dropped by
|
||||||
return serialize(() => runClaude(["plugin", "install", s, "--scope", sc, "-y"]));
|
// claudeCommand): there such a plugin fails instead.
|
||||||
|
return queued(["plugin", "install", s, "--scope", sc, "-y"], acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pluginUninstall({ spec }) {
|
async function pluginUninstall({ spec, acct }) {
|
||||||
const s = assertPluginSpec(spec);
|
const s = assertPluginSpec(spec);
|
||||||
return serialize(() => runClaude(["plugin", "uninstall", s]));
|
return queued(["plugin", "uninstall", s], acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pluginEnable({ spec }) {
|
async function pluginEnable({ spec, acct }) {
|
||||||
const s = assertPluginSpec(spec);
|
const s = assertPluginSpec(spec);
|
||||||
return serialize(() => runClaude(["plugin", "enable", s]));
|
return queued(["plugin", "enable", s], acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pluginDisable({ spec }) {
|
async function pluginDisable({ spec, acct }) {
|
||||||
const s = assertPluginSpec(spec);
|
const s = assertPluginSpec(spec);
|
||||||
return serialize(() => runClaude(["plugin", "disable", s]));
|
return queued(["plugin", "disable", s], acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pluginUpdate({ spec }) {
|
async function pluginUpdate({ spec, acct }) {
|
||||||
const s = assertPluginSpec(spec);
|
const s = assertPluginSpec(spec);
|
||||||
// `-y` for the same reason as pluginInstall: the CLI requires it whenever
|
// `-y` for the same reason as pluginInstall: the CLI requires it whenever
|
||||||
// stdin is not a TTY (runClaude closes stdin), and without it an update whose
|
// stdin is not a TTY (runClaude closes stdin), and without it an update whose
|
||||||
// marketplace declares an install command fails on a prompt nobody can answer.
|
// marketplace declares an install command fails on a prompt nobody can answer.
|
||||||
return serialize(() => runClaude(["plugin", "update", s, "-y"]));
|
return queued(["plugin", "update", s, "-y"], acct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -253,10 +309,14 @@ async function pluginUpdate({ spec }) {
|
|||||||
* registered marketplace, plus what is already installed. Read-only, so it
|
* registered marketplace, plus what is already installed. Read-only, so it
|
||||||
* skips the mutation queue.
|
* skips the mutation queue.
|
||||||
*
|
*
|
||||||
|
* @param {{acct?: object}} [opts] `acct`: the member's own list (S3).
|
||||||
* @returns {Promise<{installed:object[], available:object[]}>}
|
* @returns {Promise<{installed:object[], available:object[]}>}
|
||||||
*/
|
*/
|
||||||
async function listAvailable() {
|
async function listAvailable({ acct } = {}) {
|
||||||
const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 });
|
const res = await runClaude(["plugin", "list", "--available", "--json"], {
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
acct,
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
|
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
|
||||||
}
|
}
|
||||||
@@ -309,4 +369,5 @@ module.exports = {
|
|||||||
assertMarketplaceName,
|
assertMarketplaceName,
|
||||||
assertCliScope,
|
assertCliScope,
|
||||||
PLUGIN_SPEC_RE,
|
PLUGIN_SPEC_RE,
|
||||||
|
__setExecFileImpl,
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-5
@@ -20,6 +20,11 @@
|
|||||||
*
|
*
|
||||||
* Only `claude` lanes are addressed: Codex has no such command, and typing a
|
* Only `claude` lanes are addressed: Codex has no such command, and typing a
|
||||||
* slash command into a Codex pane would land as literal prompt text.
|
* slash command into a Codex pane would land as literal prompt text.
|
||||||
|
*
|
||||||
|
* Whose panes: by default the host's own (a member's pane runs on their own
|
||||||
|
* `~/.claude`, which a change to the host's config does not touch). With
|
||||||
|
* `account` (S3: a member reloading after changing their OWN config) only the
|
||||||
|
* panes that run as that member, and nobody else's.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -60,7 +65,7 @@ const RELOAD_COMMANDS = {
|
|||||||
*
|
*
|
||||||
* @returns {{id: string, laneId: number|null, paneCommand: string|null}[]}
|
* @returns {{id: string, laneId: number|null, paneCommand: string|null}[]}
|
||||||
*/
|
*/
|
||||||
function reloadableLanes() {
|
function reloadableLanes(account = null) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const run of listRuns()) {
|
for (const run of listRuns()) {
|
||||||
if (run.status !== "running") continue;
|
if (run.status !== "running") continue;
|
||||||
@@ -70,7 +75,8 @@ function reloadableLanes() {
|
|||||||
// EMEMBERGONE (a disabled member's pane) is a member's too: skip it,
|
// EMEMBERGONE (a disabled member's pane) is a member's too: skip it,
|
||||||
// never let one throw end the reload for every other pane.
|
// never let one throw end the reload for every other pane.
|
||||||
try {
|
try {
|
||||||
if (require("./run-as").accountForSession(run.id)) continue;
|
const acct = require("./run-as").accountForSession(run.id);
|
||||||
|
if (account ? acct?.name !== account.name : acct) continue;
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -99,10 +105,10 @@ function reloadableLanes() {
|
|||||||
* separate submissions, and waiting for that without blocking the event loop is
|
* separate submissions, and waiting for that without blocking the event loop is
|
||||||
* the only way a live dashboard keeps broadcasting while it types.
|
* the only way a live dashboard keeps broadcasting while it types.
|
||||||
*
|
*
|
||||||
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [options] which
|
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number, account?: object}} [options] which
|
||||||
* commands to send; defaults to both, plugins first (a skill shipped BY a
|
* commands to send; defaults to both, plugins first (a skill shipped BY a
|
||||||
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
|
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
|
||||||
* is a test seam — no route passes it.
|
* is a test seam — no route passes it. `account`: only that member's panes.
|
||||||
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
||||||
* commands: string[], lanes_total: number}>}
|
* commands: string[], lanes_total: number}>}
|
||||||
*/
|
*/
|
||||||
@@ -112,7 +118,7 @@ async function reloadOpenSessions(options = {}) {
|
|||||||
const settleMs = options.settleMs ?? SETTLE_MS;
|
const settleMs = options.settleMs ?? SETTLE_MS;
|
||||||
const lanes = [];
|
const lanes = [];
|
||||||
|
|
||||||
for (const lane of reloadableLanes()) {
|
for (const lane of reloadableLanes(options.account || null)) {
|
||||||
const sent = [];
|
const sent = [];
|
||||||
let error;
|
let error;
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @file cc-target.js
|
||||||
|
* @description Whose Claude Code configuration a `/api/cc-config` request reads
|
||||||
|
* and writes — the ONE resolver every handler of `routes/cc-config.js` uses,
|
||||||
|
* and the allowlist of the endpoints a non-admin may reach there at all.
|
||||||
|
*
|
||||||
|
* - Local mode, or an admin: the host's Claude home (`getClaudeHome()`), with
|
||||||
|
* every handler behaving exactly as it did before members had a config page.
|
||||||
|
* - Server mode, a non-admin with an enabled, provisioned Linux account (S3):
|
||||||
|
* their OWN `<home>/.claude` and `<home>/.claude.json`. Their writes run as
|
||||||
|
* them (cc-mutate's `ioFor`, cc-plugin-cli's sudo), never as the host.
|
||||||
|
* - An analyst with no such account, reading: the host's, read-only — what an
|
||||||
|
* analyst read before. `?view=host` gives an analyst WITH an account that
|
||||||
|
* same read-only view.
|
||||||
|
* - Anyone else (a developer with no account, any non-admin write without
|
||||||
|
* one): 403 ENOACCOUNT — never "the host's then".
|
||||||
|
*
|
||||||
|
* Nothing here takes a home or a path from the request.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require("node:path");
|
||||||
|
const { getClaudeHome } = require("./claude-home");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a non-admin may call under `/api/cc-config` ("METHOD /path", lower
|
||||||
|
* case, HEAD counted as GET): reads of their own config and the writes that
|
||||||
|
* run as them. Everything else — `/profiles*` (the host's login switching),
|
||||||
|
* `/plugins/update-all` (the host's Update all, AgentHub's own plugin
|
||||||
|
* included), and any endpoint added later — stays an admin's until it is
|
||||||
|
* listed here.
|
||||||
|
*/
|
||||||
|
const MEMBER_ROUTES = new Set([
|
||||||
|
"GET /target",
|
||||||
|
"GET /overview",
|
||||||
|
"GET /skills",
|
||||||
|
"GET /agents",
|
||||||
|
"GET /commands",
|
||||||
|
"GET /output-styles",
|
||||||
|
"GET /plugins",
|
||||||
|
"GET /plugins/available",
|
||||||
|
"GET /mcp",
|
||||||
|
"GET /hooks",
|
||||||
|
"GET /settings",
|
||||||
|
"GET /memory",
|
||||||
|
"GET /marketplaces",
|
||||||
|
"GET /keybindings",
|
||||||
|
"GET /statusline",
|
||||||
|
"GET /hook-scripts",
|
||||||
|
"GET /file",
|
||||||
|
"GET /backups",
|
||||||
|
"PUT /keybindings",
|
||||||
|
"PUT /file",
|
||||||
|
"DELETE /file",
|
||||||
|
"PATCH /settings",
|
||||||
|
"POST /project-memory",
|
||||||
|
"POST /plugins/install",
|
||||||
|
"POST /plugins/action",
|
||||||
|
"POST /marketplaces/add",
|
||||||
|
"POST /marketplaces/action",
|
||||||
|
"POST /mcp/add",
|
||||||
|
"POST /mcp/remove",
|
||||||
|
"POST /reload",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** The host-wide reads an analyst had before (staff read everything): still read-only. */
|
||||||
|
const ANALYST_READS = new Set(["GET /profiles", "GET /plugins/update-all"]);
|
||||||
|
|
||||||
|
/** `"METHOD /path"` as the allowlists spell it (decoded, lower case, no trailing slash). */
|
||||||
|
function routeKey(req) {
|
||||||
|
const p = require("./auth").apiPath(req).toLowerCase().replace(/\/+$/, "") || "/";
|
||||||
|
const method = req.method === "HEAD" ? "GET" : String(req.method || "").toUpperCase();
|
||||||
|
return `${method} ${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `req.user` may reach this cc-config endpoint at all (local mode and admins: yes). */
|
||||||
|
function mayReach(req) {
|
||||||
|
const u = req.user;
|
||||||
|
if (!u || u.role === "admin") return true;
|
||||||
|
const key = routeKey(req);
|
||||||
|
return MEMBER_ROUTES.has(key) || (u.role === "analyst" && ANALYST_READS.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Express middleware: the router's own gate (403 EADMIN), the single rule for `/cc-config`. */
|
||||||
|
function ccGate(req, res, next) {
|
||||||
|
if (mayReach(req)) return next();
|
||||||
|
res.status(403).json({ error: { code: "EADMIN", message: "only an admin may open this" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRead = (req) => req.method === "GET" || req.method === "HEAD";
|
||||||
|
|
||||||
|
function host(readOnly = false) {
|
||||||
|
return {
|
||||||
|
acct: null,
|
||||||
|
own: false,
|
||||||
|
readOnly,
|
||||||
|
home: getClaudeHome(),
|
||||||
|
claudeJson: require("./cc-discovery").getClaudeJsonPath(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The target of `req`: `{acct, own, readOnly, home, claudeJson}` — `acct` and
|
||||||
|
* `own` set for a member's own config.
|
||||||
|
* @throws ENOACCOUNT (status 403) for a non-admin with no account to act on
|
||||||
|
*/
|
||||||
|
function resolveCcTarget(req) {
|
||||||
|
const u = req.user;
|
||||||
|
if (!u || u.role === "admin") return host();
|
||||||
|
if (u.role === "analyst" && isRead(req) && req.query?.view === "host") return host(true);
|
||||||
|
let acct = null;
|
||||||
|
try {
|
||||||
|
acct = require("./run-as").accountForUser(u.username);
|
||||||
|
} catch {
|
||||||
|
acct = null; // a disabled / unprovisioned account is no account: refuse below
|
||||||
|
}
|
||||||
|
if (acct) {
|
||||||
|
return {
|
||||||
|
acct,
|
||||||
|
own: true,
|
||||||
|
readOnly: false,
|
||||||
|
home: path.join(acct.home, ".claude"),
|
||||||
|
claudeJson: path.join(acct.home, ".claude.json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (u.role === "analyst" && isRead(req)) return host(true);
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
"your Claude Code config lives in your own account, and this server has none for you yet"
|
||||||
|
),
|
||||||
|
{ code: "ENOACCOUNT", status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveCcTarget, ccGate, mayReach, routeKey, MEMBER_ROUTES, ANALYST_READS };
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("./yaml");
|
||||||
const { parseEnvFile } = require("./lane-profile");
|
const { parseEnvFile } = require("./lane-profile");
|
||||||
const { isListening } = require("./ports");
|
const { isListening } = require("./ports");
|
||||||
const { SECRETS_PATH } = require("./secrets");
|
const { SECRETS_PATH } = require("./secrets");
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
/**
|
||||||
|
* @file nine-router-update.js
|
||||||
|
* @description The **Update 9router** button of Settings → AI Proxy: one
|
||||||
|
* in-memory job that shuts the host's 9router down, updates its npm package and
|
||||||
|
* starts it again hidden — in that order, because 9router's own updater relaunches
|
||||||
|
* through `npx` and kills by `ps aux | grep`, which neither respects a
|
||||||
|
* `9router.service` user unit (`Restart=always` would fight it) nor leaves a
|
||||||
|
* pane alone.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. **shutdown** — disconnect every client by stopping 9router: a running
|
||||||
|
* `9router.service` user unit is `systemctl --user stop`ped; otherwise the
|
||||||
|
* 9router processes of THIS account (matched by the install's own path in
|
||||||
|
* their argv, never by name) get SIGTERM — its CLI kills its server and tray
|
||||||
|
* icon on that — then SIGKILL after a grace period, until the port is free.
|
||||||
|
* 2. **update** — `npm install -g --prefix <its own prefix> 9router@latest`, so
|
||||||
|
* the copy that is updated is the one that runs. A prefix this account
|
||||||
|
* cannot write runs the same argv under `sudo -n`, and only when sudoers
|
||||||
|
* already allows that npm without a password (probed BEFORE step 1 with
|
||||||
|
* `sudo -n <npm> --version`, so a refused update never leaves 9router
|
||||||
|
* down); otherwise
|
||||||
|
* the start is refused with the command to run by hand.
|
||||||
|
* 3. **relaunch** — only when 9router was running before step 1: the unit is
|
||||||
|
* started again, or `cli.js --tray --no-browser --skip-update -p <port>` is
|
||||||
|
* spawned detached (inside its own `systemd-run --user --scope` on Linux, so
|
||||||
|
* a dashboard restart never takes it along). A failed update still
|
||||||
|
* relaunches — the old version is better than none.
|
||||||
|
*
|
||||||
|
* Nothing here takes a command, path or package name from a request: the
|
||||||
|
* package is always `9router`, its location comes from PATH.
|
||||||
|
*
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const net = require("node:net");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFile, spawn } = require("node:child_process");
|
||||||
|
|
||||||
|
const aiTools = require("./ai-tools");
|
||||||
|
const versions = require("./ai-tool-versions");
|
||||||
|
const { compareVersions } = require("./update-check");
|
||||||
|
const { desktopEnv } = require("./desktop-env");
|
||||||
|
|
||||||
|
const PKG = "9router";
|
||||||
|
const UNIT = "9router.service";
|
||||||
|
const DEFAULT_PORT = 20128;
|
||||||
|
const LATEST_CACHE_MS = 10 * 60 * 1000;
|
||||||
|
const LOG_CAP = 128 * 1024;
|
||||||
|
const STEPS = ["shutdown", "update", "relaunch"];
|
||||||
|
|
||||||
|
const timing = {
|
||||||
|
installTimeoutMs: 10 * 60 * 1000,
|
||||||
|
termGraceMs: 5000,
|
||||||
|
portFreeMs: 15000,
|
||||||
|
portUpMs: 45000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeErr = (code, message, extra = {}) => Object.assign(new Error(message), { code }, extra);
|
||||||
|
|
||||||
|
/** execFile as a promise that never rejects: `{code, stdout, stderr}`. */
|
||||||
|
const realExec = (file, args, opts = {}) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
execFile(file, args, { encoding: "utf8", timeout: 10000, ...opts }, (err, stdout, stderr) =>
|
||||||
|
resolve({
|
||||||
|
code: err ? (typeof err.code === "number" ? err.code : 1) : 0,
|
||||||
|
stdout: String(stdout || ""),
|
||||||
|
stderr: String(stderr || err?.message || ""),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seams (tests): command runner, the systemd user manager, the latest-version
|
||||||
|
// lookup, and whether a relaunch may use systemd-run.
|
||||||
|
let exec = realExec;
|
||||||
|
let useSystemd = process.platform === "linux";
|
||||||
|
let fetchLatestImpl = async () => {
|
||||||
|
const r = await fetch(`https://registry.npmjs.org/${PKG}/latest`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const v = (await r.json())?.version;
|
||||||
|
return typeof v === "string" ? v : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let job = null;
|
||||||
|
let latestCache = null; // { at, version }
|
||||||
|
|
||||||
|
/** The env a `systemctl --user` needs; a server started outside a login lacks XDG_RUNTIME_DIR. */
|
||||||
|
function userEnv() {
|
||||||
|
const env = { ...process.env, PATH: versions.jobPath() };
|
||||||
|
if (!env.XDG_RUNTIME_DIR && typeof process.getuid === "function") {
|
||||||
|
const dir = `/run/user/${process.getuid()}`;
|
||||||
|
if (fs.existsSync(dir)) env.XDG_RUNTIME_DIR = dir;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where 9router is installed: `{bin, cli, pkgDir, prefix, version}`, or null.
|
||||||
|
* `prefix` is null for a layout that is not an npm global install
|
||||||
|
* (`<prefix>/lib/node_modules/9router`), which this updater does not touch.
|
||||||
|
*/
|
||||||
|
function locate() {
|
||||||
|
const bin = aiTools.which(PKG, versions.jobPath());
|
||||||
|
if (!bin) return null;
|
||||||
|
let cli;
|
||||||
|
try {
|
||||||
|
cli = fs.realpathSync(bin);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pkgDir = path.dirname(cli);
|
||||||
|
let version = null;
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
||||||
|
if (pkg.name !== PKG) return null;
|
||||||
|
version = typeof pkg.version === "string" ? pkg.version : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nm = path.dirname(pkgDir);
|
||||||
|
const lib = path.dirname(nm);
|
||||||
|
const prefix =
|
||||||
|
path.basename(nm) === "node_modules" && path.basename(lib) === "lib" ? path.dirname(lib) : null;
|
||||||
|
return { bin, cli, pkgDir, prefix, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this account can write `dir`. */
|
||||||
|
function canWrite(dir) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(dir, fs.constants.W_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root's npm cache for a sudo run. Never root's HOME: the AgentHub unit makes
|
||||||
|
// `/root` inaccessible (`InaccessiblePaths=`), so `mkdir /root/.npm` fails with
|
||||||
|
// ENOENT (npm exit 254). A root-owned path, never one in /tmp a user could plant.
|
||||||
|
const SUDO_NPM_CACHE = "/var/cache/agenthub-9router-npm";
|
||||||
|
|
||||||
|
const npmArgs = (prefix, sudo = false) => [
|
||||||
|
"install",
|
||||||
|
"-g",
|
||||||
|
"--prefix",
|
||||||
|
prefix,
|
||||||
|
"--prefer-online",
|
||||||
|
...(sudo ? ["--cache", SUDO_NPM_CACHE] : []),
|
||||||
|
`${PKG}@latest`,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** `direct` (we can write the prefix), `sudo` (passwordless sudo allows it), or null. */
|
||||||
|
async function installMode(loc, npm) {
|
||||||
|
if (
|
||||||
|
canWrite(path.join(loc.prefix, "lib", "node_modules")) &&
|
||||||
|
canWrite(path.join(loc.prefix, "bin"))
|
||||||
|
)
|
||||||
|
return "direct";
|
||||||
|
// `sudo -l` would answer yes for a rule that still asks for a password, so
|
||||||
|
// the probe is a harmless real run of the same npm.
|
||||||
|
const r = await exec("sudo", ["-n", "--", npm, "--version"], { timeout: 10000 });
|
||||||
|
return r.code === 0 ? "sudo" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the `9router.service` user unit is running. */
|
||||||
|
async function unitActive() {
|
||||||
|
if (!useSystemd) return false;
|
||||||
|
const r = await exec("systemctl", ["--user", "is-active", "--quiet", UNIT], {
|
||||||
|
env: userEnv(),
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
return r.code === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This account's 9router processes — those whose argv names the install itself.
|
||||||
|
* `roots` are its CLI (argv carries cli.js or the bin link): SIGTERM lets each
|
||||||
|
* stop its own server and tray; `all` adds their descendants, for the SIGKILL.
|
||||||
|
*/
|
||||||
|
async function findProcesses(loc) {
|
||||||
|
const uid = typeof process.getuid === "function" ? String(process.getuid()) : null;
|
||||||
|
const r = await exec("ps", ["-ww", ...(uid ? ["-U", uid] : ["-e"]), "-o", "pid=,ppid=,args="], {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const rows = [];
|
||||||
|
for (const line of r.stdout.split("\n")) {
|
||||||
|
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
||||||
|
if (m && Number(m[1]) !== process.pid)
|
||||||
|
rows.push({ pid: Number(m[1]), ppid: Number(m[2]), argv: m[3].split(/\s+/) });
|
||||||
|
}
|
||||||
|
const roots = [];
|
||||||
|
let port = null;
|
||||||
|
let host = null;
|
||||||
|
for (const { pid, argv } of rows) {
|
||||||
|
if (!argv.some((a) => a === loc.cli || a === loc.bin)) continue;
|
||||||
|
roots.push(pid);
|
||||||
|
const pi = argv.findIndex((a) => a === "-p" || a === "--port");
|
||||||
|
if (pi >= 0 && /^\d{2,5}$/.test(argv[pi + 1] || "")) port = Number(argv[pi + 1]);
|
||||||
|
const hi = argv.findIndex((a) => a === "-H" || a === "--host");
|
||||||
|
if (hi >= 0 && /^[\w.:-]+$/.test(argv[hi + 1] || "")) host = argv[hi + 1];
|
||||||
|
}
|
||||||
|
// Its server renames itself (`next-server`), so children are found by parent,
|
||||||
|
// plus anything still running from inside the install.
|
||||||
|
const all = new Set(roots);
|
||||||
|
for (const { pid, argv } of rows)
|
||||||
|
if (argv.some((a) => a.startsWith(loc.pkgDir + path.sep))) all.add(pid);
|
||||||
|
for (let grew = true; grew;) {
|
||||||
|
grew = false;
|
||||||
|
for (const { pid, ppid } of rows)
|
||||||
|
if (all.has(ppid) && !all.has(pid)) {
|
||||||
|
all.add(pid);
|
||||||
|
grew = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { roots, all: [...all], port, host };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether something answers on 127.0.0.1:`port`. */
|
||||||
|
const portOpen = (port) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const s = net.connect({ host: "127.0.0.1", port });
|
||||||
|
const done = (v) => {
|
||||||
|
s.destroy();
|
||||||
|
resolve(v);
|
||||||
|
};
|
||||||
|
s.setTimeout(1000, () => done(false));
|
||||||
|
s.once("connect", () => done(true));
|
||||||
|
s.once("error", () => done(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitFor(check, ms) {
|
||||||
|
const until = Date.now() + ms;
|
||||||
|
for (;;) {
|
||||||
|
if (await check()) return true;
|
||||||
|
if (Date.now() >= until) return false;
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alive = (pid) => {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signal = (pids, sig) => {
|
||||||
|
for (const pid of pids) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, sig);
|
||||||
|
} catch {
|
||||||
|
/* gone already */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function latestVersion({ fresh = false } = {}) {
|
||||||
|
if (!fresh && latestCache && Date.now() - latestCache.at < LATEST_CACHE_MS)
|
||||||
|
return latestCache.version;
|
||||||
|
let version = null;
|
||||||
|
try {
|
||||||
|
version = await fetchLatestImpl();
|
||||||
|
} catch {
|
||||||
|
version = null;
|
||||||
|
}
|
||||||
|
latestCache = { at: Date.now(), version };
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The public view of the job (a copy). */
|
||||||
|
const view = (j) => (j ? JSON.parse(JSON.stringify(j)) : null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the page shows: `{installed, version, latest, updateAvailable, running,
|
||||||
|
* managedBy, blocker, job}`. `blocker` (`{code, message, command?}`) is why a
|
||||||
|
* start would be refused right now; `command` is the one to run by hand.
|
||||||
|
*/
|
||||||
|
async function status({ fresh = false } = {}) {
|
||||||
|
const loc = locate();
|
||||||
|
if (!loc) {
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
version: null,
|
||||||
|
latest: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
running: false,
|
||||||
|
managedBy: null,
|
||||||
|
blocker: { code: "ENOTINSTALLED", message: "9router is not installed on this machine" },
|
||||||
|
job: view(job),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [latest, unit, procs] = await Promise.all([
|
||||||
|
latestVersion({ fresh }),
|
||||||
|
unitActive(),
|
||||||
|
findProcesses(loc),
|
||||||
|
]);
|
||||||
|
const updateAvailable = Boolean(
|
||||||
|
latest && loc.version && compareVersions(latest, loc.version) > 0
|
||||||
|
);
|
||||||
|
const manual = `sudo npm install -g ${PKG}@latest`;
|
||||||
|
let blocker = null;
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
blocker = {
|
||||||
|
code: "EUNSUPPORTED",
|
||||||
|
message: "updating 9router from AgentHub is not supported on Windows",
|
||||||
|
};
|
||||||
|
} else if (!loc.prefix) {
|
||||||
|
blocker = {
|
||||||
|
code: "EUNSUPPORTED",
|
||||||
|
message: `9router at ${loc.pkgDir} is not an npm global install`,
|
||||||
|
};
|
||||||
|
} else if (!updateAvailable) {
|
||||||
|
blocker = latest
|
||||||
|
? { code: "EUPTODATE", message: `9router ${loc.version} is the latest version` }
|
||||||
|
: { code: "ENOLATEST", message: "the npm registry could not be reached" };
|
||||||
|
} else {
|
||||||
|
const npm = versions.npmBin();
|
||||||
|
if (!npm) blocker = { code: "ENONPM", message: "npm was not found on the login shell's PATH" };
|
||||||
|
else if (!(await installMode(loc, npm)))
|
||||||
|
blocker = {
|
||||||
|
code: "ENOPERM",
|
||||||
|
message: `${loc.prefix} is not writable by this account and sudo needs a password`,
|
||||||
|
command: manual,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
version: loc.version,
|
||||||
|
latest,
|
||||||
|
updateAvailable,
|
||||||
|
running: unit || procs.roots.length > 0,
|
||||||
|
managedBy: unit ? "systemd" : procs.roots.length ? "process" : null,
|
||||||
|
blocker,
|
||||||
|
job: view(job),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(id) {
|
||||||
|
return job.steps.find((s) => s.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(text) {
|
||||||
|
job.log = (job.log + text).slice(-LOG_CAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStep(id, state, message) {
|
||||||
|
const s = step(id);
|
||||||
|
s.status = state;
|
||||||
|
if (message !== undefined) s.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doShutdown(loc, unit, procs) {
|
||||||
|
if (unit) {
|
||||||
|
const r = await exec("systemctl", ["--user", "stop", UNIT], { env: userEnv(), timeout: 30000 });
|
||||||
|
if (r.code !== 0) throw makeErr("ESTOP", `systemctl --user stop ${UNIT}: ${r.stderr.trim()}`);
|
||||||
|
} else {
|
||||||
|
signal(procs.roots, "SIGTERM");
|
||||||
|
await waitFor(async () => !procs.roots.some(alive), timing.termGraceMs);
|
||||||
|
// Whatever survived (and any child the CLI did not take down), by force.
|
||||||
|
const left = (await findProcesses(loc)).all;
|
||||||
|
signal([...new Set([...procs.all, ...left])].filter(alive), "SIGKILL");
|
||||||
|
}
|
||||||
|
const port = procs.port || DEFAULT_PORT;
|
||||||
|
if (!(await waitFor(async () => !(await portOpen(port)), timing.portFreeMs)))
|
||||||
|
throw makeErr("EPORTBUSY", `port ${port} is still in use after 9router stopped`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run npm, streaming its output into the job log. Resolves its exit code. */
|
||||||
|
function runInstall(argv) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
log(`$ ${argv.join(" ")}\n`);
|
||||||
|
const child = spawn(argv[0], argv.slice(1), {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: { ...process.env, PATH: versions.jobPath() },
|
||||||
|
detached: true,
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
log("\n[agenthub] npm timed out\n");
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
|
}, timing.installTimeoutMs);
|
||||||
|
child.stdout.on("data", (d) => log(String(d)));
|
||||||
|
child.stderr.on("data", (d) => log(String(d)));
|
||||||
|
child.on("error", (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
log(`\n${e.message}\n`);
|
||||||
|
resolve(1);
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(code ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start 9router hidden, detached from this server. */
|
||||||
|
async function launchHidden(loc, procs) {
|
||||||
|
const node = aiTools.which("node", versions.jobPath()) || process.execPath;
|
||||||
|
const port = procs.port || DEFAULT_PORT;
|
||||||
|
const args = [
|
||||||
|
"--dns-result-order=ipv4first",
|
||||||
|
loc.cli,
|
||||||
|
"--tray",
|
||||||
|
"--no-browser",
|
||||||
|
"--skip-update",
|
||||||
|
"-p",
|
||||||
|
String(port),
|
||||||
|
];
|
||||||
|
if (procs.host) args.push("-H", procs.host);
|
||||||
|
const env = { ...userEnv(), ...desktopEnv() };
|
||||||
|
delete env.TRAY_MODE;
|
||||||
|
const spawnDetached = (file, argv) => {
|
||||||
|
const child = spawn(file, argv, { detached: true, stdio: "ignore", env, cwd: loc.pkgDir });
|
||||||
|
child.unref();
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
const systemdRun = useSystemd && env.XDG_RUNTIME_DIR && aiTools.which("systemd-run", env.PATH);
|
||||||
|
if (systemdRun) {
|
||||||
|
const child = spawnDetached(systemdRun, [
|
||||||
|
"--user",
|
||||||
|
"--scope",
|
||||||
|
"--quiet",
|
||||||
|
"--collect",
|
||||||
|
"--description=9Router",
|
||||||
|
"--",
|
||||||
|
node,
|
||||||
|
...args,
|
||||||
|
]);
|
||||||
|
// A scope that cannot be created exits at once; anything else is 9router running.
|
||||||
|
const failed = await new Promise((resolve) => {
|
||||||
|
const t = setTimeout(() => resolve(false), 1500);
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(code !== 0);
|
||||||
|
});
|
||||||
|
child.once("error", () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!failed) return port;
|
||||||
|
log("[agenthub] systemd-run --user --scope failed; starting 9router directly\n");
|
||||||
|
}
|
||||||
|
spawnDetached(node, args);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRelaunch(loc, unit, procs) {
|
||||||
|
let port = procs.port || DEFAULT_PORT;
|
||||||
|
if (unit) {
|
||||||
|
const r = await exec("systemctl", ["--user", "start", UNIT], {
|
||||||
|
env: userEnv(),
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
if (r.code !== 0) throw makeErr("ESTART", `systemctl --user start ${UNIT}: ${r.stderr.trim()}`);
|
||||||
|
} else {
|
||||||
|
port = await launchHidden(loc, procs);
|
||||||
|
}
|
||||||
|
if (!(await waitFor(() => portOpen(port), timing.portUpMs)))
|
||||||
|
throw makeErr("ENOTUP", `9router did not answer on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJob(loc, npm, mode, unit, procs) {
|
||||||
|
const wasRunning = unit || procs.roots.length > 0;
|
||||||
|
let failed = false;
|
||||||
|
if (!wasRunning) setStep("shutdown", "skipped", "9router was not running");
|
||||||
|
else {
|
||||||
|
setStep("shutdown", "running");
|
||||||
|
try {
|
||||||
|
await doShutdown(loc, unit, procs);
|
||||||
|
setStep("shutdown", "done");
|
||||||
|
} catch (e) {
|
||||||
|
// Still running (or unknown): updating underneath it is not safe.
|
||||||
|
setStep("shutdown", "failed", e.message);
|
||||||
|
setStep("update", "skipped");
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!failed) {
|
||||||
|
setStep("update", "running");
|
||||||
|
const argv = [npm, ...npmArgs(loc.prefix, mode === "sudo")];
|
||||||
|
const code = await runInstall(mode === "sudo" ? ["sudo", "-n", "--", ...argv] : argv);
|
||||||
|
const after = locate();
|
||||||
|
job.to = after?.version || null;
|
||||||
|
if (code === 0) setStep("update", "done", job.to ? `${job.from} → ${job.to}` : undefined);
|
||||||
|
else {
|
||||||
|
setStep("update", "failed", `npm exited with code ${code}`);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!wasRunning) setStep("relaunch", "skipped", "9router was not running before the update");
|
||||||
|
else {
|
||||||
|
setStep("relaunch", "running");
|
||||||
|
try {
|
||||||
|
await doRelaunch(locate() || loc, unit, procs);
|
||||||
|
setStep("relaunch", "done");
|
||||||
|
} catch (e) {
|
||||||
|
setStep("relaunch", "failed", e.message);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job.status = failed ? "failed" : "done";
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
latestCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the update; refuses (with the status blocker's code) when it cannot run. */
|
||||||
|
async function start() {
|
||||||
|
if (job?.status === "running") throw makeErr("EBUSY", "a 9router update is already running");
|
||||||
|
const s = await status({ fresh: true });
|
||||||
|
if (job?.status === "running") throw makeErr("EBUSY", "a 9router update is already running");
|
||||||
|
if (s.blocker)
|
||||||
|
throw makeErr(
|
||||||
|
s.blocker.code,
|
||||||
|
s.blocker.message,
|
||||||
|
s.blocker.command ? { command: s.blocker.command } : {}
|
||||||
|
);
|
||||||
|
const loc = locate();
|
||||||
|
const npm = versions.npmBin();
|
||||||
|
const mode = await installMode(loc, npm);
|
||||||
|
if (!mode) throw makeErr("ENOPERM", "the npm prefix is not writable and sudo needs a password");
|
||||||
|
const unit = await unitActive();
|
||||||
|
const procs = await findProcesses(loc);
|
||||||
|
job = {
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: null,
|
||||||
|
from: loc.version,
|
||||||
|
to: null,
|
||||||
|
managedBy: unit ? "systemd" : procs.roots.length ? "process" : null,
|
||||||
|
install: mode,
|
||||||
|
steps: STEPS.map((id) => ({ id, status: "pending", message: null })),
|
||||||
|
log: "",
|
||||||
|
};
|
||||||
|
runJob(loc, npm, mode, unit, procs).catch((e) => {
|
||||||
|
job.status = "failed";
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
log(`\n${e.message}\n`);
|
||||||
|
});
|
||||||
|
return view(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = () => view(job);
|
||||||
|
|
||||||
|
function __setSeams({ exec: e, useSystemd: u, fetchLatest, timing: t } = {}) {
|
||||||
|
if (e) exec = e;
|
||||||
|
if (u !== undefined) useSystemd = u;
|
||||||
|
if (fetchLatest) fetchLatestImpl = fetchLatest;
|
||||||
|
if (t) Object.assign(timing, t);
|
||||||
|
latestCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function __reset() {
|
||||||
|
job = null;
|
||||||
|
latestCache = null;
|
||||||
|
exec = realExec;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { status, start, current, locate, STEPS, __setSeams, __reset };
|
||||||
@@ -190,7 +190,7 @@ function providerEntry() {
|
|||||||
* member's, and is never overwritten with something it did not say.
|
* member's, and is never overwritten with something it did not say.
|
||||||
*/
|
*/
|
||||||
function mergedModels(text) {
|
function mergedModels(text) {
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("./yaml");
|
||||||
const doc = text && text.trim() ? yaml.load(text) : {};
|
const doc = text && text.trim() ? yaml.load(text) : {};
|
||||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||||
throw new Error("models.yml is not a YAML mapping");
|
throw new Error("models.yml is not a YAML mapping");
|
||||||
@@ -227,7 +227,7 @@ function writeMemberModels(acct) {
|
|||||||
/** The host's own default omp model (`modelRoles.default` in `~/.omp/agent/config.yml`), or null. */
|
/** The host's own default omp model (`modelRoles.default` in `~/.omp/agent/config.yml`), or null. */
|
||||||
function hostDefaultModel() {
|
function hostDefaultModel() {
|
||||||
try {
|
try {
|
||||||
const doc = require("js-yaml").load(
|
const doc = require("./yaml").load(
|
||||||
fs.readFileSync(path.join(ompDir(), "agent", "config.yml"), "utf8")
|
fs.readFileSync(path.join(ompDir(), "agent", "config.yml"), "utf8")
|
||||||
);
|
);
|
||||||
const m = doc?.modelRoles?.default;
|
const m = doc?.modelRoles?.default;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function loadAll() {
|
|||||||
icon: n.icon || "",
|
icon: n.icon || "",
|
||||||
gate: !!n.gate,
|
gate: !!n.gate,
|
||||||
// The one command that drives THIS node — a slash command
|
// The one command that drives THIS node — a slash command
|
||||||
// (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook
|
// (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook
|
||||||
// ci-gate`), mirroring whatever the node's own `detect` rules watch
|
// ci-gate`), mirroring whatever the node's own `detect` rules watch
|
||||||
// for. `doc.skill` above names the driver for the whole pipeline;
|
// for. `doc.skill` above names the driver for the whole pipeline;
|
||||||
// this narrows it to the step, so a reader of the pipeline map can
|
// this narrows it to the step, so a reader of the pipeline map can
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
* clobber an existing memory file;
|
* clobber an existing memory file;
|
||||||
* - a symlink at `<dir>/CLAUDE.md` pointing elsewhere is refused.
|
* - a symlink at `<dir>/CLAUDE.md` pointing elsewhere is refused.
|
||||||
*
|
*
|
||||||
|
* Under S3 the file is written AS the account whose home the folder is in
|
||||||
|
* (`run-as.accountForPath`), never by the host.
|
||||||
|
*
|
||||||
* The capability is not new in kind: the Run page already spawns `claude` in
|
* The capability is not new in kind: the Run page already spawns `claude` in
|
||||||
* any existing directory the same way. What it must not become is a general
|
* any existing directory the same way. What it must not become is a general
|
||||||
* "write arbitrary content to an arbitrary path" endpoint — hence the fixed
|
* "write arbitrary content to an arbitrary path" endpoint — hence the fixed
|
||||||
@@ -91,7 +94,14 @@ function createProjectMemory({ tool, dir, content }) {
|
|||||||
}
|
}
|
||||||
if (taken) throw makeError("EEXISTS", `${filename} already exists in ${realDir}`);
|
if (taken) throw makeError("EEXISTS", `${filename} already exists in ${realDir}`);
|
||||||
|
|
||||||
atomicWriteFile(file, content);
|
// S3: a folder in a member's home is written AS that member (`write-as`,
|
||||||
|
// content on stdin) — an admin's create there included; the host never
|
||||||
|
// writes inside a member's home itself. EMEMBERGONE (no usable account)
|
||||||
|
// refuses, never "the host then".
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
const acct = runAs.accountForPath(file);
|
||||||
|
if (acct) runAs.writeAs(acct, file, "replace", content);
|
||||||
|
else atomicWriteFile(file, content);
|
||||||
return { file, size: Buffer.byteLength(content, "utf8") };
|
return { file, size: Buffer.byteLength(content, "utf8") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ function deleteProof(laneId, { slug, group, images } = {}) {
|
|||||||
* already points at the right place.
|
* already points at the right place.
|
||||||
*
|
*
|
||||||
* Never auto-invoked by this codebase; exposed only as the explicit
|
* Never auto-invoked by this codebase; exposed only as the explicit
|
||||||
* `agenthub lanes proof-link` primitive.
|
* `agenthub workspace proof-link` primitive.
|
||||||
*
|
*
|
||||||
* @param {object} lane - A hydrated lane row (must have `cwd`).
|
* @param {object} lane - A hydrated lane row (must have `cwd`).
|
||||||
* @returns {{linked: boolean}} `linked: false` when nothing changed.
|
* @returns {{linked: boolean}} `linked: false` when nothing changed.
|
||||||
|
|||||||
@@ -162,12 +162,16 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
|||||||
const [file, args] = require("./drop-caps").ptyArgv(
|
const [file, args] = require("./drop-caps").ptyArgv(
|
||||||
...(tmux.memberAttach(runId) || [attachBinary(), ["attach-session", "-t", tmux.session(runId)]])
|
...(tmux.memberAttach(runId) || [attachBinary(), ["attach-session", "-t", tmux.session(runId)]])
|
||||||
);
|
);
|
||||||
|
// An attach with no size of its own (a terminal on a hidden tab, 0×0) takes
|
||||||
|
// the window's current size, so attaching it never shrinks the pane.
|
||||||
|
const own =
|
||||||
|
cols && rows ? { cols, rows } : (!lead && tmux.windowSize(runId)) || { cols: 80, rows: 24 };
|
||||||
// A follower attaches at the driver's size: tmux sizes the window to the
|
// A follower attaches at the driver's size: tmux sizes the window to the
|
||||||
// newest client, so its own size would resize the driver's pane first.
|
// newest client, so its own size would resize the driver's pane first.
|
||||||
const pty = spawnImpl(file, args, {
|
const pty = spawnImpl(file, args, {
|
||||||
name: "xterm-256color",
|
name: "xterm-256color",
|
||||||
cols: lead ? lead.cols : cols || 80,
|
cols: lead ? lead.cols : own.cols,
|
||||||
rows: lead ? lead.rows : rows || 24,
|
rows: lead ? lead.rows : own.rows,
|
||||||
// Windows' inbox ConPTY re-renders psmux's output instead of passing it
|
// Windows' inbox ConPTY re-renders psmux's output instead of passing it
|
||||||
// through, and wherever its glyph widths disagree with xterm.js's (⏺, ✻,
|
// through, and wherever its glyph widths disagree with xterm.js's (⏺, ✻,
|
||||||
// Vietnamese) it leaves stray cells: a torn left edge, bars mid-screen.
|
// Vietnamese) it leaves stray cells: a torn left edge, bars mid-screen.
|
||||||
@@ -176,7 +180,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
|||||||
...(platform === "win32" && { useConptyDll: true }),
|
...(platform === "win32" && { useConptyDll: true }),
|
||||||
});
|
});
|
||||||
swallowPipeErrors(pty);
|
swallowPipeErrors(pty);
|
||||||
const me = { ws, pty, cols: cols || 80, rows: rows || 24, exited: false };
|
const me = { ws, pty, cols: own.cols, rows: own.rows, exited: false };
|
||||||
group.viewers.add(me);
|
group.viewers.add(me);
|
||||||
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
|
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
|
||||||
else group.driver = me;
|
else group.driver = me;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*
|
*
|
||||||
* Every session is named `agenthub-lane-<laneId>` so a real terminal can attach
|
* Every session is named `agenthub-lane-<laneId>` so a real terminal can attach
|
||||||
* to the exact same session (`tmux attach -t agenthub-lane-<id>`, or
|
* to the exact same session (`tmux attach -t agenthub-lane-<id>`, or
|
||||||
* `agenthub lanes shell`) — that's the whole point: the dashboard both creates
|
* `agenthub workspace shell`) — that's the whole point: the dashboard both creates
|
||||||
* the session (one-click Start/Resume) and is just one of possibly several
|
* the session (one-click Start/Resume) and is just one of possibly several
|
||||||
* attached clients tmux already keeps in sync.
|
* attached clients tmux already keeps in sync.
|
||||||
*
|
*
|
||||||
@@ -719,7 +719,7 @@ function spawnRun(args) {
|
|||||||
schedulePaste();
|
schedulePaste();
|
||||||
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
|
||||||
// The session exists but its pane is sitting at a bare shell prompt — a
|
// The session exists but its pane is sitting at a bare shell prompt — a
|
||||||
// `agenthub lanes shell`, or a `claude` that already exited. Adopting it
|
// `agenthub workspace shell`, or a `claude` that already exited. Adopting it
|
||||||
// silently here would swallow the whole request: a Resume would spawn no
|
// silently here would swallow the whole request: a Resume would spawn no
|
||||||
// `--resume` and an initial prompt would never be typed, while the API
|
// `--resume` and an initial prompt would never be typed, while the API
|
||||||
// still answered 200. Run the argv in the pane the user already sees
|
// still answered 200. Run the argv in the pane the user already sees
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user