Files
nntrivi2001 cc85e35439 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.
2026-09-10 16:03:07 +07:00

90 lines
3.7 KiB
JavaScript

/**
* @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 };