diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 42ed2974..f6baeba0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -344,7 +344,9 @@ graph TD | `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set | | `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`/skills//SKILL.md`), subagents (`/agents/*.md`), slash commands (`/commands/*.md`), output styles (`/output-styles/*.md`), plugins (`/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects//memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines | | `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `/cc-config-backups//..bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` | -| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | +| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: 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, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws | +| `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race | +| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | | `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`.json` and `/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` | | `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down | | `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`/skills//SKILL.md`), agents (`/agents/*.toml`), rules (`/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store | @@ -1961,15 +1963,26 @@ These behaviors are reinforced by explicit `Cache-Control` headers from the prod ## Update Notifier Subsystem -The Update Notifier is a **detection-only** subsystem that tells the user when the dashboard's git checkout is behind its tracked upstream branch. It never mutates the checkout or restarts the server — those actions are intentionally left to the user in a terminal, because a process cannot reliably replace itself without an external supervisor. +The Update Notifier tells the user when a newer **release** of the dashboard has been published, and — on an ordinary git checkout that can be fast-forwarded — can apply it on one click. + +The version signal is the forge's own release list, read over REST at `/api/v1/repos///releases/latest` with the owner and repo derived from the `origin` remote URL (no hardcoded host, no credentials for a public repo). The published tag is compared against this install's `package.json` version. When no release can be read — a non-HTTP remote, an offline machine, a repo with no releases — detection falls back to the original commit count against the canonical default branch and puts the reason in `release_check`, so "up to date" never means "we couldn't look". + +Two states the release model makes visible that a commit count cannot: + +- **`dev_build`** — the install is strictly ahead of the newest release and missing nothing from it. It is a build off the branch, not a shipped version, and the Sidebar says so instead of "Up to date". +- **`plugin_managed`** — the install runs from `~/.claude/plugins/cache/`. Claude Code owns that directory and replaces it wholesale, so `applyUpdate` will not rewrite it: it refuses with `reason: "plugin_managed"`, decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. **The button still works here**, by a different route: `POST /api/updates/apply` catches that refusal and hands the job to the detached chain in `server/lib/plugin-update.js` — `claude plugin marketplace update ` → `claude plugin update @ -y` → `plugin-bootstrap.js --force` **from the new cache directory**, which stops this server and starts the new one. It answers `200 {applied:true, mode:"plugin"}`. The one thing no background process can do is `/reload-plugins`: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because `plugin-bootstrap.js` derives `PLUGIN_ROOT` from its own `__dirname` rather than from `${CLAUDE_PLUGIN_ROOT}`. + +A **shallow clone** (`--depth`) is the third special case, and the plugin cache is one: history is grafted, so every commit-distance number is a graft artefact rather than a distance. All `commits_*_release` fields are `null` there and `dev_build` is never asserted; the version string is the only signal used. ### Module Layout ```mermaid graph TD subgraph Server - LIB["update-check.js
getUpdatesStatus"] + LIB["update-check.js
getUpdatesStatus + applyUpdate
forge releases API"] SCHED["update-scheduler.js
startUpdateScheduler"] - ROUTE["routes/updates.js
GET status, POST check"] + ROUTE["routes/updates.js
GET status, POST check, POST apply"] + PLUG["lib/plugin-update.js
schedulePluginUpdate"] + HELPER["scripts/plugin-update-helper.js
detached: claude plugin update
then plugin-bootstrap --force"] WS["websocket.js
broadcast update_status"] end @@ -1982,6 +1995,9 @@ graph TD SCHED -->|tick every 5 min| LIB ROUTE -->|on request| LIB + ROUTE -->|apply, plugin install| PLUG + PLUG -->|spawn detached| HELPER + HELPER -->|verdict file| LIB SCHED -->|fingerprint changed| WS ROUTE -->|on POST check| WS WS -->|update_status frame| API @@ -2041,11 +2057,12 @@ sequenceDiagram | Component | Responsibility | | --- | --- | -| **`server/lib/update-check.js`** | Pure function `getUpdatesStatus(root?, { skipFetch? })`. Runs every git call via `execFile` (no shell, 10s–120s timeouts). **Branch- and fork-aware:** prefers `upstream` over `origin` when both exist (standard fork convention), resolves `/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation: `git pull --ff-only` only when the local branch tracks the canonical ref; `git fetch && git merge --ff-only ` for forks (local branch name matches canonical but tracks a different remote); `git fetch ` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. Adds `situation`, `situation_note`, `canonical_remote`, `current_branch`, `tracking_upstream`, and `tracks_canonical` to the response. | +| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from the remote (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch --tags && git merge --ff-only ` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch && git merge --ff-only ` for forks; `git fetch ` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. | | **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. | -| **`server/routes/updates.js`** | Two endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast). Mounted under `/api/*`, so they ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. There is **no** `POST /apply` route. | -| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps `dismissedSha` in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. | -| **`Sidebar.tsx`** | Always-visible "Check for updates" button in the footer. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. | +| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. | +| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. | +| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. | +| **`Sidebar.tsx`** | Always-visible "Check for updates" button in the footer. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `" available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. | ### Payload Shape @@ -2060,6 +2077,8 @@ interface UpdateStatusPayload { tracking_upstream?: string | null; // e.g. "origin/feature/foo", null if no upstream tracks_canonical?: boolean; // true when branch upstream === remote_ref situation?: // categorical hint for the UI + | "plugin_managed" // ~/.claude/plugins/cache — /apply delegates + | "release_available" // fast-forward onto the release tag | "tracking_canonical" | "fork_or_diverged_tracking" | "feature_branch" @@ -2071,10 +2090,37 @@ interface UpdateStatusPayload { manual_command?: string | null; // shaped for the user's situation message?: string | null; fetch_error?: string; // set when git fetch fails + + // ── Release layer ── + install_kind?: "plugin" | "checkout"; + plugin_marketplace?: string | null; // "sg" — parsed from the cache path + plugin_name?: string | null; // "agenthub" — same source + last_plugin_update?: { // verdict of the previous chain, if any + ok: boolean; + step: string; // "validate" | "resolve_claude" | ... + error?: string; + note?: string; + plugin_root?: string; + finished_at?: string; + } | null; + local_version?: string | null; // this install's package.json version + shallow_clone?: boolean; // true => every commits_* below is null + release_check?: string; // "ok" | "no_remote" | "remote_not_http" + // | "no_releases" | "unreachable" + // | "bad_payload" | "http_" + latest_release?: string | null; // tag, e.g. "v1.5.0" + latest_release_name?: string | null; + latest_release_url?: string | null; + latest_release_published_at?: string | null; + latest_release_notes?: string | null; // truncated server-side to 4000 chars + release_sha?: string | null; // commit the tag resolves to locally + commits_behind_release?: number | null; + commits_ahead_of_release?: number | null; + dev_build?: boolean; // ahead of the newest release, missing nothing } ``` -The same shape is used by `GET /status`, `POST /check`, and the `update_status` WS message. +The same shape is used by `GET /status`, `POST /check`, and the `update_status` WS message. `POST /apply` returns it plus `applied: boolean`; in plugin mode it also carries `mode: "plugin"` and `runtime_dir`, and on a 409 a `reason` of `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. `update_available` and `dev_build` are never both true. ### Failure Mode Matrix @@ -2085,20 +2131,29 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status` | `git fetch` failed (offline, auth) | `{git_repo:true, update_available:false, canonical_remote, fetch_error:""}` | Sidebar button goes amber; modal stays suppressed until a successful check. | | Canonical default branch unresolvable | `{git_repo:true, update_available:false, canonical_remote, message:"Could not resolve /master..."}` | Modal suppressed. | | Healthy, up to date | `{git_repo:true, update_available:false, commits_behind:0, situation:"tracking_canonical"\|...}` | Sidebar neutral, modal suppressed. | +| Release lookup failed (offline, non-HTTP remote, no releases) | `{release_check:"unreachable"\|"remote_not_http"\|"no_releases"\|..., latest_release:null}` | Detection falls back to the commit count; the modal states the reason so "up to date" is not read as "release checked". | +| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). | +| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. | +| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. | +| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. | +| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. | +| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. | +| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed"}` | No chain is spawned. Message points at `/agenthub-update` to force a refresh. | | Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. | | Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. | | Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch "}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. | -### Why Detection-Only +### Why Self-Apply Stays Narrow -The dashboard does not expose an apply/restart endpoint by design. A process cannot reliably replace itself without an external supervisor, and several real constraints make an in-process self-update path strictly worse than letting the user run two commands in a terminal: +`POST /api/updates/apply` is the one path in this dashboard that mutates its own working tree and restarts the process. It is deliberately narrow: in checkout mode it acts only on `release_available`, `tracking_canonical`, and `fork_or_diverged_tracking`, always with `--ff-only`, and refuses outright on a feature branch or a detached HEAD. A `plugin_managed` install is not a git operation at all — it is delegated to `claude plugin update`, whose own tooling owns that directory. Everywhere else the dashboard prints a command and lets the user run it, because the constraints below are real and an automatic path cannot answer them: - **Supervisor ambiguity.** `npm run dev` (concurrently), `npm start`, `pm2`, `systemd`, and `launchd` each need different restart logic; an in-process helper could only encode one of them and would silently mis-restart the rest. - **Silent failures.** `npm install` / `npm run build` / port-release timing issues surface as a dead server with no user-facing feedback once the original process has exited. - **No rollback.** A partial pull + install leaves a broken checkout with no atomic recovery — the working tree is mutated mid-flight. -- **Branch coverage.** Even with the situation-aware `manual_command` produced by the detection layer, an automatic apply would still need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon. +- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button. +- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved. -The detection layer carries all of the signal value: the dashboard tells the user *when* to update and *exactly what to run*; the user owns the *how* in their own shell. +What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor. --- diff --git a/CLAUDE.md b/CLAUDE.md index c9021f0f..8db8d7de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,15 +1,15 @@ # Claude Code Working Guide -_Last synced: 58774f2 (2026-09-04) + per-node run command (`agenthub lanes node`) and its slash commands._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. - Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI). ## Repo map -- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes optional git upstream checks and `routes/updates.js`, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, the same idea for `codex` lanes: it tails Codex's own rollout journals because Codex emits no hooks at all). +- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, the same idea for `codex` lanes: it tails Codex's own rollout journals because Codex emits no hooks at all). - `client/`: React + Vite UI. -- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`. `POST /api/updates/apply` (only when the checkout is fast-forwardable) pulls, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; otherwise users run the printed manual command, surfaced in the UI and by `agenthub update-check`.) +- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.) - `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:` (bare `/` works as an alias). Put a skill here only if it should reach plugin users; `agenthub skills install` remains a fallback that copies `ship-feature-lane` into `~/.claude/skills/` for plain checkouts. - `agents/`: the same story for subagents — plugin root, so all four (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`, `frontend-reviewer`) ship as `agenthub:`. This is also the source `agenthub lanes agents install` copies the two pipeline agents from into a lane's `.claude/agents/`, which is what keeps the bare `subagent_type: qc-local` working there. One file per agent, never a second copy. - `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`. - **Reporting a required plugin is derived; installing one is allowlisted.** `server/lib/skill-plugins.js` reads the `/plugin:skill` strings out of the loaded pipeline templates — right for the `/agenthub-doctor` row, wrong for an install: a template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`, so installing whatever name it contains would make "edit a JSON file" mean "run this vendor's install command next session". The bootstrap installs only what `AUTO_INSTALLABLE` (a constant in that file, `superpowers` today) lists, in its DETACHED worker, never in the 10 s hook, recorded once as `state.skillPluginsSettled` (also gating `fastPathOk`), never throwing, and off under `AGENTHUB_AUTO_INSTALL_PLUGINS=0|false|off`. Do not derive an install target from a template. @@ -43,6 +43,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states. ## Non-negotiable engineering rules +- **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. - Never silently weaken safety controls around destructive actions. diff --git a/bin/agenthub.js b/bin/agenthub.js index c3e995ff..bbef4f7f 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -1190,7 +1190,7 @@ async function cmdPricing(flags, positional) { // ── Updates ───────────────────────────────────────────────────────────────── async function cmdUpdateCheck() { - console.log(c.dim("Checking the canonical remote — this can take a few seconds…")); + console.log(c.dim("Checking the repository's latest release — this can take a few seconds…")); // POST /check (rather than GET /status) so a dashboard open in the browser // sees the same fresh result via the update_status websocket broadcast. const s = await post("/api/updates/check"); @@ -1203,6 +1203,13 @@ async function cmdUpdateCheck() { console.log(`${c.yellow("!")} ${s.message} ${c.dim(`(${s.fetch_error})`)}`); return; } + // Say WHY the release lookup produced nothing, so a green "up to date" is + // never mistaken for "the release list was actually read". + if (s.release_check && s.release_check !== "ok") { + console.log( + c.dim(` could not read the latest release (${s.release_check}) — compared commits instead`) + ); + } if (s.update_available) { console.log(`${c.yellow("⬆")} ${s.message}`); if (s.situation_note) console.log(c.dim(` ${s.situation_note}`)); @@ -2990,7 +2997,7 @@ const COMMAND_GROUPS = [ ["export", "[file.json]", "Export all data as JSON"], ["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"], ["reinstall-hooks", "", "Reinstall Claude Code hooks"], - ["update-check", "", "Check whether the dashboard checkout is behind upstream"], + ["update-check", "", "Check whether a newer release of the dashboard was published"], ["clear-data", "--yes", "Delete ALL data (requires --yes)"], ["open", "", "Open the dashboard in your browser"], ["version", "", "Print the agenthub version (also: --version, -v)"], @@ -3254,7 +3261,8 @@ const SERVER_ONLY_REASONS = { cleanup: "cleanup is a server-side mutation", "clear-data": "data wipes must go through the server", "reinstall-hooks": "hook installation is performed by the server", - "update-check": "the update check runs server-side (git fetch against the canonical remote)", + "update-check": + "the update check runs server-side (reads the repo's latest release, git fetch as fallback)", info: "system info (uptime, memory, WS connections) only exists on a running server", health: "health is, by definition, a check against the running server", }; diff --git a/client/index.html b/client/index.html index 4834e308..85c4892b 100644 --- a/client/index.html +++ b/client/index.html @@ -9,7 +9,7 @@ - Agent Dashboard - Claude Code Monitor + Agent Dashboard - SmartGift Solution - + - + @@ -66,21 +78,29 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react"; +import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import type { UpdateStatusPayload, WSMessage } from "../lib/types"; -/** `localStorage` key storing the dismissed upstream SHA. */ +/** `localStorage` key storing the dismissed release tag (or upstream SHA). */ const DISMISS_KEY = "agent-monitor-update-dismissed-sha"; /** How often to silently re-check for updates in the background. */ const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000; -/** Situations `POST /api/updates/apply` will actually act on — mirrors the - * server-side check in `server/lib/update-check.js`'s `applyUpdate`. */ +/** Situations `POST /api/updates/apply` will actually act on. Mirrors the + * server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking` + * go through a git fast-forward, and `plugin_managed` goes through the detached + * `plugin-update.js` chain (`claude plugin update` then `plugin-bootstrap --force`), + * which is why it belongs here even though `applyUpdate` itself refuses it. */ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean { - return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking"; + return ( + situation === "plugin_managed" || + situation === "release_available" || + situation === "tracking_canonical" || + situation === "fork_or_diverged_tracking" + ); } /** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */ @@ -88,7 +108,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload { return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x; } -/** Read the last dismissed upstream SHA from `localStorage`, or null. */ +/** Read the last dismissed release tag / upstream SHA from `localStorage`. */ function loadDismissedSha(): string | null { try { return localStorage.getItem(DISMISS_KEY); @@ -99,7 +119,7 @@ function loadDismissedSha(): string | null { /** * Git update availability modal — mounted once in {@link Layout}. - * @returns `null` when no update is available or the current SHA was dismissed. + * @returns `null` when no update is available or this release was dismissed. */ export function UpdateNotifier() { const { t } = useTranslation("updates"); @@ -161,19 +181,22 @@ export function UpdateNotifier() { return () => window.removeEventListener("dashboard:reset-update-dismissal", handler); }, []); - const show = Boolean( - status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha - ); + // Keyed by the release tag when there is one, so dismissing v1.5.0 doesn't + // also swallow v1.6.0 — and so the prompt survives the tag pointing at a + // commit the user already has. Falls back to the upstream SHA for the + // commit-count path (no release readable). + const dismissKey = status?.latest_release || status?.remote_sha || null; + const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey); const dismiss = useCallback(() => { - if (restarting || !status?.remote_sha) return; + if (restarting || !dismissKey) return; try { - localStorage.setItem(DISMISS_KEY, status.remote_sha); + localStorage.setItem(DISMISS_KEY, dismissKey); } catch { /* ignore */ } - setDismissedSha(status.remote_sha); - }, [restarting, status?.remote_sha]); + setDismissedSha(dismissKey); + }, [restarting, dismissKey]); // Escape to dismiss - standard modal affordance. useEffect(() => { @@ -213,12 +236,27 @@ export function UpdateNotifier() { // Polls `status()` until the restarted server answers again, then reloads // so the tab picks up the new client bundle too — the server can't push // this over its own WebSocket since it's mid-restart. - const pollUntilBack = useCallback(() => { + /** + * @param requireDownFirst wait for the server to actually disappear before + * treating a success as "it's back". The git path rebuilds and restarts + * within seconds, but the plugin chain pulls a new cache directory and + * reinstalls dependencies first — the OLD server stays up for that whole + * stretch, so polling naively would get a 200 from the process we are + * waiting to replace and reload onto the same old bundle. + */ + const pollUntilBack = useCallback((requireDownFirst = false) => { + let sawDown = !requireDownFirst; const attempt = () => { api.updates .status() - .then(() => window.location.reload()) - .catch(() => setTimeout(attempt, 1500)); + .then(() => { + if (sawDown) window.location.reload(); + else setTimeout(attempt, 1500); + }) + .catch(() => { + sawDown = true; + setTimeout(attempt, 1500); + }); }; setTimeout(attempt, 1500); }, []); @@ -232,11 +270,17 @@ export function UpdateNotifier() { if (result.applied) { setApplying(false); setRestarting(true); - pollUntilBack(); + // mode "plugin": the old server lingers while the chain installs, so + // only treat a reachable server as "back" after it has gone away once. + pollUntilBack(result.mode === "plugin"); return; } setError( - result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError") + result.reason === "plugin_managed" + ? t("reasonPluginManaged") + : result.reason === "not_fast_forwardable" + ? t("reasonNotFastForwardable") + : t("applyError") ); } catch (e) { setError(e instanceof Error ? e.message : t("applyError")); @@ -249,6 +293,8 @@ export function UpdateNotifier() { const refLabel = status.remote_ref || "origin"; const behind = status.commits_behind ?? 0; + const release = status.latest_release || null; + const pluginManaged = status.situation === "plugin_managed"; return (
- {t("title")} + {release ? t("releaseTitle", { version: release }) : t("title")}

- {t("commitsBehind", { count: behind, ref: refLabel })} + {/* Never print a commit count on a shallow clone — the number + is a graft artefact, not a distance. */} + {release && !status.shallow_clone && status.local_version + ? t("releaseSubtitle", { current: status.local_version }) + : t("commitsBehind", { count: behind, ref: refLabel })}

@@ -291,7 +341,30 @@ export function UpdateNotifier() { {/* Body */}
-

{t("lead")}

+

+ {pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")} +

+ + {status.latest_release_url ? ( + + + {t("viewRelease")} + {status.latest_release_name ? ` — ${status.latest_release_name}` : null} + + ) : null} + + {/* Release lookup failed and detection fell back to commit counting: + say so, so "up to date" is never read as "release checked". */} + {status.release_check && status.release_check !== "ok" ? ( +
+ {t("releaseUnavailable", { reason: status.release_check })} +
+ ) : null} {status.fetch_error ? (
@@ -323,7 +396,8 @@ export function UpdateNotifier() { {/* The restart hint only applies when the printed command actually * rewrites the working tree. Feature-branch / detached-HEAD commands * are fetch-only - restarting the dashboard would change nothing. */} - {status.situation === "tracking_canonical" || + {status.situation === "release_available" || + status.situation === "tracking_canonical" || status.situation === "fork_or_diverged_tracking" ? (

{t("restartNote")}

) : null} @@ -331,7 +405,22 @@ export function UpdateNotifier() { {restarting ? (
- {t("restarting")} + {/* The plugin chain pulls a new cache directory and reinstalls + dependencies before it restarts, so it takes minutes, not + seconds — saying "restarting" there would look hung. */} + {pluginManaged ? t("pluginUpdating") : t("restarting")} +
+ ) : null} + + {/* A previous chain that failed: the only place the user would ever + learn why, since the server it would have reported from is the one + that never came back. */} + {status.last_plugin_update && status.last_plugin_update.ok === false ? ( +
+ {t("lastPluginUpdateFailed", { + step: status.last_plugin_update.step, + error: status.last_plugin_update.error || "", + })}
) : null} diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 47af8ecc..8917fd6b 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -26,7 +26,7 @@ describe("Sidebar", () => { it("should render the subtitle", () => { renderSidebar(true); - expect(screen.getByText("Claude Code Monitor")).toBeInTheDocument(); + expect(screen.getByText("SmartGift Solution")).toBeInTheDocument(); }); it("should render all navigation links", () => { diff --git a/client/src/components/__tests__/UpdateNotifier.test.tsx b/client/src/components/__tests__/UpdateNotifier.test.tsx index 014b9029..56824a6e 100644 --- a/client/src/components/__tests__/UpdateNotifier.test.tsx +++ b/client/src/components/__tests__/UpdateNotifier.test.tsx @@ -90,6 +90,72 @@ describe("UpdateNotifier — Update now", () => { expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument(); }); + it("offers Update now for a plugin-cache install, noting the session still needs a reload", async () => { + // The button works here via the detached `claude plugin update` chain — but + // it moves only the dashboard server, so the note about /reload-plugins is + // part of the promise, not decoration. + statusMock.mockResolvedValue({ + ...BASE_STATUS, + situation: "plugin_managed", + install_kind: "plugin", + plugin_marketplace: "sg", + plugin_name: "agenthub", + shallow_clone: true, + commits_behind: 0, + local_version: "1.5.0", + release_check: "ok", + latest_release: "v1.6.0", + manual_command: "claude plugin marketplace update sg && claude plugin update agenthub@sg -y", + }); + render(); + + await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })); + expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument(); + expect(screen.getByText(i18n.t("updates:pluginAutoNote"))).toBeInTheDocument(); + }); + + it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => { + statusMock.mockResolvedValue({ + ...BASE_STATUS, + situation: "plugin_managed", + install_kind: "plugin", + plugin_marketplace: "sg", + plugin_name: "agenthub", + shallow_clone: true, + commits_behind: 0, + local_version: "1.5.0", + release_check: "ok", + latest_release: "v1.6.0", + last_plugin_update: { + ok: false, + step: "resolve_claude", + error: "the `claude` CLI is not on this server's PATH", + finished_at: new Date().toISOString(), + }, + }); + render(); + + await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })); + expect(screen.getByText(/resolve_claude/)).toBeInTheDocument(); + }); + + it("titles the modal with the release version when one was read", async () => { + statusMock.mockResolvedValue({ + ...BASE_STATUS, + situation: "release_available", + release_check: "ok", + local_version: "1.5.0", + latest_release: "v1.6.0", + commits_behind_release: 3, + commits_ahead_of_release: 0, + }); + render(); + + await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })); + // A release IS fast-forwardable on a checkout, so the button stays. + expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument(); + }); + it("surfaces the decline reason instead of restarting on a 409", async () => { applyMock.mockResolvedValue({ ...BASE_STATUS, diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 0d782eb2..f88a3ef3 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -4,7 +4,7 @@ "analytics": "Analytics", "avg": "avg", "brand": "Agent Dashboard", - "brandSub": "Claude Code Monitor", + "brandSub": "SmartGift Solution", "ccConfig": "Claude Config", "checkFailed": "Update check failed", "checkForUpdates": "Check for updates", @@ -65,5 +65,7 @@ "website": "Website", "workflows": "Workflows", "workspace": "Workspace", - "wsEndpoint": "WebSocket" + "wsEndpoint": "WebSocket", + "releaseAvailable": "{{version}} available", + "devBuild": "Dev build · {{count}} ahead" } diff --git a/client/src/i18n/locales/en/splash.json b/client/src/i18n/locales/en/splash.json index b39ace1a..439de8e7 100644 --- a/client/src/i18n/locales/en/splash.json +++ b/client/src/i18n/locales/en/splash.json @@ -8,7 +8,7 @@ "tagline": "Nothing your agents do stays in the dark.", "sub1": "Real-time observability for every Claude Code session.", "sub2": "Tokens, tools, costs, and workflows, all live.", - "brand": "Claude Code Monitor", + "brand": "SmartGift Solution", "taglines": [ "Nothing your agents do stays in the dark.", "See every move your agents make.", diff --git a/client/src/i18n/locales/en/updates.json b/client/src/i18n/locales/en/updates.json index 2717ecc0..cda6202e 100644 --- a/client/src/i18n/locales/en/updates.json +++ b/client/src/i18n/locales/en/updates.json @@ -16,5 +16,14 @@ "updating": "Updating...", "restarting": "Rebuilt — restarting the dashboard...", "applyError": "Could not apply the update", - "reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually." + "reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually.", + "releaseTitle": "Release {{version}} available", + "releaseSubtitle": "You are running {{current}}", + "releaseLead": "A new release of the dashboard has been published. \"Update now\" fast-forwards this install onto the release tag, reinstalls dependencies, rebuilds, and restarts the server.", + "viewRelease": "Release notes", + "reasonPluginManaged": "This install is managed by the Claude Code plugin cache — run /agenthub-update instead.", + "releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.", + "pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.", + "pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...", + "lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover." } diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index b7e67b05..d69449c3 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -4,7 +4,7 @@ "analytics": "Phân tích", "avg": "TB", "brand": "Bảng điều khiển Agent", - "brandSub": "Giám sát Claude Code", + "brandSub": "SmartGift Solution", "ccConfig": "Cấu hình Claude", "checkFailed": "Kiểm tra cập nhật thất bại", "checkForUpdates": "Kiểm tra cập nhật", @@ -65,5 +65,7 @@ "website": "Website", "workflows": "Quy trình", "workspace": "Không gian làm việc", - "wsEndpoint": "WebSocket" + "wsEndpoint": "WebSocket", + "releaseAvailable": "Có {{version}}", + "devBuild": "Bản dev · trước {{count}} commit" } diff --git a/client/src/i18n/locales/vi/splash.json b/client/src/i18n/locales/vi/splash.json index a724a426..6a78511a 100644 --- a/client/src/i18n/locales/vi/splash.json +++ b/client/src/i18n/locales/vi/splash.json @@ -8,7 +8,7 @@ "tagline": "Mọi điều agent của bạn làm đều không ẩn trong bóng tối.", "sub1": "Khả năng quan sát thời gian thực cho mọi phiên Claude Code.", "sub2": "Token, công cụ, chi phí và workflow, tất cả trực tiếp.", - "brand": "Giám sát Claude Code", + "brand": "SmartGift Solution", "taglines": [ "Mọi điều agent của bạn làm đều không ẩn trong bóng tối.", "Thấy rõ từng bước đi của agent.", diff --git a/client/src/i18n/locales/vi/updates.json b/client/src/i18n/locales/vi/updates.json index 95cd586a..f4c6102a 100644 --- a/client/src/i18n/locales/vi/updates.json +++ b/client/src/i18n/locales/vi/updates.json @@ -16,5 +16,14 @@ "updating": "Đang cập nhật...", "restarting": "Đã build xong — đang khởi động lại bảng điều khiển...", "applyError": "Không thể áp dụng cập nhật", - "reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công." + "reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công.", + "releaseTitle": "Đã có bản phát hành {{version}}", + "releaseSubtitle": "Bạn đang chạy {{current}}", + "releaseLead": "Repo vừa phát hành bản mới. Bấm \"Cập nhật ngay\" để fast-forward install này lên tag của bản phát hành, cài lại dependency, build lại và khởi động lại server.", + "viewRelease": "Ghi chú phát hành", + "reasonPluginManaged": "Bản cài này do plugin cache của Claude Code quản lý — hãy chạy /agenthub-update.", + "releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.", + "pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.", + "pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...", + "lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục." } diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index e5bc990e..156233d6 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1255,6 +1255,8 @@ export interface UpdateStatusPayload { * shape (e.g. show "Restart after running" only when the command * actually rewrites the working tree). */ situation?: + | "plugin_managed" + | "release_available" | "tracking_canonical" | "fork_or_diverged_tracking" | "feature_branch" @@ -1285,9 +1287,79 @@ export interface UpdateStatusPayload { * restart itself). Absent from `status`/`check` responses. */ applied?: boolean; /** Set alongside `applied: false` on an `/apply` response that declined to - * act - `"up_to_date"` or `"not_fast_forwardable"` (feature branch / - * detached HEAD, where there's no safe automatic move). */ - reason?: "up_to_date" | "not_fast_forwardable"; + * act - `"up_to_date"`, `"not_fast_forwardable"` (feature branch / + * detached HEAD, where there's no safe automatic move), or + * `"plugin_managed"` (the install lives in the Claude Code plugin cache, + * which only `/reload-plugins` can swap - the server must not touch it). */ + reason?: "up_to_date" | "not_fast_forwardable" | "plugin_managed"; + + // ── Release layer ── + /** How this copy was installed. `"plugin"` means it runs out of + * `~/.claude/plugins/cache/`, where self-update is refused. */ + install_kind?: "plugin" | "checkout"; + /** Marketplace the plugin came from (e.g. `"sg"`), parsed from the cache + * path. null for a checkout install. Used to print the exact + * `claude plugin update @` command. */ + plugin_marketplace?: string | null; + /** Version this install declares in its own package.json, e.g. `"1.5.0"`. + * Compared against `latest_release` to decide `update_available`. */ + local_version?: string | null; + /** True for a `--depth` clone, where every commit-distance field is + * meaningless and therefore null - the UI must not render an + * "N commits behind" badge. The Claude Code plugin cache is one. */ + shallow_clone?: boolean; + /** `"ok"`, or why the release lookup produced nothing: `"no_remote"`, + * `"remote_not_http"`, `"no_releases"`, `"unreachable"`, `"bad_payload"`, + * or `"http_"`. Anything but `"ok"` means detection fell back to + * the commit-count comparison. */ + release_check?: string; + /** Tag of the newest published release, e.g. `"v1.5.0"`. null when + * `release_check !== "ok"`. */ + latest_release?: string | null; + /** Human title of that release, when the forge has one. */ + latest_release_name?: string | null; + /** Browser URL of the release page, for a "view release notes" link. */ + latest_release_url?: string | null; + /** ISO timestamp the release was published. */ + latest_release_published_at?: string | null; + /** Release body/changelog, truncated server-side to 4000 chars. */ + latest_release_notes?: string | null; + /** Commit the release tag resolves to locally; null when the tag hasn't + * been fetched yet. */ + release_sha?: string | null; + /** Commits in the release that this install lacks. null on a shallow + * clone (see `shallow_clone`). */ + commits_behind_release?: number | null; + /** Commits this install carries that the release does not. null on a + * shallow clone. Drives the "dev build" label. */ + commits_ahead_of_release?: number | null; + /** True when this install is strictly ahead of the newest release and + * missing nothing from it - a build off the branch, not a shipped + * version. Never true at the same time as `update_available`. */ + dev_build?: boolean; + /** Plugin name parsed from the cache path (e.g. `"agenthub"`). null for a + * checkout install. Combined with `plugin_marketplace` it forms the + * `claude plugin update @` target. */ + plugin_name?: string | null; + /** Present only on an `/apply` response. `"plugin"` means the detached + * plugin chain took the job (minutes, and the old server stays up while it + * installs); absent means an in-place git fast-forward + restart. */ + mode?: "plugin"; + /** Runtime directory the plugin chain writes its log/verdict into. Present + * only on an `/apply` response with `mode: "plugin"`. */ + runtime_dir?: string; + /** Verdict of the most recent plugin-update chain, or null when there has + * never been one. `ok: false` is the ONLY way a failed chain becomes + * visible - the server that would have reported it is the one that never + * came back. Read on every status poll for a plugin install. */ + last_plugin_update?: { + ok: boolean; + step: string; + error?: string; + note?: string; + plugin_root?: string; + finished_at?: string; + } | null; } // ───── Terminal run status ───── @@ -1316,8 +1388,28 @@ export interface CcConfigChangedPayload { /** Whether the dashboard itself made the write, or an fs watcher observed * an external change (e.g. the user editing a file directly). */ source: "dashboard" | "fs"; - /** Mutation kind; absent for some fs-watcher events that only report scope. */ - action?: "write" | "delete"; + /** Mutation kind; absent for some fs-watcher events that only report scope. + * Covers the file editor (`write`/`delete`), settings (`patch`), the + * `claude plugin …` routes + * (`install`/`enable`/`disable`/`update`/`uninstall` for a plugin, + * `add`/`remove`/`update` for a marketplace) and the auth profiles + * (`save`/`rename`/`activate`/`delete`). Keep in step with the `emitChanged` + * call sites in `server/routes/cc-config.js` — the union was previously just + * `write | delete`, which every plugin route already contradicted. */ + action?: + | "write" + | "delete" + | "patch" + | "install" + | "enable" + | "disable" + | "update" + | "uninstall" + | "add" + | "remove" + | "save" + | "rename" + | "activate"; /** "user" (~/.claude) or "project" (repo's .claude) config root affected. * Lets a listening tab decide whether the change is relevant to its view. */ scope?: "user" | "project"; diff --git a/docs/CLI.md b/docs/CLI.md index 6aad22cb..7113ef3d 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -277,7 +277,7 @@ Because a lane's services are fully detached, restarting the dashboard (or `agen | `agenthub export [file.json]` | Full JSON data export (sessions, agents, events, tokens, workflows, dashboard runs, alert rules, pricing) — defaults to a dated filename. Re-importable via `agenthub import-data` | | `agenthub cleanup --hours N --days M` | Abandon active sessions idle for `N` hours and/or purge completed sessions older than `M` days | | `agenthub reinstall-hooks` | Rewrite the Claude Code hook entries in `~/.claude/settings.json` | -| `agenthub update-check` | Ask the server whether the dashboard checkout is behind the canonical remote (branch- and fork-aware). Prints the behind-by count, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. When the checkout is fast-forwardable, the dashboard UI can also self-apply (`POST /api/updates/apply`: pull, rebuild, self-restart) instead of running the command by hand. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) | +| `agenthub update-check` | Ask the server whether a newer **release** has been published, read from the forge's own releases API (derived from the `origin` remote); falls back to a commit count against the canonical remote when no release can be read, with the reason in `release_check`. Prints the release tag against the running version, a situation note for fork/feature-branch checkouts, and the **copy-paste update command**. Reports `dev_build` when the install is ahead of the newest release, and `plugin_managed` for an install under `~/.claude/plugins/cache/`. The dashboard UI can self-apply either kind (`POST /api/updates/apply`): a fast-forwardable checkout is fast-forwarded onto the release tag, rebuilt and self-restarted; a plugin install is handed to a detached `claude plugin marketplace update` → `claude plugin update` → `plugin-bootstrap.js --force` chain instead. `/agenthub-update` remains the manual equivalent, and an already-open Claude Code session still needs `/reload-plugins` to pick up new skills and commands. Also refreshes the update banner in any open dashboard tab (same `update_status` broadcast) | | `agenthub clear-data --yes` | Delete **all** data (schema preserved). Refuses to run without `--yes` | | `agenthub open` | Open the dashboard in your default browser (`open` / `xdg-open` / `start`) | | `agenthub version` | Print the agenthub version (also `--version` / `-v`) | diff --git a/scripts/plugin-update-helper.js b/scripts/plugin-update-helper.js new file mode 100644 index 00000000..5e31ccf2 --- /dev/null +++ b/scripts/plugin-update-helper.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/** + * @file Detached helper that performs a full plugin-cache update end to end, so + * the dashboard's "Update now" button works for an install running out of + * `~/.claude/plugins/cache/`. Spawned by `server/lib/plugin-update.js` after the + * HTTP response is written, because step 3 SIGTERMs the very server that asked + * for the update — an in-process version would kill itself mid-flight. + * + * The three steps: + * 1. `claude plugin marketplace update ` + * 2. `claude plugin update @ -y` → new cache dir appears + * 3. `node /scripts/plugin-bootstrap.js --force` → stops the old + * server, waits for the ports, installs deps and starts the new one + * + * Step 3 is what makes `/reload-plugins` unnecessary here: `plugin-bootstrap.js` + * derives its own `PLUGIN_ROOT` from `__dirname`, not from `${CLAUDE_PLUGIN_ROOT}`, + * so running it out of the NEW cache directory is enough to move the dashboard + * onto the new code. `/reload-plugins` remains needed only for an interactive + * Claude Code session to pick up new skills/commands/hooks — a thing no + * background process can do on that session's behalf. + * + * Every outcome, including failure, is recorded in `plugin-update.json` under the + * runtime directory (outside the plugin cache, so it survives the swap) and the + * new server reports it back as `last_plugin_update`. Without that, a failed + * chain would look identical to a slow one: the tab would poll a server that + * never comes back and say nothing about why. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("fs"); +const path = require("path"); +const { execFileSync, spawnSync } = require("child_process"); + +const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv; + +const RESULT_FILE = path.join(runtimeDirArg, "plugin-update.json"); +const LOG_FILE = path.join(runtimeDirArg, "plugin-update.log"); + +// Names come from the local cache PATH, never from an HTTP body, but they still +// reach an argv — so they are re-validated here rather than trusted across the +// process boundary. Anything else is a bug or tampering, and either way this +// helper must not run. +const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/; + +function log(line) { + try { + fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`); + } catch { + /* best effort */ + } +} + +function record(result) { + try { + fs.mkdirSync(runtimeDirArg, { recursive: true }); + fs.writeFileSync( + RESULT_FILE, + JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2) + ); + } catch { + /* best effort */ + } +} + +/** Resolve the `claude` CLI. The server is started detached from a hook, so its + * PATH is not the user's login shell PATH — check the documented install + * location before giving up. */ +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; +} + +/** Newest cache directory for this plugin, excluding `exclude`. */ +function findNewRoot(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) + // A cache directory without the bootstrap script is not a usable root. + .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; +} + +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, 4000)); + return out; +} + +function main() { + if (!SAFE_NAME.test(marketplace || "") || !SAFE_NAME.test(plugin || "")) { + record({ ok: false, step: "validate", error: "unsafe marketplace or plugin name" }); + return; + } + + const claude = resolveClaude(); + if (!claude) { + record({ + ok: false, + step: "resolve_claude", + error: "the `claude` CLI is not on this server's PATH; run /agenthub-update instead", + }); + return; + } + + try { + run(claude, ["plugin", "marketplace", "update", marketplace], 300_000); + } catch (err) { + record({ ok: false, step: "marketplace_update", error: String(err.stderr || err.message) }); + return; + } + + try { + run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000); + } catch (err) { + record({ ok: false, step: "plugin_update", error: String(err.stderr || err.message) }); + return; + } + + const newRoot = findNewRoot(oldRoot); + if (!newRoot) { + // Already newest: the marketplace had nothing to pull. Not a failure — the + // old server is still running and untouched, so say so plainly. + record({ + ok: true, + step: "already_current", + note: "no new plugin version appeared; the running server was left alone", + plugin_root: oldRoot, + }); + return; + } + + log(`new plugin root: ${newRoot}`); + try { + // Foreground and blocking: this stops the old server (us), waits for the + // ports, then starts the new one. It must finish before we record success. + run( + process.execPath, + [path.join(newRoot, "scripts", "plugin-bootstrap.js"), "--force"], + 900_000 + ); + } catch (err) { + record({ + ok: false, + step: "bootstrap", + error: String(err.stderr || err.message), + plugin_root: newRoot, + note: "the old server was stopped; run /agenthub-update to recover", + }); + return; + } + + record({ ok: true, step: "done", plugin_root: newRoot }); +} + +try { + main(); +} catch (err) { + // A helper that dies without a verdict is the failure mode this file exists + // to prevent, so even an unexpected throw gets recorded. + record({ ok: false, step: "unexpected", error: err.message || String(err) }); +} diff --git a/server/__tests__/plugin-update.test.js b/server/__tests__/plugin-update.test.js new file mode 100644 index 00000000..f0e0632f --- /dev/null +++ b/server/__tests__/plugin-update.test.js @@ -0,0 +1,171 @@ +/** + * @file Tests for the plugin-cache update chain: the verdict file the detached + * helper leaves behind (the only channel through which a failed chain becomes + * visible, since the server that would report it is the one being replaced), + * and the helper's own name guard. + * + * The guard test runs the REAL helper, deliberately, with a name that must be + * rejected — that is the one execution path safe to exercise, because it must + * bail out before invoking the `claude` CLI. No test here ever runs the chain + * with a valid name: that would pull a plugin version onto the machine running + * the suite and stop the developer's own dashboard. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update"); + +const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js"); + +let tmpDir; + +before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pluginupd-")); +}); + +after(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } +}); + +/** Wait for `file` to exist, polling — the helper is a separate process. */ +function waitForFile(file, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + const sleeper = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(file) && Date.now() < deadline) { + Atomics.wait(sleeper, 0, 0, 100); + } + return fs.existsSync(file); +} + +describe("readLastPluginUpdate", () => { + it("returns null when the verdict file has never been written", () => { + const rt = path.join(tmpDir, "empty"); + fs.mkdirSync(rt, { recursive: true }); + assert.equal(readLastPluginUpdate(rt), null); + }); + + it("returns null for malformed JSON rather than throwing on a status poll", () => { + // This is read on EVERY status request for a plugin install, so a corrupt + // file must degrade to "nothing to report", never break the endpoint. + const rt = path.join(tmpDir, "malformed"); + fs.mkdirSync(rt, { recursive: true }); + fs.writeFileSync(path.join(rt, "plugin-update.json"), "{not json"); + assert.equal(readLastPluginUpdate(rt), null); + }); + + it("returns null for valid JSON that isn't an object", () => { + const rt = path.join(tmpDir, "notobj"); + fs.mkdirSync(rt, { recursive: true }); + fs.writeFileSync(path.join(rt, "plugin-update.json"), "42"); + assert.equal(readLastPluginUpdate(rt), null); + }); + + it("reads back a recorded failure, including the step it died at", () => { + const rt = path.join(tmpDir, "failed"); + fs.mkdirSync(rt, { recursive: true }); + fs.writeFileSync( + path.join(rt, "plugin-update.json"), + JSON.stringify({ ok: false, step: "bootstrap", error: "npm ci exploded" }) + ); + const v = readLastPluginUpdate(rt); + assert.equal(v.ok, false); + assert.equal(v.step, "bootstrap"); + assert.match(v.error, /npm ci exploded/); + }); +}); + +describe("schedulePluginUpdate", () => { + it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => { + // Redirect the runtime dir into the temp tree — this must not touch the + // real ~/.claude/agenthub/runtime of whoever runs the suite. + const dataDir = path.join(tmpDir, "datadir"); + const prev = process.env.DASHBOARD_DATA_DIR; + process.env.DASHBOARD_DATA_DIR = dataDir; + + // Point it at a root with no helper script: the spawn fails harmlessly + // (detached, stdio ignored) and nothing can rewrite the verdict, which is + // exactly what makes the clearing observable. + const fakeRoot = path.join(tmpDir, "noscripts"); + fs.mkdirSync(fakeRoot, { recursive: true }); + + try { + const rt = runtimeDir(); + fs.mkdirSync(rt, { recursive: true }); + const verdict = path.join(rt, "plugin-update.json"); + fs.writeFileSync(verdict, JSON.stringify({ ok: true, step: "done" })); + + const res = schedulePluginUpdate({ marketplace: "sg", plugin: "agenthub", root: fakeRoot }); + assert.equal(res.spawned, true); + assert.equal(res.runtime_dir, rt); + assert.equal(fs.existsSync(verdict), false, "stale verdict must be removed"); + assert.equal(readLastPluginUpdate(rt), null); + } finally { + if (prev === undefined) delete process.env.DASHBOARD_DATA_DIR; + else process.env.DASHBOARD_DATA_DIR = prev; + } + }); +}); + +describe("plugin-update-helper name guard", () => { + it("refuses a path-traversal marketplace name without invoking the claude CLI", () => { + const rt = path.join(tmpDir, "guard"); + fs.mkdirSync(rt, { recursive: true }); + + // Run synchronously and let it record its own verdict. A name like this can + // only arrive from a bug or tampering; either way the helper must stop + // before it builds an argv for `claude`. + execFileSync(process.execPath, [HELPER, "../../../evil", "agenthub", "/nonexistent/root", rt], { + stdio: "ignore", + timeout: 30_000, + }); + + assert.ok(waitForFile(path.join(rt, "plugin-update.json")), "helper must record a verdict"); + const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8")); + assert.equal(v.ok, false); + assert.equal(v.step, "validate"); + + // And it must not have gotten as far as running a command. + const logPath = path.join(rt, "plugin-update.log"); + const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : ""; + assert.doesNotMatch(log, /claude plugin/, "no claude invocation may be logged"); + }); + + it("refuses an empty plugin name the same way", () => { + const rt = path.join(tmpDir, "guard2"); + fs.mkdirSync(rt, { recursive: true }); + execFileSync(process.execPath, [HELPER, "sg", "", "/nonexistent/root", rt], { + stdio: "ignore", + timeout: 30_000, + }); + const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8")); + assert.equal(v.ok, false); + assert.equal(v.step, "validate"); + }); + + it("stamps every verdict with a finish time", () => { + // The failure mode this file exists to prevent is a helper that dies + // silently: the tab would poll a server that never returns and say nothing. + // Only the rejected-name path is exercised — running the helper with a + // valid name would really update this machine's plugin and stop its server. + const rt = path.join(tmpDir, "guard3"); + fs.mkdirSync(rt, { recursive: true }); + execFileSync(process.execPath, [HELPER, "sg", "agent hub", "/nonexistent/root", rt], { + stdio: "ignore", + timeout: 30_000, + }); + const v = JSON.parse(fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8")); + assert.equal(v.step, "validate"); + assert.ok(v.finished_at, "every verdict is timestamped"); + assert.ok(!Number.isNaN(Date.parse(v.finished_at))); + }); +}); diff --git a/server/__tests__/update-check-release.test.js b/server/__tests__/update-check-release.test.js new file mode 100644 index 00000000..c3c3146d --- /dev/null +++ b/server/__tests__/update-check-release.test.js @@ -0,0 +1,481 @@ +/** + * @file Tests for the RELEASE layer of update detection: version comparison, + * forge-API URL derivation from a git remote, plugin-cache recognition, and the + * shallow-clone guard. Every scenario injects `options.latestRelease` instead of + * letting the module reach the network, so the suite is offline and + * deterministic; the one case that must NOT be reachable (a local file-path + * remote) is asserted to short-circuit before any request is attempted. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const { + getUpdatesStatus, + applyUpdate, + compareVersions, + releaseApiUrl, + detectPluginInstall, + fetchLatestRelease, + isShallowClone, +} = require("../lib/update-check"); + +// Same scrub as update-check.test.js: a parent git hook's GIT_* vars override +// an explicit cwd, which would point these commands at the real repo. +const GIT_ENV = { ...process.env }; +for (const k of [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_PREFIX", + "GIT_NAMESPACE", + "GIT_CONFIG_PARAMETERS", +]) { + delete GIT_ENV[k]; +} + +const IDENT = ["-c", "user.email=t@t", "-c", "user.name=t"]; + +function git(cwd, args) { + return execFileSync("git", args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + env: GIT_ENV, + }).trim(); +} + +/** + * A repo with `version` in package.json, one commit, an optional tag, and a + * real pushed `origin` so `origin/main` resolves — without remote-tracking + * refs, getUpdatesStatus returns its "could not resolve remote ref" payload + * and never reaches the release layer under test. The remote is a local bare + * path on purpose: `releaseApiUrl` rejects it, so nothing here can touch the + * network even if a test forgets to inject `latestRelease`. + */ +function makeVersionedRepo(parent, dir, version, tag) { + const remote = path.join(parent, `${dir}-remote.git`); + fs.mkdirSync(remote, { recursive: true }); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "--bare", remote], { + stdio: "ignore", + env: GIT_ENV, + }); + + const repo = path.join(parent, dir); + fs.mkdirSync(repo, { recursive: true }); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], { + stdio: "ignore", + env: GIT_ENV, + }); + fs.writeFileSync(path.join(repo, "package.json"), JSON.stringify({ name: "fx", version })); + git(repo, [...IDENT, "add", "."]); + git(repo, [...IDENT, "commit", "-m", "release commit"]); + if (tag) git(repo, ["tag", tag]); + git(repo, ["remote", "add", "origin", remote]); + git(repo, ["push", "-u", "origin", "main"]); + return repo; +} + +/** + * Repoints `origin` at an https URL AFTER the push, so `origin/main` still + * resolves (the refs survive a URL change) while `releaseApiUrl` now produces + * an endpoint — which is what lets a stubbed `fetch` be reached at all. + */ +function setHttpOrigin(repo) { + git(repo, ["remote", "set-url", "origin", "https://git.example.com/Owner/Repo.git"]); + return repo; +} + +let tmpDir; + +before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "updrel-")); +}); + +after(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } +}); + +describe("compareVersions", () => { + it("orders numeric triples", () => { + assert.equal(compareVersions("1.6.0", "1.5.0"), 1); + assert.equal(compareVersions("1.5.0", "1.6.0"), -1); + assert.equal(compareVersions("1.5.0", "1.5.0"), 0); + assert.equal(compareVersions("1.5.1", "1.5.0"), 1); + assert.equal(compareVersions("2.0.0", "1.99.99"), 1); + }); + + it("treats a leading v as noise — this repo tags both ways", () => { + // Real tags in this repository: `v1.5.0` and `1.4.6`. + assert.equal(compareVersions("v1.5.0", "1.5.0"), 0); + assert.equal(compareVersions("v1.5.0", "1.4.6"), 1); + assert.equal(compareVersions("1.4.6", "v1.5.0"), -1); + }); + + it("compares only the release triple, ignoring prerelease/build suffixes", () => { + assert.equal(compareVersions("1.5.0-rc.1", "1.5.0"), 0); + assert.equal(compareVersions("1.5.0+build9", "1.5.0"), 0); + }); + + it("treats missing or junk components as zero rather than NaN", () => { + assert.equal(compareVersions("2", "1.9.9"), 1); + assert.equal(compareVersions("1.5", "1.5.0"), 0); + assert.equal(compareVersions("junk", "0.0.0"), 0); + }); +}); + +describe("releaseApiUrl", () => { + it("derives the Gitea endpoint from an https remote", () => { + assert.equal( + releaseApiUrl("https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git"), + "https://git.smartgift.io.vn/api/v1/repos/Smartgift-AI/AgentHub/releases/latest" + ); + }); + + it("handles a missing .git suffix, a trailing slash, and embedded credentials", () => { + const want = "https://git.example.com/api/v1/repos/Owner/Repo/releases/latest"; + assert.equal(releaseApiUrl("https://git.example.com/Owner/Repo"), want); + assert.equal(releaseApiUrl("https://git.example.com/Owner/Repo/"), want); + assert.equal(releaseApiUrl("https://oauth2:tok@git.example.com/Owner/Repo.git"), want); + }); + + it("converts scp-style and ssh:// remotes to the https API host", () => { + const want = "https://git.example.com/api/v1/repos/Owner/Repo/releases/latest"; + assert.equal(releaseApiUrl("git@git.example.com:Owner/Repo.git"), want); + assert.equal(releaseApiUrl("ssh://git@git.example.com:2222/Owner/Repo.git"), want); + }); + + it("returns null for anything not owner/repo shaped — this is what keeps local-path remotes offline", () => { + assert.equal(releaseApiUrl("/tmp/some/bare-remote.git"), null); + assert.equal(releaseApiUrl("../relative/remote"), null); + assert.equal(releaseApiUrl(""), null); + // No dot in the host: a bare hostname is not a forge we can call. + assert.equal(releaseApiUrl("https://localhost/Owner/Repo.git"), null); + }); +}); + +describe("fetchLatestRelease", () => { + it("reports remote_not_http for a local file-path remote, without a network call", async () => { + const repo = path.join(tmpDir, "localremote"); + fs.mkdirSync(repo, { recursive: true }); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], { + stdio: "ignore", + env: GIT_ENV, + }); + git(repo, ["remote", "add", "origin", path.join(tmpDir, "nowhere.git")]); + + // Any attempt to reach the network would throw here, since fetch is gone. + const realFetch = global.fetch; + global.fetch = () => { + throw new Error("fetchLatestRelease must not call fetch for a non-http remote"); + }; + try { + const res = await fetchLatestRelease(repo); + assert.equal(res.ok, false); + assert.equal(res.reason, "remote_not_http"); + } finally { + global.fetch = realFetch; + } + }); + + it("reports no_remote when the repo has no remotes at all", async () => { + const repo = path.join(tmpDir, "noremote"); + fs.mkdirSync(repo, { recursive: true }); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], { + stdio: "ignore", + env: GIT_ENV, + }); + const res = await fetchLatestRelease(repo); + assert.equal(res.ok, false); + assert.equal(res.reason, "no_remote"); + }); + + it("maps a 404 to no_releases rather than an error — a repo with no releases is normal", async () => { + const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "rel404", "1.0.0", null)); + const realFetch = global.fetch; + global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) }); + try { + const res = await fetchLatestRelease(repo); + assert.equal(res.ok, false); + assert.equal(res.reason, "no_releases"); + } finally { + global.fetch = realFetch; + } + }); + + it("never throws when the request fails — it resolves to unreachable", async () => { + const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "relboom", "1.0.0", null)); + const realFetch = global.fetch; + global.fetch = async () => { + throw new Error("ENOTFOUND"); + }; + try { + const res = await fetchLatestRelease(repo); + assert.equal(res.ok, false); + assert.equal(res.reason, "unreachable"); + } finally { + global.fetch = realFetch; + } + }); + + it("caps release notes so a huge changelog can't bloat every status poll", async () => { + const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "relbig", "1.0.0", null)); + const realFetch = global.fetch; + global.fetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ tag_name: "v9.0.0", body: "x".repeat(10_000) }), + }); + try { + const res = await fetchLatestRelease(repo); + assert.equal(res.ok, true); + assert.equal(res.notes.length, 4000); + } finally { + global.fetch = realFetch; + } + }); +}); + +describe("detectPluginInstall", () => { + it("extracts the marketplace and plugin name from a plugin cache path", () => { + assert.deepEqual( + detectPluginInstall("/home/u/.claude/plugins/cache/sg/agenthub/eb6356932438"), + { + marketplace: "sg", + plugin: "agenthub", + } + ); + }); + + it("returns null for an ordinary checkout", () => { + assert.equal(detectPluginInstall("/home/u/Projects/agenthub"), null); + // A directory merely NAMED like the cache is not the cache. + assert.equal(detectPluginInstall("/home/u/plugins/cache/sg/agenthub"), null); + }); +}); + +describe("getUpdatesStatus — release drives update_available", () => { + it("reports the release as available when its tag outranks the local version", async () => { + const repo = makeVersionedRepo(tmpDir, "behind", "1.5.0", "v1.5.0"); + const status = await getUpdatesStatus(repo, { + skipFetch: true, + latestRelease: { + ok: true, + tag: "v1.6.0", + name: "1.6.0", + url: "u", + published_at: null, + notes: null, + }, + }); + + assert.equal(status.update_available, true); + assert.equal(status.situation, "release_available"); + assert.equal(status.latest_release, "v1.6.0"); + assert.equal(status.local_version, "1.5.0"); + assert.equal(status.dev_build, false); + // Moves onto the TAG, and fetches tags first so the tag exists locally. + assert.match(status.manual_command, /git fetch origin --tags/); + assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/); + }); + + it("is up to date — not a dev build — when local version equals the release and no commits are ahead", async () => { + const repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0"); + const status = await getUpdatesStatus(repo, { + skipFetch: true, + latestRelease: { + ok: true, + tag: "v1.5.0", + name: "1.5.0", + url: null, + published_at: null, + notes: null, + }, + }); + + assert.equal(status.update_available, false); + assert.equal(status.dev_build, false); + assert.equal(status.commits_ahead_of_release, 0); + assert.equal(status.commits_behind_release, 0); + }); + + it("reports dev_build with a commit count when the checkout is ahead of the newest release", async () => { + const repo = makeVersionedRepo(tmpDir, "ahead", "1.5.0", "v1.5.0"); + fs.writeFileSync(path.join(repo, "extra.txt"), "unreleased work\n"); + git(repo, [...IDENT, "add", "."]); + git(repo, [...IDENT, "commit", "-m", "unreleased"]); + + const status = await getUpdatesStatus(repo, { + skipFetch: true, + latestRelease: { + ok: true, + tag: "v1.5.0", + name: "1.5.0", + url: null, + published_at: null, + notes: null, + }, + }); + + assert.equal(status.update_available, false, "a dev build is not an available update"); + assert.equal(status.dev_build, true); + assert.equal(status.commits_ahead_of_release, 1); + assert.equal(status.commits_behind_release, 0); + assert.match(status.message, /Dev build: 1 commit/); + }); + + it("does not call a release ahead when the local version already outranks it", async () => { + const repo = makeVersionedRepo(tmpDir, "newer", "2.0.0", "v2.0.0"); + const status = await getUpdatesStatus(repo, { + skipFetch: true, + latestRelease: { + ok: true, + tag: "v1.5.0", + name: "1.5.0", + url: null, + published_at: null, + notes: null, + }, + }); + assert.equal(status.update_available, false); + }); + + it("records why the release lookup failed and falls back to the commit count", async () => { + const repo = makeVersionedRepo(tmpDir, "fallback", "1.5.0", "v1.5.0"); + const status = await getUpdatesStatus(repo, { + skipFetch: true, + latestRelease: { ok: false, reason: "unreachable" }, + }); + + assert.equal(status.release_check, "unreachable"); + assert.equal(status.latest_release, null); + assert.equal(status.dev_build, false); + // Not "up to date" by silence: the reason is on the payload for the UI. + assert.match(status.message, /tip of the canonical default branch|commit\(s\) on/); + }); +}); + +describe("getUpdatesStatus — shallow clone", () => { + it("returns null commit distances and never claims a dev build", async () => { + // The Claude Code plugin cache is a depth-1 clone. Before this guard it + // reported "306 behind AND 1 ahead" of a tag that is a plain ancestor. + const origin = makeVersionedRepo(tmpDir, "deep-origin", "1.5.0", "v1.5.0"); + for (let i = 0; i < 3; i += 1) { + fs.writeFileSync(path.join(origin, `f${i}.txt`), `${i}\n`); + git(origin, [...IDENT, "add", "."]); + git(origin, [...IDENT, "commit", "-m", `c${i}`]); + } + const shallow = path.join(tmpDir, "shallow-clone"); + execFileSync("git", ["clone", "--depth", "1", `file://${origin}`, shallow], { + stdio: "ignore", + env: GIT_ENV, + }); + + assert.equal(await isShallowClone(shallow), true, "fixture must actually be shallow"); + assert.equal(await isShallowClone(origin), false); + + const status = await getUpdatesStatus(shallow, { + skipFetch: true, + latestRelease: { + ok: true, + tag: "v1.5.0", + name: "1.5.0", + url: null, + published_at: null, + notes: null, + }, + }); + + assert.equal(status.shallow_clone, true); + assert.equal(status.commits_behind_release, null); + assert.equal(status.commits_ahead_of_release, null); + assert.equal(status.dev_build, false); + // Version compare still works, and it is the only signal used here. + assert.equal(status.update_available, false); + }); + + it("still reports a newer release on a shallow clone, from versions alone", async () => { + const origin = makeVersionedRepo(tmpDir, "deep-origin2", "1.5.0", "v1.5.0"); + const shallow = path.join(tmpDir, "shallow-clone2"); + execFileSync("git", ["clone", "--depth", "1", `file://${origin}`, shallow], { + stdio: "ignore", + env: GIT_ENV, + }); + + const status = await getUpdatesStatus(shallow, { + skipFetch: true, + latestRelease: { + ok: true, + tag: "v1.7.0", + name: "1.7.0", + url: null, + published_at: null, + notes: null, + }, + }); + + assert.equal(status.shallow_clone, true); + assert.equal(status.update_available, true); + assert.equal(status.commits_behind_release, null); + }); +}); + +describe("applyUpdate — plugin cache is Claude Code's to replace", () => { + it("refuses with reason plugin_managed and touches nothing", async () => { + // Build the repo underneath a path that looks like the plugin cache, since + // that path IS the signal (see detectPluginInstall). + const cacheRoot = path.join(tmpDir, "home", ".claude", "plugins", "cache", "sg", "agenthub"); + fs.mkdirSync(cacheRoot, { recursive: true }); + const repo = setHttpOrigin(makeVersionedRepo(cacheRoot, "abc123def456", "1.5.0", "v1.5.0")); + const headBefore = git(repo, ["rev-parse", "HEAD"]); + + const status = await getUpdatesStatus(repo, { + skipFetch: true, + latestRelease: { + ok: true, + tag: "v1.9.0", + name: "1.9.0", + url: null, + published_at: null, + notes: null, + }, + }); + assert.equal(status.install_kind, "plugin"); + assert.equal(status.plugin_marketplace, "sg"); + assert.equal(status.situation, "plugin_managed"); + assert.equal(status.update_available, true); + // The printed commands are Claude Code's own update path, not git. + assert.match(status.manual_command, /claude plugin marketplace update sg/); + assert.match(status.manual_command, /claude plugin update agenthub@sg -y/); + assert.match(status.situation_note, /reload-plugins/); + + // applyUpdate hits the network-free path only because it re-reads status; + // stub fetch so the refusal is decided by the path, not by connectivity. + const realFetch = global.fetch; + global.fetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ tag_name: "v1.9.0" }), + }); + try { + const result = await applyUpdate(repo); + assert.equal(result.applied, false); + assert.equal(result.reason, "plugin_managed"); + } finally { + global.fetch = realFetch; + } + assert.equal(git(repo, ["rev-parse", "HEAD"]), headBefore, "HEAD must be untouched"); + }); +}); diff --git a/server/__tests__/updates.test.js b/server/__tests__/updates.test.js index ebb597ed..a6e5b793 100644 --- a/server/__tests__/updates.test.js +++ b/server/__tests__/updates.test.js @@ -95,12 +95,22 @@ describe("POST /api/updates/apply", () => { // The test repo checkout has no update pending (or isn't fast-forwardable // from a bare test run), so this exercises the decline path only — it must // never touch the working tree or process in a test run. + // + // The `plugin_managed` branch cannot fire from a checkout: it additionally + // requires plugin_marketplace/plugin_name, which only a path under + // ~/.claude/plugins/cache/ produces. A suite run from such a copy would get + // 200 + mode:"plugin" and spawn the update chain, so guard on the shape + // rather than the status code alone. const res = await httpFetch("/api/updates/apply", { method: "POST", body: "{}", }); + assert.equal(res.body.install_kind, "checkout", "suite must run from a git checkout"); assert.equal(res.status, 409); assert.equal(res.body.applied, false); - assert.ok(["up_to_date", "not_fast_forwardable"].includes(res.body.reason)); + assert.ok( + ["up_to_date", "not_fast_forwardable", "plugin_managed"].includes(res.body.reason), + `unexpected reason: ${res.body.reason}` + ); }); }); diff --git a/server/lib/plugin-update.js b/server/lib/plugin-update.js new file mode 100644 index 00000000..e99866a7 --- /dev/null +++ b/server/lib/plugin-update.js @@ -0,0 +1,89 @@ +/** + * @file Launches and reports on the plugin-cache update chain — the path that + * makes "Update now" work for an install running out of + * `~/.claude/plugins/cache/` instead of a plain git checkout. + * + * All the work happens in the DETACHED `scripts/plugin-update-helper.js`, for a + * structural reason: its last step runs `plugin-bootstrap.js --force`, which + * SIGTERMs the dashboard server that asked for the update. An in-process + * version would kill itself halfway through and leave no server to finish the + * job or report what happened. Same split as `self-restart.js`, one step + * further: there the helper only waits and respawns, here it also pulls the new + * plugin version first. + * + * This module deliberately does NOT run `/reload-plugins`. That is a Claude Code + * REPL action, and it re-resolves plugins only for the session that runs it — a + * background process cannot perform it on an interactive session's behalf, and a + * throwaway one-shot session would reload its own plugin view and exit. The + * dashboard does not need it: `plugin-bootstrap.js` derives `PLUGIN_ROOT` from + * its own `__dirname`, so being executed out of the new cache directory is + * sufficient to move the server onto the new code. Only new + * skills/commands/hooks in an already-open Claude Code session still require the + * user to reload. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); +const { getDataDir } = require("./claude-home"); + +/** Runtime directory the helper writes its verdict into. Outside the plugin + * cache on purpose — that directory is replaced by the update itself. */ +function runtimeDir() { + return path.join(getDataDir(), "runtime"); +} + +/** + * Result of the most recent plugin update attempt, or null when there has never + * been one. Surfaced on the update-status payload as `last_plugin_update` so a + * failed chain is visible instead of looking like a server that is merely slow + * to come back. + * + * @returns {{ok: boolean, step: string, error?: string, note?: string, + * plugin_root?: string, finished_at?: string} | null} + */ +function readLastPluginUpdate(rt = runtimeDir()) { + try { + const raw = fs.readFileSync(path.join(rt, "plugin-update.json"), "utf8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; // never run, unreadable, or malformed — all "nothing to report" + } +} + +/** + * Spawn the detached update chain. Returns immediately; the caller must have + * already written its HTTP response, because this process is about to be + * stopped by the helper's final step. + * + * Clears any previous verdict first, so a stale success can never be read back + * as the outcome of THIS run while the chain is still in flight. + * + * @param {{marketplace: string, plugin: string, root: string}} install + * @returns {{spawned: true, runtime_dir: string}} + */ +function schedulePluginUpdate({ marketplace, plugin, root }) { + const rt = runtimeDir(); + try { + fs.mkdirSync(rt, { recursive: true }); + fs.rmSync(path.join(rt, "plugin-update.json"), { force: true }); + } catch { + /* best effort — a stale verdict is better than failing the update */ + } + + const helper = path.join(root, "scripts", "plugin-update-helper.js"); + const child = spawn(process.execPath, [helper, marketplace, plugin, root, rt], { + // cwd is the runtime dir, NOT the plugin root: the root is about to be + // superseded, 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 = { schedulePluginUpdate, readLastPluginUpdate, runtimeDir }; diff --git a/server/lib/update-check.js b/server/lib/update-check.js index 329cc6b9..dea3e219 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -1,15 +1,29 @@ /** - * @file Detects whether the dashboard git checkout is behind the canonical - * remote default branch (origin/master or origin/main on a - * direct clone) after a non-destructive fetch. Branch- and fork-aware: - * picks the right remote, recognises feature-branch checkouts, and shapes - * manual_command so it actually closes the gap for the user's situation. + * @file Detects whether this install is behind the newest published RELEASE of + * its own repository, and can fast-forward onto that release tag on request. + * + * The release is read straight from the forge's REST API, derived from the + * `origin` remote URL (`/api/v1/repos///releases/latest` — the + * Gitea/Forgejo shape this project is hosted on), so the check needs no + * hardcoded host and no credentials for a public repo. When no release can be + * read — a non-HTTP remote, an offline machine, a repo with no releases yet — + * detection degrades to the original commit-count comparison against the + * canonical default branch rather than reporting a false "up to date". + * + * Two states the release model makes visible that a commit count cannot: + * a checkout sitting AHEAD of the newest release tag is a `dev_build`, not an + * up-to-date install; and an install living under `~/.claude/plugins/cache/` + * is `plugin_managed`, where this process must NOT self-update — Claude Code + * only re-resolves `${CLAUDE_PLUGIN_ROOT}` on `/reload-plugins`, a REPL-only + * action no server can trigger, so a self-pull would leave a cache directory + * whose name (a commit sha) no longer matches its contents. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const path = require("path"); const { execFile } = require("child_process"); +const { readLastPluginUpdate } = require("./plugin-update"); const DEFAULT_ROOT = path.join(__dirname, "..", ".."); @@ -141,6 +155,150 @@ function stripRemotePrefix(ref) { return idx === -1 ? ref : ref.slice(idx + 1); } +/** This install's own declared version, or null when package.json is unreadable. */ +function readLocalVersion(root) { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + return typeof pkg.version === "string" ? pkg.version : null; + } catch { + return null; + } +} + +/** + * The plugin cache coordinates when `root` lives under + * `~/.claude/plugins/cache///`, else null. + * Read from the path because it is the one place the marketplace name is + * available locally — shelling out to `claude plugin list` to learn it would + * make a status poll depend on another CLI being on PATH. + */ +function detectPluginInstall(root) { + const posix = root.split(path.sep).join("/"); + const m = posix.match(/\/\.claude\/plugins\/cache\/([^/]+)\/([^/]+)(?:\/|$)/); + return m ? { marketplace: m[1], plugin: m[2] } : null; +} + +/** + * Numeric-triple version compare: -1 / 0 / 1. Tolerates the inconsistent tag + * naming this repo already carries (`v1.5.0` alongside `1.4.6`) by stripping a + * leading `v`. Prerelease and build suffixes are dropped rather than ordered — + * this project publishes plain releases, and a wrong prerelease ordering would + * be worse than ignoring one. + */ +function compareVersions(a, b) { + const parts = (v) => + String(v) + .trim() + .replace(/^v/i, "") + .split(/[-+]/)[0] + .split(".") + .map((n) => Number.parseInt(n, 10) || 0); + const x = parts(a); + const y = parts(b); + for (let i = 0; i < 3; i += 1) { + const diff = (x[i] || 0) - (y[i] || 0); + if (diff !== 0) return diff < 0 ? -1 : 1; + } + return 0; +} + +/** + * Derives the forge's "latest release" REST endpoint from a git remote URL. + * Returns null for anything that isn't owner/repo shaped, which is what keeps + * the local file-path remotes used by the tests off the network entirely. + */ +function releaseApiUrl(remoteUrl) { + const raw = String(remoteUrl).trim(); + let m = raw.match(/^https?:\/\/(?:[^@/]*@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (!m) { + // scp-style (`git@host:owner/repo.git`) and ssh:// forms. + m = raw.match(/^(?:ssh:\/\/)?[^@/]+@([^:/]+)(?::\d+)?[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + } + if (!m) return null; + const [, host, owner, repo] = m; + if (!host.includes(".")) return null; + return `https://${host}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent( + repo + )}/releases/latest`; +} + +/** + * Reads the newest published release from the forge API. Never throws: every + * failure resolves to `{ ok: false, reason }` so a status poll degrades to the + * commit-count path instead of surfacing an error to the user. + * + * @returns {Promise<{ok: true, tag: string, name: string|null, url: string|null, + * published_at: string|null, notes: string|null} | {ok: false, reason: string}>} + */ +async function fetchLatestRelease(root, timeoutMs = 10_000) { + let remoteUrl; + try { + const remote = await pickCanonicalRemote(root); + if (!remote) return { ok: false, reason: "no_remote" }; + remoteUrl = await execGit(root, ["remote", "get-url", remote], { timeout: 10_000 }); + } catch { + return { ok: false, reason: "no_remote" }; + } + + const api = releaseApiUrl(remoteUrl); + if (!api) return { ok: false, reason: "remote_not_http" }; + + try { + const res = await fetch(api, { + signal: AbortSignal.timeout(timeoutMs), + headers: { accept: "application/json" }, + redirect: "follow", + }); + // A repo with no releases yet answers 404 — a normal state, not a fault. + if (res.status === 404) return { ok: false, reason: "no_releases" }; + if (!res.ok) return { ok: false, reason: `http_${res.status}` }; + const body = await res.json(); + if (!body || typeof body.tag_name !== "string" || !body.tag_name) { + return { ok: false, reason: "bad_payload" }; + } + return { + ok: true, + tag: body.tag_name, + name: typeof body.name === "string" && body.name ? body.name : null, + url: typeof body.html_url === "string" ? body.html_url : null, + published_at: typeof body.published_at === "string" ? body.published_at : null, + // Release notes are rendered in a modal, not stored — cap the size so a + // pathological changelog can't bloat every status poll and WS broadcast. + notes: typeof body.body === "string" && body.body ? body.body.slice(0, 4000) : null, + }; + } catch (err) { + return { ok: false, reason: "unreachable", error: err.message || String(err) }; + } +} + +/** + * True for a shallow clone (`--depth`), where EVERY commit-distance number is + * a lie: history is grafted, so `git rev-list --count ..HEAD` counts the + * graft boundary instead of real ancestry. The Claude Code plugin cache is + * exactly this — a depth-1 clone — which reported "306 behind AND 1 ahead" of + * a tag that is actually a plain ancestor. Callers must fall back to comparing + * version strings, the one signal a shallow clone still answers correctly. + */ +async function isShallowClone(root) { + try { + const out = await execGit(root, ["rev-parse", "--is-shallow-repository"], { timeout: 10_000 }); + return out.trim() === "true"; + } catch { + return false; + } +} + +/** Commit count for `range`, or 0 when it can't be resolved. */ +async function countCommits(root, range) { + try { + const out = await execGit(root, ["rev-list", "--count", range], { timeout: 30_000 }); + const n = Number.parseInt(out, 10); + return Number.isNaN(n) ? 0 : n; + } catch { + return 0; + } +} + /** * @param {string} [gitRoot] * @param {{ skipFetch?: boolean }} [options] @@ -175,7 +333,9 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) { if (!options.skipFetch) { try { - await execGit(root, ["fetch", canonicalRemote, "--prune"], { timeout: 120_000 }); + // `--tags` so the release tag resolved below exists locally; without it + // a freshly published release has no local commit to compare against. + await execGit(root, ["fetch", canonicalRemote, "--prune", "--tags"], { timeout: 120_000 }); } catch (err) { return { git_repo: true, @@ -225,7 +385,54 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) { }; } - const updateAvailable = commitsBehind > 0; + // ── Release layer ──────────────────────────────────────────────────────── + // The published release is the version signal; the commit count above stays + // as the fallback for when no release can be read, and as the raw distance + // that tells a dev build apart from a stale one. + const localVersion = readLocalVersion(root); + const pluginInstall = detectPluginInstall(root); + const shallow = await isShallowClone(root); + const release = options.latestRelease ?? (await fetchLatestRelease(root)); + + let releaseSha = null; + let commitsBehindRelease = null; + let commitsAheadOfRelease = null; + let releaseIsNewer = false; + if (release.ok) { + try { + releaseSha = await execGit( + root, + ["rev-parse", "--verify", `refs/tags/${release.tag}^{commit}`], + { timeout: 10_000 } + ); + } catch { + releaseSha = null; // tag not fetched locally (offline, or skipFetch in tests) + } + // Distances stay null on a shallow clone: see isShallowClone(). + if (releaseSha && !shallow) { + commitsBehindRelease = await countCommits(root, `HEAD..${releaseSha}`); + commitsAheadOfRelease = await countCommits(root, `${releaseSha}..HEAD`); + } + // Version compare is the primary test, and the ONLY one a shallow clone can + // answer. The commit distance decides only when this install declares no + // version of its own and the distance is actually trustworthy. + releaseIsNewer = localVersion + ? compareVersions(release.tag, localVersion) > 0 + : commitsBehindRelease !== null && commitsBehindRelease > 0; + } + + const updateAvailable = release.ok + ? releaseIsNewer + : shallow + ? false // no release to compare and no usable commit distance — say nothing + : commitsBehind > 0; + // Ahead of the newest release with nothing of that release missing: a build + // from the branch, not a shipped version. Reported so the widget never claims + // "up to date" for a checkout carrying commits no release contains. Requires + // behind === 0, or a diverged history would be mislabelled a dev build. + const devBuild = + release.ok && !releaseIsNewer && commitsBehindRelease === 0 && (commitsAheadOfRelease ?? 0) > 0; + const isProd = process.env.NODE_ENV === "production"; const installSteps = ["npm run setup"]; if (isProd) installSteps.push("npm run build"); @@ -245,7 +452,35 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) { let manualParts; let situationNote; let situation; - if (tracksCanonical) { + if (pluginInstall) { + // Claude Code owns this directory and replaces it wholesale on update, so + // the only correct move is its own update path. The printed commands do + // pull the new version, but `/reload-plugins` is a REPL action — nothing + // here can finish the job, which is why `applyUpdate` refuses outright. + situation = "plugin_managed"; + manualParts = [ + `claude plugin marketplace update ${pluginInstall.marketplace}`, + `claude plugin update ${pluginInstall.plugin}@${pluginInstall.marketplace} -y`, + ]; + situationNote = + `This dashboard runs from the Claude Code plugin cache (marketplace '${pluginInstall.marketplace}'), ` + + `so it can't update itself. Run the commands above, then '/reload-plugins' in Claude Code — ` + + `or just run '/agenthub-update', which does both and rebuilds the runtime.`; + } else if (release.ok && releaseIsNewer) { + // Move onto the release TAG rather than the branch tip: the tag is what + // was published, and a fast-forward keeps the tracking branch intact (a + // `git checkout ` would detach HEAD and break every later update). + situation = "release_available"; + manualParts = [ + `cd "${root}"`, + `git fetch ${canonicalRemote} --tags`, + `git merge --ff-only ${release.tag}`, + ...installSteps, + ]; + situationNote = releaseSha + ? null + : `Release ${release.tag} isn't in your local clone yet — the fetch above brings its tag in.`; + } else if (tracksCanonical) { situation = "tracking_canonical"; manualParts = [`cd "${root}"`, "git pull --ff-only", ...installSteps]; situationNote = null; @@ -285,9 +520,42 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) { commits_behind: commitsBehind, manual_command: manualCommand, situation_note: situationNote, + + // Release layer. `release_check` names why the release fields are absent + // when they are, so the UI can say "offline" rather than "up to date". + install_kind: pluginInstall ? "plugin" : "checkout", + plugin_marketplace: pluginInstall ? pluginInstall.marketplace : null, + plugin_name: pluginInstall ? pluginInstall.plugin : null, + // Verdict of the last detached plugin-update chain, if any. Read here so + // every consumer (route, WS broadcast, CLI) gets it from one place — and so + // a chain that died is visible instead of looking like a slow restart. + last_plugin_update: pluginInstall ? readLastPluginUpdate() : null, + local_version: localVersion, + // True when commit distances are unusable and version strings are the only + // signal — the UI must not render an "N commits" badge in that case. + shallow_clone: shallow, + release_check: release.ok ? "ok" : release.reason, + latest_release: release.ok ? release.tag : null, + latest_release_name: release.ok ? release.name : null, + latest_release_url: release.ok ? release.url : null, + latest_release_published_at: release.ok ? release.published_at : null, + latest_release_notes: release.ok ? release.notes : null, + release_sha: releaseSha, + commits_behind_release: commitsBehindRelease, + commits_ahead_of_release: commitsAheadOfRelease, + dev_build: devBuild, + message: updateAvailable - ? `${commitsBehind} commit(s) on ${remoteRef} not in your checkout.` - : "Your checkout includes the tip of the canonical default branch.", + ? release.ok + ? `Release ${release.tag} is available (this install reports ${localVersion || "no version"}).` + : `${commitsBehind} commit(s) on ${remoteRef} not in your checkout.` + : devBuild + ? `Dev build: ${commitsAheadOfRelease} commit(s) ahead of release ${release.tag}.` + : release.ok + ? `Running the newest release (${release.tag}).` + : shallow + ? `Shallow clone — could not read the latest release (${release.reason}); version comparison unavailable.` + : "Your checkout includes the tip of the canonical default branch.", }; } @@ -306,11 +574,27 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) { * `reason` when `applied` is false). */ async function applyUpdate(gitRoot = DEFAULT_ROOT) { + // A plugin-cache install is Claude Code's to replace. Pulling here would + // leave a cache directory named after one commit but holding another, and + // the running session would keep resolving ${CLAUDE_PLUGIN_ROOT} to the old + // path until `/reload-plugins` — which no server can invoke. Refuse and let + // the printed commands (or `/agenthub-update`) do it. + // + // Decided from the PATH, before any status lookup: the refusal is true + // whether or not the machine is online and whatever the version gap is. + // Reading it off `status.situation` instead would mis-report `up_to_date` + // every time the fetch failed, since that error returns early. + if (detectPluginInstall(path.resolve(gitRoot))) { + const status = await getUpdatesStatus(gitRoot); + return { ...status, applied: false, reason: "plugin_managed" }; + } + const status = await getUpdatesStatus(gitRoot); if (!status.update_available) { return { ...status, applied: false, reason: "up_to_date" }; } if ( + status.situation !== "release_available" && status.situation !== "tracking_canonical" && status.situation !== "fork_or_diverged_tracking" ) { @@ -318,7 +602,13 @@ async function applyUpdate(gitRoot = DEFAULT_ROOT) { } const root = path.resolve(gitRoot); - if (status.situation === "tracking_canonical") { + if (status.situation === "release_available") { + await execGit(root, ["fetch", status.canonical_remote, "--tags"], { timeout: 120_000 }); + // --ff-only is the guard: a release that isn't a descendant of HEAD (a + // rollback, or a rewritten history) aborts here instead of creating a + // merge commit in the user's install. + await execGit(root, ["merge", "--ff-only", status.latest_release], { timeout: 30_000 }); + } else if (status.situation === "tracking_canonical") { await execGit(root, ["pull", "--ff-only"], { timeout: 120_000 }); } else { await execGit(root, ["fetch", status.canonical_remote], { timeout: 120_000 }); @@ -334,4 +624,14 @@ async function applyUpdate(gitRoot = DEFAULT_ROOT) { return { ...refreshed, applied: true }; } -module.exports = { getUpdatesStatus, applyUpdate, DEFAULT_ROOT }; +module.exports = { + getUpdatesStatus, + applyUpdate, + DEFAULT_ROOT, + // Exported for tests: each is a pure decision the status shape depends on. + compareVersions, + releaseApiUrl, + detectPluginInstall, + fetchLatestRelease, + isShallowClone, +}; diff --git a/server/routes/updates.js b/server/routes/updates.js index ed3d1d65..e994b29f 100644 --- a/server/routes/updates.js +++ b/server/routes/updates.js @@ -1,15 +1,26 @@ /** - * @file HTTP routes for dashboard upstream-update detection, plus the one - * self-applying path: POST /apply fast-forwards, rebuilds, and restarts the - * process on the user's explicit click (see `server/lib/update-check.js`'s - * `applyUpdate` and `server/lib/self-restart.js`). Everywhere else, the - * dashboard only ever prints a command for the user to run themselves. + * @file HTTP routes for release-update detection plus the self-applying path. + * `POST /apply` has two modes, chosen by how this copy was installed: + * + * - **checkout** — fast-forward onto the newest release tag, rebuild, and + * restart in place (`update-check.js`'s `applyUpdate` + `self-restart.js`). + * - **plugin** — `applyUpdate` cannot touch a `~/.claude/plugins/cache/` + * directory, so it refuses with `plugin_managed`; this route catches that and + * hands the job to the detached chain in `plugin-update.js` instead + * (`claude plugin marketplace update` → `claude plugin update` → + * `plugin-bootstrap.js --force`, which retires this server and starts the new + * one). Answered 200 with `mode: "plugin"`, not 409. + * + * Both modes reply BEFORE anything stops the process, and the request is only + * ever a trigger: the marketplace and plugin names come from the local cache + * path, never from the body, so there is nothing in the payload to validate. * @author Nguyễn Ngọc Trí Vĩ */ const { Router } = require("express"); const { getUpdatesStatus, applyUpdate } = require("../lib/update-check"); const { scheduleRestart } = require("../lib/self-restart"); +const { schedulePluginUpdate } = require("../lib/plugin-update"); const router = Router(); @@ -44,6 +55,33 @@ router.post("/check", async (_req, res) => { router.post("/apply", async (_req, res) => { try { const result = await applyUpdate(); + + // A plugin-cache install is refused by applyUpdate on purpose (it must not + // rewrite a directory Claude Code owns). Route it to the detached chain, + // which pulls the new version and re-bootstraps from the NEW cache dir. + // Only when there is genuinely something to pull — a `plugin_managed` + // refusal with nothing newer published must stay a 409, not spawn a + // process that would stop the server for no reason. + if ( + !result.applied && + result.reason === "plugin_managed" && + result.update_available && + result.plugin_marketplace && + result.plugin_name && + result.repo_root + ) { + const scheduled = schedulePluginUpdate({ + marketplace: result.plugin_marketplace, + plugin: result.plugin_name, + root: result.repo_root, + }); + // `applied: true` because the update IS being applied — just by the + // plugin chain rather than by a git fast-forward. `mode` tells the client + // which one, so it can word the progress text honestly. + res.json({ ...result, applied: true, mode: "plugin", ...scheduled }); + return; + } + if (!result.applied) { res.status(409).json({ ...result, @@ -52,7 +90,9 @@ router.post("/apply", async (_req, res) => { message: result.reason === "up_to_date" ? "Already up to date." - : "Current branch can't be fast-forwarded automatically.", + : result.reason === "plugin_managed" + ? "This install runs from the Claude Code plugin cache and nothing newer is published; run /agenthub-update to force a refresh." + : "Current branch can't be fast-forwarded automatically.", }, }); return;