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)
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 `&`-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.