Files
kandev/apps/cli/src/service/macos.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

259 lines
9.3 KiB
TypeScript

import { spawnSync } 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 { dumpLaunchdLogs, waitForServiceHealth } from "./health_check";
import { commandExists, writeUnitFile } from "./install_helpers";
import { reloadService } from "./launchctl";
import {
buildServiceInstallMetadata,
serviceMetadataPath,
writeServiceInstallMetadata,
} from "./metadata";
import {
captureLauncher,
LAUNCHD_LABEL,
macosUserAgentDir,
MACOS_SYSTEM_DAEMON_DIR,
resolveHomeDir,
resolveLogDir,
resolveServiceUser,
} from "./paths";
import { renderLaunchdPlist } from "./templates";
type Ctx = {
args: ServiceArgs;
plistPath: string;
isSystem: boolean;
/** launchctl domain target, e.g. gui/501 or system. */
domain: string;
/** Full launchctl service target, e.g. gui/501/com.kdlbs.kandev. */
target: string;
};
function makeCtx(args: ServiceArgs): Ctx {
const isSystem = !!args.system;
const dir = isSystem ? MACOS_SYSTEM_DAEMON_DIR : macosUserAgentDir();
const uid = os.userInfo().uid;
const domain = isSystem ? "system" : `gui/${uid}`;
return {
args,
plistPath: path.join(dir, `${LAUNCHD_LABEL}.plist`),
isSystem,
domain,
target: `${domain}/${LAUNCHD_LABEL}`,
};
}
export async function runMacosService(args: ServiceArgs): Promise<void> {
if (!commandExists("launchctl")) {
throw new Error("launchctl not found. macOS service install requires launchd.");
}
const ctx = makeCtx(args);
switch (args.action) {
case "install":
return installAsync(ctx);
case "uninstall":
return uninstall(ctx);
case "start":
return startService(ctx);
case "stop":
return stopService(ctx);
case "restart":
return restartService(ctx);
case "status":
return showStatus(ctx);
case "logs":
return showLogs(ctx);
case "config":
// Handled by the dispatcher in index.ts before reaching the platform layer.
throw new Error("unreachable: config action handled in service/index.ts");
case "self-update":
// Handled by the dispatcher in index.ts before reaching the platform layer.
throw new Error("unreachable: self-update action handled in service/index.ts");
default: {
const _exhaustive: never = args.action;
throw new Error(`unhandled service action: ${_exhaustive as string}`);
}
}
}
async function installAsync(ctx: Ctx): Promise<void> {
const { logDir } = installSync(ctx);
await waitForServiceHealth(ctx.args.port, () => dumpLaunchdLogs({ logDir, lines: 50 }));
}
function installSync(ctx: Ctx): { logDir: string } {
const launcher = captureLauncher();
const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem);
const logDir = resolveLogDir(homeDir);
const metadataPath = serviceMetadataPath(homeDir);
const mode = ctx.isSystem ? "system" : "user";
const systemUser = ctx.isSystem ? resolveServiceUser(true) : undefined;
fs.mkdirSync(logDir, { recursive: true });
const plist = renderLaunchdPlist({
launcher,
homeDir,
logDir,
port: ctx.args.port,
systemUser,
mode,
serviceMetadataPath: metadataPath,
});
fs.mkdirSync(path.dirname(ctx.plistPath), { recursive: true });
const outcome = writeUnitFile(ctx.plistPath, plist);
writeServiceInstallMetadata(
metadataPath,
buildServiceInstallMetadata({
manager: "launchd",
mode,
launcher,
homeDir,
logDir,
servicePath: ctx.plistPath,
port: ctx.args.port,
systemUser,
}),
);
// launchctl bootstrap fails if the label is already loaded — bootout first
// (ignoring its error if nothing was loaded). This means 'install' is
// idempotent: re-running it reloads the unit even if the file is unchanged,
// which is how we recover from a user manually unloading the service.
//
// `reloadService` waits for bootout's async teardown before bootstrapping and
// retries the transient EIO ("Bootstrap failed: 5") that launchd returns while
// a still-running instance is torn down — the failure that broke self-update,
// where this install runs against a live service. See launchctl.ts.
reloadService(ctx.target, ctx.domain, ctx.plistPath);
runLaunchctl(["enable", ctx.target], { allowFailure: true });
console.log(
outcome === "unchanged"
? "[kandev] service is loaded and running"
: "[kandev] service loaded and started",
);
printPostInstallHints(ctx, logDir);
return { logDir };
}
function uninstall(ctx: Ctx): void {
runLaunchctl(["bootout", ctx.target], { allowFailure: true });
if (fs.existsSync(ctx.plistPath)) {
fs.unlinkSync(ctx.plistPath);
console.log(`[kandev] removed ${ctx.plistPath}`);
} else {
console.log(`[kandev] no plist at ${ctx.plistPath}`);
}
}
// `bootstrap` loads the job (start) and `bootout` fully unloads it (stop).
// We use these instead of `kickstart` + `kill` because the plist sets
// `KeepAlive=true` — under KeepAlive, `kill SIGTERM` does not stop the
// service: launchd just respawns it seconds later. Only `bootout` removes
// the job from launchd's supervision.
//
// start/restart both have to handle two pre-states — job loaded vs not —
// so each begins with a bootout-then-bootstrap dance similar to installSync.
function startService(ctx: Ctx): void {
// Idempotent: if the label is already loaded, bootstrap would fail. Bootout
// first (waiting for teardown) so start works whether the service was
// previously running or stopped.
reloadService(ctx.target, ctx.domain, ctx.plistPath);
}
function stopService(ctx: Ctx): void {
runLaunchctl(["bootout", ctx.target], { allowFailure: true });
}
// `kickstart -k` atomically kills and restarts a loaded service. If the job
// was previously stopped (bootout'd), the target no longer exists in the
// launchd domain and kickstart fails — fall back to bootstrap to reload it.
function restartService(ctx: Ctx): void {
const res = spawnSync("launchctl", ["kickstart", "-k", ctx.target], { stdio: "inherit" });
if (res.status === 0) return;
// kickstart fails when the job isn't loaded — reload it (waiting for any
// residual teardown and retrying the transient bootstrap EIO).
reloadService(ctx.target, ctx.domain, ctx.plistPath);
}
function showStatus(ctx: Ctx): void {
const res = spawnSync("launchctl", ["print", ctx.target], {
stdio: "inherit",
});
if (res.status !== 0) {
console.log(`[kandev] service not loaded in ${ctx.domain}`);
}
}
function showLogs(ctx: Ctx): void {
// Pull the log paths from the *installed* plist rather than recomputing
// from defaults — `--home-dir` is install-only, so a user who installed
// with a custom home dir would otherwise see "no logs yet" at the wrong
// location while logs accumulate at the real path.
const installed = readInstalledLogPaths(ctx.plistPath);
const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem);
const fallbackDir = resolveLogDir(homeDir);
const outPath = installed?.out ?? path.join(fallbackDir, "service.out");
const errPath = installed?.err ?? path.join(fallbackDir, "service.err");
const tailArgs: string[] = ctx.args.follow ? ["-f", "-n", "200"] : ["-n", "200"];
const targets = [outPath, errPath].filter((p) => fs.existsSync(p));
if (targets.length === 0) {
const checkedDir = installed ? path.dirname(installed.err) : fallbackDir;
console.log(`[kandev] no logs yet at ${checkedDir}`);
return;
}
spawnSync("tail", [...tailArgs, ...targets], { stdio: "inherit" });
}
/**
* Pull the literal StandardOutPath / StandardErrorPath values out of an
* installed plist. Plist XML is rigidly formatted by our renderer, so a
* regex match is enough — avoids pulling in a plist parser for two strings.
* Returns null when the plist is missing or doesn't contain the keys.
*/
export function readInstalledLogPaths(plistPath: string): { out: string; err: string } | null {
let content: string;
try {
content = fs.readFileSync(plistPath, "utf8");
} catch {
return null;
}
const outMatch = /<key>StandardOutPath<\/key>\s*<string>([^<]+)<\/string>/.exec(content);
const errMatch = /<key>StandardErrorPath<\/key>\s*<string>([^<]+)<\/string>/.exec(content);
if (!outMatch || !errMatch) return null;
// `renderLaunchdPlist` runs values through `escapeXml`, so a path containing
// `&`, `<`, etc. is stored escaped in the plist. Decode before returning so
// the caller can stat/tail the actual file on disk.
return { out: unescapeXml(outMatch[1]), err: unescapeXml(errMatch[1]) };
}
function unescapeXml(value: string): string {
return value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&"); // last — must not re-decode a `&amp;amp;`-style sequence
}
function runLaunchctl(args: string[], opts: { allowFailure?: boolean } = {}): void {
const res = spawnSync("launchctl", args, { stdio: "inherit" });
if (res.status !== 0 && !opts.allowFailure) {
throw new Error(`launchctl ${args.join(" ")} failed with code ${res.status}`);
}
}
function printPostInstallHints(ctx: Ctx, logDir: string): void {
console.log("");
console.log("[kandev] Useful commands:");
console.log(`[kandev] launchctl print ${ctx.target}`);
console.log(`[kandev] kandev service restart${ctx.isSystem ? " --system" : ""}`);
console.log(`[kandev] tail -f ${path.join(logDir, "service.err")}`);
}