From c62739d3c173342a3d4c50880f70ca05516c5492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 17 May 2026 21:12:20 +0100 Subject: [PATCH] feat(cli): kandev service install for systemd and launchd (#926) --- README.md | 2 +- apps/cli/README.md | 11 + apps/cli/src/args.test.ts | 6 + apps/cli/src/args.ts | 6 + apps/cli/src/cli.ts | 22 ++ apps/cli/src/run.ts | 7 + apps/cli/src/service/args.test.ts | 99 ++++++++ apps/cli/src/service/args.ts | 137 +++++++++++ apps/cli/src/service/config.ts | 117 ++++++++++ apps/cli/src/service/health_check.test.ts | 88 ++++++++ apps/cli/src/service/health_check.ts | 91 ++++++++ apps/cli/src/service/index.ts | 63 ++++++ apps/cli/src/service/install_helpers.test.ts | 67 ++++++ apps/cli/src/service/install_helpers.ts | 54 +++++ apps/cli/src/service/linux.ts | 174 ++++++++++++++ apps/cli/src/service/macos.test.ts | 79 +++++++ apps/cli/src/service/macos.ts | 226 +++++++++++++++++++ apps/cli/src/service/paths.test.ts | 104 +++++++++ apps/cli/src/service/paths.ts | 138 +++++++++++ apps/cli/src/service/stale_check.test.ts | 74 ++++++ apps/cli/src/service/stale_check.ts | 72 ++++++ apps/cli/src/service/templates.test.ts | 186 +++++++++++++++ apps/cli/src/service/templates.ts | 183 +++++++++++++++ docs/run-as-a-service.md | 175 ++++++++++++++ 24 files changed, 2180 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/service/args.test.ts create mode 100644 apps/cli/src/service/args.ts create mode 100644 apps/cli/src/service/config.ts create mode 100644 apps/cli/src/service/health_check.test.ts create mode 100644 apps/cli/src/service/health_check.ts create mode 100644 apps/cli/src/service/index.ts create mode 100644 apps/cli/src/service/install_helpers.test.ts create mode 100644 apps/cli/src/service/install_helpers.ts create mode 100644 apps/cli/src/service/linux.ts create mode 100644 apps/cli/src/service/macos.test.ts create mode 100644 apps/cli/src/service/macos.ts create mode 100644 apps/cli/src/service/paths.test.ts create mode 100644 apps/cli/src/service/paths.ts create mode 100644 apps/cli/src/service/stale_check.test.ts create mode 100644 apps/cli/src/service/stale_check.ts create mode 100644 apps/cli/src/service/templates.test.ts create mode 100644 apps/cli/src/service/templates.ts create mode 100644 docs/run-as-a-service.md diff --git a/README.md b/README.md index 6d0af035c6..92daaeb2fb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Manage and run tasks in parallel. Orchestrate agents. Review changes. Ship value. -[Workflows](docs/workflow-tips.md) | [Roadmap](docs/roadmap.md) | [Contributing](CONTRIBUTING.md) | [Architecture](docs/ARCHITECTURE.md) | [Discord](https://discord.gg/gWdCPGcFCD) +[Workflows](docs/workflow-tips.md) | [Run as a Service](docs/run-as-a-service.md) | [Roadmap](docs/roadmap.md) | [Contributing](CONTRIBUTING.md) | [Architecture](docs/ARCHITECTURE.md) | [Discord](https://discord.gg/gWdCPGcFCD)

Kandev Demo diff --git a/apps/cli/README.md b/apps/cli/README.md index b086054566..0ef800444a 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -35,6 +35,17 @@ The package manager owns the runtime version. `kandev@X.Y.Z` ships with the matc kandev --runtime-version v0.16.0 ``` +## Run as a Service + +Install kandev as a systemd (Linux) or launchd (macOS) service so it auto-starts and stays running across reboots: + +```bash +kandev service install # user mode (laptop, single-user) +sudo kandev service install --system # system mode (VPS, shared host) +``` + +See [docs/run-as-a-service.md](../../docs/run-as-a-service.md) for the full guide — user vs system mode comparison, update workflow, troubleshooting. + ## What You Get - **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode diff --git a/apps/cli/src/args.test.ts b/apps/cli/src/args.test.ts index c7bbaddac6..ed01a34548 100644 --- a/apps/cli/src/args.test.ts +++ b/apps/cli/src/args.test.ts @@ -71,6 +71,12 @@ describe("parseArgs", () => { expect(parseArgs(["-V"]).options.showVersion).toBe(true); }); + it("parses --headless and --no-browser as headless", () => { + expect(parseArgs(["--headless"]).options.headless).toBe(true); + expect(parseArgs(["--no-browser"]).options.headless).toBe(true); + expect(parseArgs([]).options.headless).toBeUndefined(); + }); + it("parses --runtime-version as runtimeVersion", () => { expect(parseArgs(["--runtime-version", "v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); expect(parseArgs(["--runtime-version=v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 254f5bc577..88d4681fdf 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -8,6 +8,8 @@ export type CliOptions = { verbose?: boolean; debug?: boolean; showVersion?: boolean; + /** Skip browser open + interactive prompts. Set by systemd/launchd units. */ + headless?: boolean; }; export type ParseResult = { @@ -94,6 +96,10 @@ export function parseArgs(argv: string[]): ParseResult { opts.debug = true; continue; } + if (arg === "--headless" || arg === "--no-browser") { + opts.headless = true; + continue; + } } return { options: opts, showHelp, deprecatedFlags }; } diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 2d3baa25e1..cbec367442 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -5,6 +5,8 @@ import pkg from "../package.json"; import { parseArgs, ParseError, resolvePorts } from "./args"; import { runDev } from "./dev"; import { runRelease } from "./run"; +import { runServiceCommand } from "./service"; +import { warnIfStaleServiceUnit } from "./service/stale_check"; import { runStart } from "./start"; import { ensureValidPort } from "./ports"; @@ -17,6 +19,7 @@ Usage: kandev start [--port ] [--verbose] [--debug] kandev [--port ] [--verbose] [--debug] kandev --dev [--port ] + kandev service install|uninstall|start|stop|restart|status|logs [--system] Examples: kandev @@ -32,6 +35,8 @@ Options: dev Use local repo for dev (make dev + next dev) if available. start Use local production build (make build + next start). run Use installed runtime bundle (default). + service Manage kandev as an OS service (systemd / launchd). + Run 'kandev service --help' for details. --dev Alias for "dev". --version, -V Print CLI version and exit. --port Port for the Go backend (the URL kandev opens on in @@ -39,6 +44,7 @@ Options: KANDEV_PORT or KANDEV_BACKEND_PORT. --verbose, -v Show info logs from backend + web. --debug Show debug logs + agent message dumps. + --headless Skip opening the browser. Used by service units. --help, -h Show help. Advanced: @@ -77,6 +83,13 @@ function findRepoRoot(startDir: string): string | null { } async function main(): Promise { + // The `service` subcommand has its own argv parser (own subcommands, flags). + // Handle it before the top-level flag parser to keep the two parsers isolated. + if (process.argv[2] === "service") { + await runServiceCommand(process.argv.slice(3)); + return; + } + const { options, showHelp, deprecatedFlags } = parseArgs(process.argv.slice(2)); if (options.showVersion) { @@ -97,6 +110,14 @@ async function main(): Promise { const backendPort = ensureValidPort(resolved.backendPort, "backend port"); const webPort = ensureValidPort(resolved.webPort, "web port"); + // After an npm/brew upgrade, paths baked into an installed service unit may + // be stale. Warn once before launch so the user knows to re-run install. + // Skip when invoked from a service unit (--headless) — the warning would + // land in journalctl/launchd logs and pile up on every restart. + if (!options.headless) { + warnIfStaleServiceUnit(); + } + if (options.command === "dev") { const repoRoot = findRepoRoot(process.cwd()); if (!repoRoot) { @@ -127,6 +148,7 @@ async function main(): Promise { webPort, verbose: options.verbose, debug: options.debug, + headless: options.headless, }); } diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index a64db0919f..1a2a06361f 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -29,6 +29,8 @@ export type RunOptions = { verbose?: boolean; /** Show debug logs + agent message dumps */ debug?: boolean; + /** Skip browser open. Set by service units. */ + headless?: boolean; }; type PreparedBundle = { @@ -297,6 +299,7 @@ export async function runRelease({ webPort, verbose = false, debug = false, + headless = false, }: RunOptions): Promise { const prepared = await prepareBundleForLaunch({ runtimeVersion, @@ -323,6 +326,10 @@ export async function runRelease({ quiet: !prepared.showOutput, }); await waitForUrlReady(webUrl, webProc, healthTimeoutMs); + if (headless) { + console.log(`[kandev] ready (headless) at ${prepared.backendUrl}`); + return; + } console.log("[kandev] open: " + prepared.backendUrl); openBrowser(prepared.backendUrl); } diff --git a/apps/cli/src/service/args.test.ts b/apps/cli/src/service/args.test.ts new file mode 100644 index 0000000000..2fe41680c8 --- /dev/null +++ b/apps/cli/src/service/args.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { ParseError } from "../args"; +import { parseServiceArgs } from "./args"; + +describe("parseServiceArgs", () => { + it("returns showHelp when invoked with no args", () => { + const r = parseServiceArgs([]); + expect(r.showHelp).toBe(true); + }); + + it("returns showHelp on --help with no action", () => { + expect(parseServiceArgs(["--help"]).showHelp).toBe(true); + expect(parseServiceArgs(["-h"]).showHelp).toBe(true); + }); + + it("parses each valid action", () => { + for (const action of [ + "install", + "uninstall", + "start", + "stop", + "restart", + "status", + "logs", + "config", + ] as const) { + expect(parseServiceArgs([action]).action).toBe(action); + } + }); + + it("throws ParseError on unknown action", () => { + expect(() => parseServiceArgs(["nope"])).toThrow(ParseError); + expect(() => parseServiceArgs(["nope"])).toThrow(/unknown service action/); + }); + + it("parses --system on install", () => { + expect(parseServiceArgs(["install", "--system"]).system).toBe(true); + expect(parseServiceArgs(["install"]).system).toBeUndefined(); + }); + + it("parses --port", () => { + expect(parseServiceArgs(["install", "--port", "9000"]).port).toBe(9000); + expect(parseServiceArgs(["install", "--port=9000"]).port).toBe(9000); + }); + + it("rejects invalid --port values", () => { + expect(() => parseServiceArgs(["install", "--port=abc"])).toThrow(/must be an integer/); + expect(() => parseServiceArgs(["install", "--port=0"])).toThrow(/between 1 and 65535/); + expect(() => parseServiceArgs(["install", "--port=65536"])).toThrow(/between 1 and 65535/); + }); + + it("parses --home-dir", () => { + expect(parseServiceArgs(["install", "--home-dir", "/srv/kandev"]).homeDir).toBe("/srv/kandev"); + expect(parseServiceArgs(["install", "--home-dir=/srv/kandev"]).homeDir).toBe("/srv/kandev"); + }); + + it("rejects empty --home-dir", () => { + expect(() => parseServiceArgs(["install", "--home-dir="])).toThrow(/--home-dir requires/); + }); + + it("parses --no-boot-start", () => { + expect(parseServiceArgs(["install", "--no-boot-start"]).noBootStart).toBe(true); + }); + + it("parses --follow on logs", () => { + expect(parseServiceArgs(["logs", "-f"]).follow).toBe(true); + expect(parseServiceArgs(["logs", "--follow"]).follow).toBe(true); + }); + + it("throws ParseError on unknown flag", () => { + expect(() => parseServiceArgs(["install", "--nope"])).toThrow(/unknown flag/); + }); + + it("rejects --follow for actions other than logs", () => { + expect(() => parseServiceArgs(["start", "--follow"])).toThrow( + /--follow only applies to 'kandev service logs'/, + ); + expect(() => parseServiceArgs(["status", "-f"])).toThrow( + /--follow only applies to 'kandev service logs'/, + ); + // happy path + expect(parseServiceArgs(["logs", "-f"]).follow).toBe(true); + }); + + it("rejects install-only flags on non-install actions", () => { + expect(() => parseServiceArgs(["stop", "--port=9000"])).toThrow( + /--port only applies to 'kandev service install'/, + ); + expect(() => parseServiceArgs(["restart", "--home-dir=/x"])).toThrow( + /--home-dir only applies to 'kandev service install'/, + ); + expect(() => parseServiceArgs(["status", "--no-boot-start"])).toThrow( + /--no-boot-start only applies to 'kandev service install'/, + ); + // happy path + expect(parseServiceArgs(["install", "--port=9000"]).port).toBe(9000); + }); +}); diff --git a/apps/cli/src/service/args.ts b/apps/cli/src/service/args.ts new file mode 100644 index 0000000000..0645505945 --- /dev/null +++ b/apps/cli/src/service/args.ts @@ -0,0 +1,137 @@ +import { ParseError } from "../args"; + +export type ServiceAction = + | "install" + | "uninstall" + | "start" + | "stop" + | "restart" + | "status" + | "logs" + | "config"; + +export type ServiceArgs = { + action: ServiceAction; + /** Install as a system unit/daemon instead of a per-user one. */ + system?: boolean; + /** Backend port to bake into the unit (passed via KANDEV_SERVER_PORT). */ + port?: number; + /** Override KANDEV_HOME_DIR baked into the unit. */ + homeDir?: string; + /** Skip the Linux enable-linger prompt during install. */ + noBootStart?: boolean; + /** Tail logs (only valid with `logs`). */ + follow?: boolean; + /** Print help and exit. */ + showHelp?: boolean; +}; + +const VALID_ACTIONS = new Set([ + "install", + "uninstall", + "start", + "stop", + "restart", + "status", + "logs", + "config", +]); + +export function parseServiceArgs(argv: string[]): ServiceArgs { + if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { + return { action: "install", showHelp: true }; + } + const head = argv[0]; + if (!VALID_ACTIONS.has(head as ServiceAction)) { + throw new ParseError( + `unknown service action "${head}". expected one of: ${[...VALID_ACTIONS].join(", ")}`, + ); + } + const out: ServiceArgs = { action: head as ServiceAction }; + for (let i = 1; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + out.showHelp = true; + continue; + } + if (arg === "--system") { + out.system = true; + continue; + } + if (arg === "--no-boot-start") { + out.noBootStart = true; + continue; + } + if (arg === "-f" || arg === "--follow") { + out.follow = true; + continue; + } + if (arg === "--port") { + out.port = parsePort(takeValue(argv, i, "--port"), "--port"); + i += 1; + continue; + } + if (arg.startsWith("--port=")) { + out.port = parsePort(arg.slice("--port=".length), "--port"); + continue; + } + if (arg === "--home-dir") { + out.homeDir = takeValue(argv, i, "--home-dir"); + i += 1; + continue; + } + if (arg.startsWith("--home-dir=")) { + const value = arg.slice("--home-dir=".length); + if (value.length === 0) throw new ParseError("--home-dir requires a value"); + out.homeDir = value; + continue; + } + throw new ParseError(`unknown flag "${arg}" for kandev service ${head}`); + } + validateActionFlags(out); + return out; +} + +/** + * Reject flag combinations that silently no-op so the user gets immediate + * feedback instead of a successful command that ignored their input. The + * matrix is small enough that explicit checks beat a generic flag-applicability + * table. + */ +function validateActionFlags(args: ServiceArgs): void { + if (args.follow && args.action !== "logs") { + throw new ParseError(`--follow only applies to 'kandev service logs', not '${args.action}'`); + } + const installOnly: Array = ["port", "homeDir", "noBootStart"]; + if (args.action !== "install") { + for (const flag of installOnly) { + if (args[flag] !== undefined) { + const display = + flag === "homeDir" + ? "--home-dir" + : flag === "noBootStart" + ? "--no-boot-start" + : `--${flag}`; + throw new ParseError( + `${display} only applies to 'kandev service install', not '${args.action}'`, + ); + } + } + } +} + +function takeValue(argv: string[], i: number, flag: string): string { + const v = argv[i + 1]; + if (v === undefined || v.startsWith("-")) { + throw new ParseError(`${flag} requires a value`); + } + return v; +} + +function parsePort(raw: string, flag: string): number { + const n = Number(raw); + if (raw === "" || !Number.isInteger(n) || n < 1 || n > 65535) { + throw new ParseError(`${flag} value must be an integer between 1 and 65535, got "${raw}"`); + } + return n; +} diff --git a/apps/cli/src/service/config.ts b/apps/cli/src/service/config.ts new file mode 100644 index 0000000000..567fb4a378 --- /dev/null +++ b/apps/cli/src/service/config.ts @@ -0,0 +1,117 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; + +import { DEFAULT_BACKEND_PORT } from "../constants"; +import type { ServiceArgs } from "./args"; +import { + captureLauncher, + LAUNCHD_LABEL, + linuxSystemUnitPath, + linuxUserUnitPath, + macosSystemPlistPath, + macosUserPlistPath, + resolveHomeDir, + resolveLogDir, + resolveServiceUser, + SERVICE_NAME, +} from "./paths"; +import { looksLikeManagedUnit } from "./templates"; + +/** + * Print a human-readable summary of what kandev knows about the local + * service install: paths, env vars, whether a unit exists, whether it's + * active. Used for "why isn't it running?" diagnosis without needing to + * remember the right systemctl / launchctl invocation. + */ +export function printServiceConfig(args: ServiceArgs): void { + const isSystem = !!args.system; + const launcher = captureLauncher(); + const homeDir = resolveHomeDir(args.homeDir, isSystem); + const logDir = resolveLogDir(homeDir); + + console.log("=== kandev service config ==="); + console.log(`platform: ${process.platform}`); + console.log(`mode: ${isSystem ? "system" : "user"}`); + console.log(`launcher kind: ${launcher.kind}`); + console.log(`node path: ${launcher.nodePath}`); + console.log(`cli entry: ${launcher.cliEntry}`); + if (launcher.bundleDir) console.log(`bundle dir: ${launcher.bundleDir}`); + if (launcher.version) console.log(`version: ${launcher.version}`); + console.log(""); + console.log(`KANDEV_HOME_DIR: ${homeDir}`); + console.log(`log dir: ${logDir}`); + console.log(`port: ${args.port ?? `(default ${DEFAULT_BACKEND_PORT})`}`); + if (isSystem) { + console.log(`run as user: ${resolveServiceUser(true)}`); + } + console.log(""); + + if (process.platform === "linux") { + printLinuxUnit(isSystem); + } else if (process.platform === "darwin") { + printMacosUnit(isSystem); + } else { + console.log(`unit: (unsupported on ${process.platform})`); + } +} + +function printLinuxUnit(isSystem: boolean): void { + const unitPath = isSystem ? linuxSystemUnitPath() : linuxUserUnitPath(); + console.log(`unit path: ${unitPath}`); + const present = fs.existsSync(unitPath); + console.log(`installed: ${present ? "yes" : "no"}`); + if (present) { + const content = safeRead(unitPath); + console.log(`managed by us: ${content && looksLikeManagedUnit(content) ? "yes" : "no"}`); + } + const active = systemctlIsActive(isSystem); + if (active !== null) console.log(`active state: ${active}`); +} + +function printMacosUnit(isSystem: boolean): void { + const plistPath = isSystem ? macosSystemPlistPath() : macosUserPlistPath(); + console.log(`plist path: ${plistPath}`); + const present = fs.existsSync(plistPath); + console.log(`installed: ${present ? "yes" : "no"}`); + if (present) { + const content = safeRead(plistPath); + console.log(`managed by us: ${content && looksLikeManagedUnit(content) ? "yes" : "no"}`); + } + console.log(`loaded: ${launchctlIsLoaded(isSystem) ? "yes" : "no"}`); +} + +function safeRead(p: string): string | null { + try { + return fs.readFileSync(p, "utf8"); + } catch { + return null; + } +} + +function systemctlIsActive(isSystem: boolean): string | null { + try { + const args = isSystem ? ["is-active", SERVICE_NAME] : ["--user", "is-active", SERVICE_NAME]; + const out = execFileSync("systemctl", args, { encoding: "utf8" }); + return out.trim(); + } catch (err: unknown) { + // is-active returns nonzero for inactive/failed/unknown — read the output anyway. + // execFileSync attaches stdout to the thrown error; guard with a type check + // instead of an unsafe cast so this keeps working if the error shape changes. + if (err !== null && typeof err === "object" && "stdout" in err) { + const { stdout } = err as { stdout?: Buffer | string }; + if (stdout) return String(stdout).trim(); + } + return null; + } +} + +function launchctlIsLoaded(isSystem: boolean): boolean { + try { + const domain = isSystem ? "system" : `gui/${os.userInfo().uid}`; + execFileSync("launchctl", ["print", `${domain}/${LAUNCHD_LABEL}`], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} diff --git a/apps/cli/src/service/health_check.test.ts b/apps/cli/src/service/health_check.test.ts new file mode 100644 index 0000000000..e1fb12195f --- /dev/null +++ b/apps/cli/src/service/health_check.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { waitForServiceHealth } from "./health_check"; + +describe("waitForServiceHealth", () => { + const originalFetch = global.fetch; + + const originalTimeout = process.env.KANDEV_HEALTH_TIMEOUT_MS; + + beforeEach(() => { + // The error message in waitForServiceHealth suggests setting this env var + // for slow first-launch scenarios. If a developer has it exported in their + // shell, the timeout test would block waiting for the overridden deadline + // instead of the 30s default we're asserting against. + delete process.env.KANDEV_HEALTH_TIMEOUT_MS; + vi.useFakeTimers(); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + if (originalTimeout === undefined) delete process.env.KANDEV_HEALTH_TIMEOUT_MS; + else process.env.KANDEV_HEALTH_TIMEOUT_MS = originalTimeout; + vi.useRealTimers(); + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("resolves once /health responds with 200", async () => { + let calls = 0; + global.fetch = vi.fn(async () => { + calls += 1; + if (calls < 3) throw new Error("ECONNREFUSED"); + return new Response("ok", { status: 200 }); + }) as unknown as typeof fetch; + + const dump = vi.fn(); + const p = waitForServiceHealth(38429, dump); + // Advance through 2 failed polls + 1 success + await vi.advanceTimersByTimeAsync(3000); + await p; + expect(calls).toBeGreaterThanOrEqual(3); + expect(dump).not.toHaveBeenCalled(); + }); + + it("times out and dumps logs when /health never responds", async () => { + global.fetch = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + + const dump = vi.fn(); + const p = waitForServiceHealth(38429, dump); + const expected = expect(p).rejects.toThrow(/never responded/); + // Push past the 30s deadline + await vi.advanceTimersByTimeAsync(31_000); + await expected; + expect(dump).toHaveBeenCalledTimes(1); + }); + + it("passes an AbortSignal so a hung fetch can't outrun the deadline", async () => { + let lastSignal: AbortSignal | undefined; + global.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + lastSignal = init?.signal ?? undefined; + return new Response("ok", { status: 200 }); + }) as unknown as typeof fetch; + + const dump = vi.fn(); + const p = waitForServiceHealth(undefined, dump); + await vi.advanceTimersByTimeAsync(500); + await p; + // The exact timeout value is an implementation detail; what matters is + // that some abortable signal is passed through. + expect(lastSignal).toBeInstanceOf(AbortSignal); + }); + + it("uses the default port when none is given", async () => { + const seenUrls: string[] = []; + global.fetch = vi.fn(async (url: string | URL | Request) => { + seenUrls.push(typeof url === "string" ? url : url.toString()); + return new Response("ok", { status: 200 }); + }) as unknown as typeof fetch; + + const dump = vi.fn(); + const p = waitForServiceHealth(undefined, dump); + await vi.advanceTimersByTimeAsync(500); + await p; + expect(seenUrls[0]).toContain(":38429/health"); + }); +}); diff --git a/apps/cli/src/service/health_check.ts b/apps/cli/src/service/health_check.ts new file mode 100644 index 0000000000..dffeb0e114 --- /dev/null +++ b/apps/cli/src/service/health_check.ts @@ -0,0 +1,91 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { DEFAULT_BACKEND_PORT } from "../constants"; +import { delay, resolveHealthTimeoutMs } from "../health"; + +export type LogDumper = () => void; + +const DEFAULT_TIMEOUT_MS = 30_000; +const POLL_INTERVAL_MS = 500; +// Per-request timeout. Without this, undici's default headersTimeout (5min) +// can stall a single fetch — e.g. TCP accepted but the backend hangs before +// writing response headers — and silently overrun the outer deadline. +const REQUEST_TIMEOUT_MS = 2_000; + +/** + * Poll the kandev /health endpoint to confirm the freshly-installed service + * actually came up. On success, the user gets immediate confirmation; on + * failure, we dump the tail of the service logs so the diagnosis isn't a + * scavenger hunt across `journalctl` / `tail`. + * + * Timeout is fixed at 30s — long enough to absorb a slow first launch + * (binary unpacking, sqlite migration), short enough to fail fast when the + * unit is genuinely broken. + */ +export async function waitForServiceHealth( + port: number | undefined, + dumpLogs: LogDumper, +): Promise { + const url = `http://localhost:${port ?? DEFAULT_BACKEND_PORT}/health`; + const timeoutMs = resolveHealthTimeoutMs(DEFAULT_TIMEOUT_MS); + const deadline = Date.now() + timeoutMs; + process.stderr.write(`[kandev] waiting for service to be healthy at ${url}\n`); + + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (res.ok) { + process.stderr.write(`[kandev] service is healthy\n`); + return; + } + } catch { + // not up yet (or per-request timeout fired); keep polling + } + await delay(POLL_INTERVAL_MS); + } + + process.stderr.write(`[kandev] service did not become healthy within ${timeoutMs}ms\n`); + process.stderr.write(`[kandev] dumping recent service logs:\n`); + dumpLogs(); + throw new Error( + "kandev service was installed but the /health endpoint never responded. " + + "Inspect the logs above and re-run 'kandev service install' once fixed. " + + "If the service needs more time to come up, set KANDEV_HEALTH_TIMEOUT_MS=120000.", + ); +} + +/** Dump the last N lines of a systemd unit's logs via journalctl. */ +export function dumpJournalctlLogs(opts: { unit: string; isSystem: boolean; lines: number }): void { + const args = opts.isSystem + ? ["-u", opts.unit, "-n", String(opts.lines), "--no-pager"] + : ["--user-unit", opts.unit, "-n", String(opts.lines), "--no-pager"]; + try { + spawnSync("journalctl", args, { stdio: "inherit" }); + } catch (err) { + // journalctl may be unavailable in containerized or stripped-down setups. + // We're already in a failure path; don't compound it with a thrown error. + process.stderr.write( + `[kandev] (could not run journalctl: ${err instanceof Error ? err.message : String(err)})\n`, + ); + } +} + +/** Dump the last N lines of launchd-managed log files via `tail`. */ +export function dumpLaunchdLogs(opts: { logDir: string; lines: number }): void { + const candidates = ["service.err", "service.out"] + .map((name) => path.join(opts.logDir, name)) + .filter((p) => fs.existsSync(p)); + if (candidates.length === 0) { + process.stderr.write(`[kandev] (no logs found in ${opts.logDir})\n`); + return; + } + try { + spawnSync("tail", ["-n", String(opts.lines), ...candidates], { stdio: "inherit" }); + } catch (err) { + process.stderr.write( + `[kandev] (could not run tail: ${err instanceof Error ? err.message : String(err)})\n`, + ); + } +} diff --git a/apps/cli/src/service/index.ts b/apps/cli/src/service/index.ts new file mode 100644 index 0000000000..eea76decbc --- /dev/null +++ b/apps/cli/src/service/index.ts @@ -0,0 +1,63 @@ +import { parseServiceArgs } from "./args"; +import { printServiceConfig } from "./config"; +import { runLinuxService } from "./linux"; +import { runMacosService } from "./macos"; + +export function printServiceHelp(): void { + console.log(`kandev service — install kandev as an OS-managed service + +Usage: + kandev service install [--system] [--port ] [--home-dir ] [--no-boot-start] + kandev service uninstall [--system] + kandev service start|stop|restart|status [--system] + kandev service logs [-f] [--system] + kandev service config [--system] + +Modes: + default User-level service. + Linux: ~/.config/systemd/user/kandev.service + macOS: ~/Library/LaunchAgents/com.kdlbs.kandev.plist + Runs as the current user. On Linux, only starts at boot + if 'loginctl enable-linger ' has been run. + --system System-level service. Requires sudo. + Linux: /etc/systemd/system/kandev.service + macOS: /Library/LaunchDaemons/com.kdlbs.kandev.plist + Starts at boot regardless of login state. + +Flags: + --port Backend port baked into the unit (KANDEV_SERVER_PORT). + --home-dir KANDEV_HOME_DIR baked into the unit. + Defaults: ~/.kandev (user), /var/lib/kandev (system). + --no-boot-start (Linux user mode) Skip the enable-linger hint. + -f, --follow (logs) Stream logs instead of dumping the tail. + +Updates: + After 'npm update -g kandev' or 'brew upgrade kandev', re-run + 'kandev service install' to refresh paths in the unit file. +`); +} + +export async function runServiceCommand(argv: string[]): Promise { + const args = parseServiceArgs(argv); + if (args.showHelp) { + printServiceHelp(); + return; + } + // 'config' is read-only and identical across platforms — handle here so we + // don't need to duplicate it in both linux.ts and macos.ts. + if (args.action === "config") { + printServiceConfig(args); + return; + } + switch (process.platform) { + case "linux": + return runLinuxService(args); + case "darwin": + return runMacosService(args); + default: + throw new Error( + `kandev service is not yet supported on ${process.platform}. ` + + `Currently supported: linux (systemd), darwin (launchd).`, + ); + } +} diff --git a/apps/cli/src/service/install_helpers.test.ts b/apps/cli/src/service/install_helpers.test.ts new file mode 100644 index 0000000000..00bdd3e321 --- /dev/null +++ b/apps/cli/src/service/install_helpers.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { writeUnitFile } from "./install_helpers"; + +const MANAGED_HEADER = "# managed by kandev — regenerated by `kandev service install`\n"; + +describe("writeUnitFile", () => { + let tmpDir: string; + let target: string; + let logs: string[]; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-svc-")); + target = path.join(tmpDir, "kandev.service"); + logs = []; + vi.spyOn(console, "log").mockImplementation((msg: unknown) => { + logs.push(String(msg)); + }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("returns 'created' when target does not exist", () => { + const outcome = writeUnitFile(target, `${MANAGED_HEADER}body=v1\n`); + expect(outcome).toBe("created"); + expect(fs.readFileSync(target, "utf8")).toBe(`${MANAGED_HEADER}body=v1\n`); + expect(logs.some((l) => l.includes("wrote"))).toBe(true); + }); + + it("returns 'unchanged' and does not rewrite when content is identical", () => { + fs.writeFileSync(target, `${MANAGED_HEADER}body=v1\n`); + const mtimeBefore = fs.statSync(target).mtimeMs; + // Wait a tick so any rewrite would change mtime + const outcome = writeUnitFile(target, `${MANAGED_HEADER}body=v1\n`); + expect(outcome).toBe("unchanged"); + expect(fs.statSync(target).mtimeMs).toBe(mtimeBefore); + expect(logs.some((l) => l.includes("up to date"))).toBe(true); + }); + + it("returns 'updated' when existing file is managed but content differs", () => { + fs.writeFileSync(target, `${MANAGED_HEADER}body=v1\n`); + const outcome = writeUnitFile(target, `${MANAGED_HEADER}body=v2\n`); + expect(outcome).toBe("updated"); + expect(fs.readFileSync(target, "utf8")).toBe(`${MANAGED_HEADER}body=v2\n`); + expect(logs.some((l) => l.includes("updated"))).toBe(true); + // No backup for managed updates + expect(fs.existsSync(`${target}.bak`)).toBe(false); + }); + + it("returns 'replaced-foreign' and backs up when existing file is not managed", () => { + fs.writeFileSync(target, "some other tool's unit\n[Service]\nExecStart=/bin/false\n"); + const outcome = writeUnitFile(target, `${MANAGED_HEADER}body=v2\n`); + expect(outcome).toBe("replaced-foreign"); + expect(fs.readFileSync(target, "utf8")).toBe(`${MANAGED_HEADER}body=v2\n`); + expect(fs.readFileSync(`${target}.bak`, "utf8")).toBe( + "some other tool's unit\n[Service]\nExecStart=/bin/false\n", + ); + expect(logs.some((l) => l.includes("WARNING"))).toBe(true); + }); +}); diff --git a/apps/cli/src/service/install_helpers.ts b/apps/cli/src/service/install_helpers.ts new file mode 100644 index 0000000000..adbd00bf59 --- /dev/null +++ b/apps/cli/src/service/install_helpers.ts @@ -0,0 +1,54 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; + +import { looksLikeManagedUnit } from "./templates"; + +/** Cheap PATH lookup; returns true if `cmd` resolves via `which`. */ +export function commandExists(cmd: string): boolean { + const res = spawnSync("which", [cmd], { stdio: "ignore" }); + return res.status === 0; +} + +export type WriteOutcome = "created" | "updated" | "unchanged" | "replaced-foreign"; + +/** + * Write `content` to `targetPath` with idempotent + foreign-file handling. + * + * - Missing file → created (no warning) + * - Existing managed file, same content → unchanged (no write, no warning) + * - Existing managed file, different content → updated (overwrite, brief log) + * - Existing file that doesn't look managed → replaced-foreign (overwrite, + * loud warning so the user notices we clobbered something) + * + * The "managed" check looks for kandev's marker substring; users who hand-edit + * past the marker lose the no-op shortcut but still get an "updated" log line, + * which is the expected workflow. + */ +export function writeUnitFile(targetPath: string, content: string): WriteOutcome { + if (!fs.existsSync(targetPath)) { + fs.writeFileSync(targetPath, content, { mode: 0o644 }); + console.log(`[kandev] wrote ${targetPath}`); + return "created"; + } + + const existing = fs.readFileSync(targetPath, "utf8"); + if (existing === content) { + console.log(`[kandev] ${targetPath} is already up to date`); + return "unchanged"; + } + + if (!looksLikeManagedUnit(existing)) { + console.log( + `[kandev] WARNING: ${targetPath} exists but doesn't look like a kandev-managed file.`, + ); + console.log(`[kandev] The existing file will be replaced. A backup is saved alongside.`); + fs.copyFileSync(targetPath, `${targetPath}.bak`); + fs.writeFileSync(targetPath, content, { mode: 0o644 }); + console.log(`[kandev] replaced ${targetPath} (backup: ${targetPath}.bak)`); + return "replaced-foreign"; + } + + fs.writeFileSync(targetPath, content, { mode: 0o644 }); + console.log(`[kandev] updated ${targetPath}`); + return "updated"; +} diff --git a/apps/cli/src/service/linux.ts b/apps/cli/src/service/linux.ts new file mode 100644 index 0000000000..c210c337cb --- /dev/null +++ b/apps/cli/src/service/linux.ts @@ -0,0 +1,174 @@ +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 { + 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 { + 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`); +} diff --git a/apps/cli/src/service/macos.test.ts b/apps/cli/src/service/macos.test.ts new file mode 100644 index 0000000000..3f140b9a27 --- /dev/null +++ b/apps/cli/src/service/macos.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { readInstalledLogPaths } from "./macos"; +import { renderLaunchdPlist } from "./templates"; + +describe("readInstalledLogPaths", () => { + let tmp: string; + let plistPath: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-macos-")); + plistPath = path.join(tmp, "com.kdlbs.kandev.plist"); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("returns null when the plist does not exist", () => { + expect(readInstalledLogPaths(plistPath)).toBeNull(); + }); + + it("returns null when the plist exists but lacks the keys", () => { + fs.writeFileSync(plistPath, ""); + expect(readInstalledLogPaths(plistPath)).toBeNull(); + }); + + it("decodes XML-escaped paths so they match real filesystem locations", () => { + // Regression: renderLaunchdPlist runs values through escapeXml, so paths + // containing &, <, etc. are stored escaped in the plist. Without the + // inverse decode, readInstalledLogPaths would return `/home/a&b/...` + // which never matches the actual `/home/a&b/...` file on disk. + const plist = renderLaunchdPlist({ + launcher: { + nodePath: "/usr/local/bin/node", + cliEntry: "/usr/local/lib/node_modules/kandev/bin/cli.js", + kind: "npm", + }, + homeDir: "/home/john&doe/.kandev", + logDir: "/home/john&doe/.kandev/logs", + mode: "user", + }); + fs.writeFileSync(plistPath, plist); + + const installed = readInstalledLogPaths(plistPath); + expect(installed).toEqual({ + out: "/home/john&doe/.kandev/logs/service.out", + err: "/home/john&doe/.kandev/logs/service.err", + }); + }); + + it("reads back the same paths the renderer wrote (round-trip with custom home dir)", () => { + // Regression for the showLogs-with-custom-home-dir bug: showLogs has no + // access to args.homeDir (install-only), so it must derive log paths + // from the installed plist or it'll look in the default directory while + // logs accumulate at the custom one. + const plist = renderLaunchdPlist({ + launcher: { + nodePath: "/usr/local/bin/node", + cliEntry: "/usr/local/lib/node_modules/kandev/bin/cli.js", + kind: "npm", + }, + homeDir: "/custom/install/path", + logDir: "/custom/install/path/logs", + mode: "user", + }); + fs.writeFileSync(plistPath, plist); + + const installed = readInstalledLogPaths(plistPath); + expect(installed).toEqual({ + out: "/custom/install/path/logs/service.out", + err: "/custom/install/path/logs/service.err", + }); + }); +}); diff --git a/apps/cli/src/service/macos.ts b/apps/cli/src/service/macos.ts new file mode 100644 index 0000000000..658201dea8 --- /dev/null +++ b/apps/cli/src/service/macos.ts @@ -0,0 +1,226 @@ +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 { + 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 { + 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"); + default: { + const _exhaustive: never = args.action; + throw new Error(`unhandled service action: ${_exhaustive as string}`); + } + } +} + +async function installAsync(ctx: Ctx): Promise { + 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); + fs.mkdirSync(logDir, { recursive: true }); + + const plist = renderLaunchdPlist({ + launcher, + homeDir, + logDir, + port: ctx.args.port, + systemUser: ctx.isSystem ? resolveServiceUser(true) : undefined, + mode: ctx.isSystem ? "system" : "user", + }); + + fs.mkdirSync(path.dirname(ctx.plistPath), { recursive: true }); + const outcome = writeUnitFile(ctx.plistPath, plist); + + // 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. + spawnSync("launchctl", ["bootout", ctx.target], { stdio: "ignore" }); + runLaunchctl(["bootstrap", 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 so start works whether the service was previously running or stopped. + spawnSync("launchctl", ["bootout", ctx.target], { stdio: "ignore" }); + runLaunchctl(["bootstrap", 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; + runLaunchctl(["bootstrap", 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 = /StandardOutPath<\/key>\s*([^<]+)<\/string>/.exec(content); + const errMatch = /StandardErrorPath<\/key>\s*([^<]+)<\/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")}`); +} diff --git a/apps/cli/src/service/paths.test.ts b/apps/cli/src/service/paths.test.ts new file mode 100644 index 0000000000..98267084c7 --- /dev/null +++ b/apps/cli/src/service/paths.test.ts @@ -0,0 +1,104 @@ +import os from "node:os"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveHomeDir, resolveServiceUser } from "./paths"; + +describe("resolveServiceUser", () => { + const originalSudoUser = process.env.SUDO_USER; + + beforeEach(() => { + delete process.env.SUDO_USER; + }); + + afterEach(() => { + if (originalSudoUser === undefined) delete process.env.SUDO_USER; + else process.env.SUDO_USER = originalSudoUser; + vi.restoreAllMocks(); + }); + + it("returns the current user for user-mode installs", () => { + vi.spyOn(os, "userInfo").mockReturnValue({ + username: "alice", + uid: 1000, + gid: 1000, + shell: null, + homedir: "/home/alice", + }); + process.env.SUDO_USER = "bob"; // ignored in user mode + expect(resolveServiceUser(false)).toBe("alice"); + }); + + it("prefers SUDO_USER for system-mode installs", () => { + vi.spyOn(os, "userInfo").mockReturnValue({ + username: "root", + uid: 0, + gid: 0, + shell: null, + homedir: "/root", + }); + process.env.SUDO_USER = "alice"; + expect(resolveServiceUser(true)).toBe("alice"); + }); + + it("falls back to current user when SUDO_USER is not set", () => { + vi.spyOn(os, "userInfo").mockReturnValue({ + username: "root", + uid: 0, + gid: 0, + shell: null, + homedir: "/root", + }); + expect(resolveServiceUser(true)).toBe("root"); + }); + + it("ignores SUDO_USER=root and falls through to current user", () => { + // sudo never sets SUDO_USER=root in practice, but defend against it + // so we don't accidentally pin the daemon to root by accident. + vi.spyOn(os, "userInfo").mockReturnValue({ + username: "alice", + uid: 1000, + gid: 1000, + shell: null, + homedir: "/home/alice", + }); + process.env.SUDO_USER = "root"; + expect(resolveServiceUser(true)).toBe("alice"); + }); +}); + +describe("resolveHomeDir", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns an absolute override as-is (no tilde)", () => { + expect(resolveHomeDir("/srv/kandev", false)).toBe("/srv/kandev"); + }); + + it("expands a leading ~/ to os.homedir()", () => { + vi.spyOn(os, "homedir").mockReturnValue("/home/alice"); + expect(resolveHomeDir("~/kandev", false)).toBe("/home/alice/kandev"); + expect(resolveHomeDir("~/srv/nested", false)).toBe("/home/alice/srv/nested"); + }); + + it("expands a bare ~ to os.homedir()", () => { + vi.spyOn(os, "homedir").mockReturnValue("/home/alice"); + expect(resolveHomeDir("~", false)).toBe("/home/alice"); + }); + + it("does not expand a tilde that isn't at the start", () => { + expect(resolveHomeDir("/srv/~/data", false)).toBe("/srv/~/data"); + }); + + it("falls back to /var/lib/kandev for system mode with no override", () => { + expect(resolveHomeDir(undefined, true)).toBe("/var/lib/kandev"); + }); + + it("falls back to ~/.kandev for user mode with no override", () => { + // KANDEV_HOME_DIR is the constant from ../constants, computed at import + // time. We just check it ends with .kandev and starts with the home dir. + const result = resolveHomeDir(undefined, false); + expect(result.endsWith(".kandev")).toBe(true); + }); +}); diff --git a/apps/cli/src/service/paths.ts b/apps/cli/src/service/paths.ts new file mode 100644 index 0000000000..b211349008 --- /dev/null +++ b/apps/cli/src/service/paths.ts @@ -0,0 +1,138 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { KANDEV_HOME_DIR } from "../constants"; + +/** Service unit/plist locations. Single source of truth for where kandev */ +/** writes/reads its unit files — linux.ts, macos.ts, config.ts, stale_check.ts */ +/** all consume these. Exposed as functions (not eager constants) so tests can */ +/** mock `os.homedir()` between cases. */ +export const SERVICE_NAME = "kandev"; +export const LAUNCHD_LABEL = "com.kdlbs.kandev"; + +export const LINUX_SYSTEM_UNIT_DIR = "/etc/systemd/system"; +export const MACOS_SYSTEM_DAEMON_DIR = "/Library/LaunchDaemons"; + +export function linuxUserUnitDir(): string { + return path.join(os.homedir(), ".config", "systemd", "user"); +} +export function linuxUserUnitPath(): string { + return path.join(linuxUserUnitDir(), `${SERVICE_NAME}.service`); +} +export function linuxSystemUnitPath(): string { + return path.join(LINUX_SYSTEM_UNIT_DIR, `${SERVICE_NAME}.service`); +} + +export function macosUserAgentDir(): string { + return path.join(os.homedir(), "Library", "LaunchAgents"); +} +export function macosUserPlistPath(): string { + return path.join(macosUserAgentDir(), `${LAUNCHD_LABEL}.plist`); +} +export function macosSystemPlistPath(): string { + return path.join(MACOS_SYSTEM_DAEMON_DIR, `${LAUNCHD_LABEL}.plist`); +} + +export type LauncherKind = "homebrew" | "npm" | "unknown"; + +export type LauncherInfo = { + /** Absolute path to node executable (process.execPath at install time). */ + nodePath: string; + /** Absolute path to the cli.js entry point. */ + cliEntry: string; + /** Best-guess of how kandev was installed. Used to seed env vars. */ + kind: LauncherKind; + /** KANDEV_BUNDLE_DIR if set (Homebrew sets this). */ + bundleDir?: string; + /** KANDEV_VERSION if set (Homebrew sets this). */ + version?: string; +}; + +/** + * Snapshot the current invocation so the service unit can faithfully reproduce it. + * + * The unit file hard-codes absolute paths because systemd/launchd start with an + * empty PATH and may not see the user's `node` or `kandev` shim. By recording + * `process.execPath` (node) and `process.argv[1]` (cli.js) at install time we + * avoid any PATH lookups at service-run time. + */ +export function captureLauncher(): LauncherInfo { + const nodePath = process.execPath; + const cliEntry = resolveCliEntry(); + const bundleDir = process.env.KANDEV_BUNDLE_DIR; + const version = process.env.KANDEV_VERSION; + const kind: LauncherKind = bundleDir + ? "homebrew" + : cliEntry.includes(`${path.sep}node_modules${path.sep}`) + ? "npm" + : "unknown"; + return { nodePath, cliEntry, kind, bundleDir, version }; +} + +function resolveCliEntry(): string { + const argvEntry = process.argv[1]; + if (argvEntry && fs.existsSync(argvEntry)) { + return path.resolve(argvEntry); + } + throw new Error( + "could not resolve the kandev CLI entry path from process.argv[1]; " + + "rerun via the kandev binary", + ); +} + +/** Resolve the home directory used for the unit's KANDEV_HOME_DIR env. */ +export function resolveHomeDir(override: string | undefined, runAsRoot: boolean): string { + if (override) { + // Node's path.resolve doesn't expand `~`; users often type `--home-dir ~/foo` + // (especially via shell escapes that defer expansion), so do it ourselves. + const expanded = + override === "~" || override.startsWith(`~${path.sep}`) + ? path.join(os.homedir(), override.slice(1)) + : override; + return path.resolve(expanded); + } + if (runAsRoot) { + // System units default to /var/lib/kandev so root-owned data lives outside any + // single user's $HOME (where it would be unreachable to other users). + return "/var/lib/kandev"; + } + return KANDEV_HOME_DIR; +} + +/** Absolute path to the log directory used by the unit for stdout/stderr. */ +export function resolveLogDir(homeDir: string): string { + return path.join(homeDir, "logs"); +} + +/** Current username (the EUID the CLI is running as). */ +export function currentUsername(): string { + return os.userInfo().username; +} + +/** + * Resolve which user the service should run as. + * + * For user-mode installs this is always the current user (matters for hints + * printed back to the user, not for the unit itself — user units don't set + * `User=`). + * + * For system-mode installs the CLI is typically invoked via sudo, which makes + * `os.userInfo().username` resolve to `root`. We prefer `SUDO_USER` so the + * daemon runs as the human who installed it (with access to their `~/.kandev`, + * git config, agent CLI credentials, etc) rather than as root. + * + * If the user genuinely wants a root-owned daemon they can run sudo with + * `-E` stripped or pass `--run-as root` (future flag) — but the common case + * (`sudo kandev service install --system`) gets the safe default. + */ +export function resolveServiceUser(isSystem: boolean): string { + if (!isSystem) { + return currentUsername(); + } + const sudoUser = process.env.SUDO_USER; + if (sudoUser && sudoUser !== "root") { + return sudoUser; + } + return currentUsername(); +} diff --git a/apps/cli/src/service/stale_check.test.ts b/apps/cli/src/service/stale_check.test.ts new file mode 100644 index 0000000000..d5b06da907 --- /dev/null +++ b/apps/cli/src/service/stale_check.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { detectStaleServiceUnit } from "./stale_check"; + +// Stable values; the actual node + cli paths the renderer sees would normally +// come from process.execPath / process.argv[1], which point to whatever's +// running vitest. We override them in tests to make assertions deterministic. +const NODE_PATH = "/usr/local/bin/node"; +const CLI_ENTRY = path.resolve(__dirname, "../../bin/cli.js"); + +describe("detectStaleServiceUnit", () => { + let tmpHome: string; + let unitPath: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-stale-")); + vi.spyOn(os, "homedir").mockReturnValue(tmpHome); + vi.spyOn(process, "execPath", "get").mockReturnValue(NODE_PATH); + // argv[1] must exist on disk — captureLauncher() validates via existsSync. + const realArgv = [...process.argv]; + realArgv[1] = CLI_ENTRY; + vi.spyOn(process, "argv", "get").mockReturnValue(realArgv); + + if (process.platform === "linux") { + unitPath = path.join(tmpHome, ".config", "systemd", "user", "kandev.service"); + } else if (process.platform === "darwin") { + unitPath = path.join(tmpHome, "Library", "LaunchAgents", "com.kdlbs.kandev.plist"); + } else { + unitPath = ""; + } + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("returns null when no unit file is installed", () => { + expect(detectStaleServiceUnit()).toBeNull(); + }); + + it("returns null when unit references the current paths", () => { + if (!unitPath) return; // unsupported platform — skip + fs.mkdirSync(path.dirname(unitPath), { recursive: true }); + fs.writeFileSync( + unitPath, + `# managed by kandev\nExecStart=${NODE_PATH} ${CLI_ENTRY} --headless\n`, + ); + expect(detectStaleServiceUnit()).toBeNull(); + }); + + it("returns a warning when the cli entry no longer matches", () => { + if (!unitPath) return; + fs.mkdirSync(path.dirname(unitPath), { recursive: true }); + fs.writeFileSync( + unitPath, + `# managed by kandev\nExecStart=${NODE_PATH} /old/path/cli.js --headless\n`, + ); + const msg = detectStaleServiceUnit(); + expect(msg).not.toBeNull(); + expect(msg).toMatch(/service install/); + }); + + it("returns null for non-managed files at the same path", () => { + if (!unitPath) return; + fs.mkdirSync(path.dirname(unitPath), { recursive: true }); + fs.writeFileSync(unitPath, `[Unit]\nDescription=Something else\n`); + expect(detectStaleServiceUnit()).toBeNull(); + }); +}); diff --git a/apps/cli/src/service/stale_check.ts b/apps/cli/src/service/stale_check.ts new file mode 100644 index 0000000000..8b15a3a646 --- /dev/null +++ b/apps/cli/src/service/stale_check.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; + +import { captureLauncher, linuxUserUnitPath, macosUserPlistPath } from "./paths"; +import { looksLikeManagedUnit } from "./templates"; + +/** + * Locations of the user-mode service unit we manage. We skip system-mode + * locations because (a) they require sudo to fix anyway, (b) reading from + * arbitrary `/etc` paths from a regular launch is noisier than worth it. + */ +function userModeUnitPath(): string | null { + if (process.platform === "linux") return linuxUserUnitPath(); + if (process.platform === "darwin") return macosUserPlistPath(); + return null; +} + +/** + * Check whether an installed user-mode service unit still references the + * current invocation's paths. + * + * Called once per interactive `kandev` start (skipped for `kandev service ...` + * commands and headless service runs). The check is intentionally cheap and + * silent on the happy path — only emits when a problem is detected — so it's + * fine to run unconditionally. + * + * Returns the warning message to print, or null when there's nothing to say. + */ +export function detectStaleServiceUnit(): string | null { + const unitPath = userModeUnitPath(); + if (!unitPath) return null; + + let content: string; + try { + content = fs.readFileSync(unitPath, "utf8"); + } catch { + return null; // no unit installed + } + if (!looksLikeManagedUnit(content)) return null; + + let launcher: ReturnType; + try { + launcher = captureLauncher(); + } catch { + // captureLauncher throws when process.argv[1] is missing — extremely rare, + // but if it happens we can't tell whether the unit is stale or not. + return null; + } + // Stale = the unit's hard-coded paths no longer match the running binary. + // We match on substring rather than parsing the unit so the check works for + // both systemd Environment= lines and plist entries. + const nodeFresh = content.includes(launcher.nodePath); + const cliFresh = content.includes(launcher.cliEntry); + if (nodeFresh && cliFresh) return null; + + return ( + `Your installed kandev service unit (${unitPath}) references paths that\n` + + ` no longer match this binary. This usually happens after upgrading via\n` + + ` npm or Homebrew. Re-run 'kandev service install' to refresh the unit.` + ); +} + +/** Convenience: detect + print to stderr in one call. Safe to call from cli.ts. */ +export function warnIfStaleServiceUnit(): void { + try { + const msg = detectStaleServiceUnit(); + if (msg) { + process.stderr.write(`[kandev] notice: ${msg}\n`); + } + } catch { + // Never let a diagnostic check break startup. + } +} diff --git a/apps/cli/src/service/templates.test.ts b/apps/cli/src/service/templates.test.ts new file mode 100644 index 0000000000..c036241ea6 --- /dev/null +++ b/apps/cli/src/service/templates.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; + +import type { LauncherInfo } from "./paths"; +import { renderLaunchdPlist, renderSystemdUnit } from "./templates"; + +const NPM_LAUNCHER: LauncherInfo = { + nodePath: "/usr/local/bin/node", + cliEntry: "/usr/local/lib/node_modules/kandev/bin/cli.js", + kind: "npm", +}; + +const BREW_LAUNCHER: LauncherInfo = { + nodePath: "/opt/homebrew/bin/node", + cliEntry: "/opt/homebrew/opt/kandev/libexec/cli/bin/cli.js", + kind: "homebrew", + bundleDir: "/opt/homebrew/opt/kandev/libexec", + version: "0.49.0", +}; + +describe("renderSystemdUnit", () => { + it("renders a user unit with absolute paths and --headless", () => { + const unit = renderSystemdUnit({ + launcher: NPM_LAUNCHER, + homeDir: "/home/alice/.kandev", + logDir: "/home/alice/.kandev/logs", + mode: "user", + }); + expect(unit).toContain( + "ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/kandev/bin/cli.js --headless", + ); + expect(unit).toContain("Environment=KANDEV_HOME_DIR=/home/alice/.kandev"); + expect(unit).toContain("WantedBy=default.target"); + expect(unit).not.toContain("User="); + expect(unit).not.toContain("KANDEV_BUNDLE_DIR"); + expect(unit).not.toContain("KANDEV_SERVER_PORT"); + }); + + it("sets WantedBy=multi-user.target and User= for system mode", () => { + const unit = renderSystemdUnit({ + launcher: NPM_LAUNCHER, + homeDir: "/var/lib/kandev", + logDir: "/var/lib/kandev/logs", + mode: "system", + systemUser: "alice", + }); + expect(unit).toContain("WantedBy=multi-user.target"); + expect(unit).toContain("User=alice"); + }); + + it("bakes in Homebrew env vars when present on launcher", () => { + const unit = renderSystemdUnit({ + launcher: BREW_LAUNCHER, + homeDir: "/home/alice/.kandev", + logDir: "/home/alice/.kandev/logs", + mode: "user", + }); + expect(unit).toContain("Environment=KANDEV_BUNDLE_DIR=/opt/homebrew/opt/kandev/libexec"); + expect(unit).toContain("Environment=KANDEV_VERSION=0.49.0"); + }); + + it("bakes in KANDEV_SERVER_PORT when port is set", () => { + const unit = renderSystemdUnit({ + launcher: NPM_LAUNCHER, + homeDir: "/home/alice/.kandev", + logDir: "/home/alice/.kandev/logs", + mode: "user", + port: 9000, + }); + expect(unit).toContain("Environment=KANDEV_SERVER_PORT=9000"); + }); + + it("quotes ExecStart paths that contain spaces", () => { + const unit = renderSystemdUnit({ + launcher: { + nodePath: "/Library/Application Support/node", + cliEntry: "/Library/Application Support/kandev/cli.js", + kind: "unknown", + }, + homeDir: "/home/alice/.kandev", + logDir: "/home/alice/.kandev/logs", + mode: "user", + }); + expect(unit).toContain( + `ExecStart="/Library/Application Support/node" "/Library/Application Support/kandev/cli.js" --headless`, + ); + }); +}); + +describe("renderLaunchdPlist", () => { + it("renders a user-agent plist with KeepAlive and --headless", () => { + const plist = renderLaunchdPlist({ + launcher: NPM_LAUNCHER, + homeDir: "/Users/alice/.kandev", + logDir: "/Users/alice/.kandev/logs", + mode: "user", + }); + expect(plist).toContain("com.kdlbs.kandev"); + expect(plist).toContain("/usr/local/bin/node"); + expect(plist).toContain("--headless"); + expect(plist).toContain("KeepAlive"); + expect(plist).toContain("RunAtLoad"); + expect(plist).toContain("/Users/alice/.kandev/logs/service.err"); + expect(plist).toContain("KANDEV_HOME_DIR"); + expect(plist).not.toContain("KANDEV_BUNDLE_DIR"); + }); + + it("escapes XML special characters in paths", () => { + const plist = renderLaunchdPlist({ + launcher: { + nodePath: "/path/with/<&'\">/node", + cliEntry: "/path/cli.js", + kind: "unknown", + }, + homeDir: "/Users/alice/.kandev", + logDir: "/Users/alice/.kandev/logs", + mode: "user", + }); + expect(plist).toContain("/path/with/<&'">/node"); + expect(plist).not.toContain("<&'\""); + }); + + it("includes Homebrew env vars when present", () => { + const plist = renderLaunchdPlist({ + launcher: BREW_LAUNCHER, + homeDir: "/Users/alice/.kandev", + logDir: "/Users/alice/.kandev/logs", + mode: "user", + }); + expect(plist).toContain("KANDEV_BUNDLE_DIR"); + expect(plist).toContain("/opt/homebrew/opt/kandev/libexec"); + expect(plist).toContain("KANDEV_VERSION"); + }); + + it("quotes Environment= lines when value contains a space (greptile P1 regression)", () => { + const unit = renderSystemdUnit({ + launcher: NPM_LAUNCHER, + homeDir: "/home/john doe/.kandev", + logDir: "/home/john doe/.kandev/logs", + mode: "user", + }); + // The whole assignment must be wrapped, not just the value. + expect(unit).toContain('Environment="KANDEV_HOME_DIR=/home/john doe/.kandev"'); + // PATH always contains colons but no spaces — should NOT be quoted. + expect(unit).toMatch(/^Environment=PATH=\/usr\/local\/bin/m); + }); + + it("escapes backslash + double-quote in Environment= and ExecStart values", () => { + const unit = renderSystemdUnit({ + launcher: { + nodePath: 'C:\\Program Files\\node "Node"\\node.exe', + cliEntry: "/home/alice/cli.js", + kind: "unknown", + }, + homeDir: "C:\\Program Files\\kandev", + logDir: "/var/log", + mode: "user", + }); + // Backslashes doubled, quotes escaped, whole thing wrapped. + expect(unit).toContain('Environment="KANDEV_HOME_DIR=C:\\\\Program Files\\\\kandev"'); + expect(unit).toContain( + 'ExecStart="C:\\\\Program Files\\\\node \\"Node\\"\\\\node.exe" /home/alice/cli.js --headless', + ); + }); + + it("emits UserName for system mode with systemUser", () => { + const plist = renderLaunchdPlist({ + launcher: NPM_LAUNCHER, + homeDir: "/var/lib/kandev", + logDir: "/var/lib/kandev/logs", + mode: "system", + systemUser: "alice", + }); + expect(plist).toContain("UserName"); + expect(plist).toContain("alice"); + }); + + it("omits UserName for user mode", () => { + const plist = renderLaunchdPlist({ + launcher: NPM_LAUNCHER, + homeDir: "/Users/alice/.kandev", + logDir: "/Users/alice/.kandev/logs", + mode: "user", + }); + expect(plist).not.toContain("UserName"); + }); +}); diff --git a/apps/cli/src/service/templates.ts b/apps/cli/src/service/templates.ts new file mode 100644 index 0000000000..a6076630fd --- /dev/null +++ b/apps/cli/src/service/templates.ts @@ -0,0 +1,183 @@ +import type { LauncherInfo } from "./paths"; + +export type UnitInputs = { + launcher: LauncherInfo; + homeDir: string; + logDir: string; + /** When set, baked into the unit as KANDEV_SERVER_PORT. */ + port?: number; + /** + * User the service should run as. Set for system-mode installs so the + * daemon doesn't run as root (systemd `User=` / launchd `UserName=`). + * User-mode units leave this unset — they run as the installing user. + */ + systemUser?: string; + /** Mode controls WantedBy and User= directives. */ + mode: "user" | "system"; +}; + +const SYSTEMD_PATH = + "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin"; +const LAUNCHD_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + +/** + * Marker substring baked into every unit/plist kandev writes. Used to safely + * detect "this file is ours, overwriting is fine" vs "user has put something + * else here, warn loudly before replacing". + */ +export const MANAGED_MARKER_TEXT = "managed by kandev"; +const SYSTEMD_MARKER = `# ${MANAGED_MARKER_TEXT} — regenerated by \`kandev service install\``; +const PLIST_MARKER = ``; + +export function looksLikeManagedUnit(content: string): boolean { + return content.includes(MANAGED_MARKER_TEXT); +} + +/** + * Render a systemd unit file for kandev. + * + * The unit hard-codes absolute paths so it works without a user PATH. We pass + * `--headless` so the daemon doesn't try to open a browser. KANDEV_BUNDLE_DIR / + * KANDEV_VERSION are surfaced only when present (Homebrew installs only) so + * `npm i -g` installs don't get spurious env vars. + */ +export function renderSystemdUnit(input: UnitInputs): string { + const env: string[] = [ + envLine("KANDEV_HOME_DIR", input.homeDir), + envLine("KANDEV_LOG_LEVEL", "info"), + envLine("PATH", SYSTEMD_PATH), + ]; + if (input.port !== undefined) { + env.push(envLine("KANDEV_SERVER_PORT", String(input.port))); + } + if (input.launcher.bundleDir) { + env.push(envLine("KANDEV_BUNDLE_DIR", input.launcher.bundleDir)); + } + if (input.launcher.version) { + env.push(envLine("KANDEV_VERSION", input.launcher.version)); + } + + const wantedBy = input.mode === "system" ? "multi-user.target" : "default.target"; + const userLine = input.mode === "system" && input.systemUser ? `User=${input.systemUser}\n` : ""; + + const exec = `${quoteForUnit(input.launcher.nodePath)} ${quoteForUnit(input.launcher.cliEntry)} --headless`; + + return `${SYSTEMD_MARKER} +[Unit] +Description=Kandev autonomous agent platform +Documentation=https://github.com/kdlbs/kandev +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=${exec} +${userLine}${env.join("\n")} +Restart=on-failure +RestartSec=5s +KillMode=mixed +TimeoutStopSec=30s + +[Install] +WantedBy=${wantedBy} +`; +} + +/** + * Render a launchd plist for kandev. + * + * launchd has no equivalent to systemd's enable-linger: a user agent only runs + * while the user is logged in. For a Mac-as-server scenario, use --system to + * get a LaunchDaemon that runs at boot regardless of login. + */ +export function renderLaunchdPlist(input: UnitInputs): string { + const envEntries: Array<[string, string]> = [ + ["KANDEV_HOME_DIR", input.homeDir], + ["KANDEV_LOG_LEVEL", "info"], + ["PATH", LAUNCHD_PATH], + ]; + if (input.port !== undefined) { + envEntries.push(["KANDEV_SERVER_PORT", String(input.port)]); + } + if (input.launcher.bundleDir) { + envEntries.push(["KANDEV_BUNDLE_DIR", input.launcher.bundleDir]); + } + if (input.launcher.version) { + envEntries.push(["KANDEV_VERSION", input.launcher.version]); + } + + const envXml = envEntries + .map(([k, v]) => ` ${escapeXml(k)}\n ${escapeXml(v)}`) + .join("\n"); + + const args = [input.launcher.nodePath, input.launcher.cliEntry, "--headless"]; + const argsXml = args.map((a) => ` ${escapeXml(a)}`).join("\n"); + + // For system-mode LaunchDaemons, run as a specific user instead of root. + // For user agents this directive is omitted — the agent already runs as + // the loading user. + const userBlock = + input.mode === "system" && input.systemUser + ? ` UserName\n ${escapeXml(input.systemUser)}\n` + : ""; + + return ` +${PLIST_MARKER} + + + + Label + com.kdlbs.kandev + ProgramArguments + +${argsXml} + + EnvironmentVariables + +${envXml} + +${userBlock} RunAtLoad + + KeepAlive + + StandardOutPath + ${escapeXml(`${input.logDir}/service.out`)} + StandardErrorPath + ${escapeXml(`${input.logDir}/service.err`)} + WorkingDirectory + ${escapeXml(input.homeDir)} + + +`; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// systemd unit "Environment=" lines can contain spaces by wrapping in double quotes. +// ExecStart needs special handling for spaces too — paths with spaces must be +// double-quoted per systemd.unit(5). Escape backslashes before double-quotes so +// a literal `\` in a path is preserved instead of merging with the following +// character into an escape sequence. +function quoteForUnit(value: string): string { + if (!/[\s"\\]/.test(value)) return value; + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `"${escaped}"`; +} + +// Render a systemd `Environment=KEY=value` line, quoting the whole assignment +// when the value contains whitespace, double-quote, or backslash. Without +// quoting, systemd splits the line on the first space and treats subsequent +// tokens as separate KEY=value pairs — which silently corrupts paths like +// `/home/john doe/.kandev`. +function envLine(key: string, value: string): string { + if (!/[\s"\\]/.test(value)) return `Environment=${key}=${value}`; + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `Environment="${key}=${escaped}"`; +} diff --git a/docs/run-as-a-service.md b/docs/run-as-a-service.md new file mode 100644 index 0000000000..998c81c546 --- /dev/null +++ b/docs/run-as-a-service.md @@ -0,0 +1,175 @@ +# Run Kandev as a Service + +Install Kandev as an OS-managed service (systemd on Linux, launchd on macOS) so it auto-starts and stays running. Updates remain manual: `npm i -g kandev@latest` or `brew upgrade kandev`, then re-run `kandev service install`. + +This guide assumes you've already installed kandev via [Homebrew or npm](../apps/cli/README.md#quick-start) and that `kandev` works when run interactively. + +> **Windows:** not yet supported. See [open issues mentioning Windows](https://github.com/kdlbs/kandev/issues?q=is%3Aissue+windows) for SCM support progress, or open a new one if there isn't one yet. + +## Quick Start + +```bash +# Laptop / single-user — runs as you, starts when you log in: +kandev service install + +# Linux VPS / shared box — runs at boot, no login required: +sudo kandev service install --system +``` + +After install, kandev is reachable at `http://localhost:38429` (or `--port ` if you passed it). + +## User Mode vs `--system` Mode + +| | **User mode** (default) | **`--system` mode** | +| -------------------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Install requires sudo | No | Yes | +| Unit location (Linux) | `~/.config/systemd/user/kandev.service` | `/etc/systemd/system/kandev.service` | +| Unit location (macOS) | `~/Library/LaunchAgents/com.kdlbs.kandev.plist` | `/Library/LaunchDaemons/com.kdlbs.kandev.plist` | +| Daemon runs as | You | `$SUDO_USER` if invoked via `sudo`, else the current user | +| Auto-starts on reboot | **Linux:** only after `sudo loginctl enable-linger $USER` (run once). **macOS:** only at next login. | Always, regardless of login state | +| Survives logout / SSH disconnect | **Linux:** yes, after linger. **macOS:** no (use `--system` instead). | Yes | +| Default `KANDEV_HOME_DIR` | `~/.kandev` | `/var/lib/kandev` | +| Logs | `journalctl --user-unit kandev` (Linux) · `~/.kandev/logs/service.err` (macOS) | `sudo journalctl -u kandev` (Linux) · `/var/lib/kandev/logs/service.err` (macOS) | +| Best for | Laptop, workstation | Headless Linux VPS, Mac mini server, shared box | + +**30-second rule of thumb: VPS → `--system`. Laptop → default.** + +Linux user-mode with `loginctl enable-linger` is functionally equivalent to system mode for a single-user VPS, but the linger one-liner itself requires sudo — so you're not avoiding sudo, just deferring it. For a VPS, `--system` is one less thing to remember after a reboot. + +## Commands + +```bash +kandev service install [--system] [--port ] [--home-dir ] [--no-boot-start] +kandev service uninstall [--system] +kandev service start|stop|restart|status [--system] +kandev service logs [-f] [--system] +kandev service config [--system] +``` + +| Command | What it does | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `install` | Writes the unit file, reloads the service manager, enables auto-start, and starts the service. Then polls `/health` for up to 30s and dumps logs if it doesn't come up. | +| `uninstall` | Stops the service, disables auto-start, removes the unit file. | +| `start` / `stop` / `restart` | Control the running service without touching the unit file. | +| `status` | Print the OS service-manager view (systemd / launchctl). | +| `logs [-f]` | Dump the last 200 lines, or stream if `-f`. journalctl on Linux; `tail` on macOS log files. | +| `config` | Print the resolved paths, env vars, and whether the service is currently installed / active. Useful for diagnosis — read-only, no privileges needed. | + +### Flags + +- `--system` — system-level install. Requires sudo. See the comparison above. +- `--port ` — bake `KANDEV_SERVER_PORT=` into the unit. Defaults to 38429. +- `--home-dir ` — bake `KANDEV_HOME_DIR=` into the unit. Defaults to `~/.kandev` (user mode) or `/var/lib/kandev` (`--system`). +- `--no-boot-start` — Linux user-mode only. Skip the `loginctl enable-linger` hint at the end of install. +- `-f`, `--follow` — only for `logs`. Stream rather than dump. + +## After an Upgrade + +Both `npm` and Homebrew install kandev under a versioned directory (e.g. `node_modules/kandev/0.49.0/`, `Cellar/kandev/0.49.0/`). When you upgrade, the old version directory is removed and replaced — but the unit file you installed earlier still hard-codes the old absolute path. + +**Fix:** re-run install. It's idempotent. + +```bash +npm i -g kandev@latest # or: brew upgrade kandev +kandev service install # rewrites the unit with the new paths +``` + +If you launch `kandev` interactively after an upgrade, it will detect a stale unit and print a one-line reminder. You can also check with `kandev service config` — the `cli entry:` and `node path:` lines tell you the paths that *would* be baked in by the next install. + +## Linux Boot-Start (`loginctl enable-linger`) + +Linux user services normally run only while you're logged in. To keep kandev running across reboots without an active SSH session, run **once**: + +```bash +sudo loginctl enable-linger $USER +``` + +After this, your user's systemd instance is started at boot by `systemd-logind`, and your enabled user units (including kandev) start with it. To disable later: + +```bash +sudo loginctl disable-linger $USER +``` + +If you'd rather not deal with linger and you're already comfortable with sudo, install with `--system` instead — it sidesteps the issue entirely. + +## What's Inside the Unit File + +The unit hard-codes absolute paths so it works in the empty `PATH` that systemd/launchd give a fresh service. A typical Linux user unit looks like: + +```ini +# managed by kandev — regenerated by `kandev service install` +[Unit] +Description=Kandev autonomous agent platform +Documentation=https://github.com/kdlbs/kandev +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/kandev/bin/cli.js --headless +Environment=KANDEV_HOME_DIR=/home/alice/.kandev +Environment=KANDEV_LOG_LEVEL=info +Environment=PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin +Restart=on-failure +RestartSec=5s +KillMode=mixed +TimeoutStopSec=30s + +[Install] +WantedBy=default.target +``` + +The `--headless` flag tells the CLI not to open a browser (you'll connect to it remotely or via `localhost`). + +## Troubleshooting + +### `systemctl: command not found` / `launchctl: command not found` + +Kandev's service support requires either systemd (most Linux distros) or launchd (macOS). It does not currently support OpenRC, SysV init, or Windows SCM. You can still run kandev as a daemonized process with `nohup` / `screen` / `tmux`, or wrap it in your init system of choice using the launcher info from `kandev service config`. + +### "service did not become healthy within 30s" + +The install succeeded (unit file written, service told to start) but kandev's HTTP `/health` endpoint never responded. `install` dumps the last 50 lines of logs when this happens — common causes: + +- Port already in use → pass `--port `. +- Cold-disk + slow first launch → re-run `kandev service install`; the second start is usually fast enough. +- Missing dependency on the unit's `PATH` (e.g. `git`, `docker`) → install the missing tool and `kandev service restart`. + +### The unit warns about a "file that doesn't look like a kandev-managed file" + +`kandev service install` refuses to silently clobber files it didn't write. If you (or another tool) had previously put something at `~/.config/systemd/user/kandev.service` or the equivalent on macOS, install will: + +1. Copy the existing file to `.bak` +2. Write the kandev unit in its place +3. Print a `WARNING` line so you notice + +Inspect the `.bak` if you're not sure what was there. + +### Service runs as `root` when you wanted it to run as your user + +This happens with `--system` if `SUDO_USER` isn't set (e.g. you logged in as root directly rather than `sudo`'ing). Either run install via `sudo` from your normal user, or hand-edit the `User=` (Linux) / `UserName=` (macOS) directive in the unit file. + +### After upgrading, the service silently keeps running the old version + +The OS service manager keeps running whatever `ExecStart` it has — it doesn't know about npm/brew upgrades. **Always re-run `kandev service install` after an upgrade** so the unit picks up the new paths, then `kandev service restart` to pick up new code. + +## Updating: TL;DR + +```bash +# 1. Update the binary +npm i -g kandev@latest # or: brew upgrade kandev + +# 2. Refresh the unit file (rewrites paths to point at the new version) +kandev service install # add --system if that's what you used originally + +# 3. (install does this automatically) Verify it came back up +kandev service status +``` + +## Uninstalling + +```bash +kandev service uninstall # or: sudo kandev service uninstall --system +``` + +This stops the service, removes its unit/plist, and reloads the service manager. Data in `~/.kandev` (or `/var/lib/kandev`) is left intact — delete it manually if you want a clean slate.