feat(updates): detect the newest release and self-apply it, including plugin installs
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look". Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless. POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process. The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard. Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist. Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
This commit is contained in:
+68
-13
@@ -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 (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/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 (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/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/<slug>/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 `<memory-dir>/.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 `<CLAUDE_HOME>/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 `<root>/cc-config-backups/<type>/<base>.<ISO>.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 `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.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 `<CLAUDE_HOME>/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 (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/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/<owner>/<repo>/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 <mkt>` → `claude plugin update <plugin>@<mkt> -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<br/>getUpdatesStatus"]
|
||||
LIB["update-check.js<br/>getUpdatesStatus + applyUpdate<br/>forge releases API"]
|
||||
SCHED["update-scheduler.js<br/>startUpdateScheduler"]
|
||||
ROUTE["routes/updates.js<br/>GET status, POST check"]
|
||||
ROUTE["routes/updates.js<br/>GET status, POST check, POST apply"]
|
||||
PLUG["lib/plugin-update.js<br/>schedulePluginUpdate"]
|
||||
HELPER["scripts/plugin-update-helper.js<br/>detached: claude plugin update<br/>then plugin-bootstrap --force"]
|
||||
WS["websocket.js<br/>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 `<remote>/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 <remote> && git merge --ff-only <ref>` for forks (local branch name matches canonical but tracks a different remote); `git fetch <remote>` 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_<status>`) 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 `<remote>/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 <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` 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 — `"<tag> 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_<status>"
|
||||
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:"<stderr>"}` | 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 <remote>/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 <remote>"}` | 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:<name>` (bare `/<name>` 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:<name>`. 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.
|
||||
|
||||
+11
-3
@@ -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",
|
||||
};
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>Agent Dashboard - Claude Code Monitor</title>
|
||||
<title>Agent Dashboard - SmartGift Solution</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Agent Dashboard" />
|
||||
<meta property="og:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta property="og:title" content="Agent Dashboard - SmartGift Solution" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - SmartGift Solution" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
|
||||
@@ -353,15 +353,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
};
|
||||
|
||||
const updateAvailable = Boolean(updateStatus?.update_available);
|
||||
// Four resting states, not two: a release is out, this build is ahead of the
|
||||
// newest release (dev build), it matches the newest release, or no release
|
||||
// could be read at all. Collapsing the middle two into "Up to date" is what
|
||||
// made a checkout carrying unreleased commits look shipped.
|
||||
const checkTitle = checking
|
||||
? t("nav:checkingForUpdates")
|
||||
: checkError
|
||||
? t("nav:checkFailed")
|
||||
: updateAvailable
|
||||
? t("nav:updateAvailable")
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
? updateStatus?.latest_release
|
||||
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
|
||||
: t("nav:updateAvailable")
|
||||
: updateStatus?.dev_build
|
||||
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
|
||||
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
/**
|
||||
* @file UpdateNotifier.tsx
|
||||
* @description Modal surfaced when the dashboard's git checkout is behind its
|
||||
* remote tracking branch. Shows how many commits behind, the exact terminal
|
||||
* command to update with copy-to-clipboard, and — when the checkout is on a
|
||||
* fast-forwardable branch — an "Update now" button that calls
|
||||
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
|
||||
* then polls until it's back and reloads the page.
|
||||
* @description Modal surfaced when a newer RELEASE of the dashboard has been
|
||||
* published (or, when no release can be read, when the checkout is behind its
|
||||
* remote tracking branch). Shows the release version against the running one
|
||||
* with a link to its notes, the exact terminal command with copy-to-clipboard,
|
||||
* and — when the install is fast-forwardable — an "Update now" button that
|
||||
* calls `POST /api/updates/apply` to move onto the release tag, rebuild, and
|
||||
* restart the server itself, then polls until it's back and reloads the page.
|
||||
*
|
||||
* A `plugin_managed` install (running out of `~/.claude/plugins/cache/`) gets
|
||||
* the same button, wired to a different mechanism: the server hands it to a
|
||||
* detached chain (`claude plugin update` then `plugin-bootstrap.js --force`)
|
||||
* which pulls the new cache directory, reinstalls the runtime and restarts the
|
||||
* dashboard. It takes minutes rather than seconds, so the progress text differs
|
||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -14,8 +25,9 @@
|
||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||
*
|
||||
* ## Dismissal persistence
|
||||
* Dismissals are keyed by `remote_sha` in `localStorage` so a new upstream
|
||||
* commit re-opens the prompt. Settings can reset dismissal via the
|
||||
* Dismissals are keyed in `localStorage` by the release tag when there is one,
|
||||
* else the upstream `remote_sha`, so a newer release re-opens the prompt while
|
||||
* a dismissed one stays dismissed. Settings can reset dismissal via the
|
||||
* `dashboard:reset-update-dismissal` window event.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -272,10 +318,14 @@ export function UpdateNotifier() {
|
||||
id="update-notifier-title"
|
||||
className="text-sm font-semibold text-fg-primary truncate"
|
||||
>
|
||||
{t("title")}
|
||||
{release ? t("releaseTitle", { version: release }) : t("title")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
|
||||
{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 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,7 +341,30 @@ export function UpdateNotifier() {
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">{t("lead")}</p>
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||
</p>
|
||||
|
||||
{status.latest_release_url ? (
|
||||
<a
|
||||
href={status.latest_release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" aria-hidden />
|
||||
{t("viewRelease")}
|
||||
{status.latest_release_name ? ` — ${status.latest_release_name}` : null}
|
||||
</a>
|
||||
) : 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" ? (
|
||||
<div className="text-xs text-fg-secondary bg-surface-2 border border-border rounded-lg px-3 py-2">
|
||||
{t("releaseUnavailable", { reason: status.release_check })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.fetch_error ? (
|
||||
<div className="text-xs text-status-warning/90 bg-status-warning/5 border border-status-warning/20 rounded-lg px-3 py-2">
|
||||
@@ -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" ? (
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
|
||||
) : null}
|
||||
@@ -331,7 +405,22 @@ export function UpdateNotifier() {
|
||||
{restarting ? (
|
||||
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
|
||||
{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")}
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="text-xs text-status-danger bg-status-danger/5 border border-status-danger/20 rounded-lg px-3 py-2 leading-relaxed">
|
||||
{t("lastPluginUpdateFailed", {
|
||||
step: status.last_plugin_update.step,
|
||||
error: status.last_plugin_update.error || "",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(<UpdateNotifier />);
|
||||
|
||||
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(<UpdateNotifier />);
|
||||
|
||||
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(<UpdateNotifier />);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
+97
-5
@@ -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 <plugin>@<marketplace>` 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_<status>"`. 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 <plugin>@<marketplace>` 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";
|
||||
|
||||
+1
-1
@@ -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`) |
|
||||
|
||||
@@ -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 <marketplace>`
|
||||
* 2. `claude plugin update <plugin>@<marketplace> -y` → new cache dir appears
|
||||
* 3. `node <newRoot>/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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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) });
|
||||
}
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { 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)));
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { 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");
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 };
|
||||
+312
-12
@@ -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/<owner>/<repo>/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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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/<marketplace>/<plugin>/<sha>`, 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 <tag>..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 <tag>` 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,
|
||||
};
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user