kandev 0.48.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 +11 -0
- package/dist/args.js +4 -0
- package/dist/cli.js +20 -0
- package/dist/dev.js +7 -2
- package/dist/ports.js +23 -11
- package/dist/run.js +5 -1
- package/dist/service/args.js +105 -0
- package/dist/service/config.js +111 -0
- package/dist/service/health_check.js +84 -0
- package/dist/service/index.js +63 -0
- package/dist/service/install_helpers.js +51 -0
- package/dist/service/linux.js +147 -0
- package/dist/service/macos.js +196 -0
- package/dist/service/paths.js +126 -0
- package/dist/service/stale_check.js +78 -0
- package/dist/service/templates.js +157 -0
- package/dist/shared.js +93 -8
- package/package.json +6 -6
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
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
|
-
|
|
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
|
-
*
|
|
26
|
-
* SO_REUSEADDR (
|
|
27
|
-
*
|
|
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
|
-
|
|
38
|
+
let settled = false;
|
|
39
|
+
const done = (v) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
33
43
|
socket.destroy();
|
|
34
|
-
resolve(
|
|
35
|
-
}
|
|
36
|
-
socket.once("
|
|
37
|
-
|
|
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
|
+
}
|