041a19f060
* 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
259 lines
9.3 KiB
TypeScript
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(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, "&"); // last — must not re-decode a `&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")}`);
|
|
}
|