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

200 lines
6.4 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { ServiceArgs } from "./args";
import { dumpJournalctlLogs, waitForServiceHealth } from "./health_check";
import { commandExists, writeUnitFile } from "./install_helpers";
import {
buildServiceInstallMetadata,
serviceMetadataPath,
writeServiceInstallMetadata,
} from "./metadata";
import {
captureLauncher,
currentUsername,
linuxUserUnitDir,
LINUX_SYSTEM_UNIT_DIR,
resolveHomeDir,
resolveLogDir,
resolveServiceUser,
SERVICE_NAME,
} from "./paths";
import { renderSystemdUnit } from "./templates";
type Ctx = {
args: ServiceArgs;
systemctlArgs: string[];
unitPath: string;
isSystem: boolean;
};
function makeCtx(args: ServiceArgs): Ctx {
const isSystem = !!args.system;
const unitDir = isSystem ? LINUX_SYSTEM_UNIT_DIR : linuxUserUnitDir();
const systemctlArgs = isSystem ? [] : ["--user"];
return {
args,
systemctlArgs,
unitPath: path.join(unitDir, `${SERVICE_NAME}.service`),
isSystem,
};
}
export async function runLinuxService(args: ServiceArgs): Promise<void> {
if (!commandExists("systemctl")) {
throw new Error("systemctl not found. Linux service install requires systemd.");
}
const ctx = makeCtx(args);
switch (args.action) {
case "install":
return installAsync(ctx);
case "uninstall":
return uninstall(ctx);
case "start":
return runSystemctl(ctx, ["start", SERVICE_NAME]);
case "stop":
return runSystemctl(ctx, ["stop", SERVICE_NAME]);
case "restart":
return runSystemctl(ctx, ["restart", SERVICE_NAME]);
case "status":
return runSystemctl(ctx, ["status", SERVICE_NAME], { allowFailure: true });
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> {
installSync(ctx);
await waitForServiceHealth(ctx.args.port, () =>
dumpJournalctlLogs({ unit: SERVICE_NAME, isSystem: ctx.isSystem, lines: 50 }),
);
}
function installSync(ctx: Ctx): void {
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;
const unit = renderSystemdUnit({
launcher,
homeDir,
logDir,
port: ctx.args.port,
systemUser,
mode,
serviceMetadataPath: metadataPath,
});
fs.mkdirSync(path.dirname(ctx.unitPath), { recursive: true });
const outcome = writeUnitFile(ctx.unitPath, unit);
writeServiceInstallMetadata(
metadataPath,
buildServiceInstallMetadata({
manager: "systemd",
mode,
launcher,
homeDir,
logDir,
servicePath: ctx.unitPath,
port: ctx.args.port,
systemUser,
}),
);
runSystemctl(ctx, ["daemon-reload"]);
// Always run enable --now so 'install' is fully idempotent: if the user
// manually disabled or stopped the service, re-running install brings it
// back online without changing the unit file.
runSystemctl(ctx, ["enable", "--now", SERVICE_NAME]);
console.log(
outcome === "unchanged"
? "[kandev] service is enabled and running"
: "[kandev] service enabled and started",
);
if (!ctx.isSystem && !ctx.args.noBootStart) {
maybePromptLinger();
}
printPostInstallHints(ctx);
}
function uninstall(ctx: Ctx): void {
// Disable and stop, ignoring failures since the unit may already be stopped.
runSystemctl(ctx, ["disable", "--now", SERVICE_NAME], { allowFailure: true });
if (fs.existsSync(ctx.unitPath)) {
fs.unlinkSync(ctx.unitPath);
console.log(`[kandev] removed ${ctx.unitPath}`);
} else {
console.log(`[kandev] no unit file at ${ctx.unitPath}`);
}
runSystemctl(ctx, ["daemon-reload"], { allowFailure: true });
}
function showLogs(ctx: Ctx): void {
const journalArgs: string[] = ctx.isSystem ? ["-u", SERVICE_NAME] : ["--user-unit", SERVICE_NAME];
if (ctx.args.follow) journalArgs.push("-f");
else journalArgs.push("-n", "200", "--no-pager");
const res = spawnSync("journalctl", journalArgs, { stdio: "inherit" });
if (res.status !== 0 && !ctx.args.follow) {
throw new Error(`journalctl exited with code ${res.status}`);
}
}
function runSystemctl(ctx: Ctx, args: string[], opts: { allowFailure?: boolean } = {}): void {
const argv = [...ctx.systemctlArgs, ...args];
const res = spawnSync("systemctl", argv, { stdio: "inherit" });
if (res.status !== 0 && !opts.allowFailure) {
throw new Error(`systemctl ${argv.join(" ")} failed with code ${res.status}`);
}
}
function lingerEnabled(user: string): boolean {
try {
const out = execFileSync("loginctl", ["show-user", user, "--property=Linger"], {
encoding: "utf8",
});
return out.trim().toLowerCase() === "linger=yes";
} catch {
// loginctl may not be available or user record missing — assume off.
return false;
}
}
function maybePromptLinger(): void {
const user = currentUsername();
if (lingerEnabled(user)) {
console.log("[kandev] enable-linger already active — kandev will start at boot");
return;
}
console.log("");
console.log("[kandev] User services only run while you're logged in.");
console.log("[kandev] To start kandev at boot, run:");
console.log(`[kandev] sudo loginctl enable-linger ${user}`);
console.log("[kandev] (Pass --no-boot-start to skip this notice next time.)");
}
function printPostInstallHints(ctx: Ctx): void {
const ctl = ctx.isSystem ? "sudo systemctl" : "systemctl --user";
const journal = ctx.isSystem ? "sudo journalctl" : "journalctl --user-unit";
console.log("");
console.log("[kandev] Useful commands:");
console.log(`[kandev] ${ctl} status ${SERVICE_NAME}`);
console.log(`[kandev] ${ctl} restart ${SERVICE_NAME}`);
console.log(`[kandev] ${journal} ${ctx.isSystem ? "-u " : ""}${SERVICE_NAME} -f`);
}