feat(cli): kandev service install for systemd and launchd (#926)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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&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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&"); // last — must not re-decode a `&amp;`-style sequence
|
||||
}
|
||||
|
||||
function runLaunchctl(args: string[], opts: { allowFailure?: boolean } = {}): void {
|
||||
const res = spawnSync("launchctl", args, { stdio: "inherit" });
|
||||
if (res.status !== 0 && !opts.allowFailure) {
|
||||
throw new Error(`launchctl ${args.join(" ")} failed with code ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printPostInstallHints(ctx: Ctx, logDir: string): void {
|
||||
console.log("");
|
||||
console.log("[kandev] Useful commands:");
|
||||
console.log(`[kandev] launchctl print ${ctx.target}`);
|
||||
console.log(`[kandev] kandev service restart${ctx.isSystem ? " --system" : ""}`);
|
||||
console.log(`[kandev] tail -f ${path.join(logDir, "service.err")}`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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/<&'">/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>");
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.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}"`;
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user