kandev 0.54.0 → 0.56.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/dist/constants.js CHANGED
@@ -4,6 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.DEV_KANDEV_DOTDIR = exports.DATA_DIR = exports.CACHE_DIR = exports.KANDEV_TASKS_DIR = exports.KANDEV_HOME_DIR = exports.KANDEV_DOTDIR = exports.HEALTH_TIMEOUT_MS_DEV = exports.HEALTH_TIMEOUT_MS_RELEASE = exports.RANDOM_PORT_RETRIES = exports.RANDOM_PORT_MAX = exports.RANDOM_PORT_MIN = exports.DEFAULT_AGENTCTL_PORT = exports.DEFAULT_WEB_PORT = exports.DEFAULT_BACKEND_PORT = void 0;
7
+ exports.resolveKandevHomeDir = resolveKandevHomeDir;
8
+ exports.resolveDataDir = resolveDataDir;
9
+ exports.resolveCacheDir = resolveCacheDir;
10
+ exports.resolveDatabasePath = resolveDatabasePath;
7
11
  exports.devKandevHome = devKandevHome;
8
12
  const node_os_1 = __importDefault(require("node:os"));
9
13
  const node_path_1 = __importDefault(require("node:path"));
@@ -29,6 +33,18 @@ exports.KANDEV_TASKS_DIR = node_path_1.default.join(exports.KANDEV_HOME_DIR, "ta
29
33
  // Local user cache/data directories for release bundles and DB.
30
34
  exports.CACHE_DIR = node_path_1.default.join(exports.KANDEV_HOME_DIR, "bin");
31
35
  exports.DATA_DIR = node_path_1.default.join(exports.KANDEV_HOME_DIR, "data");
36
+ function resolveKandevHomeDir(env = process.env) {
37
+ return env.KANDEV_HOME_DIR?.trim() || exports.KANDEV_HOME_DIR;
38
+ }
39
+ function resolveDataDir(env = process.env) {
40
+ return node_path_1.default.join(resolveKandevHomeDir(env), "data");
41
+ }
42
+ function resolveCacheDir(env = process.env) {
43
+ return node_path_1.default.join(resolveKandevHomeDir(env), "bin");
44
+ }
45
+ function resolveDatabasePath(env = process.env) {
46
+ return env.KANDEV_DATABASE_PATH?.trim() || node_path_1.default.join(resolveDataDir(env), "kandev.db");
47
+ }
32
48
  // Dev-mode root: an isolated kandev home inside the repo so that running
33
49
  // `make dev` from inside a kandev-spawned task workspace does not touch the
34
50
  // user's production state.
package/dist/run.js CHANGED
@@ -27,8 +27,9 @@ const web_1 = require("./web");
27
27
  * the highest semver tag available in the cache.
28
28
  */
29
29
  function findCachedRelease(platformDir, version) {
30
+ const rootCacheDir = (0, constants_1.resolveCacheDir)();
30
31
  if (version) {
31
- const cacheDir = node_path_1.default.join(constants_1.CACHE_DIR, version, platformDir);
32
+ const cacheDir = node_path_1.default.join(rootCacheDir, version, platformDir);
32
33
  const bundleDir = node_path_1.default.join(cacheDir, "kandev");
33
34
  const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
34
35
  if (node_fs_1.default.existsSync(backendBin)) {
@@ -37,14 +38,14 @@ function findCachedRelease(platformDir, version) {
37
38
  return null;
38
39
  }
39
40
  // No version specified — scan for cached tags and pick the latest.
40
- if (!node_fs_1.default.existsSync(constants_1.CACHE_DIR))
41
+ if (!node_fs_1.default.existsSync(rootCacheDir))
41
42
  return null;
42
- const entries = node_fs_1.default.readdirSync(constants_1.CACHE_DIR).filter((d) => d.startsWith("v"));
43
+ const entries = node_fs_1.default.readdirSync(rootCacheDir).filter((d) => d.startsWith("v"));
43
44
  if (entries.length === 0)
44
45
  return null;
45
46
  const sorted = (0, version_1.sortVersionsDesc)(entries);
46
47
  for (const tag of sorted) {
47
- const cacheDir = node_path_1.default.join(constants_1.CACHE_DIR, tag, platformDir);
48
+ const cacheDir = node_path_1.default.join(rootCacheDir, tag, platformDir);
48
49
  const bundleDir = node_path_1.default.join(cacheDir, "kandev");
49
50
  const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
50
51
  if (node_fs_1.default.existsSync(backendBin)) {
@@ -59,10 +60,11 @@ function findCachedRelease(platformDir, version) {
59
60
  * The previous version is kept as a fallback for offline use.
60
61
  */
61
62
  function cleanOldReleases(currentTag) {
63
+ const rootCacheDir = (0, constants_1.resolveCacheDir)();
62
64
  try {
63
- if (!node_fs_1.default.existsSync(constants_1.CACHE_DIR))
65
+ if (!node_fs_1.default.existsSync(rootCacheDir))
64
66
  return;
65
- const entries = node_fs_1.default.readdirSync(constants_1.CACHE_DIR).filter((d) => d.startsWith("v"));
67
+ const entries = node_fs_1.default.readdirSync(rootCacheDir).filter((d) => d.startsWith("v"));
66
68
  if (entries.length <= 2)
67
69
  return;
68
70
  const sorted = (0, version_1.sortVersionsDesc)(entries);
@@ -70,7 +72,7 @@ function cleanOldReleases(currentTag) {
70
72
  const keep = new Set([currentTag, sorted[0], sorted[1]]);
71
73
  for (const entry of entries) {
72
74
  if (!keep.has(entry)) {
73
- node_fs_1.default.rmSync(node_path_1.default.join(constants_1.CACHE_DIR, entry), { recursive: true, force: true });
75
+ node_fs_1.default.rmSync(node_path_1.default.join(rootCacheDir, entry), { recursive: true, force: true });
74
76
  }
75
77
  }
76
78
  }
@@ -87,7 +89,7 @@ async function downloadRuntimeVersion(runtimeVersion) {
87
89
  const release = await (0, github_1.getRelease)(runtimeVersion);
88
90
  const tag = release.tag_name;
89
91
  const assetName = `kandev-${platformDir}.tar.gz`;
90
- const cacheDir = node_path_1.default.join(constants_1.CACHE_DIR, tag, platformDir);
92
+ const cacheDir = node_path_1.default.join((0, constants_1.resolveCacheDir)(), tag, platformDir);
91
93
  const archivePath = await (0, github_1.ensureAsset)(tag, assetName, cacheDir, (downloaded, total) => {
92
94
  const percent = total ? Math.round((downloaded / total) * 100) : 0;
93
95
  const mb = (downloaded / (1024 * 1024)).toFixed(1);
@@ -114,7 +116,7 @@ async function prepareBundleForLaunch({ runtimeVersion, backendPort, webPort, ve
114
116
  else {
115
117
  try {
116
118
  tag = await downloadRuntimeVersion(runtimeVersion);
117
- const cacheDir = node_path_1.default.join(constants_1.CACHE_DIR, tag, platformDir);
119
+ const cacheDir = node_path_1.default.join((0, constants_1.resolveCacheDir)(), tag, platformDir);
118
120
  bundleDir = (0, bundle_1.findBundleRoot)(cacheDir);
119
121
  }
120
122
  catch (err) {
@@ -141,25 +143,33 @@ async function prepareBundleForLaunch({ runtimeVersion, backendPort, webPort, ve
141
143
  const backendUrl = `http://localhost:${actualBackendPort}`;
142
144
  const showOutput = verbose || debug;
143
145
  const logLevel = process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn");
144
- node_fs_1.default.mkdirSync(constants_1.DATA_DIR, { recursive: true });
145
- const dbPath = node_path_1.default.join(constants_1.DATA_DIR, "kandev.db");
146
+ const dataDir = (0, constants_1.resolveDataDir)();
147
+ node_fs_1.default.mkdirSync(dataDir, { recursive: true });
148
+ const dbPath = (0, constants_1.resolveDatabasePath)();
146
149
  const backendBin = node_path_1.default.join(bundleDir, "bin", (0, platform_1.getBinaryName)("kandev"));
147
- const backendEnv = {
148
- ...process.env,
149
- KANDEV_SERVER_PORT: String(actualBackendPort),
150
- KANDEV_WEB_INTERNAL_URL: `http://localhost:${actualWebPort}`,
151
- KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
152
- KANDEV_DATABASE_PATH: dbPath,
153
- KANDEV_LOG_LEVEL: logLevel,
154
- ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}),
155
- };
156
- const webEnv = {
157
- ...process.env,
158
- KANDEV_API_BASE_URL: backendUrl,
159
- PORT: String(actualWebPort),
160
- HOSTNAME: "127.0.0.1",
161
- };
162
- webEnv.NODE_ENV = "production";
150
+ const backendEnv = (0, shared_1.buildBackendEnv)({
151
+ ports: {
152
+ backendPort: actualBackendPort,
153
+ webPort: actualWebPort,
154
+ agentctlPort,
155
+ backendUrl,
156
+ },
157
+ logLevel,
158
+ extra: {
159
+ KANDEV_DATABASE_PATH: dbPath,
160
+ ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}),
161
+ },
162
+ });
163
+ const webEnv = (0, shared_1.buildWebEnv)({
164
+ ports: {
165
+ backendPort: actualBackendPort,
166
+ webPort: actualWebPort,
167
+ agentctlPort,
168
+ backendUrl,
169
+ },
170
+ production: true,
171
+ debug,
172
+ });
163
173
  return {
164
174
  bundleDir,
165
175
  backendBin,
@@ -11,6 +11,7 @@ const VALID_ACTIONS = new Set([
11
11
  "status",
12
12
  "logs",
13
13
  "config",
14
+ "self-update",
14
15
  ]);
15
16
  function parseServiceArgs(argv) {
16
17
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
@@ -39,6 +40,22 @@ function parseServiceArgs(argv) {
39
40
  out.follow = true;
40
41
  continue;
41
42
  }
43
+ if (arg === "--dry-run") {
44
+ out.dryRun = true;
45
+ continue;
46
+ }
47
+ if (arg === "--intent") {
48
+ out.intent = takeValue(argv, i, "--intent");
49
+ i += 1;
50
+ continue;
51
+ }
52
+ if (arg.startsWith("--intent=")) {
53
+ const value = arg.slice("--intent=".length);
54
+ if (value.length === 0)
55
+ throw new args_1.ParseError("--intent requires a value");
56
+ out.intent = value;
57
+ continue;
58
+ }
42
59
  if (arg === "--port") {
43
60
  out.port = parsePort(takeValue(argv, i, "--port"), "--port");
44
61
  i += 1;
@@ -75,6 +92,15 @@ function validateActionFlags(args) {
75
92
  if (args.follow && args.action !== "logs") {
76
93
  throw new args_1.ParseError(`--follow only applies to 'kandev service logs', not '${args.action}'`);
77
94
  }
95
+ if (args.dryRun && args.action !== "self-update") {
96
+ throw new args_1.ParseError(`--dry-run only applies to 'kandev service self-update', not '${args.action}'`);
97
+ }
98
+ if (args.intent && args.action !== "self-update") {
99
+ throw new args_1.ParseError(`--intent only applies to 'kandev service self-update', not '${args.action}'`);
100
+ }
101
+ if (args.action === "self-update" && !args.showHelp && !args.intent) {
102
+ throw new args_1.ParseError("kandev service self-update requires --intent <path>");
103
+ }
78
104
  const installOnly = ["port", "homeDir", "noBootStart"];
79
105
  if (args.action !== "install") {
80
106
  for (const flag of installOnly) {
@@ -6,6 +6,7 @@ const args_1 = require("./args");
6
6
  const config_1 = require("./config");
7
7
  const linux_1 = require("./linux");
8
8
  const macos_1 = require("./macos");
9
+ const self_update_1 = require("./self_update");
9
10
  function printServiceHelp() {
10
11
  console.log(`kandev service — install kandev as an OS-managed service
11
12
 
@@ -51,6 +52,12 @@ async function runServiceCommand(argv) {
51
52
  (0, config_1.printServiceConfig)(args);
52
53
  return;
53
54
  }
55
+ // Hidden helper entrypoint used by the backend self-update endpoint. It is
56
+ // intentionally absent from help output.
57
+ if (args.action === "self-update") {
58
+ (0, self_update_1.runSelfUpdateCommand)(args);
59
+ return;
60
+ }
54
61
  switch (process.platform) {
55
62
  case "linux":
56
63
  return (0, linux_1.runLinuxService)(args);
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.spawnLaunchctl = spawnLaunchctl;
4
+ exports.sleepSync = sleepSync;
5
+ exports.bootoutAndWait = bootoutAndWait;
6
+ exports.bootstrapWithRetry = bootstrapWithRetry;
7
+ exports.reloadService = reloadService;
8
+ const node_child_process_1 = require("node:child_process");
9
+ const BOOTOUT_POLL_INTERVAL_MS = 100;
10
+ const BOOTOUT_POLL_ATTEMPTS = 50; // ~5s ceiling waiting for teardown
11
+ const BOOTSTRAP_MAX_ATTEMPTS = 5;
12
+ const BOOTSTRAP_RETRY_BASE_MS = 300;
13
+ function spawnLaunchctl(args, stdio) {
14
+ const res = (0, node_child_process_1.spawnSync)("launchctl", args, { stdio });
15
+ return { status: res.status };
16
+ }
17
+ // Block the current thread for `ms`. The launchctl orchestration runs in the
18
+ // synchronous `service install`/`start`/`restart` paths, so we can't await a
19
+ // timer — Atomics.wait gives a dependency-free blocking sleep.
20
+ function sleepSync(ms) {
21
+ if (ms <= 0)
22
+ return;
23
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
24
+ }
25
+ function isLoaded(target, run) {
26
+ // `launchctl print <target>` exits non-zero once the label is gone.
27
+ return run(["print", target], "ignore").status === 0;
28
+ }
29
+ /**
30
+ * Boot out a launchd job and wait until launchd has fully removed it. A no-op
31
+ * (returns promptly) when the label isn't loaded, so fresh installs aren't
32
+ * slowed down.
33
+ */
34
+ function bootoutAndWait(target, deps = {}) {
35
+ const run = deps.run ?? spawnLaunchctl;
36
+ const sleep = deps.sleep ?? sleepSync;
37
+ run(["bootout", target], "ignore");
38
+ for (let attempt = 0; attempt < BOOTOUT_POLL_ATTEMPTS; attempt += 1) {
39
+ if (!isLoaded(target, run)) {
40
+ return;
41
+ }
42
+ sleep(BOOTOUT_POLL_INTERVAL_MS);
43
+ }
44
+ }
45
+ /**
46
+ * Bootstrap a launchd job, retrying through the transient EIO that launchd
47
+ * returns while a previous instance of the same label finishes tearing down.
48
+ * Throws with the last exit code if every attempt fails.
49
+ */
50
+ function bootstrapWithRetry(domain, plistPath, deps = {}) {
51
+ const run = deps.run ?? spawnLaunchctl;
52
+ const sleep = deps.sleep ?? sleepSync;
53
+ let lastStatus = null;
54
+ for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt += 1) {
55
+ lastStatus = run(["bootstrap", domain, plistPath], "inherit").status;
56
+ if (lastStatus === 0) {
57
+ return;
58
+ }
59
+ if (attempt < BOOTSTRAP_MAX_ATTEMPTS) {
60
+ sleep(BOOTSTRAP_RETRY_BASE_MS * attempt);
61
+ }
62
+ }
63
+ throw new Error(`launchctl bootstrap ${domain} ${plistPath} failed with code ${lastStatus}`);
64
+ }
65
+ /**
66
+ * Reload a launchd job: fully unload (waiting for teardown) then bootstrap with
67
+ * retry. This is the safe sequence whenever the target might currently be
68
+ * running — installs that refresh a live service, and `start`.
69
+ */
70
+ function reloadService(target, domain, plistPath, deps = {}) {
71
+ bootoutAndWait(target, deps);
72
+ bootstrapWithRetry(domain, plistPath, deps);
73
+ }
@@ -9,6 +9,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
10
  const health_check_1 = require("./health_check");
11
11
  const install_helpers_1 = require("./install_helpers");
12
+ const metadata_1 = require("./metadata");
12
13
  const paths_1 = require("./paths");
13
14
  const templates_1 = require("./templates");
14
15
  function makeCtx(args) {
@@ -45,6 +46,9 @@ async function runLinuxService(args) {
45
46
  case "config":
46
47
  // Handled by the dispatcher in index.ts before reaching the platform layer.
47
48
  throw new Error("unreachable: config action handled in service/index.ts");
49
+ case "self-update":
50
+ // Handled by the dispatcher in index.ts before reaching the platform layer.
51
+ throw new Error("unreachable: self-update action handled in service/index.ts");
48
52
  default: {
49
53
  const _exhaustive = args.action;
50
54
  throw new Error(`unhandled service action: ${_exhaustive}`);
@@ -59,16 +63,30 @@ function installSync(ctx) {
59
63
  const launcher = (0, paths_1.captureLauncher)();
60
64
  const homeDir = (0, paths_1.resolveHomeDir)(ctx.args.homeDir, ctx.isSystem);
61
65
  const logDir = (0, paths_1.resolveLogDir)(homeDir);
66
+ const metadataPath = (0, metadata_1.serviceMetadataPath)(homeDir);
67
+ const mode = ctx.isSystem ? "system" : "user";
68
+ const systemUser = ctx.isSystem ? (0, paths_1.resolveServiceUser)(true) : undefined;
62
69
  const unit = (0, templates_1.renderSystemdUnit)({
63
70
  launcher,
64
71
  homeDir,
65
72
  logDir,
66
73
  port: ctx.args.port,
67
- systemUser: ctx.isSystem ? (0, paths_1.resolveServiceUser)(true) : undefined,
68
- mode: ctx.isSystem ? "system" : "user",
74
+ systemUser,
75
+ mode,
76
+ serviceMetadataPath: metadataPath,
69
77
  });
70
78
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(ctx.unitPath), { recursive: true });
71
79
  const outcome = (0, install_helpers_1.writeUnitFile)(ctx.unitPath, unit);
80
+ (0, metadata_1.writeServiceInstallMetadata)(metadataPath, (0, metadata_1.buildServiceInstallMetadata)({
81
+ manager: "systemd",
82
+ mode,
83
+ launcher,
84
+ homeDir,
85
+ logDir,
86
+ servicePath: ctx.unitPath,
87
+ port: ctx.args.port,
88
+ systemUser,
89
+ }));
72
90
  runSystemctl(ctx, ["daemon-reload"]);
73
91
  // Always run enable --now so 'install' is fully idempotent: if the user
74
92
  // manually disabled or stopped the service, re-running install brings it
@@ -11,6 +11,8 @@ const node_os_1 = __importDefault(require("node:os"));
11
11
  const node_path_1 = __importDefault(require("node:path"));
12
12
  const health_check_1 = require("./health_check");
13
13
  const install_helpers_1 = require("./install_helpers");
14
+ const launchctl_1 = require("./launchctl");
15
+ const metadata_1 = require("./metadata");
14
16
  const paths_1 = require("./paths");
15
17
  const templates_1 = require("./templates");
16
18
  function makeCtx(args) {
@@ -49,6 +51,9 @@ async function runMacosService(args) {
49
51
  case "config":
50
52
  // Handled by the dispatcher in index.ts before reaching the platform layer.
51
53
  throw new Error("unreachable: config action handled in service/index.ts");
54
+ case "self-update":
55
+ // Handled by the dispatcher in index.ts before reaching the platform layer.
56
+ throw new Error("unreachable: self-update action handled in service/index.ts");
52
57
  default: {
53
58
  const _exhaustive = args.action;
54
59
  throw new Error(`unhandled service action: ${_exhaustive}`);
@@ -63,23 +68,41 @@ function installSync(ctx) {
63
68
  const launcher = (0, paths_1.captureLauncher)();
64
69
  const homeDir = (0, paths_1.resolveHomeDir)(ctx.args.homeDir, ctx.isSystem);
65
70
  const logDir = (0, paths_1.resolveLogDir)(homeDir);
71
+ const metadataPath = (0, metadata_1.serviceMetadataPath)(homeDir);
72
+ const mode = ctx.isSystem ? "system" : "user";
73
+ const systemUser = ctx.isSystem ? (0, paths_1.resolveServiceUser)(true) : undefined;
66
74
  node_fs_1.default.mkdirSync(logDir, { recursive: true });
67
75
  const plist = (0, templates_1.renderLaunchdPlist)({
68
76
  launcher,
69
77
  homeDir,
70
78
  logDir,
71
79
  port: ctx.args.port,
72
- systemUser: ctx.isSystem ? (0, paths_1.resolveServiceUser)(true) : undefined,
73
- mode: ctx.isSystem ? "system" : "user",
80
+ systemUser,
81
+ mode,
82
+ serviceMetadataPath: metadataPath,
74
83
  });
75
84
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(ctx.plistPath), { recursive: true });
76
85
  const outcome = (0, install_helpers_1.writeUnitFile)(ctx.plistPath, plist);
86
+ (0, metadata_1.writeServiceInstallMetadata)(metadataPath, (0, metadata_1.buildServiceInstallMetadata)({
87
+ manager: "launchd",
88
+ mode,
89
+ launcher,
90
+ homeDir,
91
+ logDir,
92
+ servicePath: ctx.plistPath,
93
+ port: ctx.args.port,
94
+ systemUser,
95
+ }));
77
96
  // launchctl bootstrap fails if the label is already loaded — bootout first
78
97
  // (ignoring its error if nothing was loaded). This means 'install' is
79
98
  // idempotent: re-running it reloads the unit even if the file is unchanged,
80
99
  // which is how we recover from a user manually unloading the service.
81
- (0, node_child_process_1.spawnSync)("launchctl", ["bootout", ctx.target], { stdio: "ignore" });
82
- runLaunchctl(["bootstrap", ctx.domain, ctx.plistPath]);
100
+ //
101
+ // `reloadService` waits for bootout's async teardown before bootstrapping and
102
+ // retries the transient EIO ("Bootstrap failed: 5") that launchd returns while
103
+ // a still-running instance is torn down — the failure that broke self-update,
104
+ // where this install runs against a live service. See launchctl.ts.
105
+ (0, launchctl_1.reloadService)(ctx.target, ctx.domain, ctx.plistPath);
83
106
  runLaunchctl(["enable", ctx.target], { allowFailure: true });
84
107
  console.log(outcome === "unchanged"
85
108
  ? "[kandev] service is loaded and running"
@@ -107,9 +130,9 @@ function uninstall(ctx) {
107
130
  // so each begins with a bootout-then-bootstrap dance similar to installSync.
108
131
  function startService(ctx) {
109
132
  // Idempotent: if the label is already loaded, bootstrap would fail. Bootout
110
- // first so start works whether the service was previously running or stopped.
111
- (0, node_child_process_1.spawnSync)("launchctl", ["bootout", ctx.target], { stdio: "ignore" });
112
- runLaunchctl(["bootstrap", ctx.domain, ctx.plistPath]);
133
+ // first (waiting for teardown) so start works whether the service was
134
+ // previously running or stopped.
135
+ (0, launchctl_1.reloadService)(ctx.target, ctx.domain, ctx.plistPath);
113
136
  }
114
137
  function stopService(ctx) {
115
138
  runLaunchctl(["bootout", ctx.target], { allowFailure: true });
@@ -121,7 +144,9 @@ function restartService(ctx) {
121
144
  const res = (0, node_child_process_1.spawnSync)("launchctl", ["kickstart", "-k", ctx.target], { stdio: "inherit" });
122
145
  if (res.status === 0)
123
146
  return;
124
- runLaunchctl(["bootstrap", ctx.domain, ctx.plistPath]);
147
+ // kickstart fails when the job isn't loaded — reload it (waiting for any
148
+ // residual teardown and retrying the transient bootstrap EIO).
149
+ (0, launchctl_1.reloadService)(ctx.target, ctx.domain, ctx.plistPath);
125
150
  }
126
151
  function showStatus(ctx) {
127
152
  const res = (0, node_child_process_1.spawnSync)("launchctl", ["print", ctx.target], {
@@ -0,0 +1,46 @@
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.serviceMetadataPath = serviceMetadataPath;
7
+ exports.buildServiceInstallMetadata = buildServiceInstallMetadata;
8
+ exports.writeServiceInstallMetadata = writeServiceInstallMetadata;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ function serviceMetadataPath(homeDir) {
12
+ return node_path_1.default.join(homeDir, "service", "install.json");
13
+ }
14
+ function buildServiceInstallMetadata(input) {
15
+ const out = {
16
+ version: 1,
17
+ manager: input.manager,
18
+ mode: input.mode,
19
+ kind: input.launcher.kind,
20
+ home_dir: input.homeDir,
21
+ log_dir: input.logDir,
22
+ service_path: input.servicePath,
23
+ node_path: input.launcher.nodePath,
24
+ cli_entry: input.launcher.cliEntry,
25
+ installed_at: (input.now ?? new Date()).toISOString(),
26
+ };
27
+ if (input.launcher.bundleDir)
28
+ out.bundle_dir = input.launcher.bundleDir;
29
+ if (input.launcher.version)
30
+ out.launcher_version = input.launcher.version;
31
+ if (input.port !== undefined)
32
+ out.port = input.port;
33
+ if (input.systemUser)
34
+ out.system_user = input.systemUser;
35
+ return out;
36
+ }
37
+ function writeServiceInstallMetadata(metadataPath, metadata) {
38
+ const dir = node_path_1.default.dirname(metadataPath);
39
+ // `mode` on mkdir/writeFile only applies when the path is created. chmod after
40
+ // so a pre-existing service dir / install.json is tightened to owner-only too
41
+ // (it can hold launcher paths and the metadata that gates self-update).
42
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ node_fs_1.default.chmodSync(dir, 0o700);
44
+ node_fs_1.default.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
45
+ node_fs_1.default.chmodSync(metadataPath, 0o600);
46
+ }
@@ -75,8 +75,8 @@ function homebrewShimPath(cliEntry) {
75
75
  *
76
76
  * The unit file hard-codes absolute paths because systemd/launchd start with an
77
77
  * empty PATH and may not see the user's `node` or `kandev` shim. By recording
78
- * `process.execPath` (node) and `process.argv[1]` (cli.js) at install time we
79
- * avoid any PATH lookups at service-run time.
78
+ * `process.execPath` (node) and the resolved CLI entry at install time we avoid
79
+ * any PATH lookups at service-run time.
80
80
  */
81
81
  function captureLauncher() {
82
82
  const nodePath = process.execPath;
@@ -85,9 +85,11 @@ function captureLauncher() {
85
85
  const version = process.env.KANDEV_VERSION;
86
86
  const kind = bundleDir
87
87
  ? "homebrew"
88
- : cliEntry.includes(`${node_path_1.default.sep}node_modules${node_path_1.default.sep}`)
89
- ? "npm"
90
- : "unknown";
88
+ : looksLikeNpxEntry(cliEntry)
89
+ ? "npx"
90
+ : cliEntry.includes(`${node_path_1.default.sep}node_modules${node_path_1.default.sep}`)
91
+ ? "npm"
92
+ : "unknown";
91
93
  // For Homebrew installs, prefer the floating bin shim so the unit survives
92
94
  // `brew upgrade` (which deletes the versioned Cellar dir baked into nodePath
93
95
  // /cliEntry). Only adopt it when the shim actually exists on disk; otherwise
@@ -100,10 +102,13 @@ function captureLauncher() {
100
102
  }
101
103
  return { nodePath, cliEntry, kind, bundleDir, version, shimPath };
102
104
  }
105
+ function looksLikeNpxEntry(cliEntry) {
106
+ return cliEntry.includes(`${node_path_1.default.sep}_npx${node_path_1.default.sep}`);
107
+ }
103
108
  function resolveCliEntry() {
104
109
  const argvEntry = process.argv[1];
105
110
  if (argvEntry && node_fs_1.default.existsSync(argvEntry)) {
106
- return node_path_1.default.resolve(argvEntry);
111
+ return node_path_1.default.resolve(node_fs_1.default.realpathSync(argvEntry));
107
112
  }
108
113
  throw new Error("could not resolve the kandev CLI entry path from process.argv[1]; " +
109
114
  "rerun via the kandev binary");
@@ -0,0 +1,223 @@
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.readSelfUpdateIntent = readSelfUpdateIntent;
7
+ exports.planSelfUpdate = planSelfUpdate;
8
+ exports.runSelfUpdateCommand = runSelfUpdateCommand;
9
+ const node_child_process_1 = require("node:child_process");
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_os_1 = __importDefault(require("node:os"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const paths_1 = require("./paths");
14
+ function readSelfUpdateIntent(intentPath) {
15
+ return JSON.parse(node_fs_1.default.readFileSync(intentPath, "utf8"));
16
+ }
17
+ function planSelfUpdate(intent, opts = {}) {
18
+ const platform = opts.platform ?? process.platform;
19
+ const target = npmVersion(intent.target_version || intent.target_tag);
20
+ const install = intent.install;
21
+ const installArgs = serviceInstallArgs(install);
22
+ const commands = [];
23
+ if (install.kind === "homebrew") {
24
+ // `brew upgrade` installs the tap formula's current version; it can't be
25
+ // pinned to intent.target_version the way npm/npx can (Homebrew has no
26
+ // stable "install version X" without a separate versioned formula). In
27
+ // practice both target_version and the formula derive from the same latest
28
+ // GitHub release, so they match. If the formula lags, the restarted backend
29
+ // reports the older version and the frontend progress poll (which waits for
30
+ // info.version === target_version) times out gracefully rather than
31
+ // reporting a false success.
32
+ commands.push({ command: "brew", args: ["upgrade", "kandev"] });
33
+ // Re-run service install via the upgraded `kandev` wrapper (resolved on
34
+ // PATH), NOT `node <cli_entry>`. Homebrew installs into version-pinned
35
+ // Cellar dirs, so the recorded cli_entry still points at the OLD bundle
36
+ // after `brew upgrade`; worse, invoking node on it directly runs without
37
+ // KANDEV_BUNDLE_DIR/KANDEV_VERSION, so the install is mis-detected as
38
+ // kind "unknown" and the regenerated unit loses the bundle env — the
39
+ // restarted backend then can't find its runtime and the service stays down.
40
+ // The wrapper is version-stable and re-sets the bundle env. (Mirrors the
41
+ // manual `kandev service install` we already document for Homebrew.)
42
+ commands.push({ command: "kandev", args: installArgs });
43
+ }
44
+ else if (install.kind === "npm") {
45
+ commands.push({ command: "npm", args: npmInstallArgs(install.cli_entry, target) });
46
+ commands.push({ command: install.node_path, args: [install.cli_entry, ...installArgs] });
47
+ }
48
+ else if (install.kind === "npx") {
49
+ commands.push({ command: "npx", args: ["-y", `kandev@${target}`, ...installArgs] });
50
+ }
51
+ else {
52
+ throw new Error(`unsupported install kind "${install.kind}"`);
53
+ }
54
+ commands.push(restartCommand(install, platform, opts.uid));
55
+ return commands;
56
+ }
57
+ function runSelfUpdateCommand(args, runner = spawnCommand) {
58
+ if (!args.intent) {
59
+ throw new Error("kandev service self-update requires --intent <path>");
60
+ }
61
+ const intent = readSelfUpdateIntent(args.intent);
62
+ const commands = planSelfUpdate(intent);
63
+ if (args.dryRun || process.env.KANDEV_E2E_MOCK === "true") {
64
+ console.log(JSON.stringify({
65
+ dry_run: !!args.dryRun,
66
+ fake: process.env.KANDEV_E2E_MOCK === "true",
67
+ target_version: intent.target_version,
68
+ commands,
69
+ }, null, 2));
70
+ return;
71
+ }
72
+ const log = openSelfUpdateLog(intent);
73
+ try {
74
+ log?.line(`self-update target ${intent.target_tag} (${intent.target_version})`);
75
+ log?.line(`install kind=${intent.install.kind} manager=${intent.install.manager}`);
76
+ log?.line(`planned ${commands.length} command(s):`);
77
+ commands.forEach((step, i) => log?.line(` [${i + 1}/${commands.length}] ${formatCommand(step)}`));
78
+ runSelfUpdateSteps(commands, runner, log);
79
+ log?.line("self-update completed successfully");
80
+ }
81
+ finally {
82
+ log?.close();
83
+ }
84
+ }
85
+ function runSelfUpdateSteps(commands, runner, log) {
86
+ for (const [i, step] of commands.entries()) {
87
+ log?.line(`\n[${i + 1}/${commands.length}] $ ${formatCommand(step)}`);
88
+ const res = runner(step.command, step.args);
89
+ teeCommandOutput(log, res);
90
+ if (res.error) {
91
+ log?.line(`! spawn error: ${res.error.message}`);
92
+ throw res.error;
93
+ }
94
+ if (res.status !== 0) {
95
+ log?.line(`! exited with code ${res.status}`);
96
+ throw new Error(`${formatCommand(step)} failed with code ${res.status}`);
97
+ }
98
+ log?.line(" exit 0");
99
+ }
100
+ }
101
+ function formatCommand(step) {
102
+ return [step.command, ...step.args].join(" ");
103
+ }
104
+ function serviceInstallArgs(install) {
105
+ const args = ["service", "install"];
106
+ if (install.mode === "system")
107
+ args.push("--system");
108
+ args.push("--home-dir", install.home_dir);
109
+ if (install.port !== undefined) {
110
+ args.push("--port", String(install.port));
111
+ }
112
+ return args;
113
+ }
114
+ function npmInstallArgs(cliEntry, target) {
115
+ const args = ["install", "-g"];
116
+ const prefix = npmPrefixFromCliEntry(cliEntry);
117
+ if (prefix) {
118
+ args.push("--prefix", prefix);
119
+ }
120
+ args.push(`kandev@${target}`);
121
+ return args;
122
+ }
123
+ function npmPrefixFromCliEntry(cliEntry) {
124
+ const marker = `${node_path_1.default.sep}lib${node_path_1.default.sep}node_modules${node_path_1.default.sep}kandev${node_path_1.default.sep}`;
125
+ const index = cliEntry.indexOf(marker);
126
+ if (index < 0) {
127
+ return undefined;
128
+ }
129
+ return index === 0 ? node_path_1.default.sep : cliEntry.slice(0, index);
130
+ }
131
+ function restartCommand(install, platform, uid) {
132
+ if (platform === "linux") {
133
+ return install.mode === "system"
134
+ ? { command: "systemctl", args: ["restart", paths_1.SERVICE_NAME] }
135
+ : { command: "systemctl", args: ["--user", "restart", paths_1.SERVICE_NAME] };
136
+ }
137
+ if (platform === "darwin") {
138
+ // Resolve the uid lazily inside the darwin branch — Linux never needs it, so
139
+ // os.userInfo() shouldn't run there.
140
+ const resolvedUid = uid ?? node_os_1.default.userInfo().uid;
141
+ const domain = install.mode === "system" ? "system" : `gui/${resolvedUid}`;
142
+ return { command: "launchctl", args: ["kickstart", "-k", `${domain}/${paths_1.LAUNCHD_LABEL}`] };
143
+ }
144
+ throw new Error(`unsupported platform "${platform}"`);
145
+ }
146
+ function npmVersion(versionOrTag) {
147
+ const stripped = versionOrTag.replace(/^v/, "");
148
+ return stripped || "latest";
149
+ }
150
+ function spawnCommand(command, args) {
151
+ // Capture stdout/stderr (rather than inheriting) so the self-update log can
152
+ // tee each step's output. The helper runs detached under launchd/systemd, so
153
+ // its console output would otherwise go nowhere visible — the log file is the
154
+ // only forensic trail when an update fails mid-flight. maxBuffer is bumped
155
+ // well above the 1 MiB default because `npm install -g` is chatty.
156
+ return (0, node_child_process_1.spawnSync)(command, args, { maxBuffer: 64 * 1024 * 1024 });
157
+ }
158
+ /**
159
+ * Open a timestamped log file under the service's log dir for this self-update
160
+ * run. The helper is spawned outside the service process (so a restart doesn't
161
+ * kill it mid-update), which means it has no service log of its own — without
162
+ * this file a failed update on macOS/launchd is nearly invisible. Best-effort:
163
+ * if the log dir can't be created we return null and the update still runs.
164
+ */
165
+ function openSelfUpdateLog(intent) {
166
+ const dir = intent.install.log_dir || logDirFromHome(intent.install.home_dir);
167
+ if (!dir) {
168
+ return null;
169
+ }
170
+ try {
171
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
172
+ // Self-update logs can contain install paths/env; keep the dir owner-only
173
+ // even if it pre-existed with looser perms.
174
+ node_fs_1.default.chmodSync(dir, 0o700);
175
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
176
+ const filePath = node_path_1.default.join(dir, `self-update-${stamp}.log`);
177
+ const fd = node_fs_1.default.openSync(filePath, "a");
178
+ const write = (chunk) => {
179
+ try {
180
+ // The `Buffer | string` union matches neither writeSync overload
181
+ // directly; the cast picks one — both write the runtime value correctly.
182
+ node_fs_1.default.writeSync(fd, chunk);
183
+ }
184
+ catch {
185
+ // A write failure mid-update must not abort the update itself.
186
+ }
187
+ };
188
+ process.stdout.write(`[kandev] self-update log: ${filePath}\n`);
189
+ write(`# kandev self-update ${new Date().toISOString()}\n`);
190
+ return {
191
+ path: filePath,
192
+ line: (message) => write(`${message}\n`),
193
+ raw: (chunk) => write(chunk),
194
+ close: () => {
195
+ try {
196
+ node_fs_1.default.closeSync(fd);
197
+ }
198
+ catch {
199
+ // already closed / never opened
200
+ }
201
+ },
202
+ };
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
208
+ function logDirFromHome(homeDir) {
209
+ return homeDir ? node_path_1.default.join(homeDir, "logs") : "";
210
+ }
211
+ // Mirror a command's captured output to both the helper log and the process's
212
+ // own stdout/stderr, so whatever does manage to capture the helper's streams
213
+ // (e.g. launchd's StandardOut/ErrorPath, systemd's journal) still sees it.
214
+ function teeCommandOutput(log, res) {
215
+ if (res.stdout && res.stdout.length) {
216
+ process.stdout.write(res.stdout);
217
+ log?.raw(res.stdout);
218
+ }
219
+ if (res.stderr && res.stderr.length) {
220
+ process.stderr.write(res.stderr);
221
+ log?.raw(res.stderr);
222
+ }
223
+ }
@@ -82,6 +82,9 @@ function renderSystemdUnit(input) {
82
82
  if (!shimPath && input.launcher.version) {
83
83
  env.push(envLine("KANDEV_VERSION", input.launcher.version));
84
84
  }
85
+ if (input.serviceMetadataPath) {
86
+ env.push(...serviceEnvLines(input, "systemd"));
87
+ }
85
88
  const wantedBy = input.mode === "system" ? "multi-user.target" : "default.target";
86
89
  const userLine = input.mode === "system" && input.systemUser ? `User=${input.systemUser}\n` : "";
87
90
  const exec = shimPath
@@ -133,6 +136,9 @@ function renderLaunchdPlist(input) {
133
136
  if (!shimPath && input.launcher.version) {
134
137
  envEntries.push(["KANDEV_VERSION", input.launcher.version]);
135
138
  }
139
+ if (input.serviceMetadataPath) {
140
+ envEntries.push(...serviceEnvEntries(input, "launchd"));
141
+ }
136
142
  const envXml = envEntries
137
143
  .map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
138
144
  .join("\n");
@@ -205,3 +211,17 @@ function envLine(key, value) {
205
211
  const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
206
212
  return `Environment="${key}=${escaped}"`;
207
213
  }
214
+ function serviceEnvLines(input, manager) {
215
+ return serviceEnvEntries(input, manager).map(([key, value]) => envLine(key, value));
216
+ }
217
+ function serviceEnvEntries(input, manager) {
218
+ if (!input.serviceMetadataPath)
219
+ return [];
220
+ return [
221
+ ["KANDEV_RUNNING_AS_SERVICE", "true"],
222
+ ["KANDEV_SERVICE_MODE", input.mode],
223
+ ["KANDEV_SERVICE_MANAGER", manager],
224
+ ["KANDEV_INSTALL_KIND", input.launcher.kind],
225
+ ["KANDEV_SERVICE_METADATA", input.serviceMetadataPath],
226
+ ];
227
+ }
package/dist/shared.js CHANGED
@@ -97,6 +97,7 @@ function buildWebEnv(options) {
97
97
  env.NEXT_ALLOWED_DEV_ORIGINS = merged;
98
98
  }
99
99
  if (debug) {
100
+ env.KANDEV_DEBUG = "true";
100
101
  env.NEXT_PUBLIC_KANDEV_DEBUG = "true";
101
102
  }
102
103
  return env;
package/dist/start.js CHANGED
@@ -126,7 +126,11 @@ async function runStart({ repoRoot, backendPort, webPort, verbose = false, debug
126
126
  }
127
127
  // Production mode: use warn log level for clean output unless verbose/debug
128
128
  const showOutput = verbose || debug;
129
- const dbPath = process.env.KANDEV_DATABASE_PATH || node_path_1.default.join(constants_1.DATA_DIR, "kandev.db");
129
+ node_fs_1.default.mkdirSync((0, constants_1.resolveDataDir)(), { recursive: true });
130
+ // The data dir holds the SQLite DB; keep it owner-only even if it pre-existed
131
+ // with a looser umask-derived mode.
132
+ node_fs_1.default.chmodSync((0, constants_1.resolveDataDir)(), 0o700);
133
+ const dbPath = (0, constants_1.resolveDatabasePath)();
130
134
  const logLevel = process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn");
131
135
  const backendEnv = (0, shared_1.buildBackendEnv)({
132
136
  ports,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kandev",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "private": false,
5
5
  "description": "Launcher for Kandev — manage tasks, orchestrate agents, review changes, and ship value",
6
6
  "license": "AGPL-3.0-only",
@@ -22,11 +22,11 @@
22
22
  "npm": ">=7"
23
23
  },
24
24
  "optionalDependencies": {
25
- "@kdlbs/runtime-linux-x64": "0.54.0",
26
- "@kdlbs/runtime-linux-arm64": "0.54.0",
27
- "@kdlbs/runtime-darwin-x64": "0.54.0",
28
- "@kdlbs/runtime-darwin-arm64": "0.54.0",
29
- "@kdlbs/runtime-win32-x64": "0.54.0"
25
+ "@kdlbs/runtime-linux-x64": "0.56.0",
26
+ "@kdlbs/runtime-linux-arm64": "0.56.0",
27
+ "@kdlbs/runtime-darwin-x64": "0.56.0",
28
+ "@kdlbs/runtime-darwin-arm64": "0.56.0",
29
+ "@kdlbs/runtime-win32-x64": "0.56.0"
30
30
  },
31
31
  "dependencies": {
32
32
  "tar": "^7.5.11",