kandev 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,17 @@ The package manager owns the runtime version. `kandev@X.Y.Z` ships with the matc
35
35
  kandev --runtime-version v0.16.0
36
36
  ```
37
37
 
38
+ ## Run as a Service
39
+
40
+ Install kandev as a systemd (Linux) or launchd (macOS) service so it auto-starts and stays running across reboots:
41
+
42
+ ```bash
43
+ kandev service install # user mode (laptop, single-user)
44
+ sudo kandev service install --system # system mode (VPS, shared host)
45
+ ```
46
+
47
+ 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.
48
+
38
49
  ## What You Get
39
50
 
40
51
  - **Multi-agent support** - Claude Code, Codex, GitHub Copilot, Gemini CLI, Amp, Auggie, OpenCode
package/dist/args.js CHANGED
@@ -83,6 +83,10 @@ function parseArgs(argv) {
83
83
  opts.debug = true;
84
84
  continue;
85
85
  }
86
+ if (arg === "--headless" || arg === "--no-browser") {
87
+ opts.headless = true;
88
+ continue;
89
+ }
86
90
  }
87
91
  return { options: opts, showHelp, deprecatedFlags };
88
92
  }
package/dist/cli.js CHANGED
@@ -9,6 +9,8 @@ const package_json_1 = __importDefault(require("../package.json"));
9
9
  const args_1 = require("./args");
10
10
  const dev_1 = require("./dev");
11
11
  const run_1 = require("./run");
12
+ const service_1 = require("./service");
13
+ const stale_check_1 = require("./service/stale_check");
12
14
  const start_1 = require("./start");
13
15
  const ports_1 = require("./ports");
14
16
  function printHelp() {
@@ -20,6 +22,7 @@ Usage:
20
22
  kandev start [--port <port>] [--verbose] [--debug]
21
23
  kandev [--port <port>] [--verbose] [--debug]
22
24
  kandev --dev [--port <port>]
25
+ kandev service install|uninstall|start|stop|restart|status|logs [--system]
23
26
 
24
27
  Examples:
25
28
  kandev
@@ -35,6 +38,8 @@ Options:
35
38
  dev Use local repo for dev (make dev + next dev) if available.
36
39
  start Use local production build (make build + next start).
37
40
  run Use installed runtime bundle (default).
41
+ service Manage kandev as an OS service (systemd / launchd).
42
+ Run 'kandev service --help' for details.
38
43
  --dev Alias for "dev".
39
44
  --version, -V Print CLI version and exit.
40
45
  --port Port for the Go backend (the URL kandev opens on in
@@ -42,6 +47,7 @@ Options:
42
47
  KANDEV_PORT or KANDEV_BACKEND_PORT.
43
48
  --verbose, -v Show info logs from backend + web.
44
49
  --debug Show debug logs + agent message dumps.
50
+ --headless Skip opening the browser. Used by service units.
45
51
  --help, -h Show help.
46
52
 
47
53
  Advanced:
@@ -78,6 +84,12 @@ function findRepoRoot(startDir) {
78
84
  }
79
85
  }
80
86
  async function main() {
87
+ // The `service` subcommand has its own argv parser (own subcommands, flags).
88
+ // Handle it before the top-level flag parser to keep the two parsers isolated.
89
+ if (process.argv[2] === "service") {
90
+ await (0, service_1.runServiceCommand)(process.argv.slice(3));
91
+ return;
92
+ }
81
93
  const { options, showHelp, deprecatedFlags } = (0, args_1.parseArgs)(process.argv.slice(2));
82
94
  if (options.showVersion) {
83
95
  console.log(package_json_1.default.version);
@@ -93,6 +105,13 @@ async function main() {
93
105
  const resolved = (0, args_1.resolvePorts)(options, process.env);
94
106
  const backendPort = (0, ports_1.ensureValidPort)(resolved.backendPort, "backend port");
95
107
  const webPort = (0, ports_1.ensureValidPort)(resolved.webPort, "web port");
108
+ // After an npm/brew upgrade, paths baked into an installed service unit may
109
+ // be stale. Warn once before launch so the user knows to re-run install.
110
+ // Skip when invoked from a service unit (--headless) — the warning would
111
+ // land in journalctl/launchd logs and pile up on every restart.
112
+ if (!options.headless) {
113
+ (0, stale_check_1.warnIfStaleServiceUnit)();
114
+ }
96
115
  if (options.command === "dev") {
97
116
  const repoRoot = findRepoRoot(process.cwd());
98
117
  if (!repoRoot) {
@@ -121,6 +140,7 @@ async function main() {
121
140
  webPort,
122
141
  verbose: options.verbose,
123
142
  debug: options.debug,
143
+ headless: options.headless,
124
144
  });
125
145
  }
126
146
  main().catch((err) => {
package/dist/dev.js CHANGED
@@ -24,6 +24,7 @@ async function runDev({ repoRoot, backendPort, webPort }) {
24
24
  (0, shared_1.logStartupInfo)({
25
25
  header: "dev mode: using local repo",
26
26
  ports,
27
+ primary: "web",
27
28
  dbPath,
28
29
  logLevel,
29
30
  });
@@ -67,9 +68,13 @@ async function runDev({ repoRoot, backendPort, webPort }) {
67
68
  // is assumed to be leaked from the parent backend and is ignored. In a normal
68
69
  // shell, an explicit KANDEV_DATABASE_PATH is honored as an escape hatch.
69
70
  function resolveDevBackendEnv(repoRoot) {
71
+ // Profile-selector only: the backend reads profiles.yaml at startup
72
+ // and applies the matching `dev:` values (mock agent, pprof,
73
+ // feature flags, etc.) to its own env. We don't repeat those here —
74
+ // profiles.yaml at the repo root is the single source of truth.
75
+ // See docs/decisions/0007-runtime-feature-flags.md.
70
76
  const baseExtra = {
71
- KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
72
- KANDEV_DEBUG_PPROF_ENABLED: "true",
77
+ KANDEV_DEBUG_DEV_MODE: "true",
73
78
  };
74
79
  const devHome = (0, constants_1.devKandevHome)(repoRoot);
75
80
  // Display only; the backend derives its own DB path from KANDEV_HOME_DIR
package/dist/ports.js CHANGED
@@ -3,12 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.__testing = void 0;
6
7
  exports.ensureValidPort = ensureValidPort;
7
8
  exports.pickAvailablePort = pickAvailablePort;
8
9
  exports.pickAndReservePort = pickAndReservePort;
9
10
  const node_crypto_1 = __importDefault(require("node:crypto"));
10
11
  const node_net_1 = __importDefault(require("node:net"));
11
12
  const constants_1 = require("./constants");
13
+ const CONNECT_PROBE_TIMEOUT_MS = 500;
12
14
  function ensureValidPort(port, name) {
13
15
  if (port === undefined) {
14
16
  return undefined;
@@ -20,22 +22,30 @@ function ensureValidPort(port, name) {
20
22
  }
21
23
  /**
22
24
  * Tries to connect to a port on the given host. Returns true if something
23
- * is already listening (i.e. the port is in use).
25
+ * is already listening (i.e. the port is in use). Returns false on connection
26
+ * refused, on any other socket error, or when the probe takes longer than
27
+ * `timeoutMs` — under WSL2 mirrored networking the kernel can silently drop
28
+ * the SYN to an unbound loopback port, so an unbounded connect would hang
29
+ * forever and deadlock isPortAvailable.
24
30
  *
25
- * This is more reliable than a bind-based check on macOS where
26
- * SO_REUSEADDR (set by default in Node.js) can allow a bind to succeed
27
- * even when another process is already listening on the same port.
31
+ * Used alongside canBindPort because it detects ports where a listener is
32
+ * bound with SO_REUSEADDR (Node's default on macOS), which a bind-only check
33
+ * would miss.
28
34
  */
29
- function isPortInUse(port, host) {
35
+ function isPortInUse(port, host, timeoutMs = CONNECT_PROBE_TIMEOUT_MS) {
30
36
  return new Promise((resolve) => {
31
37
  const socket = node_net_1.default.createConnection({ port, host });
32
- socket.once("connect", () => {
38
+ let settled = false;
39
+ const done = (v) => {
40
+ if (settled)
41
+ return;
42
+ settled = true;
33
43
  socket.destroy();
34
- resolve(true);
35
- });
36
- socket.once("error", () => {
37
- resolve(false);
38
- });
44
+ resolve(v);
45
+ };
46
+ socket.once("connect", () => done(true));
47
+ socket.once("error", () => done(false));
48
+ setTimeout(() => done(false), timeoutMs).unref();
39
49
  });
40
50
  }
41
51
  /**
@@ -128,3 +138,5 @@ async function pickAndReservePort(preferred, retries = constants_1.RANDOM_PORT_R
128
138
  }
129
139
  throw new Error(`Unable to reserve a free port after ${retries + 1} attempts`);
130
140
  }
141
+ // Exported for tests only.
142
+ exports.__testing = { isPortInUse, isPortAvailable };
package/dist/run.js CHANGED
@@ -229,7 +229,7 @@ function launchBundle(prepared) {
229
229
  }
230
230
  return { supervisor, backendProc, webServerPath, dumpBackendLogs };
231
231
  }
232
- async function runRelease({ runtimeVersion, backendPort, webPort, verbose = false, debug = false, }) {
232
+ async function runRelease({ runtimeVersion, backendPort, webPort, verbose = false, debug = false, headless = false, }) {
233
233
  const prepared = await prepareBundleForLaunch({
234
234
  runtimeVersion,
235
235
  backendPort,
@@ -254,6 +254,10 @@ async function runRelease({ runtimeVersion, backendPort, webPort, verbose = fals
254
254
  quiet: !prepared.showOutput,
255
255
  });
256
256
  await (0, health_1.waitForUrlReady)(webUrl, webProc, healthTimeoutMs);
257
+ if (headless) {
258
+ console.log(`[kandev] ready (headless) at ${prepared.backendUrl}`);
259
+ return;
260
+ }
257
261
  console.log("[kandev] open: " + prepared.backendUrl);
258
262
  (0, web_1.openBrowser)(prepared.backendUrl);
259
263
  }
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseServiceArgs = parseServiceArgs;
4
+ const args_1 = require("../args");
5
+ const VALID_ACTIONS = new Set([
6
+ "install",
7
+ "uninstall",
8
+ "start",
9
+ "stop",
10
+ "restart",
11
+ "status",
12
+ "logs",
13
+ "config",
14
+ ]);
15
+ function parseServiceArgs(argv) {
16
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
17
+ return { action: "install", showHelp: true };
18
+ }
19
+ const head = argv[0];
20
+ if (!VALID_ACTIONS.has(head)) {
21
+ throw new args_1.ParseError(`unknown service action "${head}". expected one of: ${[...VALID_ACTIONS].join(", ")}`);
22
+ }
23
+ const out = { action: head };
24
+ for (let i = 1; i < argv.length; i += 1) {
25
+ const arg = argv[i];
26
+ if (arg === "--help" || arg === "-h") {
27
+ out.showHelp = true;
28
+ continue;
29
+ }
30
+ if (arg === "--system") {
31
+ out.system = true;
32
+ continue;
33
+ }
34
+ if (arg === "--no-boot-start") {
35
+ out.noBootStart = true;
36
+ continue;
37
+ }
38
+ if (arg === "-f" || arg === "--follow") {
39
+ out.follow = true;
40
+ continue;
41
+ }
42
+ if (arg === "--port") {
43
+ out.port = parsePort(takeValue(argv, i, "--port"), "--port");
44
+ i += 1;
45
+ continue;
46
+ }
47
+ if (arg.startsWith("--port=")) {
48
+ out.port = parsePort(arg.slice("--port=".length), "--port");
49
+ continue;
50
+ }
51
+ if (arg === "--home-dir") {
52
+ out.homeDir = takeValue(argv, i, "--home-dir");
53
+ i += 1;
54
+ continue;
55
+ }
56
+ if (arg.startsWith("--home-dir=")) {
57
+ const value = arg.slice("--home-dir=".length);
58
+ if (value.length === 0)
59
+ throw new args_1.ParseError("--home-dir requires a value");
60
+ out.homeDir = value;
61
+ continue;
62
+ }
63
+ throw new args_1.ParseError(`unknown flag "${arg}" for kandev service ${head}`);
64
+ }
65
+ validateActionFlags(out);
66
+ return out;
67
+ }
68
+ /**
69
+ * Reject flag combinations that silently no-op so the user gets immediate
70
+ * feedback instead of a successful command that ignored their input. The
71
+ * matrix is small enough that explicit checks beat a generic flag-applicability
72
+ * table.
73
+ */
74
+ function validateActionFlags(args) {
75
+ if (args.follow && args.action !== "logs") {
76
+ throw new args_1.ParseError(`--follow only applies to 'kandev service logs', not '${args.action}'`);
77
+ }
78
+ const installOnly = ["port", "homeDir", "noBootStart"];
79
+ if (args.action !== "install") {
80
+ for (const flag of installOnly) {
81
+ if (args[flag] !== undefined) {
82
+ const display = flag === "homeDir"
83
+ ? "--home-dir"
84
+ : flag === "noBootStart"
85
+ ? "--no-boot-start"
86
+ : `--${flag}`;
87
+ throw new args_1.ParseError(`${display} only applies to 'kandev service install', not '${args.action}'`);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ function takeValue(argv, i, flag) {
93
+ const v = argv[i + 1];
94
+ if (v === undefined || v.startsWith("-")) {
95
+ throw new args_1.ParseError(`${flag} requires a value`);
96
+ }
97
+ return v;
98
+ }
99
+ function parsePort(raw, flag) {
100
+ const n = Number(raw);
101
+ if (raw === "" || !Number.isInteger(n) || n < 1 || n > 65535) {
102
+ throw new args_1.ParseError(`${flag} value must be an integer between 1 and 65535, got "${raw}"`);
103
+ }
104
+ return n;
105
+ }
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.printServiceConfig = printServiceConfig;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const constants_1 = require("../constants");
11
+ const paths_1 = require("./paths");
12
+ const templates_1 = require("./templates");
13
+ /**
14
+ * Print a human-readable summary of what kandev knows about the local
15
+ * service install: paths, env vars, whether a unit exists, whether it's
16
+ * active. Used for "why isn't it running?" diagnosis without needing to
17
+ * remember the right systemctl / launchctl invocation.
18
+ */
19
+ function printServiceConfig(args) {
20
+ const isSystem = !!args.system;
21
+ const launcher = (0, paths_1.captureLauncher)();
22
+ const homeDir = (0, paths_1.resolveHomeDir)(args.homeDir, isSystem);
23
+ const logDir = (0, paths_1.resolveLogDir)(homeDir);
24
+ console.log("=== kandev service config ===");
25
+ console.log(`platform: ${process.platform}`);
26
+ console.log(`mode: ${isSystem ? "system" : "user"}`);
27
+ console.log(`launcher kind: ${launcher.kind}`);
28
+ console.log(`node path: ${launcher.nodePath}`);
29
+ console.log(`cli entry: ${launcher.cliEntry}`);
30
+ if (launcher.bundleDir)
31
+ console.log(`bundle dir: ${launcher.bundleDir}`);
32
+ if (launcher.version)
33
+ console.log(`version: ${launcher.version}`);
34
+ console.log("");
35
+ console.log(`KANDEV_HOME_DIR: ${homeDir}`);
36
+ console.log(`log dir: ${logDir}`);
37
+ console.log(`port: ${args.port ?? `(default ${constants_1.DEFAULT_BACKEND_PORT})`}`);
38
+ if (isSystem) {
39
+ console.log(`run as user: ${(0, paths_1.resolveServiceUser)(true)}`);
40
+ }
41
+ console.log("");
42
+ if (process.platform === "linux") {
43
+ printLinuxUnit(isSystem);
44
+ }
45
+ else if (process.platform === "darwin") {
46
+ printMacosUnit(isSystem);
47
+ }
48
+ else {
49
+ console.log(`unit: (unsupported on ${process.platform})`);
50
+ }
51
+ }
52
+ function printLinuxUnit(isSystem) {
53
+ const unitPath = isSystem ? (0, paths_1.linuxSystemUnitPath)() : (0, paths_1.linuxUserUnitPath)();
54
+ console.log(`unit path: ${unitPath}`);
55
+ const present = node_fs_1.default.existsSync(unitPath);
56
+ console.log(`installed: ${present ? "yes" : "no"}`);
57
+ if (present) {
58
+ const content = safeRead(unitPath);
59
+ console.log(`managed by us: ${content && (0, templates_1.looksLikeManagedUnit)(content) ? "yes" : "no"}`);
60
+ }
61
+ const active = systemctlIsActive(isSystem);
62
+ if (active !== null)
63
+ console.log(`active state: ${active}`);
64
+ }
65
+ function printMacosUnit(isSystem) {
66
+ const plistPath = isSystem ? (0, paths_1.macosSystemPlistPath)() : (0, paths_1.macosUserPlistPath)();
67
+ console.log(`plist path: ${plistPath}`);
68
+ const present = node_fs_1.default.existsSync(plistPath);
69
+ console.log(`installed: ${present ? "yes" : "no"}`);
70
+ if (present) {
71
+ const content = safeRead(plistPath);
72
+ console.log(`managed by us: ${content && (0, templates_1.looksLikeManagedUnit)(content) ? "yes" : "no"}`);
73
+ }
74
+ console.log(`loaded: ${launchctlIsLoaded(isSystem) ? "yes" : "no"}`);
75
+ }
76
+ function safeRead(p) {
77
+ try {
78
+ return node_fs_1.default.readFileSync(p, "utf8");
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ function systemctlIsActive(isSystem) {
85
+ try {
86
+ const args = isSystem ? ["is-active", paths_1.SERVICE_NAME] : ["--user", "is-active", paths_1.SERVICE_NAME];
87
+ const out = (0, node_child_process_1.execFileSync)("systemctl", args, { encoding: "utf8" });
88
+ return out.trim();
89
+ }
90
+ catch (err) {
91
+ // is-active returns nonzero for inactive/failed/unknown — read the output anyway.
92
+ // execFileSync attaches stdout to the thrown error; guard with a type check
93
+ // instead of an unsafe cast so this keeps working if the error shape changes.
94
+ if (err !== null && typeof err === "object" && "stdout" in err) {
95
+ const { stdout } = err;
96
+ if (stdout)
97
+ return String(stdout).trim();
98
+ }
99
+ return null;
100
+ }
101
+ }
102
+ function launchctlIsLoaded(isSystem) {
103
+ try {
104
+ const domain = isSystem ? "system" : `gui/${node_os_1.default.userInfo().uid}`;
105
+ (0, node_child_process_1.execFileSync)("launchctl", ["print", `${domain}/${paths_1.LAUNCHD_LABEL}`], { stdio: "ignore" });
106
+ return true;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.waitForServiceHealth = waitForServiceHealth;
7
+ exports.dumpJournalctlLogs = dumpJournalctlLogs;
8
+ exports.dumpLaunchdLogs = dumpLaunchdLogs;
9
+ const node_child_process_1 = require("node:child_process");
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const constants_1 = require("../constants");
13
+ const health_1 = require("../health");
14
+ const DEFAULT_TIMEOUT_MS = 30000;
15
+ const POLL_INTERVAL_MS = 500;
16
+ // Per-request timeout. Without this, undici's default headersTimeout (5min)
17
+ // can stall a single fetch — e.g. TCP accepted but the backend hangs before
18
+ // writing response headers — and silently overrun the outer deadline.
19
+ const REQUEST_TIMEOUT_MS = 2000;
20
+ /**
21
+ * Poll the kandev /health endpoint to confirm the freshly-installed service
22
+ * actually came up. On success, the user gets immediate confirmation; on
23
+ * failure, we dump the tail of the service logs so the diagnosis isn't a
24
+ * scavenger hunt across `journalctl` / `tail`.
25
+ *
26
+ * Timeout is fixed at 30s — long enough to absorb a slow first launch
27
+ * (binary unpacking, sqlite migration), short enough to fail fast when the
28
+ * unit is genuinely broken.
29
+ */
30
+ async function waitForServiceHealth(port, dumpLogs) {
31
+ const url = `http://localhost:${port ?? constants_1.DEFAULT_BACKEND_PORT}/health`;
32
+ const timeoutMs = (0, health_1.resolveHealthTimeoutMs)(DEFAULT_TIMEOUT_MS);
33
+ const deadline = Date.now() + timeoutMs;
34
+ process.stderr.write(`[kandev] waiting for service to be healthy at ${url}\n`);
35
+ while (Date.now() < deadline) {
36
+ try {
37
+ const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
38
+ if (res.ok) {
39
+ process.stderr.write(`[kandev] service is healthy\n`);
40
+ return;
41
+ }
42
+ }
43
+ catch {
44
+ // not up yet (or per-request timeout fired); keep polling
45
+ }
46
+ await (0, health_1.delay)(POLL_INTERVAL_MS);
47
+ }
48
+ process.stderr.write(`[kandev] service did not become healthy within ${timeoutMs}ms\n`);
49
+ process.stderr.write(`[kandev] dumping recent service logs:\n`);
50
+ dumpLogs();
51
+ throw new Error("kandev service was installed but the /health endpoint never responded. " +
52
+ "Inspect the logs above and re-run 'kandev service install' once fixed. " +
53
+ "If the service needs more time to come up, set KANDEV_HEALTH_TIMEOUT_MS=120000.");
54
+ }
55
+ /** Dump the last N lines of a systemd unit's logs via journalctl. */
56
+ function dumpJournalctlLogs(opts) {
57
+ const args = opts.isSystem
58
+ ? ["-u", opts.unit, "-n", String(opts.lines), "--no-pager"]
59
+ : ["--user-unit", opts.unit, "-n", String(opts.lines), "--no-pager"];
60
+ try {
61
+ (0, node_child_process_1.spawnSync)("journalctl", args, { stdio: "inherit" });
62
+ }
63
+ catch (err) {
64
+ // journalctl may be unavailable in containerized or stripped-down setups.
65
+ // We're already in a failure path; don't compound it with a thrown error.
66
+ process.stderr.write(`[kandev] (could not run journalctl: ${err instanceof Error ? err.message : String(err)})\n`);
67
+ }
68
+ }
69
+ /** Dump the last N lines of launchd-managed log files via `tail`. */
70
+ function dumpLaunchdLogs(opts) {
71
+ const candidates = ["service.err", "service.out"]
72
+ .map((name) => node_path_1.default.join(opts.logDir, name))
73
+ .filter((p) => node_fs_1.default.existsSync(p));
74
+ if (candidates.length === 0) {
75
+ process.stderr.write(`[kandev] (no logs found in ${opts.logDir})\n`);
76
+ return;
77
+ }
78
+ try {
79
+ (0, node_child_process_1.spawnSync)("tail", ["-n", String(opts.lines), ...candidates], { stdio: "inherit" });
80
+ }
81
+ catch (err) {
82
+ process.stderr.write(`[kandev] (could not run tail: ${err instanceof Error ? err.message : String(err)})\n`);
83
+ }
84
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.printServiceHelp = printServiceHelp;
4
+ exports.runServiceCommand = runServiceCommand;
5
+ const args_1 = require("./args");
6
+ const config_1 = require("./config");
7
+ const linux_1 = require("./linux");
8
+ const macos_1 = require("./macos");
9
+ function printServiceHelp() {
10
+ console.log(`kandev service — install kandev as an OS-managed service
11
+
12
+ Usage:
13
+ kandev service install [--system] [--port <port>] [--home-dir <path>] [--no-boot-start]
14
+ kandev service uninstall [--system]
15
+ kandev service start|stop|restart|status [--system]
16
+ kandev service logs [-f] [--system]
17
+ kandev service config [--system]
18
+
19
+ Modes:
20
+ default User-level service.
21
+ Linux: ~/.config/systemd/user/kandev.service
22
+ macOS: ~/Library/LaunchAgents/com.kdlbs.kandev.plist
23
+ Runs as the current user. On Linux, only starts at boot
24
+ if 'loginctl enable-linger <user>' has been run.
25
+ --system System-level service. Requires sudo.
26
+ Linux: /etc/systemd/system/kandev.service
27
+ macOS: /Library/LaunchDaemons/com.kdlbs.kandev.plist
28
+ Starts at boot regardless of login state.
29
+
30
+ Flags:
31
+ --port <port> Backend port baked into the unit (KANDEV_SERVER_PORT).
32
+ --home-dir <path> KANDEV_HOME_DIR baked into the unit.
33
+ Defaults: ~/.kandev (user), /var/lib/kandev (system).
34
+ --no-boot-start (Linux user mode) Skip the enable-linger hint.
35
+ -f, --follow (logs) Stream logs instead of dumping the tail.
36
+
37
+ Updates:
38
+ After 'npm update -g kandev' or 'brew upgrade kandev', re-run
39
+ 'kandev service install' to refresh paths in the unit file.
40
+ `);
41
+ }
42
+ async function runServiceCommand(argv) {
43
+ const args = (0, args_1.parseServiceArgs)(argv);
44
+ if (args.showHelp) {
45
+ printServiceHelp();
46
+ return;
47
+ }
48
+ // 'config' is read-only and identical across platforms — handle here so we
49
+ // don't need to duplicate it in both linux.ts and macos.ts.
50
+ if (args.action === "config") {
51
+ (0, config_1.printServiceConfig)(args);
52
+ return;
53
+ }
54
+ switch (process.platform) {
55
+ case "linux":
56
+ return (0, linux_1.runLinuxService)(args);
57
+ case "darwin":
58
+ return (0, macos_1.runMacosService)(args);
59
+ default:
60
+ throw new Error(`kandev service is not yet supported on ${process.platform}. ` +
61
+ `Currently supported: linux (systemd), darwin (launchd).`);
62
+ }
63
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.commandExists = commandExists;
7
+ exports.writeUnitFile = writeUnitFile;
8
+ const node_child_process_1 = require("node:child_process");
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const templates_1 = require("./templates");
11
+ /** Cheap PATH lookup; returns true if `cmd` resolves via `which`. */
12
+ function commandExists(cmd) {
13
+ const res = (0, node_child_process_1.spawnSync)("which", [cmd], { stdio: "ignore" });
14
+ return res.status === 0;
15
+ }
16
+ /**
17
+ * Write `content` to `targetPath` with idempotent + foreign-file handling.
18
+ *
19
+ * - Missing file → created (no warning)
20
+ * - Existing managed file, same content → unchanged (no write, no warning)
21
+ * - Existing managed file, different content → updated (overwrite, brief log)
22
+ * - Existing file that doesn't look managed → replaced-foreign (overwrite,
23
+ * loud warning so the user notices we clobbered something)
24
+ *
25
+ * The "managed" check looks for kandev's marker substring; users who hand-edit
26
+ * past the marker lose the no-op shortcut but still get an "updated" log line,
27
+ * which is the expected workflow.
28
+ */
29
+ function writeUnitFile(targetPath, content) {
30
+ if (!node_fs_1.default.existsSync(targetPath)) {
31
+ node_fs_1.default.writeFileSync(targetPath, content, { mode: 0o644 });
32
+ console.log(`[kandev] wrote ${targetPath}`);
33
+ return "created";
34
+ }
35
+ const existing = node_fs_1.default.readFileSync(targetPath, "utf8");
36
+ if (existing === content) {
37
+ console.log(`[kandev] ${targetPath} is already up to date`);
38
+ return "unchanged";
39
+ }
40
+ if (!(0, templates_1.looksLikeManagedUnit)(existing)) {
41
+ console.log(`[kandev] WARNING: ${targetPath} exists but doesn't look like a kandev-managed file.`);
42
+ console.log(`[kandev] The existing file will be replaced. A backup is saved alongside.`);
43
+ node_fs_1.default.copyFileSync(targetPath, `${targetPath}.bak`);
44
+ node_fs_1.default.writeFileSync(targetPath, content, { mode: 0o644 });
45
+ console.log(`[kandev] replaced ${targetPath} (backup: ${targetPath}.bak)`);
46
+ return "replaced-foreign";
47
+ }
48
+ node_fs_1.default.writeFileSync(targetPath, content, { mode: 0o644 });
49
+ console.log(`[kandev] updated ${targetPath}`);
50
+ return "updated";
51
+ }