Files
kandev/apps/cli/src/service/linux.ts
T

175 lines
5.7 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 {
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");
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 unit = renderSystemdUnit({
launcher,
homeDir,
logDir,
port: ctx.args.port,
systemUser: ctx.isSystem ? resolveServiceUser(true) : undefined,
mode: ctx.isSystem ? "system" : "user",
});
fs.mkdirSync(path.dirname(ctx.unitPath), { recursive: true });
const outcome = writeUnitFile(ctx.unitPath, unit);
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`);
}