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