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/LICENSE +1 -1
- package/README.md +4 -2
- package/bin/kandown.js +381 -130
- package/bin/tui.js +367 -189
- package/dist/index.html +64 -64
- package/package.json +5 -2
package/bin/kandown.js
CHANGED
|
@@ -60,10 +60,27 @@ import {
|
|
|
60
60
|
statSync,
|
|
61
61
|
unlinkSync,
|
|
62
62
|
renameSync,
|
|
63
|
-
|
|
63
|
+
rmSync,
|
|
64
|
+
openSync,
|
|
65
|
+
writeSync,
|
|
66
|
+
closeSync,
|
|
64
67
|
} from 'node:fs';
|
|
65
|
-
import {
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
1257
|
+
atomicWriteFileSync(join(archiveDir, `${id}.md`), serializeFrontmatter(parsed.frontmatter, parsed.body));
|
|
1160
1258
|
try { unlinkSync(path); } catch { /* already absent */ }
|
|
1161
1259
|
} else {
|
|
1162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1435
|
+
writeFileSync(gitignorePath, runtimeEntries.join('\n') + '\n', 'utf8');
|
|
1331
1436
|
return;
|
|
1332
1437
|
}
|
|
1333
1438
|
const existing = readFileSync(gitignorePath, 'utf8');
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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-
|
|
1491
|
-
'Access-Control-Allow-
|
|
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
|
-
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
writeJson(res, 400, { error: 'Invalid
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// 📖
|
|
2259
|
-
//
|
|
2260
|
-
|
|
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':
|