feat(cc-config): update every plugin and marketplace at once, then reload the open sessions
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect. Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement. readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race. Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: a one-shot claude -p "/reload-plugins" would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command. Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action. Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted. Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
This commit is contained in:
@@ -305,6 +305,21 @@
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"updateAll": {
|
||||
"title": "Update everything",
|
||||
"help": "Refreshes every marketplace, then updates all installed plugins, then tells the Claude Code sessions this dashboard has open to pick up the changes. AgentHub itself goes last, because updating it restarts this dashboard.",
|
||||
"button": "Update all",
|
||||
"working": "Updating…",
|
||||
"started": "Update-all started for {{count}} plugin(s).",
|
||||
"failed": "Could not start the update.",
|
||||
"reloadButton": "Reload sessions",
|
||||
"reloaded": "Reload sent to {{count}} open session(s).",
|
||||
"reloadedNone": "No open Claude Code session to reload.",
|
||||
"reloadedCount": "— {{count}} session(s)",
|
||||
"restartWarning": "Keep this tab open. If AgentHub itself is updated, the dashboard restarts at the end and this page reconnects on its own.",
|
||||
"abandoned": "The previous batch stopped without finishing — most likely the machine restarted. Run it again.",
|
||||
"bootstrapFailed": "The dashboard was stopped but did not come back: {{error}}. Run /agenthub-update to recover."
|
||||
},
|
||||
"manage": {
|
||||
"installTitle": "Install a plugin",
|
||||
"installPlaceholder": "owner/repo, https://… or plugin@marketplace",
|
||||
|
||||
@@ -300,6 +300,21 @@
|
||||
"copy": "Sao chép",
|
||||
"copied": "Đã sao chép"
|
||||
},
|
||||
"updateAll": {
|
||||
"title": "Cập nhật toàn bộ",
|
||||
"help": "Làm mới mọi marketplace, rồi cập nhật tất cả plugin đã cài, sau đó yêu cầu các session Claude Code mà dashboard đang mở nạp lại thay đổi. AgentHub đứng cuối, vì cập nhật nó sẽ khởi động lại dashboard này.",
|
||||
"button": "Cập nhật tất cả",
|
||||
"working": "Đang cập nhật…",
|
||||
"started": "Đã bắt đầu cập nhật {{count}} plugin.",
|
||||
"failed": "Không khởi động được tiến trình cập nhật.",
|
||||
"reloadButton": "Nạp lại session",
|
||||
"reloaded": "Đã gửi lệnh nạp lại tới {{count}} session đang mở.",
|
||||
"reloadedNone": "Không có session Claude Code nào đang mở để nạp lại.",
|
||||
"reloadedCount": "— {{count}} session",
|
||||
"restartWarning": "Giữ tab này mở. Nếu chính AgentHub được cập nhật, dashboard sẽ khởi động lại ở bước cuối và trang tự kết nối lại.",
|
||||
"abandoned": "Đợt cập nhật trước dừng giữa đường — rất có thể do máy khởi động lại. Hãy chạy lại.",
|
||||
"bootstrapFailed": "Dashboard đã bị dừng nhưng không sống lại: {{error}}. Chạy /agenthub-update để khôi phục."
|
||||
},
|
||||
"manage": {
|
||||
"installTitle": "Cài plugin",
|
||||
"installPlaceholder": "owner/repo, https://… hoặc plugin@marketplace",
|
||||
|
||||
+104
-3
@@ -1609,11 +1609,45 @@ export const api = {
|
||||
/**
|
||||
* POST /api/cc-config/plugins/action - enable/disable/update/uninstall an
|
||||
* installed plugin by its `<name>@<marketplace>` key.
|
||||
*
|
||||
* `reload` additionally types `/reload-plugins` + `/reload-skills` into the
|
||||
* live Claude Code lane panes, but only when the action itself succeeded —
|
||||
* "update one, reload that one", as opposed to the batch below which
|
||||
* reloads once after everything.
|
||||
*/
|
||||
pluginAction: (key: string, action: CcPluginAction) =>
|
||||
request<CcCliResult>("/cc-config/plugins/action", {
|
||||
pluginAction: (key: string, action: CcPluginAction, reload?: boolean) =>
|
||||
request<CcCliResult & { reloaded?: CcReloadResult }>("/cc-config/plugins/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key, action }),
|
||||
body: JSON.stringify({ key, action, reload }),
|
||||
}),
|
||||
/**
|
||||
* POST /api/cc-config/reload - tell the Claude Code sessions the dashboard
|
||||
* has open to re-read plugins and/or skills from disk.
|
||||
*
|
||||
* Both slash commands are scoped to the session that runs them, so this
|
||||
* types them into the live lane panes over tmux. Sessions running outside
|
||||
* the dashboard cannot be reached and must run the command themselves.
|
||||
*/
|
||||
reload: (kinds?: CcReloadKind[]) =>
|
||||
request<{ ok: true } & CcReloadResult>("/cc-config/reload", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kinds }),
|
||||
}),
|
||||
/** GET /api/cc-config/plugins/update-all - progress of the current or last batch. */
|
||||
updateAllStatus: () =>
|
||||
request<{ batch: CcUpdateAllBatch | null }>("/cc-config/plugins/update-all"),
|
||||
/**
|
||||
* POST /api/cc-config/plugins/update-all - update every marketplace, then
|
||||
* every installed plugin, then reload the open sessions.
|
||||
*
|
||||
* Returns as soon as the detached worker is spawned: the batch ends with
|
||||
* AgentHub itself when the dashboard runs from the plugin cache, and that
|
||||
* step restarts this server. Follow it with {@link updateAllStatus}.
|
||||
*/
|
||||
updateAll: () =>
|
||||
request<CcUpdateAllStarted>("/cc-config/plugins/update-all", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
/**
|
||||
* POST /api/cc-config/marketplaces/add - register a marketplace from a
|
||||
@@ -2761,6 +2795,73 @@ export type CcPluginAction = "enable" | "disable" | "update" | "uninstall";
|
||||
/** What POST /api/cc-config/marketplaces/action can do to a marketplace. */
|
||||
export type CcMarketplaceAction = "update" | "remove";
|
||||
|
||||
/** Which reload slash command to send. The command strings themselves are
|
||||
* fixed server-side — a pane's stdin is not a place to forward client input. */
|
||||
export type CcReloadKind = "plugins" | "skills";
|
||||
|
||||
/** What POST /api/cc-config/reload reached. `lanes_total` counts only panes
|
||||
* that were running a live `claude`; a lane sitting at a shell prompt has no
|
||||
* REPL to reload and is skipped rather than reported as failed. */
|
||||
export interface CcReloadResult {
|
||||
commands: string[];
|
||||
lanes_total: number;
|
||||
lanes: {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
/** Commands actually delivered — empty when the pane died mid-send. */
|
||||
sent: string[];
|
||||
error?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** One step of the update-all batch. `key` is stable (`marketplaces`,
|
||||
* `plugin:<id>`, `reload`) so the UI can keep a row's identity across polls. */
|
||||
export interface CcUpdateAllStep {
|
||||
key: string;
|
||||
label: string;
|
||||
status: "running" | "ok" | "failed" | "skipped";
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
error?: string;
|
||||
note?: string;
|
||||
/** Set on the AgentHub step that triggers the dashboard restart. */
|
||||
restarted?: boolean;
|
||||
/** Set on the reload step — the lane sessions that were typed into. */
|
||||
lanes?: string[];
|
||||
lanes_total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cc-config/plugins/update-all - the batch worker's on-disk journal.
|
||||
*
|
||||
* `abandoned` is derived, not written: the worker is detached, so a host reboot
|
||||
* leaves a `running` journal with nobody to finish it. `partial` means the
|
||||
* batch ran to the end with at least one failed step.
|
||||
*/
|
||||
export interface CcUpdateAllBatch {
|
||||
status: "running" | "done" | "partial" | "failed" | "abandoned";
|
||||
steps: CcUpdateAllStep[];
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
error?: string;
|
||||
/** Set when the final `plugin-bootstrap --force` failed, i.e. the old server
|
||||
* was stopped and no replacement came up. */
|
||||
bootstrap_error?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** POST /api/cc-config/plugins/update-all - acknowledgement, not a result. */
|
||||
export interface CcUpdateAllStarted {
|
||||
ok: true;
|
||||
spawned: true;
|
||||
runtime_dir: string;
|
||||
/** How many installed plugins the worker will walk. */
|
||||
planned: number;
|
||||
/** The plugin this dashboard itself runs from, updated LAST because its
|
||||
* bootstrap restarts the server. Null for a plain git checkout. */
|
||||
self_plugin: { marketplace: string; plugin: string } | null;
|
||||
}
|
||||
|
||||
/** Result of a `claude plugin …` run the dashboard triggered. A non-zero CLI
|
||||
* exit arrives as an HTTP 422 error instead of one of these. */
|
||||
export interface CcCliResult {
|
||||
|
||||
@@ -92,6 +92,11 @@ import {
|
||||
CircleSlash,
|
||||
ChevronDown,
|
||||
UserCog,
|
||||
ArrowUpCircle,
|
||||
RotateCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
MinusCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { FileViewer } from "../components/config/FileViewer";
|
||||
@@ -133,6 +138,7 @@ import type {
|
||||
CcPlugin,
|
||||
CcPluginItem,
|
||||
CcPluginsResponse,
|
||||
CcUpdateAllBatch,
|
||||
CcProfile,
|
||||
CcAccountIdentity,
|
||||
CcScope,
|
||||
@@ -1301,6 +1307,172 @@ function ActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
/** Icon + colour per batch-step state. */
|
||||
type StepLook = { Icon: typeof CheckCircle2; className: string };
|
||||
const STEP_FALLBACK: StepLook = { Icon: MinusCircle, className: "text-fg-muted" };
|
||||
const STEP_LOOK: Record<string, StepLook> = {
|
||||
running: { Icon: RefreshCw, className: "text-accent animate-spin" },
|
||||
ok: { Icon: CheckCircle2, className: "text-status-success" },
|
||||
failed: { Icon: XCircle, className: "text-status-error" },
|
||||
skipped: { Icon: MinusCircle, className: "text-fg-muted" },
|
||||
};
|
||||
|
||||
/**
|
||||
* "Update all" + "Reload sessions" for the whole Claude Code install.
|
||||
*
|
||||
* The batch runs in a detached server-side worker and is followed by polling
|
||||
* rather than by the POST's response, because the last step restarts the
|
||||
* dashboard when AgentHub is itself a plugin install. Polling therefore has to
|
||||
* tolerate the server disappearing mid-batch: a failed poll is ignored and
|
||||
* retried, never rendered as an error, because the most likely cause is the
|
||||
* successful restart this button asked for.
|
||||
*/
|
||||
function UpdateAllCard({
|
||||
onToast,
|
||||
onChanged,
|
||||
}: {
|
||||
onToast: (t: NonNullable<Toast>) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [batch, setBatch] = useState<CcUpdateAllBatch | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const running = batch?.status === "running";
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.ccConfig.updateAllStatus();
|
||||
setBatch(res.batch);
|
||||
return res.batch;
|
||||
} catch {
|
||||
// Server is down (very likely the AgentHub restart we triggered) — keep
|
||||
// the last known steps on screen and try again on the next tick.
|
||||
return undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Pick up a batch already in flight, including one started by the server we
|
||||
// were talking to BEFORE it restarted itself.
|
||||
useEffect(() => {
|
||||
void poll();
|
||||
}, [poll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const id = setInterval(() => void poll(), 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [running, poll]);
|
||||
|
||||
// Refresh the surrounding inventory once the batch stops moving, so version
|
||||
// numbers and install paths catch up without a manual reload.
|
||||
const prevStatus = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (prevStatus.current === "running" && batch?.status !== "running") onChanged();
|
||||
prevStatus.current = batch?.status;
|
||||
}, [batch?.status, onChanged]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
const res = await api.ccConfig.updateAll();
|
||||
onToast({ kind: "success", message: t("updateAll.started", { count: res.planned }) });
|
||||
await poll();
|
||||
} catch (err: unknown) {
|
||||
onToast({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : t("updateAll.failed"),
|
||||
});
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [onToast, poll, t]);
|
||||
|
||||
const reloadOnly = useCallback(async () => {
|
||||
setReloading(true);
|
||||
try {
|
||||
const res = await api.ccConfig.reload();
|
||||
onToast({
|
||||
kind: "success",
|
||||
message:
|
||||
res.lanes_total > 0
|
||||
? t("updateAll.reloaded", { count: res.lanes_total })
|
||||
: t("updateAll.reloadedNone"),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
onToast({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : t("updateAll.failed"),
|
||||
});
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}, [onToast, t]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="text-[11px] font-semibold text-fg-secondary">{t("updateAll.title")}</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reloadOnly()}
|
||||
disabled={reloading || running}
|
||||
className="h-8 text-[11px] font-medium px-3 rounded-md border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<RotateCw className={`w-3 h-3 ${reloading ? "animate-spin" : ""}`} />
|
||||
{t("updateAll.reloadButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={starting || running}
|
||||
className="h-8 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<ArrowUpCircle className="w-3 h-3" />
|
||||
{running || starting ? t("updateAll.working") : t("updateAll.button")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] text-fg-muted leading-relaxed">{t("updateAll.help")}</p>
|
||||
|
||||
{batch && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{batch.steps.map((step) => {
|
||||
const look = STEP_LOOK[step.status] ?? STEP_FALLBACK;
|
||||
return (
|
||||
<div key={step.key} className="flex items-start gap-1.5 text-[11px]">
|
||||
<look.Icon className={`w-3 h-3 mt-0.5 shrink-0 ${look.className}`} />
|
||||
<span className="font-mono text-fg-secondary truncate">{step.label}</span>
|
||||
{step.note && <span className="text-fg-muted truncate">— {step.note}</span>}
|
||||
{step.error && (
|
||||
<span className="text-status-error truncate" title={step.error}>
|
||||
— {step.error}
|
||||
</span>
|
||||
)}
|
||||
{step.key === "reload" && step.lanes_total !== undefined && (
|
||||
<span className="text-fg-muted">
|
||||
{t("updateAll.reloadedCount", { count: step.lanes_total })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{batch.status === "abandoned" && (
|
||||
<p className="text-[10px] text-status-warning">{t("updateAll.abandoned")}</p>
|
||||
)}
|
||||
{batch.bootstrap_error && (
|
||||
<p className="text-[10px] text-status-error">
|
||||
{t("updateAll.bootstrapFailed", { error: batch.bootstrap_error })}
|
||||
</p>
|
||||
)}
|
||||
{running && <p className="text-[10px] text-fg-muted">{t("updateAll.restartWarning")}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginsPanel({
|
||||
data,
|
||||
search,
|
||||
@@ -1373,6 +1545,8 @@ function PluginsPanel({
|
||||
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
|
||||
/>
|
||||
|
||||
<UpdateAllCard onToast={onToast} onChanged={onChanged} />
|
||||
|
||||
<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">
|
||||
{t("manage.installTitle")}
|
||||
@@ -1717,7 +1891,9 @@ function PluginCard({
|
||||
onClick={() =>
|
||||
void run(
|
||||
p.key,
|
||||
() => api.ccConfig.pluginAction(p.key, "update"),
|
||||
// reload=true: updating one plugin reloads the open sessions
|
||||
// right away. The batch reloads once at the end instead.
|
||||
() => api.ccConfig.pluginAction(p.key, "update", true),
|
||||
t("manage.updated", { name: p.key })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @file Covers the "Update all" card on the Claude Code config panel's plugins
|
||||
* tab: it starts the detached batch, renders the worker's journal as it polls,
|
||||
* survives the dashboard restart the batch's last step causes, and keeps the
|
||||
* per-plugin update button reloading only that one plugin.
|
||||
*
|
||||
* The restart case is the one worth a test: while AgentHub replaces itself the
|
||||
* status endpoint stops answering, and a naive implementation would render that
|
||||
* as a failure right at the moment everything is going to plan.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/.claude",
|
||||
projectClaudeDir: "/repo/.claude",
|
||||
projectRoot: "/repo",
|
||||
},
|
||||
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: 1,
|
||||
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: "/home/.claude/plugins/installed_plugins.json",
|
||||
manifestExists: true,
|
||||
plugins: [{ key: "ponytail@ponytail", enabled: true, version: "4.9.0" }],
|
||||
}),
|
||||
marketplaces: r({ marketplaces: [] }),
|
||||
mcp: r({ mcp: [] }),
|
||||
hooks: r({ hooks: [] }),
|
||||
settings: r({ sources: [] }),
|
||||
keybindings: r({ groups: [] }),
|
||||
statusline: r({}),
|
||||
hookScripts: r({ items: [] }),
|
||||
profiles: r({ profiles: [], active: null }),
|
||||
backups: r({ items: [] }),
|
||||
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||
pluginsAvailable: r({ installed: [], available: [] }),
|
||||
pluginAction: r({ ok: true, stdout: "", stderr: "" }),
|
||||
updateAllStatus: r({ batch: null }),
|
||||
updateAll: r({
|
||||
ok: true,
|
||||
spawned: true,
|
||||
runtime_dir: "/home/.claude/agenthub/runtime",
|
||||
planned: 7,
|
||||
self_plugin: { marketplace: "sg", plugin: "agenthub" },
|
||||
}),
|
||||
reload: r({ ok: true, commands: ["/reload-plugins"], lanes: [], lanes_total: 2 }),
|
||||
};
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the plugins tab, where the Update all card lives. */
|
||||
async function openPlugins() {
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({ batch: null }) as never;
|
||||
ccConfigMock.reload = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
commands: ["/reload-plugins"],
|
||||
lanes: [],
|
||||
lanes_total: 2,
|
||||
}) as never;
|
||||
});
|
||||
|
||||
describe("CcConfig — Update all", () => {
|
||||
it("starts the batch and reports how many plugins it will walk", async () => {
|
||||
await openPlugins();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /update all/i }));
|
||||
await settle();
|
||||
|
||||
expect(ccConfigMock.updateAll).toHaveBeenCalledTimes(1);
|
||||
// The count comes from the server's plan, not from the rendered list.
|
||||
expect(await screen.findByText(/7 plugin/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders each step of the worker's journal, including a failure", async () => {
|
||||
ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({
|
||||
batch: {
|
||||
status: "partial",
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString(),
|
||||
steps: [
|
||||
{ key: "marketplaces", label: "Update all marketplaces", status: "ok" },
|
||||
{ key: "plugin:ponytail@ponytail", label: "ponytail@ponytail", status: "ok" },
|
||||
{
|
||||
key: "plugin:vercel@claude-plugins-official",
|
||||
label: "vercel@claude-plugins-official",
|
||||
status: "failed",
|
||||
error: "marketplace unreachable",
|
||||
},
|
||||
{
|
||||
key: "reload",
|
||||
label: "Reload open Claude Code sessions",
|
||||
status: "ok",
|
||||
lanes_total: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as never;
|
||||
|
||||
await openPlugins();
|
||||
|
||||
expect(await screen.findByText("Update all marketplaces")).toBeInTheDocument();
|
||||
expect(screen.getByText("ponytail@ponytail")).toBeInTheDocument();
|
||||
expect(screen.getByText(/marketplace unreachable/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Reload open Claude Code sessions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the last known steps on screen when the status endpoint stops answering", async () => {
|
||||
// This is what a successful AgentHub self-update looks like from the tab:
|
||||
// the server it was polling is gone. Rendering an error here would be
|
||||
// wrong — the restart IS the batch working.
|
||||
const runningBatch = {
|
||||
batch: {
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "ok" }],
|
||||
},
|
||||
};
|
||||
ccConfigMock.updateAllStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(runningBatch)
|
||||
.mockRejectedValue(new Error("Failed to fetch")) as never;
|
||||
|
||||
await openPlugins();
|
||||
expect(await screen.findByText("Update all marketplaces")).toBeInTheDocument();
|
||||
|
||||
// Let the polling interval fire into the rejection.
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 2200));
|
||||
});
|
||||
|
||||
expect(screen.getByText("Update all marketplaces")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Failed to fetch/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables both buttons while a batch is running, so two can't overlap", async () => {
|
||||
ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({
|
||||
batch: {
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "running" }],
|
||||
},
|
||||
}) as never;
|
||||
|
||||
await openPlugins();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /updating/i })).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /reload sessions/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("re-enables the button for an abandoned batch instead of spinning forever", async () => {
|
||||
// A detached worker killed by a reboot leaves a `running` journal; the
|
||||
// server reports it as abandoned so the button is usable again.
|
||||
ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({
|
||||
batch: {
|
||||
status: "abandoned",
|
||||
started_at: new Date(Date.now() - 7200_000).toISOString(),
|
||||
steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "running" }],
|
||||
},
|
||||
}) as never;
|
||||
|
||||
await openPlugins();
|
||||
|
||||
expect(await screen.findByRole("button", { name: /update all/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("reload-only sends the slash commands without starting a batch", async () => {
|
||||
await openPlugins();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /reload sessions/i }));
|
||||
await settle();
|
||||
|
||||
expect(ccConfigMock.reload).toHaveBeenCalledTimes(1);
|
||||
expect(ccConfigMock.updateAll).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/2 open session/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so plainly when there is no open session to reload", async () => {
|
||||
ccConfigMock.reload = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, commands: [], lanes: [], lanes_total: 0 }) as never;
|
||||
await openPlugins();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /reload sessions/i }));
|
||||
await settle();
|
||||
|
||||
expect(await screen.findByText(/no open claude code session/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updating one plugin asks for a reload of just that change", async () => {
|
||||
// The batch reloads once at the end; a single update reloads immediately.
|
||||
await openPlugins();
|
||||
|
||||
const [firstUpdate] = screen.getAllByRole("button", { name: /^update$/i });
|
||||
await userEvent.click(firstUpdate!);
|
||||
await settle();
|
||||
|
||||
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "update", true);
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -1605,7 +1605,10 @@ POST /api/cc-config/project-memory Body: { dir, content }
|
||||
PATCH /api/cc-config/settings Body: { scope, set?: { "dotted.key": value }, unset?: ["dotted.key"] }
|
||||
GET /api/cc-config/plugins/available
|
||||
POST /api/cc-config/plugins/install Body: { spec, scope? }
|
||||
POST /api/cc-config/plugins/action Body: { key, action: enable|disable|update|uninstall }
|
||||
POST /api/cc-config/plugins/action Body: { key, action: enable|disable|update|uninstall, reload? }
|
||||
GET /api/cc-config/plugins/update-all
|
||||
POST /api/cc-config/plugins/update-all Body: { reload? }
|
||||
POST /api/cc-config/reload Body: { kinds?: ["plugins"|"skills"] }
|
||||
POST /api/cc-config/marketplaces/add Body: { source, scope? }
|
||||
POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
|
||||
POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?, headers?, command?, args?, env? }
|
||||
@@ -1616,6 +1619,18 @@ POST /api/cc-config/mcp/remove Body: { name, scope? }
|
||||
|
||||
`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.
|
||||
|
||||
### Updating everything, and reloading sessions
|
||||
|
||||
`POST /api/cc-config/plugins/update-all` refreshes every marketplace, then updates every installed plugin, then tells the open Claude Code sessions to pick the changes up. It **returns as soon as a detached worker is spawned** — the response is an acknowledgement, not a result — and progress is polled from `GET` on the same path, which reads the worker's journal at `~/.claude/agenthub/runtime/cc-update-all.json`.
|
||||
|
||||
That indirection is structural, not stylistic: the plugin this dashboard *itself* runs from is updated **last**, followed by `plugin-bootstrap.js --force`, which stops this server and starts the replacement. A request handler doing the work would die partway through with the remaining plugins unrun and nothing able to report why, so the journal lives outside the plugin cache the update replaces and whichever server is alive serves it. Plugin ids come from `claude plugin list --json`, never from the request body, and the "self" plugin is derived from the server's own path — no caller can nominate a different plugin for the restart. A second concurrent batch is refused with 409 `EBUSY`.
|
||||
|
||||
Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again.
|
||||
|
||||
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
|
||||
|
||||
`POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action.
|
||||
|
||||
`GET /api/cc-config/memory` also surfaces the per-project file-based memory store — every `*.md` under `~/.claude/projects/<slug>/memory/` (the common pattern of a `MEMORY.md` index plus one file per remembered fact). Those items have `scope: "auto-memory"` and carry `project` (the `projects/<slug>` dir name), `name` (filename), `isIndex` (true for `MEMORY.md` / `INDEX-*.md`, which sort first), and parsed `frontmatter`. They are **editable**: `PUT`/`DELETE /api/cc-config/file` accept `{ scope: "auto-memory", type: "auto-memory", project, name, content? }` and create a timestamped backup under `<memory-dir>/.cc-config-backups/auto-memory/` before mutating (an invalid `project` slug returns `EBADPROJECT`). `GET /api/cc-config/backups` lists these with `scope: "auto-memory"` and `project` set. Bodies are also readable via `GET /api/cc-config/file` (they live under `CLAUDE_HOME`).
|
||||
|
||||
**`POST /api/{cc,codex}-config/project-memory` (a new project's memory file).** Creates `CLAUDE.md` (Claude) or `AGENTS.md` (Codex) inside the directory named by `dir` — the one write path that honours a caller-supplied directory, because bootstrapping a project the dashboard has never run in is impossible from a writer pinned to the server's own cwd (which is what `PUT /file`'s `"project"` scope means). It stays narrow: `dir` must be absolute and already exist (nothing is created for you), the filename comes from the route and is never taken from the body, and an existing file — including a symlink under that name — returns **409 `EEXISTS`** rather than being overwritten, so this endpoint can only ever create. Bad or missing directories return 400 `EBADDIR`. Unlike the other config routes it sits behind the same loopback-`Origin` guard `/api/run` uses (403 `EBADORIGIN`): a page in the user's browser could otherwise plant agent instructions in a project directory. Returns `{ file, size, created: true }`, where `file` is the realpath'd target, and broadcasts `cc_config_changed`.
|
||||
|
||||
+101
-4
@@ -6788,17 +6788,18 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Updates
|
||||
summary: Pull, rebuild, and restart the dashboard when fast-forwardable
|
||||
summary: Update this install to the newest release and restart the dashboard
|
||||
description: 'Two modes, chosen by how this copy was installed and never by the request body (which is ignored). checkout: fast-forward onto the release tag, npm run setup, npm run build in production, then self-restart. plugin: the install runs from ~/.claude/plugins/cache/, so a detached helper runs `claude plugin marketplace update`, `claude plugin update -y`, then plugin-bootstrap.js --force from the NEW cache directory, which stops this server and starts the new one. In both modes the response is written before anything stops the process.'
|
||||
operationId: applyUpdate
|
||||
responses:
|
||||
'200':
|
||||
description: Checkout fast-forwarded and rebuilt; the process is about to self-restart
|
||||
description: 'Update is being applied; the process is about to be replaced. mode: "plugin" means the detached plugin chain is running, otherwise the checkout was fast-forwarded and rebuilt in-place'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: 'Same shape as GET /api/updates/status, plus applied: true.'
|
||||
description: 'Same shape as GET /api/updates/status, plus applied: true. In plugin mode also mode: "plugin" and runtime_dir (where the chain writes its verdict, read back afterwards as last_plugin_update).'
|
||||
'409':
|
||||
description: Declined - nothing to apply, or the checkout isn't safely fast-forwardable
|
||||
content:
|
||||
@@ -6806,7 +6807,7 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable".'
|
||||
description: 'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing newer published; when a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.'
|
||||
'500':
|
||||
description: Update apply failed
|
||||
content:
|
||||
@@ -7975,6 +7976,102 @@ paths:
|
||||
pluginJson:
|
||||
name: superpowers
|
||||
version: 1.2.0
|
||||
/api/cc-config/plugins/update-all:
|
||||
get:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Progress of the current or last update-all batch
|
||||
description: Read-only. Returns the detached batch worker's on-disk journal from ~/.claude/agenthub/runtime/cc-update-all.json, or `{batch:null}` when no batch has ever run. `status` is `running`|`done`|`partial`|`failed`|`abandoned`; `abandoned` is DERIVED, not stored — a `running` journal older than one hour is reported that way because the worker is detached and a host reboot leaves nobody to write the terminal entry. `partial` means the batch finished with at least one failed step. Malformed or half-written journals return null rather than erroring, since the UI polls this endpoint.
|
||||
operationId: ccConfigGetUpdateAll
|
||||
responses:
|
||||
'200':
|
||||
description: The batch journal, or null.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
example:
|
||||
batch:
|
||||
status: partial
|
||||
started_at: '2026-09-10T08:30:00.000Z'
|
||||
finished_at: '2026-09-10T08:34:12.000Z'
|
||||
steps:
|
||||
- key: marketplaces
|
||||
label: Update all marketplaces
|
||||
status: ok
|
||||
- key: plugin:ponytail@ponytail
|
||||
label: ponytail@ponytail
|
||||
status: ok
|
||||
- key: plugin:vercel@claude-plugins-official
|
||||
label: vercel@claude-plugins-official
|
||||
status: failed
|
||||
error: marketplace unreachable
|
||||
- key: reload
|
||||
label: Reload open Claude Code sessions
|
||||
status: ok
|
||||
lanes_total: 2
|
||||
post:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Update every marketplace and plugin, then reload the open sessions
|
||||
description: 'Mutating. Spawns a DETACHED worker and returns immediately — the response is an acknowledgement, not a result; poll GET on this path to follow it. Order: `claude plugin marketplace update` (no name = all), then `claude plugin update <id> -y` for every installed plugin, then `/reload-plugins` + `/reload-skills` typed into the live lane panes. The plugin this dashboard itself runs from is updated LAST and followed by `plugin-bootstrap.js --force`, which stops this server and starts the replacement — which is why the work cannot happen in the request handler and why the journal lives outside the plugin cache. Plugin ids come from `claude plugin list --json`, never from the body, and the "self" plugin is derived from this server''s own path, so no caller can nominate a different plugin for the restart. Answers 409 EBUSY when a batch is already in flight; the body is ignored apart from an optional `reload:false`.'
|
||||
operationId: ccConfigUpdateAll
|
||||
responses:
|
||||
'200':
|
||||
description: Batch worker spawned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
example:
|
||||
ok: true
|
||||
spawned: true
|
||||
runtime_dir: /Users/son/.claude/agenthub/runtime
|
||||
planned: 7
|
||||
self_plugin:
|
||||
marketplace: sg
|
||||
plugin: agenthub
|
||||
'409':
|
||||
description: A batch is already running.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/cc-config/reload:
|
||||
post:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Tell the open Claude Code sessions to re-read plugins and skills
|
||||
description: 'Types `/reload-plugins` and `/reload-skills` into the dashboard''s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.'
|
||||
operationId: ccConfigReload
|
||||
responses:
|
||||
'200':
|
||||
description: Commands delivered. `lanes_total` counts only panes running a live claude; zero is a normal outcome, not a failure.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
example:
|
||||
ok: true
|
||||
commands:
|
||||
- /reload-plugins
|
||||
- /reload-skills
|
||||
lanes_total: 1
|
||||
lanes:
|
||||
- id: agenthub-lane-3
|
||||
laneId: 3
|
||||
sent:
|
||||
- /reload-plugins
|
||||
- /reload-skills
|
||||
'400':
|
||||
description: '`kinds` was present but not an array.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/cc-config/mcp:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Detached worker for the dashboard's "Update all" button. Runs the whole
|
||||
* batch and writes each step's outcome to `cc-update-all.json` in the runtime
|
||||
* directory as it goes, so the UI can follow along by polling.
|
||||
*
|
||||
* Order, and why:
|
||||
* 1. `claude plugin marketplace update` (no name = every marketplace) — a
|
||||
* plugin update can only see a version its marketplace already knows about.
|
||||
* 2. `claude plugin update <id> -y` for every installed plugin EXCEPT the one
|
||||
* the dashboard itself runs from.
|
||||
* 3. That one last, followed by `plugin-bootstrap.js --force` from the NEW
|
||||
* cache directory — this stops the dashboard server and starts the
|
||||
* replacement. Anything ordered after it would run while the server is
|
||||
* down, which is exactly why it is last.
|
||||
* 4. `/reload-plugins` + `/reload-skills` typed into the live lane panes.
|
||||
* Deliberately after everything: one reload picks up every pending change.
|
||||
*
|
||||
* Spawned detached precisely because step 3 kills its parent. It must never
|
||||
* throw its way out without a terminal journal entry — a silent death would
|
||||
* leave the UI polling a batch that says "running" forever.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, spawnSync } = require("child_process");
|
||||
|
||||
const [, , runtimeDirArg, planJson] = process.argv;
|
||||
|
||||
const JOURNAL = path.join(runtimeDirArg || ".", "cc-update-all.json");
|
||||
const LOG_FILE = path.join(runtimeDirArg || ".", "cc-update-all.log");
|
||||
|
||||
// Ids reach an argv, so they are re-validated here rather than trusted across
|
||||
// the process boundary — same reasoning as scripts/plugin-update-helper.js.
|
||||
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
||||
const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
/** @type {{status: string, started_at: string, finished_at?: string, steps: object[]}} */
|
||||
const state = { status: "running", started_at: new Date().toISOString(), steps: [] };
|
||||
|
||||
function log(line) {
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist after every transition — the UI polls this file, and the process can
|
||||
* be SIGTERMed at step 3 without warning. */
|
||||
function flush() {
|
||||
try {
|
||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||
fs.writeFileSync(JOURNAL, JSON.stringify(state, null, 2));
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
function addStep(key, label) {
|
||||
const step = { key, label, status: "running", started_at: new Date().toISOString() };
|
||||
state.steps.push(step);
|
||||
flush();
|
||||
return step;
|
||||
}
|
||||
|
||||
function finishStep(step, status, extra = {}) {
|
||||
step.status = status;
|
||||
step.finished_at = new Date().toISOString();
|
||||
Object.assign(step, extra);
|
||||
flush();
|
||||
}
|
||||
|
||||
function resolveClaude() {
|
||||
const candidates = ["claude", path.join(require("os").homedir(), ".local", "bin", "claude")];
|
||||
for (const cmd of candidates) {
|
||||
const probe = spawnSync(cmd, ["--version"], { stdio: "ignore", timeout: 20_000 });
|
||||
if (!probe.error && probe.status === 0) return cmd;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function run(cmd, args, timeout) {
|
||||
log(`$ ${cmd} ${args.join(" ")}`);
|
||||
const out = execFileSync(cmd, args, {
|
||||
encoding: "utf8",
|
||||
timeout,
|
||||
maxBuffer: 10_000_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (out) log(out.trim().slice(0, 2000));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Newest cache dir for the self plugin, excluding the one being replaced. */
|
||||
function findNewRoot(marketplace, plugin, exclude) {
|
||||
const base = path.join(
|
||||
require("os").homedir(),
|
||||
".claude",
|
||||
"plugins",
|
||||
"cache",
|
||||
marketplace,
|
||||
plugin
|
||||
);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidates = entries
|
||||
.map((e) => path.join(base, e.name))
|
||||
.filter((p) => p !== exclude)
|
||||
.filter((p) => fs.existsSync(path.join(p, "scripts", "plugin-bootstrap.js")))
|
||||
.map((p) => ({ p, mtime: fs.statSync(p).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
return candidates.length ? candidates[0].p : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the reload commands into every live `claude` lane pane. Uses tmux
|
||||
* directly rather than the server's lib: by this point the server may be a
|
||||
* different process than the one that started the batch.
|
||||
*/
|
||||
function reloadPanes() {
|
||||
const step = addStep("reload", "Reload open Claude Code sessions");
|
||||
let names;
|
||||
try {
|
||||
const out = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
names = out
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.startsWith("agenthub-lane-"));
|
||||
} catch {
|
||||
// No tmux server at all means no open sessions — nothing to reload, which
|
||||
// is a normal outcome and not a batch failure.
|
||||
finishStep(step, "skipped", { note: "no tmux sessions are open" });
|
||||
return;
|
||||
}
|
||||
|
||||
const reloaded = [];
|
||||
for (const name of names) {
|
||||
let paneCmd = null;
|
||||
try {
|
||||
paneCmd = execFileSync(
|
||||
"tmux",
|
||||
["display-message", "-p", "-t", name, "#{pane_current_command}"],
|
||||
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
} catch {
|
||||
continue; // pane vanished between listing and probe
|
||||
}
|
||||
// A pane at a shell prompt has no REPL to reload; the text would be run as
|
||||
// a shell command instead.
|
||||
if (SHELL_COMMANDS.has(paneCmd)) continue;
|
||||
try {
|
||||
for (const command of ["/reload-plugins", "/reload-skills"]) {
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||
}
|
||||
reloaded.push(name);
|
||||
} catch {
|
||||
/* pane died mid-send; the rest of the batch stands */
|
||||
}
|
||||
}
|
||||
finishStep(step, "ok", { lanes: reloaded, lanes_total: reloaded.length });
|
||||
}
|
||||
|
||||
function main() {
|
||||
let plan;
|
||||
try {
|
||||
plan = JSON.parse(planJson);
|
||||
} catch {
|
||||
state.status = "failed";
|
||||
state.error = "unreadable plan";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
|
||||
const claude = resolveClaude();
|
||||
if (!claude) {
|
||||
const step = addStep("resolve_claude", "Locate the claude CLI");
|
||||
finishStep(step, "failed", {
|
||||
error: "the `claude` CLI is not on this server's PATH; run the update from a terminal",
|
||||
});
|
||||
state.status = "failed";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Every marketplace, in one call (no name = all).
|
||||
const mktStep = addStep("marketplaces", "Update all marketplaces");
|
||||
try {
|
||||
run(claude, ["plugin", "marketplace", "update"], 300_000);
|
||||
finishStep(mktStep, "ok");
|
||||
} catch (err) {
|
||||
// A marketplace that can't be refreshed (offline, auth) does not stop the
|
||||
// batch: the plugins may still have updates from the marketplaces that did.
|
||||
finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
}
|
||||
|
||||
const self = plan.selfPlugin;
|
||||
const selfId = self ? `${self.plugin}@${self.marketplace}` : null;
|
||||
const others = (plan.plugins || []).filter((id) => PLUGIN_SPEC_RE.test(id) && id !== selfId);
|
||||
|
||||
// 2. Every other plugin.
|
||||
for (const id of others) {
|
||||
const step = addStep(`plugin:${id}`, id);
|
||||
try {
|
||||
run(claude, ["plugin", "update", id, "-y"], 600_000);
|
||||
finishStep(step, "ok");
|
||||
} catch (err) {
|
||||
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. AgentHub itself, last — its bootstrap stops this server.
|
||||
if (self && SAFE_NAME.test(self.marketplace) && SAFE_NAME.test(self.plugin)) {
|
||||
const step = addStep(`plugin:${selfId}`, `${selfId} (this dashboard)`);
|
||||
try {
|
||||
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
|
||||
} catch (err) {
|
||||
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
||||
reloadPanes();
|
||||
state.status = "failed";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const newRoot = findNewRoot(self.marketplace, self.plugin, plan.selfRoot);
|
||||
if (!newRoot) {
|
||||
// Nothing newer was published: the running server is correct as-is.
|
||||
finishStep(step, "ok", { note: "already on the newest version; server left running" });
|
||||
} else {
|
||||
// Mark the step done BEFORE the restart: bootstrap SIGTERMs us mid-call,
|
||||
// so a journal entry written afterwards may never be written at all.
|
||||
finishStep(step, "ok", { note: `restarting the dashboard from ${newRoot}`, restarted: true });
|
||||
reloadPanes();
|
||||
state.status = "done";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
try {
|
||||
run(
|
||||
process.execPath,
|
||||
[path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"],
|
||||
900_000
|
||||
);
|
||||
} catch (err) {
|
||||
log(`bootstrap failed: ${err.message}`);
|
||||
state.status = "failed";
|
||||
state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000);
|
||||
state.note = "the old server was stopped; run /agenthub-update to recover";
|
||||
flush();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reload, once, after every update.
|
||||
if (plan.reload !== false) reloadPanes();
|
||||
|
||||
state.status = state.steps.some((s) => s.status === "failed") ? "partial" : "done";
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
// A worker that dies without a terminal entry leaves the UI polling forever.
|
||||
state.status = "failed";
|
||||
state.error = err.message || String(err);
|
||||
state.finished_at = new Date().toISOString();
|
||||
flush();
|
||||
}
|
||||
@@ -151,10 +151,13 @@ describe("cc-plugin-cli argv construction", () => {
|
||||
"disable",
|
||||
"demo@m",
|
||||
]);
|
||||
// `-y` is mandatory, not cosmetic: runClaude closes stdin, and the CLI
|
||||
// refuses to update without it whenever stdin is not a TTY.
|
||||
assert.deepEqual(argvOf(await cli.pluginUpdate({ spec: "demo@m" })), [
|
||||
"plugin",
|
||||
"update",
|
||||
"demo@m",
|
||||
"-y",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceRemove({ name: "m" })), [
|
||||
"plugin",
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @file Unit tests for typing `/reload-plugins` / `/reload-skills` into the
|
||||
* live Claude Code lane panes. Uses tmux.js's exec seam, so no real tmux binary
|
||||
* is invoked and no real session is ever typed into.
|
||||
*
|
||||
* The behaviours worth locking down are the ones that would silently do the
|
||||
* wrong thing: sending to a pane sitting at a shell prompt (the slash command
|
||||
* would run as a shell command instead), forwarding a caller-supplied string
|
||||
* into a REPL, and letting one dead pane fail the whole batch. The `tool ===
|
||||
* "claude"` filter is not covered here — it reads a dashboard-runs DB row,
|
||||
* which these stubs do not populate.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = 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.mkdtempSync(path.join(os.tmpdir(), "cc-reload-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const tmux = require("../lib/tmux");
|
||||
const { reloadOpenSessions, reloadableLanes } = require("../lib/cc-reload");
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stub tmux so `listRuns()` sees `sessions` and each pane reports the command
|
||||
* given in `panes` (defaulting to a live `claude`).
|
||||
* @returns {any[][]} the recorded argv arrays
|
||||
*/
|
||||
function stubTmux(sessions, panes = {}) {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "list-sessions") return sessions.join("\n");
|
||||
if (args[0] === "has-session") {
|
||||
if (sessions.includes(args[2])) return "";
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
if (args[0] === "display-message") {
|
||||
const name = args[3];
|
||||
const cmd = panes[name] ?? "claude";
|
||||
if (cmd === null) {
|
||||
const e = new Error("pane gone");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return `${cmd}\n`;
|
||||
}
|
||||
if (args[0] === "send-keys") {
|
||||
if (panes[args[2]] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("reloadableLanes", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("lists a lane whose pane is running claude", () => {
|
||||
stubTmux(["agenthub-lane-1"]);
|
||||
const lanes = reloadableLanes();
|
||||
assert.equal(lanes.length, 1);
|
||||
assert.equal(lanes[0].id, "agenthub-lane-1");
|
||||
assert.equal(lanes[0].laneId, 1);
|
||||
});
|
||||
|
||||
it("skips a pane sitting at a shell prompt", () => {
|
||||
// The claude process there has exited, so `/reload-plugins` would be typed
|
||||
// at a bash prompt and run as a shell command instead.
|
||||
stubTmux(["agenthub-lane-1"], { "agenthub-lane-1": "bash" });
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
|
||||
it("skips every shell name, not just bash", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"], {
|
||||
"agenthub-lane-1": "zsh",
|
||||
"agenthub-lane-2": "fish",
|
||||
"agenthub-lane-3": "dash",
|
||||
});
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
|
||||
it("returns nothing when no lane sessions exist at all", () => {
|
||||
stubTmux([]);
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadOpenSessions", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("types both reload commands, plugins before skills", () => {
|
||||
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
||||
// plugin itself has been reloaded.
|
||||
const calls = stubTmux(["agenthub-lane-7"]);
|
||||
const out = reloadOpenSessions();
|
||||
|
||||
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(out.lanes_total, 1);
|
||||
assert.deepEqual(out.lanes[0].sent, ["/reload-plugins", "/reload-skills"]);
|
||||
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins", "/reload-skills"]);
|
||||
// Each command must be submitted, or it just sits in the input box.
|
||||
assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2);
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", () => {
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["skills"] });
|
||||
assert.deepEqual(out.commands, ["/reload-skills"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-skills"]);
|
||||
});
|
||||
|
||||
it("ignores an unknown kind rather than typing it into a REPL", () => {
|
||||
// The command set is fixed in the module; a pane's stdin is not a place to
|
||||
// forward caller-supplied strings.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["plugins", "rm -rf /"] });
|
||||
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins"]);
|
||||
});
|
||||
|
||||
it("types into several lanes in one call", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
||||
const out = reloadOpenSessions();
|
||||
assert.equal(out.lanes_total, 2);
|
||||
assert.deepEqual(
|
||||
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
||||
[1, 2]
|
||||
);
|
||||
});
|
||||
|
||||
it("records a pane that dies mid-send without failing the others", () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" });
|
||||
const out = reloadOpenSessions();
|
||||
const failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
||||
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
||||
assert.match(failed.error, /died mid-send/);
|
||||
assert.deepEqual(failed.sent, [], "nothing was delivered to the dead pane");
|
||||
assert.deepEqual(ok.sent, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(ok.error, undefined);
|
||||
});
|
||||
|
||||
it("reports an empty result instead of throwing when nothing is open", () => {
|
||||
stubTmux([]);
|
||||
const out = reloadOpenSessions();
|
||||
assert.equal(out.lanes_total, 0);
|
||||
assert.deepEqual(out.lanes, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @file Unit tests for the "Update all" batch journal — the only channel the UI
|
||||
* has for following a batch that deliberately outlives the server process.
|
||||
*
|
||||
* The batch worker itself is never run to completion here: with a valid plan it
|
||||
* would really update this machine's plugins and stop its dashboard. Only the
|
||||
* paths that must terminate WITHOUT touching anything are exercised against the
|
||||
* real script, because a worker that dies without a terminal journal entry
|
||||
* leaves the UI polling "running" forever — the exact failure this journal
|
||||
* exists to prevent.
|
||||
* @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("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const HELPER = path.join(__dirname, "..", "..", "scripts", "cc-update-all-helper.js");
|
||||
|
||||
let tmpDir;
|
||||
let prevDataDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-update-all-"));
|
||||
prevDataDir = process.env.DASHBOARD_DATA_DIR;
|
||||
// Redirect the runtime dir: these tests must never read or write the real
|
||||
// ~/.claude/agenthub/runtime of whoever runs the suite.
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(tmpDir, "data");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (prevDataDir === undefined) delete process.env.DASHBOARD_DATA_DIR;
|
||||
else process.env.DASHBOARD_DATA_DIR = prevDataDir;
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
readUpdateAll,
|
||||
isRunning,
|
||||
journalPath,
|
||||
runtimeDir,
|
||||
STALE_MS,
|
||||
} = require("../lib/cc-update-all");
|
||||
|
||||
/** Write a journal into a fresh runtime dir and return that dir. */
|
||||
function withJournal(name, body) {
|
||||
const rt = path.join(tmpDir, name);
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rt, "cc-update-all.json"),
|
||||
typeof body === "string" ? body : JSON.stringify(body)
|
||||
);
|
||||
return rt;
|
||||
}
|
||||
|
||||
describe("readUpdateAll", () => {
|
||||
it("returns null when no batch has ever run", () => {
|
||||
const rt = path.join(tmpDir, "never");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
assert.equal(readUpdateAll(rt), null);
|
||||
});
|
||||
|
||||
it("returns null for malformed JSON rather than throwing on a poll", () => {
|
||||
assert.equal(readUpdateAll(withJournal("bad", "{nope")), null);
|
||||
});
|
||||
|
||||
it("returns null for JSON without a steps array", () => {
|
||||
// Guards against reporting a half-written file as a batch.
|
||||
assert.equal(readUpdateAll(withJournal("nosteps", { status: "running" })), null);
|
||||
});
|
||||
|
||||
it("reads back a finished batch with its per-step outcomes", () => {
|
||||
const rt = withJournal("done", {
|
||||
status: "partial",
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString(),
|
||||
steps: [
|
||||
{ key: "marketplaces", label: "Update all marketplaces", status: "ok" },
|
||||
{
|
||||
key: "plugin:ponytail@ponytail",
|
||||
label: "ponytail@ponytail",
|
||||
status: "failed",
|
||||
error: "boom",
|
||||
},
|
||||
],
|
||||
});
|
||||
const batch = readUpdateAll(rt);
|
||||
assert.equal(batch.status, "partial");
|
||||
assert.equal(batch.steps.length, 2);
|
||||
assert.equal(batch.steps[1].status, "failed");
|
||||
assert.match(batch.steps[1].error, /boom/);
|
||||
});
|
||||
|
||||
it("reports a fresh running batch as running", () => {
|
||||
const rt = withJournal("fresh", {
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
steps: [{ key: "marketplaces", status: "running" }],
|
||||
});
|
||||
assert.equal(readUpdateAll(rt).status, "running");
|
||||
assert.equal(isRunning(rt), true);
|
||||
});
|
||||
|
||||
it("reports a long-stale running batch as abandoned, so the button unwedges", () => {
|
||||
// The worker is detached: a reboot leaves nobody to write the terminal
|
||||
// entry, and the UI would otherwise spin forever.
|
||||
const rt = withJournal("stale", {
|
||||
status: "running",
|
||||
started_at: new Date(Date.now() - STALE_MS - 60_000).toISOString(),
|
||||
steps: [{ key: "marketplaces", status: "running" }],
|
||||
});
|
||||
assert.equal(readUpdateAll(rt).status, "abandoned");
|
||||
assert.equal(isRunning(rt), false, "an abandoned batch must not block a new one");
|
||||
});
|
||||
|
||||
it("treats an unparseable started_at as running rather than abandoned", () => {
|
||||
// Better to show a spinner than to start a second concurrent batch against
|
||||
// the same config dir on the strength of a garbled timestamp.
|
||||
const rt = withJournal("nodate", {
|
||||
status: "running",
|
||||
started_at: "not a date",
|
||||
steps: [{ key: "marketplaces", status: "running" }],
|
||||
});
|
||||
assert.equal(readUpdateAll(rt).status, "running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtimeDir / journalPath", () => {
|
||||
it("puts the journal under the data dir's runtime folder, outside any plugin cache", () => {
|
||||
// The batch replaces the plugin cache directory it may be running from, so
|
||||
// a journal stored in there would vanish exactly when it is needed.
|
||||
const p = journalPath();
|
||||
assert.equal(p, path.join(runtimeDir(), "cc-update-all.json"));
|
||||
assert.ok(!p.includes(`${path.sep}cache${path.sep}`), p);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cc-update-all-helper", () => {
|
||||
it("records a terminal failure for an unreadable plan without running anything", () => {
|
||||
const rt = path.join(tmpDir, "badplan");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
|
||||
execFileSync(process.execPath, [HELPER, rt, "{not json"], {
|
||||
stdio: "ignore",
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const batch = JSON.parse(fs.readFileSync(path.join(rt, "cc-update-all.json"), "utf8"));
|
||||
assert.equal(batch.status, "failed");
|
||||
assert.match(batch.error, /unreadable plan/);
|
||||
assert.ok(batch.finished_at, "a terminal entry is always timestamped");
|
||||
// Nothing was attempted, so there is no step log and no claude invocation.
|
||||
assert.deepEqual(batch.steps, []);
|
||||
const logPath = path.join(rt, "cc-update-all.log");
|
||||
const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : "";
|
||||
assert.doesNotMatch(log, /claude plugin/, "no claude command may be logged");
|
||||
});
|
||||
});
|
||||
@@ -226,7 +226,10 @@ async function pluginDisable({ spec }) {
|
||||
|
||||
async function pluginUpdate({ spec }) {
|
||||
const s = assertPluginSpec(spec);
|
||||
return serialize(() => runClaude(["plugin", "update", s]));
|
||||
// `-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
|
||||
// marketplace declares an install command fails on a prompt nobody can answer.
|
||||
return serialize(() => runClaude(["plugin", "update", s, "-y"]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file Types `/reload-plugins` and `/reload-skills` into the Claude Code
|
||||
* sessions the dashboard already has open, so a plugin or skill update takes
|
||||
* effect without the user switching to a terminal.
|
||||
*
|
||||
* Both commands are, by Claude Code's own definition, scoped to the session
|
||||
* that runs them — `/reload-plugins` "activates pending plugin changes in the
|
||||
* current session", `/reload-skills` "picks up skills added or changed on disk
|
||||
* during this session". There is therefore NO way to reload a session from the
|
||||
* outside, and no background process can do it on a session's behalf. What a
|
||||
* dashboard CAN do is what a human would: type the command into a session that
|
||||
* is already running. Every lane pane is a tmux session (`agenthub-lane-<id>`),
|
||||
* so `tmux send-keys` reaches a live REPL's stdin directly.
|
||||
*
|
||||
* This deliberately never spawns a `claude` process. A one-shot
|
||||
* `claude -p "/reload-plugins"` would reload only its own short-lived session
|
||||
* and then exit — no effect on anything the user is using — while still costing
|
||||
* an API round-trip and leaving a junk session in the dashboard's own history.
|
||||
* Reusing live panes means this module creates no sessions at all.
|
||||
*
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tmux = require("./tmux");
|
||||
const { listRuns } = require("./pty-run");
|
||||
|
||||
/** Shell process names — a pane sitting at one of these has no REPL to reload. */
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
/** The only commands this module is ever allowed to type. Not caller-supplied:
|
||||
* a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */
|
||||
const RELOAD_COMMANDS = {
|
||||
plugins: "/reload-plugins",
|
||||
skills: "/reload-skills",
|
||||
};
|
||||
|
||||
/**
|
||||
* Live `claude` panes that can accept a slash command right now.
|
||||
*
|
||||
* A pane whose `pane_current_command` is a shell is excluded: the `claude`
|
||||
* process there has exited (or never started), so the text would be typed at a
|
||||
* bash prompt and run as a shell command instead — `/reload-plugins` would come
|
||||
* back as "No such file or directory".
|
||||
*
|
||||
* @returns {{id: string, laneId: number|null, paneCommand: string|null}[]}
|
||||
*/
|
||||
function reloadableLanes() {
|
||||
const out = [];
|
||||
for (const run of listRuns()) {
|
||||
if (run.status !== "running") continue;
|
||||
if (run.tool !== "claude") continue;
|
||||
const cmd = tmux.paneCommand(run.id);
|
||||
if (SHELL_COMMANDS.has(cmd || "")) continue;
|
||||
out.push({ id: run.id, laneId: run.laneId, paneCommand: cmd });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the requested reload commands into every live `claude` pane.
|
||||
*
|
||||
* Sends to a pane even when its agent is mid-turn: tmux queues the keystrokes
|
||||
* into the REPL's input buffer, so the command lands when the turn ends rather
|
||||
* than being lost. That is a deliberate choice — a reload that arrives late is
|
||||
* still a reload, whereas skipping busy panes silently leaves exactly the
|
||||
* sessions the user is actually working in running stale code.
|
||||
*
|
||||
* Never throws: one unreachable pane (killed between the listing and the send)
|
||||
* must not fail a batch that updated everything else successfully.
|
||||
*
|
||||
* @param {{kinds?: ("plugins"|"skills")[]}} [options] which commands to send;
|
||||
* defaults to both, plugins first (a skill shipped BY a plugin only exists on
|
||||
* disk once the plugin has been reloaded).
|
||||
* @returns {{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
||||
* commands: string[], lanes_total: number}}
|
||||
*/
|
||||
function reloadOpenSessions(options = {}) {
|
||||
const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS);
|
||||
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
||||
const lanes = [];
|
||||
|
||||
for (const lane of reloadableLanes()) {
|
||||
const sent = [];
|
||||
let error;
|
||||
for (const command of commands) {
|
||||
try {
|
||||
tmux.sendText(lane.id, command);
|
||||
sent.push(command);
|
||||
} catch (err) {
|
||||
error = err.message || String(err);
|
||||
break; // the pane is gone; the next command would fail identically
|
||||
}
|
||||
}
|
||||
lanes.push({ id: lane.id, laneId: lane.laneId, sent, ...(error ? { error } : {}) });
|
||||
}
|
||||
|
||||
return { commands, lanes, lanes_total: lanes.length };
|
||||
}
|
||||
|
||||
module.exports = { reloadOpenSessions, reloadableLanes, RELOAD_COMMANDS, SHELL_COMMANDS };
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file Starts and reports on the "Update all" batch: every registered
|
||||
* marketplace, then every installed plugin, then a reload of the Claude Code
|
||||
* sessions the dashboard has open.
|
||||
*
|
||||
* Why a detached worker with an on-disk journal instead of a plain request
|
||||
* handler: the batch includes AgentHub ITSELF when the dashboard runs from the
|
||||
* plugin cache. Updating that plugin ends in `plugin-bootstrap.js --force`,
|
||||
* which SIGTERMs this very server, so an in-process loop would die partway
|
||||
* through with the remaining steps unrun and no way to report anything. The
|
||||
* worker outlives the swap, keeps going, and writes each step's outcome to
|
||||
* `cc-update-all.json` under the runtime directory — outside the plugin cache,
|
||||
* which the update replaces — so whichever server is alive can serve progress
|
||||
* from it. AgentHub is ordered LAST for the same reason: every other plugin is
|
||||
* already done by the time the restart happens.
|
||||
*
|
||||
* The reload step comes after all updates, not per-plugin, because that is what
|
||||
* "update everything, then reload" means for a session: one `/reload-plugins`
|
||||
* picks up every pending change at once, where N of them would just re-scan the
|
||||
* same directory N times. A single-plugin update reloads immediately instead —
|
||||
* see the `reload` flag on `POST /api/cc-config/plugins/action`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
const JOURNAL_NAME = "cc-update-all.json";
|
||||
/** A batch still "running" after this long is treated as abandoned, so a worker
|
||||
* killed by a reboot can't wedge the button forever. */
|
||||
const STALE_MS = 60 * 60 * 1000;
|
||||
|
||||
function runtimeDir() {
|
||||
return path.join(getDataDir(), "runtime");
|
||||
}
|
||||
|
||||
function journalPath(rt = runtimeDir()) {
|
||||
return path.join(rt, JOURNAL_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current (or last) batch state, or null when none has ever run.
|
||||
*
|
||||
* A `running` journal older than `STALE_MS` is reported as `abandoned` rather
|
||||
* than `running`: the worker is detached, so a host reboot leaves no one to
|
||||
* write the terminal entry and the UI would otherwise show a spinner forever.
|
||||
*
|
||||
* @returns {{status: string, steps: object[], started_at?: string,
|
||||
* finished_at?: string} | null}
|
||||
*/
|
||||
function readUpdateAll(rt = runtimeDir()) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(journalPath(rt), "utf8"));
|
||||
} catch {
|
||||
return null; // never run, unreadable, or malformed — all "nothing to report"
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.steps)) return null;
|
||||
if (parsed.status === "running") {
|
||||
const started = Date.parse(parsed.started_at || "");
|
||||
if (Number.isFinite(started) && Date.now() - started > STALE_MS) {
|
||||
return { ...parsed, status: "abandoned" };
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** True when a batch is in flight — used to refuse a concurrent start rather
|
||||
* than run two `claude plugin update` chains over the same config dir. */
|
||||
function isRunning(rt = runtimeDir()) {
|
||||
return readUpdateAll(rt)?.status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the detached batch worker.
|
||||
*
|
||||
* The caller must have written its HTTP response first: when `selfPlugin` is
|
||||
* set, this process is going to be stopped before the batch finishes.
|
||||
*
|
||||
* @param {{plugins: string[], selfPlugin: {marketplace: string, plugin: string}|null,
|
||||
* selfRoot: string|null, reload: boolean}} plan
|
||||
* `plugins` are `name@marketplace` ids, already validated by the caller.
|
||||
* @returns {{spawned: true, runtime_dir: string}}
|
||||
*/
|
||||
function startUpdateAll(plan) {
|
||||
const rt = runtimeDir();
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
// Clear the previous batch so a stale terminal state is never read as this
|
||||
// run's progress in the window before the worker writes its first entry.
|
||||
try {
|
||||
fs.rmSync(journalPath(rt), { force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
const helper = path.join(__dirname, "..", "..", "scripts", "cc-update-all-helper.js");
|
||||
const child = spawn(process.execPath, [helper, rt, JSON.stringify(plan)], {
|
||||
// Never the plugin root: when selfPlugin is set that directory is replaced
|
||||
// mid-run, and a cwd inside a replaced directory can wedge the process.
|
||||
cwd: rt,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
return { spawned: true, runtime_dir: rt };
|
||||
}
|
||||
|
||||
module.exports = { startUpdateAll, readUpdateAll, isRunning, runtimeDir, journalPath, STALE_MS };
|
||||
@@ -1556,6 +1556,118 @@ const paths = {
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/plugins/update-all": {
|
||||
get: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Progress of the current or last update-all batch",
|
||||
description:
|
||||
"Read-only. Returns the detached batch worker's on-disk journal from ~/.claude/agenthub/runtime/cc-update-all.json, or `{batch:null}` when no batch has ever run. `status` is `running`|`done`|`partial`|`failed`|`abandoned`; `abandoned` is DERIVED, not stored — a `running` journal older than one hour is reported that way because the worker is detached and a host reboot leaves nobody to write the terminal entry. `partial` means the batch finished with at least one failed step. Malformed or half-written journals return null rather than erroring, since the UI polls this endpoint.",
|
||||
operationId: "ccConfigGetUpdateAll",
|
||||
responses: {
|
||||
200: {
|
||||
description: "The batch journal, or null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: {
|
||||
batch: {
|
||||
status: "partial",
|
||||
started_at: "2026-09-10T08:30:00.000Z",
|
||||
finished_at: "2026-09-10T08:34:12.000Z",
|
||||
steps: [
|
||||
{ key: "marketplaces", label: "Update all marketplaces", status: "ok" },
|
||||
{ key: "plugin:ponytail@ponytail", label: "ponytail@ponytail", status: "ok" },
|
||||
{
|
||||
key: "plugin:vercel@claude-plugins-official",
|
||||
label: "vercel@claude-plugins-official",
|
||||
status: "failed",
|
||||
error: "marketplace unreachable",
|
||||
},
|
||||
{
|
||||
key: "reload",
|
||||
label: "Reload open Claude Code sessions",
|
||||
status: "ok",
|
||||
lanes_total: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Update every marketplace and plugin, then reload the open sessions",
|
||||
description:
|
||||
'Mutating. Spawns a DETACHED worker and returns immediately — the response is an acknowledgement, not a result; poll GET on this path to follow it. Order: `claude plugin marketplace update` (no name = all), then `claude plugin update <id> -y` for every installed plugin, then `/reload-plugins` + `/reload-skills` typed into the live lane panes. The plugin this dashboard itself runs from is updated LAST and followed by `plugin-bootstrap.js --force`, which stops this server and starts the replacement — which is why the work cannot happen in the request handler and why the journal lives outside the plugin cache. Plugin ids come from `claude plugin list --json`, never from the body, and the "self" plugin is derived from this server\'s own path, so no caller can nominate a different plugin for the restart. Answers 409 EBUSY when a batch is already in flight; the body is ignored apart from an optional `reload:false`.',
|
||||
operationId: "ccConfigUpdateAll",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Batch worker spawned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: {
|
||||
ok: true,
|
||||
spawned: true,
|
||||
runtime_dir: "/Users/son/.claude/agenthub/runtime",
|
||||
planned: 7,
|
||||
self_plugin: { marketplace: "sg", plugin: "agenthub" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "A batch is already running.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/reload": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
||||
description:
|
||||
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
|
||||
operationId: "ccConfigReload",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Commands delivered. `lanes_total` counts only panes running a live claude; zero is a normal outcome, not a failure.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
example: {
|
||||
ok: true,
|
||||
commands: ["/reload-plugins", "/reload-skills"],
|
||||
lanes_total: 1,
|
||||
lanes: [
|
||||
{
|
||||
id: "agenthub-lane-3",
|
||||
laneId: 3,
|
||||
sent: ["/reload-plugins", "/reload-skills"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "`kinds` was present but not an array.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/mcp": {
|
||||
get: {
|
||||
tags: ["CcConfig"],
|
||||
|
||||
+9
-6
@@ -2387,7 +2387,7 @@ function createOpenApiSpec() {
|
||||
"/api/updates/status": {
|
||||
get: {
|
||||
tags: ["Updates"],
|
||||
summary: "Check whether the dashboard git checkout is behind origin",
|
||||
summary: "Check whether a newer release of the dashboard has been published",
|
||||
operationId: "getUpdatesStatus",
|
||||
responses: {
|
||||
200: {
|
||||
@@ -2398,7 +2398,7 @@ function createOpenApiSpec() {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
"Includes git_repo, update_available, commits_behind, remote_ref, local_sha, remote_sha, manual_command, and optional error/message fields.",
|
||||
'Reads the newest published release from the forge API (derived from the origin remote) and compares its tag against this install\'s package.json version; falls back to a commit count against the canonical default branch when no release can be read, with the reason in release_check. Includes git_repo, update_available, install_kind ("plugin" | "checkout"), plugin_marketplace, local_version, shallow_clone, release_check, latest_release, latest_release_name, latest_release_url, latest_release_published_at, latest_release_notes, release_sha, commits_behind_release, commits_ahead_of_release, dev_build, commits_behind, remote_ref, local_sha, remote_sha, situation, manual_command, and optional error/message fields. dev_build is true when this install is strictly ahead of the newest release; commits_* fields are null on a shallow clone, where distances are meaningless.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2443,18 +2443,21 @@ function createOpenApiSpec() {
|
||||
"/api/updates/apply": {
|
||||
post: {
|
||||
tags: ["Updates"],
|
||||
summary: "Pull, rebuild, and restart the dashboard when fast-forwardable",
|
||||
summary: "Update this install to the newest release and restart the dashboard",
|
||||
description:
|
||||
"Two modes, chosen by how this copy was installed and never by the request body (which is ignored). checkout: fast-forward onto the release tag, npm run setup, npm run build in production, then self-restart. plugin: the install runs from ~/.claude/plugins/cache/, so a detached helper runs `claude plugin marketplace update`, `claude plugin update -y`, then plugin-bootstrap.js --force from the NEW cache directory, which stops this server and starts the new one. In both modes the response is written before anything stops the process.",
|
||||
operationId: "applyUpdate",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Checkout fast-forwarded and rebuilt; the process is about to self-restart",
|
||||
'Update is being applied; the process is about to be replaced. mode: "plugin" means the detached plugin chain is running, otherwise the checkout was fast-forwarded and rebuilt in-place',
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description: "Same shape as GET /api/updates/status, plus applied: true.",
|
||||
description:
|
||||
'Same shape as GET /api/updates/status, plus applied: true. In plugin mode also mode: "plugin" and runtime_dir (where the chain writes its verdict, read back afterwards as last_plugin_update).',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2468,7 +2471,7 @@ function createOpenApiSpec() {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'applied: false plus reason: "up_to_date" | "not_fast_forwardable".',
|
||||
'applied: false plus reason: "up_to_date" | "not_fast_forwardable" | "plugin_managed". plugin_managed appears here only when a plugin-cache install has nothing newer published; when a newer release exists the plugin chain runs and the answer is 200 instead. Run /agenthub-update to force a refresh.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+102
-4
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const path = require("path");
|
||||
const cc = require("../lib/cc-discovery");
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
const ccCli = require("../lib/cc-plugin-cli");
|
||||
@@ -32,6 +33,13 @@ const ccProfiles = require("../lib/cc-profiles");
|
||||
const projectMemory = require("../lib/project-memory");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { broadcast } = require("../websocket");
|
||||
const { reloadOpenSessions } = require("../lib/cc-reload");
|
||||
const { startUpdateAll, readUpdateAll, isRunning } = require("../lib/cc-update-all");
|
||||
const { detectPluginInstall } = require("../lib/update-check");
|
||||
|
||||
/** Mirrors cc-plugin-cli's own spec rule — ids from the CLI's JSON still reach
|
||||
* an argv in the detached batch worker, so they are filtered before they go. */
|
||||
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -265,7 +273,7 @@ function cliError(res, err) {
|
||||
}
|
||||
|
||||
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
|
||||
function cliResult(res, result, changed) {
|
||||
function cliResult(res, result, changed, extra) {
|
||||
if (!result.ok) {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
@@ -278,7 +286,7 @@ function cliResult(res, result, changed) {
|
||||
});
|
||||
}
|
||||
emitChanged(changed);
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, ...extra });
|
||||
}
|
||||
|
||||
router.get("/plugins/available", async (_req, res) => {
|
||||
@@ -307,7 +315,7 @@ const PLUGIN_ACTIONS = {
|
||||
};
|
||||
|
||||
router.post("/plugins/action", async (req, res) => {
|
||||
const { key, action } = req.body || {};
|
||||
const { key, action, reload } = req.body || {};
|
||||
const run = PLUGIN_ACTIONS[action];
|
||||
if (!run) {
|
||||
return res.status(400).json({
|
||||
@@ -319,12 +327,102 @@ router.post("/plugins/action", async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const result = await run({ spec: key });
|
||||
return cliResult(res, result, { action, type: "plugin", name: key });
|
||||
// "Update one, reload that one" — the counterpart to the batch, which
|
||||
// reloads once at the end. Only on success, and only when asked: an
|
||||
// enable/disable already needs a reload but the caller decides, and a
|
||||
// reload after a FAILED update would tell the session to activate nothing.
|
||||
let reloaded;
|
||||
if (reload && result.ok) {
|
||||
try {
|
||||
reloaded = reloadOpenSessions();
|
||||
} catch {
|
||||
/* a reload that cannot be delivered must not fail the update itself */
|
||||
}
|
||||
}
|
||||
return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Type `/reload-plugins` + `/reload-skills` into the live Claude Code lane
|
||||
* panes. Not a config mutation — it changes nothing on disk, it just tells
|
||||
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||
* mutation chain nor emits `cc_config_changed`.
|
||||
*/
|
||||
router.post("/reload", (req, res) => {
|
||||
const { kinds } = req.body || {};
|
||||
if (kinds !== undefined && !Array.isArray(kinds)) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADARG", message: "kinds must be an array of 'plugins' | 'skills'" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
res.json({ ok: true, ...reloadOpenSessions({ kinds }) });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "ERELOAD", message: err.message || String(err) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Update all ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Runs detached (see server/lib/cc-update-all.js): the batch ends with AgentHub
|
||||
// itself when this server runs from the plugin cache, and that step stops the
|
||||
// process. The response is therefore only an acknowledgement — progress is
|
||||
// polled from GET below, which reads the worker's on-disk journal.
|
||||
|
||||
router.get("/plugins/update-all", (_req, res) => {
|
||||
res.json({ batch: readUpdateAll() });
|
||||
});
|
||||
|
||||
router.post("/plugins/update-all", async (req, res) => {
|
||||
if (isRunning()) {
|
||||
return res.status(409).json({
|
||||
error: { code: "EBUSY", message: "an update-all batch is already running" },
|
||||
batch: readUpdateAll(),
|
||||
});
|
||||
}
|
||||
|
||||
let installed;
|
||||
try {
|
||||
installed = (await ccCli.listAvailable()).installed || [];
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
|
||||
// Ids come from the CLI's own JSON, never from the request body, but they
|
||||
// reach an argv in a detached process — so they are filtered here too.
|
||||
const plugins = installed.map((p) => String(p.id || "")).filter((id) => PLUGIN_SPEC_RE.test(id));
|
||||
|
||||
// Which plugin, if any, is the one this dashboard is running from. Derived
|
||||
// from our own path — never from the request — so no caller can nominate a
|
||||
// different plugin as "self" and get a bootstrap run against it.
|
||||
const selfRoot = path.resolve(__dirname, "..", "..");
|
||||
const detected = detectPluginInstall(selfRoot);
|
||||
const selfPlugin = detected
|
||||
? { marketplace: detected.marketplace, plugin: detected.plugin }
|
||||
: null;
|
||||
|
||||
const plan = {
|
||||
plugins,
|
||||
selfPlugin,
|
||||
selfRoot: selfPlugin ? selfRoot : null,
|
||||
reload: req.body?.reload !== false,
|
||||
};
|
||||
|
||||
try {
|
||||
const started = startUpdateAll(plan);
|
||||
res.json({ ok: true, ...started, planned: plugins.length, self_plugin: selfPlugin });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "EUPDATEALL", message: err.message || String(err) },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/marketplaces/add", async (req, res) => {
|
||||
const { source, scope } = req.body || {};
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user