kandev 0.52.0 → 0.54.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/backup.js ADDED
@@ -0,0 +1,98 @@
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.isProductionDb = isProductionDb;
7
+ exports.backupProductionDb = backupProductionDb;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_os_1 = __importDefault(require("node:os"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ // Prefix for dev-prod-db automatic snapshots. Distinct from the backend's
12
+ // "kandev-*" auto-snapshots and "manual-*" user snapshots so the families
13
+ // don't interfere with each other's retention policies.
14
+ const BACKUP_PREFIX = "dev-prod-db-";
15
+ const BACKUP_SUFFIX = ".db";
16
+ const MAX_BACKUPS = 5;
17
+ /**
18
+ * Returns true if the given dbPath points to a non-dev database that should
19
+ * be backed up before running dev mode. Dev-isolated databases live under
20
+ * <repo>/.kandev-dev/ and are considered disposable.
21
+ */
22
+ function isProductionDb(dbPath) {
23
+ const normalized = node_path_1.default.normalize(dbPath);
24
+ const segments = normalized.split(node_path_1.default.sep).filter(Boolean);
25
+ return !segments.includes(".kandev-dev");
26
+ }
27
+ /**
28
+ * Backs up the database at dbPath to <data-dir>/backups/ before dev mode
29
+ * runs against it. Keeps only the newest MAX_BACKUPS snapshots.
30
+ *
31
+ * Returns the path of the created backup, or null if the DB didn't exist
32
+ * or no backup was needed.
33
+ *
34
+ * The optional `homeDir` parameter is exposed for tests so they can redirect
35
+ * the backup location without mocking os.homedir(). The optional `now`
36
+ * parameter lets tests pass an explicit timestamp so back-to-back calls in
37
+ * the same millisecond produce distinct filenames + mtimes without sleeping.
38
+ */
39
+ function backupProductionDb(dbPath, homeDir, now) {
40
+ if (!node_fs_1.default.existsSync(dbPath)) {
41
+ return null;
42
+ }
43
+ const root = homeDir ?? node_os_1.default.homedir();
44
+ // Backups are always placed under ~/.kandev/data/backups/ (or the
45
+ // caller-supplied homeDir in tests), even when dbPath points elsewhere.
46
+ // This matches the dev-prod-db default flow; custom KANDEV_DATABASE_PATH
47
+ // values are advanced usage where the user is responsible for backup location.
48
+ const dataDir = node_path_1.default.join(root, ".kandev", "data");
49
+ const backupDir = node_path_1.default.join(dataDir, "backups");
50
+ node_fs_1.default.mkdirSync(backupDir, { recursive: true });
51
+ const stamp = now ?? new Date();
52
+ const ts = stamp.toISOString().replace(/[:.]/g, "");
53
+ const name = `${BACKUP_PREFIX}${ts}${BACKUP_SUFFIX}`;
54
+ const dest = node_path_1.default.join(backupDir, name);
55
+ node_fs_1.default.copyFileSync(dbPath, dest);
56
+ // Stamp both atime + mtime so pruning (which sorts by mtime) is deterministic.
57
+ node_fs_1.default.utimesSync(dest, stamp, stamp);
58
+ pruneBackups(backupDir, MAX_BACKUPS);
59
+ return dest;
60
+ }
61
+ /**
62
+ * Keeps only the `keep` newest dev-prod-db backup files in `dir`, deleting
63
+ * older ones. Non-matching files are left untouched.
64
+ */
65
+ function pruneBackups(dir, keep) {
66
+ let entries;
67
+ try {
68
+ entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
69
+ }
70
+ catch {
71
+ return;
72
+ }
73
+ const files = entries
74
+ .filter((e) => e.isFile() && e.name.startsWith(BACKUP_PREFIX) && e.name.endsWith(BACKUP_SUFFIX))
75
+ .map((e) => {
76
+ const fullPath = node_path_1.default.join(dir, e.name);
77
+ try {
78
+ const stat = node_fs_1.default.statSync(fullPath);
79
+ return { path: fullPath, mtime: stat.mtime };
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ })
85
+ .filter((f) => f !== null);
86
+ if (files.length <= keep) {
87
+ return;
88
+ }
89
+ files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
90
+ for (const f of files.slice(keep)) {
91
+ try {
92
+ node_fs_1.default.unlinkSync(f.path);
93
+ }
94
+ catch {
95
+ // Non-critical: don't fail the launch if one old backup can't be removed.
96
+ }
97
+ }
98
+ }
package/dist/dev.js CHANGED
@@ -8,6 +8,7 @@ exports.resolveDevBackendEnv = resolveDevBackendEnv;
8
8
  const node_child_process_1 = require("node:child_process");
9
9
  const node_fs_1 = __importDefault(require("node:fs"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
+ const backup_1 = require("./backup");
11
12
  const constants_1 = require("./constants");
12
13
  const health_1 = require("./health");
13
14
  const kandev_env_1 = require("./kandev-env");
@@ -17,6 +18,22 @@ const web_1 = require("./web");
17
18
  async function runDev({ repoRoot, backendPort, webPort }) {
18
19
  const ports = await (0, shared_1.pickPorts)(backendPort, webPort);
19
20
  const { dbPath, extra } = resolveDevBackendEnv(repoRoot);
21
+ if ((0, backup_1.isProductionDb)(dbPath)) {
22
+ try {
23
+ const backupPath = (0, backup_1.backupProductionDb)(dbPath);
24
+ if (backupPath) {
25
+ const name = node_path_1.default.basename(backupPath);
26
+ console.log(`[kandev] backed up production db → ${name}`);
27
+ }
28
+ }
29
+ catch (err) {
30
+ const message = err instanceof Error ? err.message : String(err);
31
+ // Abort rather than continue: the backup exists precisely to protect the
32
+ // production db before dev mode touches it. Proceeding on failure would
33
+ // remove the safety guarantee that justified introducing this guard.
34
+ throw new Error(`failed to back up production db (${message}); aborting dev startup`);
35
+ }
36
+ }
20
37
  const backendEnv = (0, shared_1.buildBackendEnv)({ ports, extra });
21
38
  const webEnv = (0, shared_1.buildWebEnv)({ ports, debug: true });
22
39
  const logLevel = process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info";
@@ -10,6 +10,7 @@ exports.linuxSystemUnitPath = linuxSystemUnitPath;
10
10
  exports.macosUserAgentDir = macosUserAgentDir;
11
11
  exports.macosUserPlistPath = macosUserPlistPath;
12
12
  exports.macosSystemPlistPath = macosSystemPlistPath;
13
+ exports.homebrewShimPath = homebrewShimPath;
13
14
  exports.captureLauncher = captureLauncher;
14
15
  exports.resolveHomeDir = resolveHomeDir;
15
16
  exports.resolveLogDir = resolveLogDir;
@@ -45,6 +46,30 @@ function macosUserPlistPath() {
45
46
  function macosSystemPlistPath() {
46
47
  return node_path_1.default.join(exports.MACOS_SYSTEM_DAEMON_DIR, `${exports.LAUNCHD_LABEL}.plist`);
47
48
  }
49
+ // Homebrew is POSIX-only, so the Cellar segment is a hardcoded "/Cellar/"
50
+ // rather than `path.sep`-based. On a Windows CI runner `path.sep` would be
51
+ // "\\", which would never match a POSIX Cellar path and silently break shim
52
+ // derivation (and its tests).
53
+ const HOMEBREW_CELLAR_SEGMENT = "/Cellar/";
54
+ /**
55
+ * Derive the floating Homebrew launcher shim from a Cellar-installed cli.js path.
56
+ *
57
+ * Homebrew installs the CLI under `<prefix>/Cellar/kandev/<version>/...` and
58
+ * symlinks a version-independent shim at `<prefix>/bin/kandev`. That shim sets
59
+ * KANDEV_BUNDLE_DIR / KANDEV_VERSION itself and execs cli.js via the floating
60
+ * `opt/node` symlink, so it keeps working after `brew upgrade` deletes the old
61
+ * Cellar dir. Returns undefined when `cliEntry` isn't a Cellar layout (npm /
62
+ * unknown installs), so callers fall back to the version-pinned paths.
63
+ */
64
+ function homebrewShimPath(cliEntry) {
65
+ const idx = cliEntry.indexOf(HOMEBREW_CELLAR_SEGMENT);
66
+ if (idx === -1)
67
+ return undefined;
68
+ const prefix = cliEntry.slice(0, idx);
69
+ // Homebrew layout is POSIX; use path.posix.join so the result keeps forward
70
+ // slashes regardless of the host the install/tests run on.
71
+ return node_path_1.default.posix.join(prefix, "bin", exports.SERVICE_NAME);
72
+ }
48
73
  /**
49
74
  * Snapshot the current invocation so the service unit can faithfully reproduce it.
50
75
  *
@@ -63,7 +88,17 @@ function captureLauncher() {
63
88
  : cliEntry.includes(`${node_path_1.default.sep}node_modules${node_path_1.default.sep}`)
64
89
  ? "npm"
65
90
  : "unknown";
66
- return { nodePath, cliEntry, kind, bundleDir, version };
91
+ // For Homebrew installs, prefer the floating bin shim so the unit survives
92
+ // `brew upgrade` (which deletes the versioned Cellar dir baked into nodePath
93
+ // /cliEntry). Only adopt it when the shim actually exists on disk; otherwise
94
+ // fall back to the version-pinned paths below.
95
+ let shimPath;
96
+ if (kind === "homebrew") {
97
+ const candidate = homebrewShimPath(cliEntry);
98
+ if (candidate && node_fs_1.default.existsSync(candidate))
99
+ shimPath = candidate;
100
+ }
101
+ return { nodePath, cliEntry, kind, bundleDir, version, shimPath };
67
102
  }
68
103
  function resolveCliEntry() {
69
104
  const argvEntry = process.argv[1];
@@ -9,12 +9,13 @@ exports.renderSystemdUnit = renderSystemdUnit;
9
9
  exports.renderLaunchdPlist = renderLaunchdPlist;
10
10
  const node_os_1 = __importDefault(require("node:os"));
11
11
  const node_path_1 = __importDefault(require("node:path"));
12
- // User-mode PATH includes ~/.local/bin so user-installed agent CLIs (npm user
13
- // prefix, pipx, fnm, etc.) are discoverable.
12
+ // User-mode PATH includes ~/.local/bin and ~/.bun/bin so user-installed agent
13
+ // CLIs (npm user prefix, pipx, fnm, Bun globals like oh-my-pi/omp, etc.) are
14
+ // discoverable.
14
15
  const SYSTEMD_SYSTEM_PATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin";
15
- const SYSTEMD_USER_PATH = `%h/.local/bin:${SYSTEMD_SYSTEM_PATH}`;
16
+ const SYSTEMD_USER_PATH = `%h/.local/bin:%h/.bun/bin:${SYSTEMD_SYSTEM_PATH}`;
16
17
  const LAUNCHD_SYSTEM_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
17
- const launchdUserPath = () => `${node_os_1.default.homedir()}/.local/bin:${LAUNCHD_SYSTEM_PATH}`;
18
+ const launchdUserPath = () => `${node_os_1.default.homedir()}/.local/bin:${node_os_1.default.homedir()}/.bun/bin:${LAUNCHD_SYSTEM_PATH}`;
18
19
  // Prepend the launcher node's bin dir so `npm`/`npx` resolve under per-user
19
20
  // node managers (fnm, nvm, asdf, volta, mise), where node lives in a versioned
20
21
  // subdirectory not covered by the system PATH. ExecStart already points at this
@@ -51,29 +52,41 @@ function looksLikeManagedUnit(content) {
51
52
  * Render a systemd unit file for kandev.
52
53
  *
53
54
  * The unit hard-codes absolute paths so it works without a user PATH. We pass
54
- * `--headless` so the daemon doesn't try to open a browser. KANDEV_BUNDLE_DIR /
55
- * KANDEV_VERSION are surfaced only when present (Homebrew installs only) so
56
- * `npm i -g` installs don't get spurious env vars.
55
+ * `--headless` so the daemon doesn't try to open a browser.
56
+ *
57
+ * For Homebrew installs the launcher carries a resolved `shimPath` — the
58
+ * floating `<prefix>/bin/kandev` shim that survives `brew upgrade`. When set we
59
+ * exec that shim and omit KANDEV_BUNDLE_DIR / KANDEV_VERSION and the
60
+ * version-pinned node bin dir from PATH, because the shim supplies all three
61
+ * itself. Otherwise (npm / unknown installs) we fall back to the version-pinned
62
+ * node + cli.js and surface KANDEV_BUNDLE_DIR / KANDEV_VERSION only when present.
57
63
  */
58
64
  function renderSystemdUnit(input) {
65
+ const shimPath = input.launcher.shimPath;
59
66
  const basePath = input.mode === "system" ? SYSTEMD_SYSTEM_PATH : SYSTEMD_USER_PATH;
67
+ // For the shim, prepend its own bin dir (the Homebrew prefix's `bin`, where
68
+ // node/npm/npx live) so npx-based agents resolve even when the prefix isn't
69
+ // one of the hardcoded defaults. pathWithNodeBinDir dedupes when it already is.
70
+ const pathValue = pathWithNodeBinDir(basePath, shimPath ?? input.launcher.nodePath);
60
71
  const env = [
61
72
  envLine("KANDEV_HOME_DIR", input.homeDir),
62
73
  envLine("KANDEV_LOG_LEVEL", "info"),
63
- envLine("PATH", pathWithNodeBinDir(basePath, input.launcher.nodePath)),
74
+ envLine("PATH", pathValue),
64
75
  ];
65
76
  if (input.port !== undefined) {
66
77
  env.push(envLine("KANDEV_SERVER_PORT", String(input.port)));
67
78
  }
68
- if (input.launcher.bundleDir) {
79
+ if (!shimPath && input.launcher.bundleDir) {
69
80
  env.push(envLine("KANDEV_BUNDLE_DIR", input.launcher.bundleDir));
70
81
  }
71
- if (input.launcher.version) {
82
+ if (!shimPath && input.launcher.version) {
72
83
  env.push(envLine("KANDEV_VERSION", input.launcher.version));
73
84
  }
74
85
  const wantedBy = input.mode === "system" ? "multi-user.target" : "default.target";
75
86
  const userLine = input.mode === "system" && input.systemUser ? `User=${input.systemUser}\n` : "";
76
- const exec = `${quoteForUnit(input.launcher.nodePath)} ${quoteForUnit(input.launcher.cliEntry)} --headless`;
87
+ const exec = shimPath
88
+ ? `${quoteForUnit(shimPath)} --headless`
89
+ : `${quoteForUnit(input.launcher.nodePath)} ${quoteForUnit(input.launcher.cliEntry)} --headless`;
77
90
  return `${SYSTEMD_MARKER}
78
91
  [Unit]
79
92
  Description=Kandev autonomous agent platform
@@ -102,25 +115,30 @@ WantedBy=${wantedBy}
102
115
  * get a LaunchDaemon that runs at boot regardless of login.
103
116
  */
104
117
  function renderLaunchdPlist(input) {
118
+ const shimPath = input.launcher.shimPath;
105
119
  const basePath = input.mode === "system" ? LAUNCHD_SYSTEM_PATH : launchdUserPath();
120
+ // See renderSystemdUnit: prepend the shim's own bin dir so npm/npx resolve.
121
+ const pathValue = pathWithNodeBinDir(basePath, shimPath ?? input.launcher.nodePath);
106
122
  const envEntries = [
107
123
  ["KANDEV_HOME_DIR", input.homeDir],
108
124
  ["KANDEV_LOG_LEVEL", "info"],
109
- ["PATH", pathWithNodeBinDir(basePath, input.launcher.nodePath)],
125
+ ["PATH", pathValue],
110
126
  ];
111
127
  if (input.port !== undefined) {
112
128
  envEntries.push(["KANDEV_SERVER_PORT", String(input.port)]);
113
129
  }
114
- if (input.launcher.bundleDir) {
130
+ if (!shimPath && input.launcher.bundleDir) {
115
131
  envEntries.push(["KANDEV_BUNDLE_DIR", input.launcher.bundleDir]);
116
132
  }
117
- if (input.launcher.version) {
133
+ if (!shimPath && input.launcher.version) {
118
134
  envEntries.push(["KANDEV_VERSION", input.launcher.version]);
119
135
  }
120
136
  const envXml = envEntries
121
137
  .map(([k, v]) => ` <key>${escapeXml(k)}</key>\n <string>${escapeXml(v)}</string>`)
122
138
  .join("\n");
123
- const args = [input.launcher.nodePath, input.launcher.cliEntry, "--headless"];
139
+ const args = shimPath
140
+ ? [shimPath, "--headless"]
141
+ : [input.launcher.nodePath, input.launcher.cliEntry, "--headless"];
124
142
  const argsXml = args.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
125
143
  // For system-mode LaunchDaemons, run as a specific user instead of root.
126
144
  // For user agents this directive is omitted — the agent already runs as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kandev",
3
- "version": "0.52.0",
3
+ "version": "0.54.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.52.0",
26
- "@kdlbs/runtime-linux-arm64": "0.52.0",
27
- "@kdlbs/runtime-darwin-x64": "0.52.0",
28
- "@kdlbs/runtime-darwin-arm64": "0.52.0",
29
- "@kdlbs/runtime-win32-x64": "0.52.0"
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"
30
30
  },
31
31
  "dependencies": {
32
32
  "tar": "^7.5.11",