Files
kandev/apps/cli/src/service/self_update.ts
T
José Almeida 041a19f060 feat: service-managed UI self-update (#1210)
* feat: add service-managed self-update

* chore: add temporary self-update test scripts

* chore: simplify temporary script markdown fences

* chore: run frontend in temporary self-update scripts

* fix: show self-update job progress after apply

* chore: add real npm self-update test script

* fix: isolate npm self-update prefix

* fix: detect npm service installs from bin shim

* chore: reset service before real npm update test

* chore: expand real npm status diagnostics

* fix: preserve service home during self-update

* fix(cli): tee self-update helper output to a log file

* fix(cli): avoid launchd bootstrap race when reloading the macOS service

* fix(backend): run macOS self-update helper once via transient launchd plist

* fix(web): show self-update progress and lock the apply button until the update lands

* fix(cli): reinstall via the kandev wrapper on Homebrew self-update

* chore: remove temp self-update test scripts

* fix(web): drop unused apply path from useUpdates hook

* fix(backend): snapshot install state once in apply preflight

* docs: align self-update docs with transient LaunchAgent flow

* chore(release): run cli bundle from apps workspace root

* fix(backend): guard against concurrent self-update applies

* fix(backend): check scheme and helper paths in self-update apply

* fix(backend): drop duplicate binary name from npx manual command

* fix(cli): allow --help for service self-update without --intent

* fix(cli): harden self-update dir perms and darwin uid resolution

* fix(web): set required POST fields last in applyUpdate

* docs: use JSON body notation for the apply endpoint

* fix(backend): expire self-update guard so a stuck helper cannot wedge apply

* refactor(cli): collapse redundant writeSync branch in self-update log
2026-06-01 17:49:15 +01:00

282 lines
10 KiB
TypeScript

import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ServiceArgs } from "./args";
import type { ServiceInstallMetadata } from "./metadata";
import { LAUNCHD_LABEL, SERVICE_NAME } from "./paths";
export type SelfUpdateIntent = {
version: 1;
target_tag: string;
target_version: string;
latest_url?: string;
install: ServiceInstallMetadata;
created_at: string;
};
export type PlannedCommand = {
command: string;
args: string[];
};
export type PlanSelfUpdateOptions = {
platform?: NodeJS.Platform;
uid?: number;
};
export type CommandResult = Pick<SpawnSyncReturns<Buffer>, "status" | "error"> & {
stdout?: Buffer | string | null;
stderr?: Buffer | string | null;
};
export type CommandRunner = (command: string, args: string[]) => CommandResult;
export function readSelfUpdateIntent(intentPath: string): SelfUpdateIntent {
return JSON.parse(fs.readFileSync(intentPath, "utf8")) as SelfUpdateIntent;
}
export function planSelfUpdate(
intent: SelfUpdateIntent,
opts: PlanSelfUpdateOptions = {},
): PlannedCommand[] {
const platform = opts.platform ?? process.platform;
const target = npmVersion(intent.target_version || intent.target_tag);
const install = intent.install;
const installArgs = serviceInstallArgs(install);
const commands: PlannedCommand[] = [];
if (install.kind === "homebrew") {
// `brew upgrade` installs the tap formula's current version; it can't be
// pinned to intent.target_version the way npm/npx can (Homebrew has no
// stable "install version X" without a separate versioned formula). In
// practice both target_version and the formula derive from the same latest
// GitHub release, so they match. If the formula lags, the restarted backend
// reports the older version and the frontend progress poll (which waits for
// info.version === target_version) times out gracefully rather than
// reporting a false success.
commands.push({ command: "brew", args: ["upgrade", "kandev"] });
// Re-run service install via the upgraded `kandev` wrapper (resolved on
// PATH), NOT `node <cli_entry>`. Homebrew installs into version-pinned
// Cellar dirs, so the recorded cli_entry still points at the OLD bundle
// after `brew upgrade`; worse, invoking node on it directly runs without
// KANDEV_BUNDLE_DIR/KANDEV_VERSION, so the install is mis-detected as
// kind "unknown" and the regenerated unit loses the bundle env — the
// restarted backend then can't find its runtime and the service stays down.
// The wrapper is version-stable and re-sets the bundle env. (Mirrors the
// manual `kandev service install` we already document for Homebrew.)
commands.push({ command: "kandev", args: installArgs });
} else if (install.kind === "npm") {
commands.push({ command: "npm", args: npmInstallArgs(install.cli_entry, target) });
commands.push({ command: install.node_path, args: [install.cli_entry, ...installArgs] });
} else if (install.kind === "npx") {
commands.push({ command: "npx", args: ["-y", `kandev@${target}`, ...installArgs] });
} else {
throw new Error(`unsupported install kind "${install.kind}"`);
}
commands.push(restartCommand(install, platform, opts.uid));
return commands;
}
export function runSelfUpdateCommand(
args: ServiceArgs,
runner: CommandRunner = spawnCommand,
): void {
if (!args.intent) {
throw new Error("kandev service self-update requires --intent <path>");
}
const intent = readSelfUpdateIntent(args.intent);
const commands = planSelfUpdate(intent);
if (args.dryRun || process.env.KANDEV_E2E_MOCK === "true") {
console.log(
JSON.stringify(
{
dry_run: !!args.dryRun,
fake: process.env.KANDEV_E2E_MOCK === "true",
target_version: intent.target_version,
commands,
},
null,
2,
),
);
return;
}
const log = openSelfUpdateLog(intent);
try {
log?.line(`self-update target ${intent.target_tag} (${intent.target_version})`);
log?.line(`install kind=${intent.install.kind} manager=${intent.install.manager}`);
log?.line(`planned ${commands.length} command(s):`);
commands.forEach((step, i) =>
log?.line(` [${i + 1}/${commands.length}] ${formatCommand(step)}`),
);
runSelfUpdateSteps(commands, runner, log);
log?.line("self-update completed successfully");
} finally {
log?.close();
}
}
function runSelfUpdateSteps(
commands: PlannedCommand[],
runner: CommandRunner,
log: SelfUpdateLog | null,
): void {
for (const [i, step] of commands.entries()) {
log?.line(`\n[${i + 1}/${commands.length}] $ ${formatCommand(step)}`);
const res = runner(step.command, step.args);
teeCommandOutput(log, res);
if (res.error) {
log?.line(`! spawn error: ${res.error.message}`);
throw res.error;
}
if (res.status !== 0) {
log?.line(`! exited with code ${res.status}`);
throw new Error(`${formatCommand(step)} failed with code ${res.status}`);
}
log?.line(" exit 0");
}
}
function formatCommand(step: PlannedCommand): string {
return [step.command, ...step.args].join(" ");
}
function serviceInstallArgs(install: ServiceInstallMetadata): string[] {
const args = ["service", "install"];
if (install.mode === "system") args.push("--system");
args.push("--home-dir", install.home_dir);
if (install.port !== undefined) {
args.push("--port", String(install.port));
}
return args;
}
function npmInstallArgs(cliEntry: string, target: string): string[] {
const args = ["install", "-g"];
const prefix = npmPrefixFromCliEntry(cliEntry);
if (prefix) {
args.push("--prefix", prefix);
}
args.push(`kandev@${target}`);
return args;
}
function npmPrefixFromCliEntry(cliEntry: string): string | undefined {
const marker = `${path.sep}lib${path.sep}node_modules${path.sep}kandev${path.sep}`;
const index = cliEntry.indexOf(marker);
if (index < 0) {
return undefined;
}
return index === 0 ? path.sep : cliEntry.slice(0, index);
}
function restartCommand(
install: ServiceInstallMetadata,
platform: NodeJS.Platform,
uid?: number,
): PlannedCommand {
if (platform === "linux") {
return install.mode === "system"
? { command: "systemctl", args: ["restart", SERVICE_NAME] }
: { command: "systemctl", args: ["--user", "restart", SERVICE_NAME] };
}
if (platform === "darwin") {
// Resolve the uid lazily inside the darwin branch — Linux never needs it, so
// os.userInfo() shouldn't run there.
const resolvedUid = uid ?? os.userInfo().uid;
const domain = install.mode === "system" ? "system" : `gui/${resolvedUid}`;
return { command: "launchctl", args: ["kickstart", "-k", `${domain}/${LAUNCHD_LABEL}`] };
}
throw new Error(`unsupported platform "${platform}"`);
}
function npmVersion(versionOrTag: string): string {
const stripped = versionOrTag.replace(/^v/, "");
return stripped || "latest";
}
function spawnCommand(command: string, args: string[]): CommandResult {
// Capture stdout/stderr (rather than inheriting) so the self-update log can
// tee each step's output. The helper runs detached under launchd/systemd, so
// its console output would otherwise go nowhere visible — the log file is the
// only forensic trail when an update fails mid-flight. maxBuffer is bumped
// well above the 1 MiB default because `npm install -g` is chatty.
return spawnSync(command, args, { maxBuffer: 64 * 1024 * 1024 });
}
type SelfUpdateLog = {
path: string;
line: (message: string) => void;
raw: (chunk: Buffer | string) => void;
close: () => void;
};
/**
* Open a timestamped log file under the service's log dir for this self-update
* run. The helper is spawned outside the service process (so a restart doesn't
* kill it mid-update), which means it has no service log of its own — without
* this file a failed update on macOS/launchd is nearly invisible. Best-effort:
* if the log dir can't be created we return null and the update still runs.
*/
function openSelfUpdateLog(intent: SelfUpdateIntent): SelfUpdateLog | null {
const dir = intent.install.log_dir || logDirFromHome(intent.install.home_dir);
if (!dir) {
return null;
}
try {
fs.mkdirSync(dir, { recursive: true });
// Self-update logs can contain install paths/env; keep the dir owner-only
// even if it pre-existed with looser perms.
fs.chmodSync(dir, 0o700);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const filePath = path.join(dir, `self-update-${stamp}.log`);
const fd = fs.openSync(filePath, "a");
const write = (chunk: Buffer | string): void => {
try {
// The `Buffer | string` union matches neither writeSync overload
// directly; the cast picks one — both write the runtime value correctly.
fs.writeSync(fd, chunk as string);
} catch {
// A write failure mid-update must not abort the update itself.
}
};
process.stdout.write(`[kandev] self-update log: ${filePath}\n`);
write(`# kandev self-update ${new Date().toISOString()}\n`);
return {
path: filePath,
line: (message) => write(`${message}\n`),
raw: (chunk) => write(chunk),
close: () => {
try {
fs.closeSync(fd);
} catch {
// already closed / never opened
}
},
};
} catch {
return null;
}
}
function logDirFromHome(homeDir: string): string {
return homeDir ? path.join(homeDir, "logs") : "";
}
// Mirror a command's captured output to both the helper log and the process's
// own stdout/stderr, so whatever does manage to capture the helper's streams
// (e.g. launchd's StandardOut/ErrorPath, systemd's journal) still sees it.
function teeCommandOutput(log: SelfUpdateLog | null, res: CommandResult): void {
if (res.stdout && res.stdout.length) {
process.stdout.write(res.stdout);
log?.raw(res.stdout);
}
if (res.stderr && res.stderr.length) {
process.stderr.write(res.stderr);
log?.raw(res.stderr);
}
}