kandown 0.4.0 → 0.7.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
@@ -180,7 +180,7 @@ Contributions are welcome! Please read the existing code style and conventions b
180
180
  git clone https://github.com/vava-nessa/kandown.git
181
181
  cd kandown
182
182
  pnpm install
183
- pnpm dev # Web UI at localhost:5173
183
+ pnpm dev # Web UI at localhost:5176
184
184
  ```
185
185
 
186
186
  ### Scripts
package/bin/kandown.js CHANGED
@@ -16,6 +16,9 @@
16
16
  * → appendAgentReference — injects a Kandown task-management reference
17
17
  * → createAgentsFileIfMissing — creates AGENTS.md when none exists
18
18
  * → parseArgs — parses shared CLI flags
19
+ * → resolveKandownBin — resolves the global kandown binary path for respawn
20
+ * → semverGt — compares two semver strings
21
+ * → checkForUpdate — non-blocking auto-updater with lock file and graceful fallback
19
22
  * → cmdInit — installs `.kandown`
20
23
  * → cmdUpdate — refreshes installed kandown.html
21
24
  * → injectServerRoot — injects the CLI server root into single-file HTML
@@ -69,50 +72,191 @@ function getCurrentVersion() {
69
72
  } catch { return null; }
70
73
  }
71
74
 
72
- // 📖 Check npm for a newer version and auto-update if outdated.
73
- // Runs in background does not block startup. Only activates when running from
74
- // an installed npm package (not local dev source, where src/ exists).
75
- // 📖 Uses npm install -g to self-upgrade, then re-spawns with the same arguments.
75
+ /**
76
+ * 📖 Resolves the kandown binary path for respawning after an update.
77
+ * Tries, in order: npm global bin pnpm global bin process.execPath fallback.
78
+ * @returns {string|null} Absolute path to the kandown binary, or null.
79
+ */
80
+ function resolveKandownBin() {
81
+ try {
82
+ const npmBin = String(execSync('npm config get prefix 2>/dev/null', {
83
+ timeout: 3000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
84
+ })).trim();
85
+ if (existsSync(join(npmBin, 'bin', 'kandown'))) return join(npmBin, 'bin', 'kandown');
86
+ } catch { /* npm not available */ }
87
+ try {
88
+ const pnpmBin = String(execSync('pnpm config get prefix 2>/dev/null', {
89
+ timeout: 3000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
90
+ })).trim();
91
+ if (existsSync(join(pnpmBin, 'bin', 'kandown'))) return join(pnpmBin, 'bin', 'kandown');
92
+ } catch { /* pnpm not available */ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * 📖 Compares two semver strings (major.minor.patch).
98
+ * @returns {number} 1 if a > b, -1 if a < b, 0 if equal.
99
+ */
100
+ function semverGt(a, b) {
101
+ const pa = a.replace(/^v/, '').split('.').map(Number);
102
+ const pb = b.replace(/^v/, '').split('.').map(Number);
103
+ for (let i = 0; i < 3; i++) {
104
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
105
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
106
+ }
107
+ return 0;
108
+ }
109
+
110
+ /**
111
+ * 📖 Check npm for a newer version and auto-update if outdated.
112
+ *
113
+ * Design principles:
114
+ * - 🚀 Non-blocking: spawns npm check as a background child process.
115
+ * - 🔒 Lock file: prevents concurrent update races when multiple kandown
116
+ * instances start simultaneously.
117
+ * - 🛡️ Resilient: if the update fails for any reason (network, permissions,
118
+ * npm registry downtime), the current version continues normally.
119
+ * - 🔄 Respawn: after a successful update, re-spawns the CLI with the same
120
+ * arguments so the user gets the new version immediately.
121
+ * - 📦 Package manager agnostic: tries npm, then pnpm, for both the update
122
+ * and the binary resolution.
123
+ *
124
+ * Only activates when running from an installed npm package
125
+ * (not local dev source, where `src/` exists in PKG_ROOT).
126
+ */
76
127
  async function checkForUpdate(argv = process.argv) {
77
- if (existsSync(join(PKG_ROOT, 'src'))) return; // local dev — skip
128
+ // 📖 Local dev — skip entirely
129
+ if (existsSync(join(PKG_ROOT, 'src'))) return;
130
+
78
131
  const current = getCurrentVersion();
79
132
  if (!current) return;
133
+
134
+ // 📖 Skip if a lock file exists — another kandown instance is already updating.
135
+ // The lock auto-expires after 60 seconds to handle stale locks from crashed processes.
136
+ const lockFile = join(PKG_ROOT, '.update.lock');
137
+ const now = Date.now();
80
138
  try {
81
- const { execSync } = await import('node:child_process');
82
- const latest = String(execSync('npm view kandown version --json 2>/dev/null', {
83
- timeout: 8000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
84
- })).trim().replace(/^"|"$/g, '');
85
- if (!latest || latest === current) return;
139
+ if (existsSync(lockFile)) {
140
+ const lockAge = now - statSync(lockFile).mtimeMs;
141
+ if (lockAge < 60_000) return; // another process is handling the update
142
+ unlinkSync(lockFile); // stale lock — remove it
143
+ }
144
+ } catch { /* ignore lock errors */ }
145
+
146
+ // 📖 Step 1: Check latest version on npm registry (non-blocking).
147
+ // We use `npm view` in a spawned child process with a short timeout.
148
+ const latest = await new Promise((resolve) => {
149
+ const child = spawn('npm', ['view', 'kandown', 'version'], {
150
+ timeout: 6000,
151
+ stdio: ['pipe', 'pipe', 'pipe'],
152
+ env: { ...process.env },
153
+ // 📖 Detach so we can kill cleanly on timeout
154
+ detached: false,
155
+ });
156
+ let stdout = '';
157
+ child.stdout.on('data', (d) => { stdout += d; });
158
+ child.stderr.on('data', () => {}); // silence stderr
159
+ child.on('error', () => resolve(null));
160
+ child.on('close', (code) => {
161
+ if (code !== 0) return resolve(null);
162
+ const v = stdout.trim().replace(/^"|"$/g, '');
163
+ resolve(v || null);
164
+ });
165
+ });
86
166
 
87
- log(`${c.yellow}⚡ Auto-updating kandown ${c.reset}${c.dim}${current}${c.reset} ${c.yellow}→${c.reset} ${c.green}${latest}${c.reset}…`);
88
- try {
89
- execSync('npm install -g kandown 2>/dev/null', {
90
- timeout: 30000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
167
+ if (!latest || semverGt(current, latest) >= 0) return; // up to date or offline
168
+
169
+ log('');
170
+ log(`${c.yellow}⚡ Update available:${c.reset} kandown ${c.dim}${current}${c.reset} ${c.green}${latest}${c.reset}`);
171
+ info('Auto-updating…');
172
+
173
+ // 📖 Step 2: Create lock file to prevent concurrent updates.
174
+ try { writeFileSync(lockFile, `${process.pid}\n${now}`, 'utf8'); } catch { /* ignore */ }
175
+
176
+ // 📖 Step 3: Run the update via npm or pnpm.
177
+ // Try npm first, fall back to pnpm.
178
+ const updateOk = await new Promise((resolve) => {
179
+ const tryInstall = (cmd) => {
180
+ return new Promise((res) => {
181
+ const child = spawn(cmd, ['install', '-g', 'kandown'], {
182
+ timeout: 45000,
183
+ stdio: ['pipe', 'pipe', 'pipe'],
184
+ env: { ...process.env },
185
+ detached: false,
186
+ });
187
+ child.stderr.on('data', () => {}); // silence npm noise
188
+ child.stdout.on('data', () => {});
189
+ child.on('error', () => res(false));
190
+ child.on('close', (code) => res(code === 0));
91
191
  });
92
- const newVersion = String(execSync('npm view kandown version 2>/dev/null', {
93
- timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
94
- })).trim().replace(/^"|"$/g, '');
95
- if (newVersion === latest) {
96
- log(`${c.green}✓ Updated to v${newVersion}${c.reset} — restarting…`);
97
- // Resolve the global bin directory from npm prefix and spawn directly,
98
- // bypassing npx which may re-resolve the cached old version.
99
- const npmPrefix = String(execSync('npm config get prefix 2>/dev/null', {
100
- timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
101
- })).trim();
102
- const isMacos = existsSync(join(npmPrefix, 'bin', 'kandown'));
103
- const kandownBin = isMacos
104
- ? join(npmPrefix, 'bin', 'kandown')
105
- : join(npmPrefix, 'bin', 'kandown');
106
- const child = spawn(kandownBin, ['--experimental-vm-modules', ...argv.slice(1)], {
107
- detached: true, stdio: 'ignore', env: { ...process.env } });
108
- child.unref();
109
- process.exit(0);
110
- }
111
- } catch {
112
- log(`${c.yellow}⚠ Auto-update failed — will retry on next run${c.reset}`);
113
- log(` Run ${c.cyan}npm install -g kandown${c.reset} to upgrade manually`);
114
- }
115
- } catch { /* offline or npm slow — silently skip */ }
192
+ };
193
+ tryInstall('npm').then((ok) => {
194
+ if (ok) resolve(true);
195
+ else tryInstall('pnpm').then(resolve);
196
+ });
197
+ });
198
+
199
+ // 📖 Clean up lock file regardless of outcome.
200
+ try { if (existsSync(lockFile)) unlinkSync(lockFile); } catch { /* ignore */ }
201
+
202
+ if (!updateOk) {
203
+ warn('Auto-update failed continuing with current version');
204
+ log(` Run ${c.cyan}npm install -g kandown${c.reset} to upgrade manually`);
205
+ log('');
206
+ return;
207
+ }
208
+
209
+ // 📖 Step 4: Verify the update actually landed.
210
+ const postVersion = await new Promise((resolve) => {
211
+ const child = spawn('npm', ['view', 'kandown', 'version'], {
212
+ timeout: 5000,
213
+ stdio: ['pipe', 'pipe', 'pipe'],
214
+ detached: false,
215
+ });
216
+ let stdout = '';
217
+ child.stdout.on('data', (d) => { stdout += d; });
218
+ child.stderr.on('data', () => {});
219
+ child.on('error', () => resolve(null));
220
+ child.on('close', (code) => {
221
+ if (code !== 0) return resolve(null);
222
+ resolve(stdout.trim().replace(/^"|"$/g, '') || null);
223
+ });
224
+ });
225
+
226
+ if (!postVersion || semverGt(postVersion, latest) < 0) {
227
+ // Install claimed success but version didn't change — probably a permissions issue.
228
+ warn('Update did not apply — continuing with current version');
229
+ log(` Run ${c.cyan}npm install -g kandown${c.reset} to upgrade manually`);
230
+ log('');
231
+ return;
232
+ }
233
+
234
+ success(`Updated to v${postVersion} — restarting…`);
235
+ log('');
236
+
237
+ // 📖 Step 5: Respawn with the new version.
238
+ // Pass --no-update-check to the child so it doesn't try to update again.
239
+ const bin = resolveKandownBin();
240
+ const childArgs = ['--no-update-check', ...argv.slice(2)];
241
+
242
+ if (bin) {
243
+ const child = spawn(bin, childArgs, {
244
+ detached: true,
245
+ stdio: 'inherit',
246
+ env: { ...process.env },
247
+ });
248
+ child.unref();
249
+ } else {
250
+ // 📖 Fallback: re-use the current binary path (works for npx).
251
+ const child = spawn(process.argv[0], [process.argv[1], ...childArgs], {
252
+ detached: true,
253
+ stdio: 'inherit',
254
+ env: { ...process.env },
255
+ });
256
+ child.unref();
257
+ }
258
+
259
+ process.exit(0);
116
260
  }
117
261
 
118
262
  const c = {
@@ -764,7 +908,8 @@ async function cmdTui(screen, rawArgs) {
764
908
  }
765
909
  }
766
910
 
767
- const [cmd, ...rest] = process.argv.slice(2);
911
+ const rawArgs = process.argv.slice(2).filter((a) => a !== '--no-update-check');
912
+ const [cmd, ...rest] = rawArgs;
768
913
 
769
914
  // 📖 Handle --version / -v before any command logic
770
915
  if (cmd === '--version' || cmd === '-v') {
@@ -773,9 +918,13 @@ if (cmd === '--version' || cmd === '-v') {
773
918
  process.exit(0);
774
919
  }
775
920
 
921
+ // 📖 Skip auto-update if this is a respawned child after an update.
922
+ // The parent passes --no-update-check to prevent an infinite update loop.
923
+ const skipUpdate = process.argv.slice(2).includes('--no-update-check');
924
+
776
925
  // 📖 Auto-update check runs before EVERY command (except --version).
777
926
  // Uses a short timeout so startup is not noticeably slower.
778
- await checkForUpdate(rest);
927
+ if (!skipUpdate) await checkForUpdate(process.argv);
779
928
 
780
929
  switch (cmd) {
781
930
  case 'init':