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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user