kandown 0.17.1 → 0.17.2

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/bin/kandown.js CHANGED
@@ -60,10 +60,27 @@ import {
60
60
  statSync,
61
61
  unlinkSync,
62
62
  renameSync,
63
- rmdirSync,
63
+ rmSync,
64
+ openSync,
65
+ writeSync,
66
+ closeSync,
64
67
  } from 'node:fs';
65
- import { spawnSync, spawn, execSync } from 'node:child_process';
68
+ import { spawn, execSync } from 'node:child_process';
66
69
  import { createConnection } from 'node:net';
70
+ import { randomBytes } from 'node:crypto';
71
+
72
+ // 📖 Global safety net: a stray exception or unhandled rejection prints a clean
73
+ // one-liner instead of a raw stack trace. The daemon (KANDOWN_DAEMON=1) logs
74
+ // and keeps serving — a single bad request must not take the web UI down.
75
+ // Set KANDOWN_DEBUG=1 to get the full stack.
76
+ function handleFatal(kind, e) {
77
+ const msg = e instanceof Error ? e.message : String(e);
78
+ console.error(`\x1b[31m✗\x1b[0m kandown ${kind}: ${msg}`);
79
+ if (process.env.KANDOWN_DEBUG && e instanceof Error) console.error(e.stack);
80
+ if (process.env.KANDOWN_DAEMON !== '1') process.exit(1);
81
+ }
82
+ process.on('uncaughtException', (e) => handleFatal('crashed', e));
83
+ process.on('unhandledRejection', (e) => handleFatal('internal error', e));
67
84
 
68
85
  const __filename = fileURLToPath(import.meta.url);
69
86
  const __dirname = dirname(__filename);
@@ -127,19 +144,52 @@ function resolveKandownBin() {
127
144
  }
128
145
 
129
146
  /**
130
- * 📖 Compares two semver strings (major.minor.patch).
147
+ * 📖 Compares two semver strings (major.minor.patch, optional -prerelease).
148
+ * Prerelease-safe: "0.18.0-beta.1" no longer parses as NaN — the numeric
149
+ * triple is compared first, and on a tie a release outranks a prerelease.
131
150
  * @returns {number} 1 if a > b, -1 if a < b, 0 if equal.
132
151
  */
133
152
  function semverGt(a, b) {
134
- const pa = a.replace(/^v/, '').split('.').map(Number);
135
- const pb = b.replace(/^v/, '').split('.').map(Number);
153
+ const parse = (v) => {
154
+ const [core, ...pre] = String(v).replace(/^v/, '').split('-');
155
+ return { nums: core.split('.').map(n => Number(n) || 0), pre: pre.length > 0 };
156
+ };
157
+ const pa = parse(a);
158
+ const pb = parse(b);
136
159
  for (let i = 0; i < 3; i++) {
137
- if ((pa[i] || 0) > (pb[i] || 0)) return 1;
138
- if ((pa[i] || 0) < (pb[i] || 0)) return -1;
160
+ if ((pa.nums[i] || 0) > (pb.nums[i] || 0)) return 1;
161
+ if ((pa.nums[i] || 0) < (pb.nums[i] || 0)) return -1;
139
162
  }
163
+ if (!pa.pre && pb.pre) return 1;
164
+ if (pa.pre && !pb.pre) return -1;
140
165
  return 0;
141
166
  }
142
167
 
168
+ /**
169
+ * 📖 Update-check throttle: remembers the last successful registry check in a
170
+ * small cache file next to the package. The network check runs at most once
171
+ * per 24h — `kandown` stays fast (and fully offline-silent) the rest of the
172
+ * time. Cache write failures are ignored (read-only installs just check more
173
+ * often, which is the pre-throttle behavior).
174
+ */
175
+ const UPDATE_CHECK_CACHE = join(PKG_ROOT, '.update-check.json');
176
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
177
+
178
+ function updateCheckedRecently() {
179
+ try {
180
+ const raw = JSON.parse(readFileSync(UPDATE_CHECK_CACHE, 'utf8'));
181
+ return Number.isFinite(raw?.lastCheck) && Date.now() - raw.lastCheck < UPDATE_CHECK_INTERVAL_MS;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ function rememberUpdateCheck() {
188
+ try {
189
+ writeFileSync(UPDATE_CHECK_CACHE, JSON.stringify({ lastCheck: Date.now() }), 'utf8');
190
+ } catch { /* read-only install — check again next time */ }
191
+ }
192
+
143
193
  /**
144
194
  * 📖 Check npm for a newer version and auto-update if outdated.
145
195
  *
@@ -161,6 +211,17 @@ async function checkForUpdate(argv = process.argv) {
161
211
  // 📖 Local dev — skip entirely
162
212
  if (existsSync(join(PKG_ROOT, 'src'))) return;
163
213
 
214
+ // 📖 Opt-out for CI / controlled environments.
215
+ if (process.env.KANDOWN_NO_UPDATE === '1') return;
216
+
217
+ // 📖 Script context (piped/captured output): never surprise-update — a
218
+ // respawn mid-pipeline would interleave output from two versions.
219
+ if (!process.stdout.isTTY) return;
220
+
221
+ // 📖 Throttle: the registry round-trip runs at most once per 24h, so the
222
+ // command stays instant (and silent offline) the rest of the time.
223
+ if (updateCheckedRecently()) return;
224
+
164
225
  const current = getCurrentVersion();
165
226
  if (!current) return;
166
227
 
@@ -197,6 +258,10 @@ async function checkForUpdate(argv = process.argv) {
197
258
  });
198
259
  });
199
260
 
261
+ // 📖 A successful registry answer (even "up to date") arms the 24h throttle.
262
+ // Offline / registry-down does NOT — we retry on the next interactive run.
263
+ if (latest) rememberUpdateCheck();
264
+
200
265
  if (!latest || semverGt(current, latest) >= 0) return; // up to date or offline
201
266
 
202
267
  tuiDone('⚡', `Update available: ${c.dim}kandown ${current}${c.reset} → ${c.green}${latest}${c.reset}`);
@@ -301,7 +366,29 @@ const c = {
301
366
  cyan: '\x1b[36m',
302
367
  };
303
368
 
304
- const log = (msg) => console.log(msg);
369
+ /**
370
+ * 📖 Atomic write (M6): write to a sibling temp file then rename over the
371
+ * target. A crash/kill mid-write can no longer leave a truncated task file or
372
+ * a corrupted kandown.json — rename is atomic on the same filesystem.
373
+ */
374
+ function atomicWriteFileSync(path, content) {
375
+ const tmp = `${path}.${process.pid}.tmp`;
376
+ try {
377
+ writeFileSync(tmp, content, 'utf8');
378
+ renameSync(tmp, path);
379
+ } catch (e) {
380
+ try { unlinkSync(tmp); } catch { /* nothing to clean */ }
381
+ throw e;
382
+ }
383
+ }
384
+
385
+ // 📖 Output contract (M2): stdout carries DATA ONLY (ids, JSON, tables, help)
386
+ // so `$(kandown shell create ...)` and `| jq` pipelines stay clean. Every
387
+ // decoration — status lines, warnings, errors, progress — goes to stderr.
388
+ /** Data output → stdout. Use ONLY for content the command was asked to produce. */
389
+ const out = (msg) => console.log(msg);
390
+ /** Decoration output → stderr (status, hints, banners). */
391
+ const log = (msg) => console.error(msg);
305
392
  const success = (msg) => log(`${c.green}✓${c.reset} ${msg}`);
306
393
  const info = (msg) => log(`${c.cyan}→${c.reset} ${msg}`);
307
394
  const warn = (msg) => log(`${c.yellow}⚠${c.reset} ${msg}`);
@@ -315,22 +402,11 @@ const err = (msg) => log(`${c.red}✗${c.reset} ${msg}`);
315
402
  const _SP_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
316
403
  let _spTimer = null;
317
404
  let _spIdx = 0;
318
- const _isTTY = process.stdout.isTTY;
405
+ // 📖 Animations are decorations → stderr, and only when stderr is a TTY.
406
+ const _isTTY = process.stderr.isTTY;
319
407
 
320
408
  function _tuiClear() {
321
- process.stdout.write('\r\x1b[K');
322
- }
323
-
324
- /** Start an animated spinner with the given text. */
325
- function tuiSpinner(text) {
326
- if (_spTimer) clearInterval(_spTimer);
327
- _spIdx = 0;
328
- _tuiClear();
329
- if (!_isTTY) { process.stdout.write(` ${text}\n`); return; }
330
- _spTimer = setInterval(() => {
331
- process.stdout.write(`\r\x1b[K${_SP_FRAMES[_spIdx % _SP_FRAMES.length]} ${text}`);
332
- _spIdx++;
333
- }, 100);
409
+ process.stderr.write('\r\x1b[K');
334
410
  }
335
411
 
336
412
  /**
@@ -342,14 +418,14 @@ function tuiProgress(text, estimateSec = 25, barWidth = 15) {
342
418
  if (_spTimer) clearInterval(_spTimer);
343
419
  _spIdx = 0;
344
420
  _tuiClear();
345
- if (!_isTTY) { process.stdout.write(` ${text}\n`); return; }
421
+ if (!_isTTY) { process.stderr.write(` ${text}\n`); return; }
346
422
  const start = Date.now();
347
423
  _spTimer = setInterval(() => {
348
424
  const elapsed = (Date.now() - start) / 1000;
349
425
  const pct = Math.min(Math.round((elapsed / estimateSec) * 100), 95);
350
426
  const filled = Math.round(barWidth * pct / 100);
351
427
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
352
- process.stdout.write(`\r\x1b[K${_SP_FRAMES[_spIdx % _SP_FRAMES.length]} ${text} ${bar} ${pct}%`);
428
+ process.stderr.write(`\r\x1b[K${_SP_FRAMES[_spIdx % _SP_FRAMES.length]} ${text} ${bar} ${pct}%`);
353
429
  _spIdx++;
354
430
  }, 120);
355
431
  }
@@ -359,7 +435,7 @@ function tuiDone(symbol, text) {
359
435
  if (_spTimer) { clearInterval(_spTimer); _spTimer = null; }
360
436
  if (_isTTY) {
361
437
  _tuiClear();
362
- process.stdout.write(`${symbol} ${text}\n`);
438
+ process.stderr.write(`${symbol} ${text}\n`);
363
439
  } else {
364
440
  log(`${symbol} ${text}`);
365
441
  }
@@ -367,7 +443,7 @@ function tuiDone(symbol, text) {
367
443
 
368
444
  function help() {
369
445
  const v = getCurrentVersion() ?? '?';
370
- log(`
446
+ out(`
371
447
  ${c.bold}kandown${c.reset} ${c.dim}· file-based kanban backed by markdown${c.reset}
372
448
  ${c.dim}v${v}${c.reset}
373
449
 
@@ -513,7 +589,7 @@ function parseArgs(argv) {
513
589
  const args = { path: '.kandown', noAgents: false, force: false, port: null };
514
590
  for (let i = 0; i < argv.length; i++) {
515
591
  const a = argv[i];
516
- if (a === '--path' || a === '-p') args.path = argv[++i];
592
+ if (a === '--path') args.path = argv[++i];
517
593
  else if (a === '--port') args.port = argv[++i];
518
594
  else if (a === '--no-agents') args.noAgents = true;
519
595
  else if (a === '--force' || a === '-f') args.force = true;
@@ -548,11 +624,6 @@ function getTasksDir(kandownDir) {
548
624
  * `{ moved, cleanedUp }` describing what was done (or nothing).
549
625
  *
550
626
  * Rules:
551
- * - If `./tasks/*.md` already exists, never move anything (avoid clobbering).
552
- * - If `.kandown/tasks/` doesn't exist or has no .md files, no-op.
553
- * - Move every `.md` file (including those in `archive/`) to `./tasks/`.
554
- * - Delete `.kandown/tasks/` only if it's now empty (preserves any non-md
555
- * files like `.scratch/` notes the user may have stashed there).
556
627
  * - Never throw — failures are logged and skipped so a single bad file
557
628
  * doesn't block the rest of the migration.
558
629
  *
@@ -560,7 +631,6 @@ function getTasksDir(kandownDir) {
560
631
  * @returns {{ moved: number, cleanedUp: boolean, skipped: boolean }}
561
632
  */
562
633
  function migrateTasksToTopLevel(kandownDir) {
563
- const projectRoot = getProjectRoot(kandownDir);
564
634
  const oldDir = join(kandownDir, 'tasks');
565
635
  const newDir = getTasksDir(kandownDir);
566
636
  const result = { moved: 0, cleanedUp: false, skipped: false };
@@ -633,7 +703,7 @@ function cleanupLegacyTasksDir(kandownDir, result) {
633
703
  const remaining = readdirSync(oldDir);
634
704
  if (remaining.length === 0) {
635
705
  try {
636
- rmdirSync(oldDir);
706
+ rmSync(oldDir, { recursive: false });
637
707
  result.cleanedUp = true;
638
708
  } catch { /* directory not empty or platform limitation — leave it */ }
639
709
  }
@@ -649,7 +719,6 @@ function cleanupLegacyTasksDir(kandownDir, result) {
649
719
  function ensureKandownDir(rawArgs) {
650
720
  const args = parseArgs(rawArgs);
651
721
  const cwd = process.cwd();
652
- const explicitPath = rawArgs.includes('--path') || rawArgs.includes('-p');
653
722
  const kandownDir = resolve(cwd, args.path);
654
723
 
655
724
  if (existsSync(kandownDir)) {
@@ -838,10 +907,40 @@ function parseFrontmatter(content) {
838
907
  }
839
908
  const yaml = content.slice(4, end);
840
909
  out.body = content.slice(end + 5).replace(/^\n+/, '');
841
- for (const line of yaml.split('\n')) {
842
- if (!line.trim() || line.trim().startsWith('#')) continue;
910
+
911
+ const lines = yaml.split('\n');
912
+ let i = 0;
913
+ while (i < lines.length) {
914
+ const line = lines[i];
915
+ if (!line.trim() || line.trim().startsWith('#')) {
916
+ i++;
917
+ continue;
918
+ }
919
+
920
+ const blockMatch = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*\|\s*$/);
921
+ if (blockMatch) {
922
+ const key = blockMatch[1];
923
+ const valLines = [];
924
+ i++;
925
+ while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) {
926
+ if (lines[i].startsWith(' ')) {
927
+ valLines.push(lines[i].slice(2));
928
+ } else if (lines[i].startsWith(' ')) {
929
+ valLines.push(lines[i].slice(1));
930
+ } else {
931
+ valLines.push(lines[i]);
932
+ }
933
+ i++;
934
+ }
935
+ out.frontmatter[key] = valLines.join('\n');
936
+ continue;
937
+ }
938
+
843
939
  const m = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
844
- if (!m) continue;
940
+ if (!m) {
941
+ i++;
942
+ continue;
943
+ }
845
944
  const key = m[1];
846
945
  let val = (m[2] || '').trim();
847
946
  if (val.startsWith('[') && val.endsWith(']')) {
@@ -856,6 +955,7 @@ function parseFrontmatter(content) {
856
955
  val = val.replace(/^["']|["']$/g, '');
857
956
  }
858
957
  out.frontmatter[key] = val;
958
+ i++;
859
959
  }
860
960
  return out;
861
961
  }
@@ -930,7 +1030,10 @@ function nextTaskId(kandownDir) {
930
1030
 
931
1031
  function shellPad(str, len) {
932
1032
  const s = String(str);
933
- if (s.length >= len) return s.slice(0, Math.max(0, len - 1)) + (s.length > len ? '…' : '');
1033
+ // 📖 Only truncate when the string is strictly LONGER than the column
1034
+ // an exact fit must render as-is (`>=` chopped the last char of e.g. the
1035
+ // longest task id, displaying "t99" as "t9").
1036
+ if (s.length > len) return s.slice(0, Math.max(0, len - 1)) + '…';
934
1037
  return s + ' '.repeat(len - s.length);
935
1038
  }
936
1039
 
@@ -977,11 +1080,6 @@ function shellList(rawArgs) {
977
1080
  err(`Unknown status: ${args.flags.status}`);
978
1081
  process.exit(1);
979
1082
  }
980
- if (statusFilter && statusFilter !== 'archived' && config && !(config.board.columns || []).map(c => c.toLowerCase()).includes(statusFilter.toLowerCase())) {
981
- err(`Status not in board columns: ${statusFilter}`);
982
- process.exit(1);
983
- }
984
-
985
1083
  const ids = listAllTaskIds(kandownDir);
986
1084
  const rows = [];
987
1085
  for (const id of ids) {
@@ -1012,18 +1110,20 @@ function shellList(rawArgs) {
1012
1110
  }
1013
1111
 
1014
1112
  if (rows.length === 0) {
1113
+ // 📖 Placeholder goes to stderr — an empty result on stdout stays empty
1114
+ // so `[ -z "$(kandown shell list ...)" ]` style checks behave.
1015
1115
  log(c.dim + '(no tasks)' + c.reset);
1016
1116
  return;
1017
1117
  }
1018
1118
 
1019
1119
  const idW = Math.max(2, ...rows.map(r => r.id.length));
1020
- log(`${c.dim}${shellPad('ID', idW)} ${shellPad('STATUS', 14)} ${shellPad('PRI', 4)} ${shellPad('ASSIGNEE', 12)} TITLE${c.reset}`);
1120
+ out(`${c.dim}${shellPad('ID', idW)} ${shellPad('STATUS', 14)} ${shellPad('PRI', 4)} ${shellPad('ASSIGNEE', 12)} TITLE${c.reset}`);
1021
1121
  for (const r of rows) {
1022
1122
  const status = (r.fm.status || 'Backlog') + (r.fm.archived === true || r.fm.archived === 'true' ? ' (archived)' : '');
1023
1123
  const pri = r.fm.priority || '';
1024
1124
  const assignee = r.fm.assignee || '';
1025
1125
  const title = (r.fm.title || '(untitled)').replace(/\n/g, ' ');
1026
- log(`${shellPad(r.id, idW)} ${shellPad(status, 14)} ${shellPad(pri, 4)} ${shellPad(assignee, 12)} ${title}`);
1126
+ out(`${shellPad(r.id, idW)} ${shellPad(status, 14)} ${shellPad(pri, 4)} ${shellPad(assignee, 12)} ${title}`);
1027
1127
  }
1028
1128
  }
1029
1129
 
@@ -1032,7 +1132,7 @@ function shellShow(rawArgs) {
1032
1132
  const args = shellParseArgs(rawArgs);
1033
1133
  const id = args.positional[0];
1034
1134
  if (!id) {
1035
- err('Usage: kandown show <id>');
1135
+ err('Usage: kandown shell show <id>');
1036
1136
  process.exit(1);
1037
1137
  }
1038
1138
  const path = findTaskFile(kandownDir, id);
@@ -1063,7 +1163,7 @@ function shellCreate(rawArgs) {
1063
1163
  const args = shellParseArgs(rawArgs);
1064
1164
  const title = args.positional.join(' ').trim();
1065
1165
  if (!title) {
1066
- err('Usage: kandown create "title" [-p priority] [-a assignee] [-t tag] [--to status]');
1166
+ err('Usage: kandown shell create "title" [-p priority] [-a assignee] [-t tag] [--to status]');
1067
1167
  process.exit(1);
1068
1168
  }
1069
1169
  const config = readKandownConfig(kandownDir);
@@ -1096,8 +1196,8 @@ function shellCreate(rawArgs) {
1096
1196
  if (args.flags.tags && args.flags.tags.length > 0) fm.tags = args.flags.tags;
1097
1197
  if (args.flags.dependsOn && args.flags.dependsOn.length > 0) fm.depends_on = args.flags.dependsOn;
1098
1198
  const content = serializeFrontmatter(fm, '');
1099
- writeFileSync(targetPath, content, 'utf8');
1100
- log(`${c.green}✓${c.reset} Created ${c.bold}${id}${c.reset} → ${targetStatus}`);
1199
+ atomicWriteFileSync(targetPath, content);
1200
+ process.stderr.write(`${c.green}✓${c.reset} Created ${c.bold}${id}${c.reset} → ${targetStatus}\n`);
1101
1201
  if (args.flags.json) {
1102
1202
  process.stdout.write(JSON.stringify({ id, ...fm }, null, 2) + '\n');
1103
1203
  } else {
@@ -1113,7 +1213,7 @@ function shellMove(rawArgs) {
1113
1213
  const [id, rawStatus] = args.positional;
1114
1214
  const targetStatus = rawStatus || args.flags.to;
1115
1215
  if (!id || !targetStatus) {
1116
- err('Usage: kandown move <id> <status>');
1216
+ err('Usage: kandown shell move <id> <status>');
1117
1217
  process.exit(1);
1118
1218
  }
1119
1219
  const config = readKandownConfig(kandownDir);
@@ -1151,15 +1251,18 @@ function shellMove(rawArgs) {
1151
1251
  parsed.frontmatter.status = resolved;
1152
1252
  if (resolved === 'archived') parsed.frontmatter.archived = true;
1153
1253
  else delete parsed.frontmatter.archived;
1154
- // 📖 When archiving, move the file to tasks/archive/ to match what the
1155
- // web UI does. Mirrors src/lib/filesystem.ts#archiveTaskFile.
1156
1254
  if (resolved === 'archived') {
1157
1255
  const archiveDir = join(getTasksDir(kandownDir), 'archive');
1158
1256
  if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
1159
- writeFileSync(join(archiveDir, `${id}.md`), serializeFrontmatter(parsed.frontmatter, parsed.body), 'utf8');
1257
+ atomicWriteFileSync(join(archiveDir, `${id}.md`), serializeFrontmatter(parsed.frontmatter, parsed.body));
1160
1258
  try { unlinkSync(path); } catch { /* already absent */ }
1161
1259
  } else {
1162
- writeFileSync(path, serializeFrontmatter(parsed.frontmatter, parsed.body), 'utf8');
1260
+ const normalTasksDir = getTasksDir(kandownDir);
1261
+ const normalPath = join(normalTasksDir, `${id}.md`);
1262
+ atomicWriteFileSync(normalPath, serializeFrontmatter(parsed.frontmatter, parsed.body));
1263
+ if (path !== normalPath) {
1264
+ try { unlinkSync(path); } catch { /* already absent */ }
1265
+ }
1163
1266
  }
1164
1267
  log(`${c.green}✓${c.reset} ${c.bold}${id}${c.reset} → ${resolved}`);
1165
1268
  }
@@ -1169,7 +1272,7 @@ function shellAssign(rawArgs) {
1169
1272
  const args = shellParseArgs(rawArgs);
1170
1273
  const [id, name] = args.positional;
1171
1274
  if (!id) {
1172
- err('Usage: kandown assign <id> [name] (omit name to unassign)');
1275
+ err('Usage: kandown shell assign <id> [name] (omit name to unassign)');
1173
1276
  process.exit(1);
1174
1277
  }
1175
1278
  const path = findTaskFile(kandownDir, id);
@@ -1180,7 +1283,7 @@ function shellAssign(rawArgs) {
1180
1283
  const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1181
1284
  if (name) parsed.frontmatter.assignee = name;
1182
1285
  else delete parsed.frontmatter.assignee;
1183
- writeFileSync(path, serializeFrontmatter(parsed.frontmatter, parsed.body), 'utf8');
1286
+ atomicWriteFileSync(path, serializeFrontmatter(parsed.frontmatter, parsed.body));
1184
1287
  log(`${c.green}✓${c.reset} ${c.bold}${id}${c.reset} → ${name ? c.cyan + name : c.dim + '(unassigned)'}${c.reset}`);
1185
1288
  }
1186
1289
 
@@ -1258,7 +1361,7 @@ function cmdShell(subcmd, rawArgs) {
1258
1361
  case 'help':
1259
1362
  case '--help':
1260
1363
  case '-h':
1261
- log(`
1364
+ out(`
1262
1365
  ${c.bold}kandown shell${c.reset} ${c.dim}· task commands (one-shot, scriptable)${c.reset}
1263
1366
 
1264
1367
  ${c.bold}Commands:${c.reset}
@@ -1270,11 +1373,11 @@ ${c.bold}Commands:${c.reset}
1270
1373
  ${c.cyan}commit${c.reset} [-m "message"] (git add tasks/ + .kandown/kandown.json + git commit)
1271
1374
 
1272
1375
  ${c.bold}Examples:${c.reset}
1273
- ${c.dim}$${c.reset} kandown list --json | jq '.[] | select(.priority=="P1")'
1274
- ${c.dim}$${c.reset} kandown create "Refactor auth middleware" -p P1 -t backend
1275
- ${c.dim}$${c.reset} kandown move t42 Done
1276
- ${c.dim}$${c.reset} kandown assign t42 alice
1277
- ${c.dim}$${c.reset} kandown commit -m "tasks: add auth refactor"
1376
+ ${c.dim}$${c.reset} kandown shell list --json | jq '.[] | select(.priority=="P1")'
1377
+ ${c.dim}$${c.reset} kandown shell create "Refactor auth middleware" -p P1 -t backend
1378
+ ${c.dim}$${c.reset} kandown shell move t42 Done
1379
+ ${c.dim}$${c.reset} kandown shell assign t42 alice
1380
+ ${c.dim}$${c.reset} kandown shell commit -m "tasks: add auth refactor"
1278
1381
  `);
1279
1382
  return;
1280
1383
  default:
@@ -1314,7 +1417,7 @@ function readDaemonMetadata(kandownDir) {
1314
1417
  }
1315
1418
 
1316
1419
  function writeDaemonMetadata(kandownDir, metadata) {
1317
- writeFileSync(daemonMetadataPath(kandownDir), JSON.stringify(metadata, null, 2) + '\n', 'utf8');
1420
+ atomicWriteFileSync(daemonMetadataPath(kandownDir), JSON.stringify(metadata, null, 2) + '\n');
1318
1421
  }
1319
1422
 
1320
1423
  function removeDaemonMetadata(kandownDir) {
@@ -1325,14 +1428,18 @@ function removeDaemonMetadata(kandownDir) {
1325
1428
 
1326
1429
  function ensureDaemonGitignore(kandownDir) {
1327
1430
  const gitignorePath = join(kandownDir, '.gitignore');
1431
+ // 📖 Runtime files that must never be committed: daemon metadata + spawn lock.
1432
+ const runtimeEntries = [DAEMON_FILE, 'daemon.lock'];
1328
1433
  try {
1329
1434
  if (!existsSync(gitignorePath)) {
1330
- writeFileSync(gitignorePath, `${DAEMON_FILE}\n`, 'utf8');
1435
+ writeFileSync(gitignorePath, runtimeEntries.join('\n') + '\n', 'utf8');
1331
1436
  return;
1332
1437
  }
1333
1438
  const existing = readFileSync(gitignorePath, 'utf8');
1334
- if (!existing.split(/\r?\n/).includes(DAEMON_FILE)) {
1335
- writeFileSync(gitignorePath, `${existing.trimEnd()}\n${DAEMON_FILE}\n`, 'utf8');
1439
+ const lines = existing.split(/\r?\n/);
1440
+ const missing = runtimeEntries.filter(entry => !lines.includes(entry));
1441
+ if (missing.length > 0) {
1442
+ writeFileSync(gitignorePath, `${existing.trimEnd()}\n${missing.join('\n')}\n`, 'utf8');
1336
1443
  }
1337
1444
  } catch { /* ignore gitignore best-effort failure */ }
1338
1445
  }
@@ -1439,57 +1546,147 @@ async function waitForDaemon(kandownDir, timeoutMs = 8000) {
1439
1546
  return { running: false, metadata: null };
1440
1547
  }
1441
1548
 
1549
+ /**
1550
+ * 📖 Spawn lock (M7): two `kandown` processes started at the same moment in
1551
+ * the same project must not BOTH spawn a daemon (the loser's daemon.json
1552
+ * write would orphan the winner's daemon). O_EXCL file creation is the mutex;
1553
+ * the loser just waits for the winner's daemon to come up. A lock older than
1554
+ * 15s is stale (crashed spawner) and gets stolen.
1555
+ */
1556
+ function acquireDaemonSpawnLock(kandownDir) {
1557
+ const lockPath = join(kandownDir, 'daemon.lock');
1558
+ try {
1559
+ const fd = openSync(lockPath, 'wx');
1560
+ writeSync(fd, String(process.pid));
1561
+ closeSync(fd);
1562
+ return lockPath;
1563
+ } catch (e) {
1564
+ if (e.code !== 'EEXIST') return null;
1565
+ try {
1566
+ if (Date.now() - statSync(lockPath).mtimeMs > 15_000) {
1567
+ unlinkSync(lockPath);
1568
+ return acquireDaemonSpawnLock(kandownDir);
1569
+ }
1570
+ } catch { /* lock vanished — racing is fine, caller waits */ }
1571
+ return null;
1572
+ }
1573
+ }
1574
+
1575
+ function releaseDaemonSpawnLock(lockPath) {
1576
+ try { unlinkSync(lockPath); } catch { /* already gone */ }
1577
+ }
1578
+
1442
1579
  async function startDaemon(kandownDir, preferredPort) {
1443
1580
  const current = await getDaemonStatus(kandownDir);
1444
1581
  if (current.running) return current;
1445
1582
 
1446
- removeDaemonMetadata(kandownDir);
1447
- ensureDaemonGitignore(kandownDir);
1448
- const daemonArgs = [
1449
- process.argv[1],
1450
- '--no-update-check',
1451
- 'daemon',
1452
- 'run',
1453
- '--path',
1454
- kandownDir,
1455
- ];
1456
- if (preferredPort !== null) daemonArgs.push('--port', String(preferredPort));
1457
-
1458
- const child = spawn(process.execPath, daemonArgs, {
1459
- cwd: dirname(kandownDir),
1460
- detached: true,
1461
- stdio: 'ignore',
1462
- env: { ...process.env, KANDOWN_DAEMON: '1' },
1463
- });
1464
- child.unref();
1583
+ const lock = acquireDaemonSpawnLock(kandownDir);
1584
+ if (!lock) {
1585
+ // 📖 Another process is spawning the daemon right now — just wait for it.
1586
+ return waitForDaemon(kandownDir);
1587
+ }
1465
1588
 
1466
- return waitForDaemon(kandownDir);
1467
- }
1589
+ try {
1590
+ // Double-checked: the daemon may have come up while we acquired the lock.
1591
+ const recheck = await getDaemonStatus(kandownDir);
1592
+ if (recheck.running) return recheck;
1468
1593
 
1469
- async function stopDaemon(kandownDir) {
1470
- const status = await getDaemonStatus(kandownDir);
1471
- if (!status.running || !status.metadata) {
1472
1594
  removeDaemonMetadata(kandownDir);
1473
- return false;
1595
+ ensureDaemonGitignore(kandownDir);
1596
+ const daemonArgs = [
1597
+ process.argv[1],
1598
+ '--no-update-check',
1599
+ 'daemon',
1600
+ 'run',
1601
+ '--path',
1602
+ kandownDir,
1603
+ ];
1604
+ if (preferredPort !== null) daemonArgs.push('--port', String(preferredPort));
1605
+
1606
+ const child = spawn(process.execPath, daemonArgs, {
1607
+ cwd: dirname(kandownDir),
1608
+ detached: true,
1609
+ stdio: 'ignore',
1610
+ env: { ...process.env, KANDOWN_DAEMON: '1' },
1611
+ });
1612
+ child.unref();
1613
+
1614
+ return await waitForDaemon(kandownDir);
1615
+ } finally {
1616
+ releaseDaemonSpawnLock(lock);
1474
1617
  }
1618
+ }
1475
1619
 
1620
+ /**
1621
+ * 📖 Guard against PID reuse before killing: the PID is only "ours" if the
1622
+ * daemon API confirms ownership, or — when the API is unreachable (wedged /
1623
+ * still starting) — the OS process table shows a kandown process launched for
1624
+ * THIS project. Without this, stale metadata left by a crash could point at a
1625
+ * recycled PID and stopDaemon would SIGKILL an unrelated process.
1626
+ */
1627
+ async function isOwnedKandownDaemon(pid, port, kandownDir) {
1628
+ const remote = await fetchDaemonInfo(port);
1629
+ if (remote) return remote.pid === pid && remote.kandownDir === kandownDir;
1476
1630
  try {
1477
- process.kill(status.metadata.pid, 'SIGTERM');
1478
- } catch { /* already stopped */ }
1631
+ const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', timeout: 2000 }).trim();
1632
+ return /kandown/.test(cmd) && cmd.includes(kandownDir);
1633
+ } catch {
1634
+ return false;
1635
+ }
1636
+ }
1479
1637
 
1480
- const started = Date.now();
1481
- while (Date.now() - started < 2500 && isProcessAlive(status.metadata.pid)) {
1482
- await new Promise(r => setTimeout(r, 100));
1638
+ async function stopDaemon(kandownDir) {
1639
+ const metadata = readDaemonMetadata(kandownDir);
1640
+ if (!metadata) return false;
1641
+
1642
+ const pid = metadata.pid;
1643
+ if (isProcessAlive(pid)) {
1644
+ if (!(await isOwnedKandownDaemon(pid, metadata.port, kandownDir))) {
1645
+ // Alive PID that isn't our daemon (recycled PID / stale metadata) — never kill.
1646
+ removeDaemonMetadata(kandownDir);
1647
+ return false;
1648
+ }
1649
+ try {
1650
+ process.kill(pid, 'SIGTERM');
1651
+ } catch { /* already stopped */ }
1652
+
1653
+ const started = Date.now();
1654
+ let killed = false;
1655
+ while (Date.now() - started < 2500) {
1656
+ if (!isProcessAlive(pid)) {
1657
+ killed = true;
1658
+ break;
1659
+ }
1660
+ await new Promise(r => setTimeout(r, 100));
1661
+ }
1662
+
1663
+ if (!killed && isProcessAlive(pid)) {
1664
+ try {
1665
+ process.kill(pid, 'SIGKILL');
1666
+ } catch {}
1667
+ }
1483
1668
  }
1669
+
1484
1670
  removeDaemonMetadata(kandownDir);
1485
1671
  return true;
1486
1672
  }
1487
1673
 
1674
+ /**
1675
+ * 📖 API auth token (M5). Generated fresh at every daemon start, stored in
1676
+ * daemon.json (CLI/TUI read it there) and injected into the served HTML as
1677
+ * `window.__KANDOWN_TOKEN__`. Every API route except `GET /api/daemon`
1678
+ * requires it via the `X-Kandown-Token` header — a drive-by web page scanning
1679
+ * localhost ports can no longer read or write the task files.
1680
+ */
1681
+ const DAEMON_TOKEN = randomBytes(24).toString('hex');
1682
+
1488
1683
  function apiHeaders() {
1684
+ // 📖 No Access-Control-Allow-Origin (M5): the web UI is served same-origin
1685
+ // by this daemon, so cross-origin browser access is intentionally blocked.
1686
+ // Non-browser clients (CLI, TUI, curl) are unaffected by CORS.
1489
1687
  return {
1490
- 'Access-Control-Allow-Origin': '*',
1491
- 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS',
1492
- 'Access-Control-Allow-Headers': 'Content-Type',
1688
+ 'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE, OPTIONS',
1689
+ 'Access-Control-Allow-Headers': 'Content-Type, X-Kandown-Token',
1493
1690
  };
1494
1691
  }
1495
1692
 
@@ -1498,6 +1695,16 @@ function handleCors(res) {
1498
1695
  res.end();
1499
1696
  }
1500
1697
 
1698
+ /**
1699
+ * 📖 Token gate for API routes. Returns true when the request carries the
1700
+ * daemon token; otherwise answers 401 and returns false.
1701
+ */
1702
+ function requireToken(req, res) {
1703
+ if (req.headers['x-kandown-token'] === DAEMON_TOKEN) return true;
1704
+ writeJson(res, 401, { error: 'missing or invalid X-Kandown-Token' });
1705
+ return false;
1706
+ }
1707
+
1501
1708
  function writeJson(res, status, body) {
1502
1709
  const headers = { ...apiHeaders(), 'Content-Type': 'application/json' };
1503
1710
  res.writeHead(status, headers);
@@ -1509,10 +1716,25 @@ function writeText(res, status, body, contentType = 'text/plain; charset=utf-8')
1509
1716
  res.end(body);
1510
1717
  }
1511
1718
 
1719
+ // 📖 Body size cap: task files and configs are small — anything above 10 MB
1720
+ // is a bug or abuse, and buffering it would balloon the daemon's memory.
1721
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
1722
+
1512
1723
  function readBody(req) {
1513
1724
  return new Promise((resolve, reject) => {
1514
1725
  const chunks = [];
1515
- req.on('data', chunk => chunks.push(chunk));
1726
+ let total = 0;
1727
+ req.on('data', chunk => {
1728
+ total += chunk.length;
1729
+ if (total > MAX_BODY_BYTES) {
1730
+ const e = new Error('Request body too large (max 10 MB)');
1731
+ e.statusCode = 413;
1732
+ req.destroy();
1733
+ reject(e);
1734
+ return;
1735
+ }
1736
+ chunks.push(chunk);
1737
+ });
1516
1738
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
1517
1739
  req.on('error', reject);
1518
1740
  });
@@ -1636,18 +1858,41 @@ function getConfig(res, kandownDir) {
1636
1858
  }
1637
1859
  }
1638
1860
 
1861
+ /**
1862
+ * 📖 Shape validation for kandown.json writes: "is valid JSON" is not enough —
1863
+ * a stray `[]` or `null` body would overwrite the config and silently reset
1864
+ * every setting on the next load. Requires a plain object, and when `board`
1865
+ * or `board.columns` are present they must have the right shape.
1866
+ */
1867
+ function isValidConfigShape(parsed) {
1868
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
1869
+ if ('board' in parsed) {
1870
+ const board = parsed.board;
1871
+ if (!board || typeof board !== 'object' || Array.isArray(board)) return false;
1872
+ if ('columns' in board) {
1873
+ if (!Array.isArray(board.columns)) return false;
1874
+ if (!board.columns.every(col => typeof col === 'string' && col.trim().length > 0)) return false;
1875
+ }
1876
+ }
1877
+ return true;
1878
+ }
1879
+
1639
1880
  function putConfig(req, res, kandownDir) {
1640
1881
  readBody(req).then(body => {
1882
+ let parsed;
1641
1883
  try {
1642
- JSON.parse(body);
1643
- const configPath = join(kandownDir, 'kandown.json');
1644
- writeFileSync(configPath, body, 'utf8');
1645
- writeJson(res, 200, { ok: true });
1646
- } catch (e) {
1647
- writeJson(res, 400, { error: 'Invalid JSON' });
1884
+ parsed = JSON.parse(body);
1885
+ } catch {
1886
+ return writeJson(res, 400, { error: 'Invalid JSON' });
1887
+ }
1888
+ if (!isValidConfigShape(parsed)) {
1889
+ return writeJson(res, 400, { error: 'Invalid config shape: expected an object (board.columns must be a non-empty string array)' });
1648
1890
  }
1891
+ const configPath = join(kandownDir, 'kandown.json');
1892
+ atomicWriteFileSync(configPath, body);
1893
+ writeJson(res, 200, { ok: true });
1649
1894
  }).catch(e => {
1650
- writeJson(res, 500, { error: `Failed to read body: ${e.message}` });
1895
+ writeJson(res, e.statusCode || 500, { error: `Failed to read body: ${e.message}` });
1651
1896
  });
1652
1897
  }
1653
1898
 
@@ -1668,7 +1913,7 @@ function getBoard(res, kandownDir) {
1668
1913
  function putBoard(req, res, kandownDir) {
1669
1914
  readBody(req).then(body => {
1670
1915
  const boardPath = join(kandownDir, 'board.md');
1671
- writeFileSync(boardPath, body, 'utf8');
1916
+ atomicWriteFileSync(boardPath, body);
1672
1917
  writeJson(res, 200, { ok: true });
1673
1918
  }).catch(e => {
1674
1919
  writeJson(res, 500, { error: `Failed to write board: ${e.message}` });
@@ -1750,7 +1995,7 @@ function putTask(req, res, kandownDir, id) {
1750
1995
  const inArchive = existing && existing.startsWith(archiveDir);
1751
1996
  const targetDir = inArchive ? archiveDir : tasksDir;
1752
1997
  const taskPath = join(targetDir, `${id}.md`);
1753
- writeFileSync(taskPath, body, 'utf8');
1998
+ atomicWriteFileSync(taskPath, body);
1754
1999
  writeJson(res, 200, { ok: true });
1755
2000
  }).catch(e => {
1756
2001
  writeJson(res, 500, { error: `Failed to write task: ${e.message}` });
@@ -1789,7 +2034,7 @@ function archiveTask(req, res, kandownDir, id) {
1789
2034
  const tasksDir = getTasksDir(kandownDir);
1790
2035
  const archiveDir = join(tasksDir, 'archive');
1791
2036
  if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
1792
- writeFileSync(join(archiveDir, `${id}.md`), body, 'utf8');
2037
+ atomicWriteFileSync(join(archiveDir, `${id}.md`), body);
1793
2038
  try {
1794
2039
  unlinkSync(join(tasksDir, `${id}.md`));
1795
2040
  } catch { /* already absent */ }
@@ -1810,7 +2055,7 @@ function unarchiveTask(req, res, kandownDir, id) {
1810
2055
  }
1811
2056
  readBody(req).then(body => {
1812
2057
  const tasksDir = getTasksDir(kandownDir);
1813
- writeFileSync(join(tasksDir, `${id}.md`), body, 'utf8');
2058
+ atomicWriteFileSync(join(tasksDir, `${id}.md`), body);
1814
2059
  try {
1815
2060
  unlinkSync(join(tasksDir, 'archive', `${id}.md`));
1816
2061
  } catch { /* already absent */ }
@@ -1844,7 +2089,10 @@ function injectServerRoot(html, kandownDir) {
1844
2089
  const marker = '</head>';
1845
2090
  const markerIndex = html.toLowerCase().lastIndexOf(marker);
1846
2091
  const safeRoot = JSON.stringify(kandownDir).replace(/</g, '\\u003c');
1847
- const script = `<script>window.__KANDOWN_ROOT__ = ${safeRoot};</script>\n`;
2092
+ // 📖 The token rides along with the root: same-origin page → full API access;
2093
+ // any other page (drive-by localhost scan) never sees it (M5).
2094
+ const safeToken = JSON.stringify(DAEMON_TOKEN).replace(/</g, '\\u003c');
2095
+ const script = `<script>window.__KANDOWN_ROOT__ = ${safeRoot}; window.__KANDOWN_TOKEN__ = ${safeToken};</script>\n`;
1848
2096
 
1849
2097
  if (markerIndex === -1) return script + html;
1850
2098
 
@@ -1856,6 +2104,14 @@ function handleApi(req, res, url, kandownDir) {
1856
2104
  const resource = parts[0];
1857
2105
  const id = parts[1];
1858
2106
 
2107
+ // 📖 Token gate (M5): every API route requires X-Kandown-Token, except
2108
+ // GET /api/daemon which stays open — it is the identity endpoint the CLI
2109
+ // and sibling daemons use to verify ownership before they have the token,
2110
+ // and it never exposes the token itself.
2111
+ if (!(resource === 'daemon' && req.method === 'GET')) {
2112
+ if (!requireToken(req, res)) return;
2113
+ }
2114
+
1859
2115
  if (resource === 'daemon') {
1860
2116
  if (req.method === 'GET') {
1861
2117
  const hook = loadAgentHook();
@@ -2111,6 +2367,10 @@ async function cmdDaemon(rawArgs) {
2111
2367
  kandownDir,
2112
2368
  startedAt: daemonStartedAt,
2113
2369
  version: getCurrentVersion(),
2370
+ // 📖 Local-only secret (M5): daemon.json is chmod-protected by the
2371
+ // user's umask and gitignored; the CLI/TUI read the token here to call
2372
+ // the API. Never exposed by GET /api/daemon.
2373
+ token: DAEMON_TOKEN,
2114
2374
  });
2115
2375
 
2116
2376
  const shutdown = () => {
@@ -2214,19 +2474,6 @@ async function openInBrowser(url) {
2214
2474
  child.unref();
2215
2475
  }
2216
2476
 
2217
- /**
2218
- * 📖 Finds the kandown directory from cwd. Checks .kandown/ and kandown/.
2219
- * Returns the resolved absolute path or null if not found.
2220
- */
2221
- function findKandownDir(cwd) {
2222
- const candidates = ['.kandown', 'kandown'];
2223
- for (const dir of candidates) {
2224
- const p = resolve(cwd, dir);
2225
- if (existsSync(p)) return p;
2226
- }
2227
- return null;
2228
- }
2229
-
2230
2477
  // 📖 Launches the fullscreen TUI for a given screen (settings, board, etc.)
2231
2478
  async function cmdTui(screen, rawArgs) {
2232
2479
  const { kandownDir } = ensureKandownDir(rawArgs);
@@ -2247,7 +2494,7 @@ const [cmd, ...rest] = rawArgs;
2247
2494
  // 📖 Handle --version / -v before any command logic
2248
2495
  if (cmd === '--version' || cmd === '-v') {
2249
2496
  const v = getCurrentVersion() ?? 'unknown';
2250
- log(`kandown v${v}`);
2497
+ out(`kandown v${v}`);
2251
2498
  process.exit(0);
2252
2499
  }
2253
2500
 
@@ -2255,9 +2502,13 @@ if (cmd === '--version' || cmd === '-v') {
2255
2502
  // The parent passes --no-update-check to prevent an infinite update loop.
2256
2503
  const skipUpdate = process.argv.slice(2).includes('--no-update-check');
2257
2504
 
2258
- // 📖 Auto-update check runs before EVERY command (except --version).
2259
- // Uses a short timeout so startup is not noticeably slower.
2260
- if (!skipUpdate) await checkForUpdate(process.argv);
2505
+ // 📖 Update policy (M1): the check only runs for INTERACTIVE commands.
2506
+ // `shell` (scripts/agents) and `daemon` (spawned children, status probes)
2507
+ // must never pay a network round-trip nor risk a mid-pipeline respawn.
2508
+ // Inside checkForUpdate there are further guards: 24h throttle, TTY-only,
2509
+ // and the KANDOWN_NO_UPDATE=1 opt-out.
2510
+ const SCRIPTED_COMMANDS = new Set(['shell', 'daemon']);
2511
+ if (!skipUpdate && !SCRIPTED_COMMANDS.has(cmd)) await checkForUpdate(process.argv);
2261
2512
 
2262
2513
  switch (cmd) {
2263
2514
  case 'init':