feat(cli): kandev service install for systemd and launchd (#926)

This commit is contained in:
Carlos Florêncio
2026-05-17 21:12:20 +01:00
committed by GitHub
parent e82859990e
commit c62739d3c1
24 changed files with 2180 additions and 1 deletions
+1 -1
View File
@@ -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)
<p align="center">
<img src="docs/screenshots/readme-intro.gif" alt="Kandev Demo">
+11
View File
@@ -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
+6
View File
@@ -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");
+6
View File
@@ -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 };
}
+22
View File
@@ -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 <port>] [--verbose] [--debug]
kandev [--port <port>] [--verbose] [--debug]
kandev --dev [--port <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<void> {
// 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<void> {
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<void> {
webPort,
verbose: options.verbose,
debug: options.debug,
headless: options.headless,
});
}
+7
View File
@@ -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<void> {
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);
}
+99
View File
@@ -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);
});
});
+137
View File
@@ -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<ServiceAction>([
"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<keyof ServiceArgs> = ["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;
}
+117
View File
@@ -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;
}
}
+88
View File
@@ -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");
});
});
+91
View File
@@ -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<void> {
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`,
);
}
}
+63
View File
@@ -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 <port>] [--home-dir <path>] [--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 <user>' 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 <port> Backend port baked into the unit (KANDEV_SERVER_PORT).
--home-dir <path> 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<void> {
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).`,
);
}
}
@@ -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);
});
});
+54
View File
@@ -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";
}
+174
View File
@@ -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<void> {
if (!commandExists("systemctl")) {
throw new Error("systemctl not found. Linux service install requires systemd.");
}
const ctx = makeCtx(args);
switch (args.action) {
case "install":
return installAsync(ctx);
case "uninstall":
return uninstall(ctx);
case "start":
return runSystemctl(ctx, ["start", SERVICE_NAME]);
case "stop":
return runSystemctl(ctx, ["stop", SERVICE_NAME]);
case "restart":
return runSystemctl(ctx, ["restart", SERVICE_NAME]);
case "status":
return runSystemctl(ctx, ["status", SERVICE_NAME], { allowFailure: true });
case "logs":
return showLogs(ctx);
case "config":
// Handled by the dispatcher in index.ts before reaching the platform layer.
throw new Error("unreachable: config action handled in service/index.ts");
default: {
const _exhaustive: never = args.action;
throw new Error(`unhandled service action: ${_exhaustive as string}`);
}
}
}
async function installAsync(ctx: Ctx): Promise<void> {
installSync(ctx);
await waitForServiceHealth(ctx.args.port, () =>
dumpJournalctlLogs({ unit: SERVICE_NAME, isSystem: ctx.isSystem, lines: 50 }),
);
}
function installSync(ctx: Ctx): void {
const launcher = captureLauncher();
const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem);
const logDir = resolveLogDir(homeDir);
const unit = renderSystemdUnit({
launcher,
homeDir,
logDir,
port: ctx.args.port,
systemUser: ctx.isSystem ? resolveServiceUser(true) : undefined,
mode: ctx.isSystem ? "system" : "user",
});
fs.mkdirSync(path.dirname(ctx.unitPath), { recursive: true });
const outcome = writeUnitFile(ctx.unitPath, unit);
runSystemctl(ctx, ["daemon-reload"]);
// Always run enable --now so 'install' is fully idempotent: if the user
// manually disabled or stopped the service, re-running install brings it
// back online without changing the unit file.
runSystemctl(ctx, ["enable", "--now", SERVICE_NAME]);
console.log(
outcome === "unchanged"
? "[kandev] service is enabled and running"
: "[kandev] service enabled and started",
);
if (!ctx.isSystem && !ctx.args.noBootStart) {
maybePromptLinger();
}
printPostInstallHints(ctx);
}
function uninstall(ctx: Ctx): void {
// Disable and stop, ignoring failures since the unit may already be stopped.
runSystemctl(ctx, ["disable", "--now", SERVICE_NAME], { allowFailure: true });
if (fs.existsSync(ctx.unitPath)) {
fs.unlinkSync(ctx.unitPath);
console.log(`[kandev] removed ${ctx.unitPath}`);
} else {
console.log(`[kandev] no unit file at ${ctx.unitPath}`);
}
runSystemctl(ctx, ["daemon-reload"], { allowFailure: true });
}
function showLogs(ctx: Ctx): void {
const journalArgs: string[] = ctx.isSystem ? ["-u", SERVICE_NAME] : ["--user-unit", SERVICE_NAME];
if (ctx.args.follow) journalArgs.push("-f");
else journalArgs.push("-n", "200", "--no-pager");
const res = spawnSync("journalctl", journalArgs, { stdio: "inherit" });
if (res.status !== 0 && !ctx.args.follow) {
throw new Error(`journalctl exited with code ${res.status}`);
}
}
function runSystemctl(ctx: Ctx, args: string[], opts: { allowFailure?: boolean } = {}): void {
const argv = [...ctx.systemctlArgs, ...args];
const res = spawnSync("systemctl", argv, { stdio: "inherit" });
if (res.status !== 0 && !opts.allowFailure) {
throw new Error(`systemctl ${argv.join(" ")} failed with code ${res.status}`);
}
}
function lingerEnabled(user: string): boolean {
try {
const out = execFileSync("loginctl", ["show-user", user, "--property=Linger"], {
encoding: "utf8",
});
return out.trim().toLowerCase() === "linger=yes";
} catch {
// loginctl may not be available or user record missing — assume off.
return false;
}
}
function maybePromptLinger(): void {
const user = currentUsername();
if (lingerEnabled(user)) {
console.log("[kandev] enable-linger already active — kandev will start at boot");
return;
}
console.log("");
console.log("[kandev] User services only run while you're logged in.");
console.log("[kandev] To start kandev at boot, run:");
console.log(`[kandev] sudo loginctl enable-linger ${user}`);
console.log("[kandev] (Pass --no-boot-start to skip this notice next time.)");
}
function printPostInstallHints(ctx: Ctx): void {
const ctl = ctx.isSystem ? "sudo systemctl" : "systemctl --user";
const journal = ctx.isSystem ? "sudo journalctl" : "journalctl --user-unit";
console.log("");
console.log("[kandev] Useful commands:");
console.log(`[kandev] ${ctl} status ${SERVICE_NAME}`);
console.log(`[kandev] ${ctl} restart ${SERVICE_NAME}`);
console.log(`[kandev] ${journal} ${ctx.isSystem ? "-u " : ""}${SERVICE_NAME} -f`);
}
+79
View File
@@ -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, "<?xml?><plist><dict></dict></plist>");
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&amp;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",
});
});
});
+226
View File
@@ -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<void> {
if (!commandExists("launchctl")) {
throw new Error("launchctl not found. macOS service install requires launchd.");
}
const ctx = makeCtx(args);
switch (args.action) {
case "install":
return installAsync(ctx);
case "uninstall":
return uninstall(ctx);
case "start":
return startService(ctx);
case "stop":
return stopService(ctx);
case "restart":
return restartService(ctx);
case "status":
return showStatus(ctx);
case "logs":
return showLogs(ctx);
case "config":
// Handled by the dispatcher in index.ts before reaching the platform layer.
throw new Error("unreachable: config action handled in service/index.ts");
default: {
const _exhaustive: never = args.action;
throw new Error(`unhandled service action: ${_exhaustive as string}`);
}
}
}
async function installAsync(ctx: Ctx): Promise<void> {
const { logDir } = installSync(ctx);
await waitForServiceHealth(ctx.args.port, () => dumpLaunchdLogs({ logDir, lines: 50 }));
}
function installSync(ctx: Ctx): { logDir: string } {
const launcher = captureLauncher();
const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem);
const logDir = resolveLogDir(homeDir);
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 = /<key>StandardOutPath<\/key>\s*<string>([^<]+)<\/string>/.exec(content);
const errMatch = /<key>StandardErrorPath<\/key>\s*<string>([^<]+)<\/string>/.exec(content);
if (!outMatch || !errMatch) return null;
// `renderLaunchdPlist` runs values through `escapeXml`, so a path containing
// `&`, `<`, etc. is stored escaped in the plist. Decode before returning so
// the caller can stat/tail the actual file on disk.
return { out: unescapeXml(outMatch[1]), err: unescapeXml(errMatch[1]) };
}
function unescapeXml(value: string): string {
return value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&"); // last — must not re-decode a `&amp;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")}`);
}
+104
View File
@@ -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);
});
});
+138
View File
@@ -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();
}
+74
View File
@@ -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();
});
});
+72
View File
@@ -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<typeof captureLauncher>;
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 <string> 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.
}
}
+186
View File
@@ -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("<string>com.kdlbs.kandev</string>");
expect(plist).toContain("<string>/usr/local/bin/node</string>");
expect(plist).toContain("<string>--headless</string>");
expect(plist).toContain("<key>KeepAlive</key>");
expect(plist).toContain("<key>RunAtLoad</key>");
expect(plist).toContain("<string>/Users/alice/.kandev/logs/service.err</string>");
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/&lt;&amp;&apos;&quot;&gt;/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("<key>UserName</key>");
expect(plist).toContain("<string>alice</string>");
});
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("<key>UserName</key>");
});
});
+183
View File
@@ -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 = `<!-- ${MANAGED_MARKER_TEXT} — regenerated by \`kandev service install\` -->`;
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]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
.join("\n");
const args = [input.launcher.nodePath, input.launcher.cliEntry, "--headless"];
const argsXml = args.map((a) => ` <string>${escapeXml(a)}</string>`).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
? ` <key>UserName</key>\n <string>${escapeXml(input.systemUser)}</string>\n`
: "";
return `<?xml version="1.0" encoding="UTF-8"?>
${PLIST_MARKER}
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.kdlbs.kandev</string>
<key>ProgramArguments</key>
<array>
${argsXml}
</array>
<key>EnvironmentVariables</key>
<dict>
${envXml}
</dict>
${userBlock} <key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${escapeXml(`${input.logDir}/service.out`)}</string>
<key>StandardErrorPath</key>
<string>${escapeXml(`${input.logDir}/service.err`)}</string>
<key>WorkingDirectory</key>
<string>${escapeXml(input.homeDir)}</string>
</dict>
</plist>
`;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
// 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}"`;
}
+175
View File
@@ -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 <N>` 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 <port>] [--home-dir <path>] [--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 <N>` — bake `KANDEV_SERVER_PORT=<N>` into the unit. Defaults to 38429.
- `--home-dir <PATH>` — bake `KANDEV_HOME_DIR=<PATH>` 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 <other>`.
- 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 `<path>.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.