kandown 0.3.5 → 0.6.1

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.
Files changed (4) hide show
  1. package/README.md +90 -378
  2. package/bin/kandown.js +212 -44
  3. package/dist/index.html +559 -4225
  4. package/package.json +5 -1
package/bin/kandown.js CHANGED
@@ -16,8 +16,12 @@
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
24
+ * → injectServerRoot — injects the CLI server root into single-file HTML
21
25
  * → createServeServer — creates the local zero-dependency HTTP server
22
26
  * → cmdServe — opens the web UI over localhost and launches the board TUI
23
27
  * → main — dispatches CLI commands
@@ -68,50 +72,191 @@ function getCurrentVersion() {
68
72
  } catch { return null; }
69
73
  }
70
74
 
71
- // 📖 Check npm for a newer version and auto-update if outdated.
72
- // Runs in background does not block startup. Only activates when running from
73
- // an installed npm package (not local dev source, where src/ exists).
74
- // 📖 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
+ */
75
127
  async function checkForUpdate(argv = process.argv) {
76
- if (existsSync(join(PKG_ROOT, 'src'))) return; // local dev — skip
128
+ // 📖 Local dev — skip entirely
129
+ if (existsSync(join(PKG_ROOT, 'src'))) return;
130
+
77
131
  const current = getCurrentVersion();
78
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();
79
138
  try {
80
- const { execSync } = await import('node:child_process');
81
- const latest = String(execSync('npm view kandown version --json 2>/dev/null', {
82
- timeout: 8000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
83
- })).trim().replace(/^"|"$/g, '');
84
- 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
+ });
85
166
 
86
- log(`${c.yellow}⚡ Auto-updating kandown ${c.reset}${c.dim}${current}${c.reset} ${c.yellow}→${c.reset} ${c.green}${latest}${c.reset}…`);
87
- try {
88
- execSync('npm install -g kandown 2>/dev/null', {
89
- 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));
90
191
  });
91
- const newVersion = String(execSync('npm view kandown version 2>/dev/null', {
92
- timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
93
- })).trim().replace(/^"|"$/g, '');
94
- if (newVersion === latest) {
95
- log(`${c.green}✓ Updated to v${newVersion}${c.reset} — restarting…`);
96
- // Resolve the global bin directory from npm prefix and spawn directly,
97
- // bypassing npx which may re-resolve the cached old version.
98
- const npmPrefix = String(execSync('npm config get prefix 2>/dev/null', {
99
- timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
100
- })).trim();
101
- const isMacos = existsSync(join(npmPrefix, 'bin', 'kandown'));
102
- const kandownBin = isMacos
103
- ? join(npmPrefix, 'bin', 'kandown')
104
- : join(npmPrefix, 'bin', 'kandown');
105
- const child = spawn(kandownBin, ['--experimental-vm-modules', ...argv.slice(1)], {
106
- detached: true, stdio: 'ignore', env: { ...process.env } });
107
- child.unref();
108
- process.exit(0);
109
- }
110
- } catch {
111
- log(`${c.yellow}⚠ Auto-update failed — will retry on next run${c.reset}`);
112
- log(` Run ${c.cyan}npm install -g kandown${c.reset} to upgrade manually`);
113
- }
114
- } 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);
115
260
  }
116
261
 
117
262
  const c = {
@@ -333,6 +478,11 @@ function cmdInit(rawArgs) {
333
478
  const kandownPath = args.path;
334
479
  const kandownDir = resolve(cwd, kandownPath);
335
480
 
481
+ if (existsSync(join(cwd, '.kandown', 'board.md'))) {
482
+ err(`Kandown already initialized in this directory. To reinitialize, remove the .kandown folder first.`);
483
+ process.exit(1);
484
+ }
485
+
336
486
  log('');
337
487
  info(`Installing kandown in ${c.bold}${kandownPath}/${c.reset}`);
338
488
  log('');
@@ -549,6 +699,22 @@ function deleteTask(res, kandownDir, id) {
549
699
  }
550
700
  }
551
701
 
702
+ /**
703
+ * 📖 The single-file Vite bundle can contain literal strings such as
704
+ * `</head>` from HTML parser libraries. Use the last closing head tag so the
705
+ * CLI does not inject server-mode globals into bundled JavaScript text.
706
+ */
707
+ function injectServerRoot(html, kandownDir) {
708
+ const marker = '</head>';
709
+ const markerIndex = html.toLowerCase().lastIndexOf(marker);
710
+ const safeRoot = JSON.stringify(kandownDir).replace(/</g, '\\u003c');
711
+ const script = `<script>window.__KANDOWN_ROOT__ = ${safeRoot};</script>\n`;
712
+
713
+ if (markerIndex === -1) return script + html;
714
+
715
+ return html.slice(0, markerIndex) + script + html.slice(markerIndex);
716
+ }
717
+
552
718
  function handleApi(req, res, url, kandownDir) {
553
719
  const parts = url.pathname.replace('/api/', '').split('/');
554
720
  const resource = parts[0];
@@ -583,10 +749,7 @@ function serveApp(res, kandownDir) {
583
749
 
584
750
  try {
585
751
  const html = readFileSync(htmlPath, 'utf8');
586
- const injected = html.replace(
587
- '</head>',
588
- `<script>window.__KANDOWN_ROOT__ = ${JSON.stringify(kandownDir)};</script>\n</head>`,
589
- );
752
+ const injected = injectServerRoot(html, kandownDir);
590
753
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
591
754
  res.end(injected);
592
755
  } catch (e) {
@@ -745,7 +908,8 @@ async function cmdTui(screen, rawArgs) {
745
908
  }
746
909
  }
747
910
 
748
- 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;
749
913
 
750
914
  // 📖 Handle --version / -v before any command logic
751
915
  if (cmd === '--version' || cmd === '-v') {
@@ -754,9 +918,13 @@ if (cmd === '--version' || cmd === '-v') {
754
918
  process.exit(0);
755
919
  }
756
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
+
757
925
  // 📖 Auto-update check runs before EVERY command (except --version).
758
926
  // Uses a short timeout so startup is not noticeably slower.
759
- await checkForUpdate(rest);
927
+ if (!skipUpdate) await checkForUpdate(process.argv);
760
928
 
761
929
  switch (cmd) {
762
930
  case 'init':