kandown 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/kandown.js CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  import { spawn, execSync } from 'node:child_process';
70
70
  import { createConnection } from 'node:net';
71
71
  import { randomBytes } from 'node:crypto';
72
+ import { watch as watchFs } from 'chokidar';
72
73
 
73
74
  // 📖 Global safety net: a stray exception or unhandled rejection prints a clean
74
75
  // one-liner instead of a raw stack trace. The daemon (KANDOWN_DAEMON=1) logs
@@ -886,6 +887,13 @@ function doInit(args, cwd, kandownPath, kandownDir) {
886
887
  }
887
888
  }
888
889
 
890
+ const hasGit = existsSync(join(cwd, '.git'));
891
+ if (!hasGit) {
892
+ log('');
893
+ warn(`No git repository detected in ${c.bold}${cwd}${c.reset}.`);
894
+ info(` Tip: Run ${c.cyan}git init${c.reset} and track ${c.bold}./tasks/${c.reset} for git history & portability!`);
895
+ }
896
+
889
897
  return true;
890
898
  }
891
899
 
@@ -1226,26 +1234,54 @@ function depIsResolved(kandownDir, id, terminalLower) {
1226
1234
  return isArch || (parsed.frontmatter.status || '').toLowerCase() === terminalLower;
1227
1235
  }
1228
1236
 
1237
+ function parseInlineQuickAdd(rawTitle) {
1238
+ let text = (rawTitle || '').trim();
1239
+ let priority;
1240
+ const tags = [];
1241
+ let assignee;
1242
+ let due;
1243
+ const dependsOn = [];
1244
+
1245
+ text = text.replace(/(?:^|\s)p([1-4])(?:\s|$)/i, (_, level) => {
1246
+ priority = `P${level}`;
1247
+ return ' ';
1248
+ });
1249
+
1250
+ text = text.replace(/(?:^|\s)#([a-zA-Z0-9_-]+)/g, (_, tag) => {
1251
+ tags.push(tag.toLowerCase());
1252
+ return ' ';
1253
+ });
1254
+
1255
+ text = text.replace(/(?:^|\s)@([a-zA-Z0-9_-]+)/g, (_, user) => {
1256
+ assignee = user;
1257
+ return ' ';
1258
+ });
1259
+
1260
+ text = text.replace(/(?:^|\s)due:([^\s]+)/i, (_, dateStr) => {
1261
+ due = dateStr;
1262
+ return ' ';
1263
+ });
1264
+
1265
+ text = text.replace(/(?:^|\s)\+([a-zA-Z0-9_-]+)/g, (_, depId) => {
1266
+ dependsOn.push(depId);
1267
+ return ' ';
1268
+ });
1269
+
1270
+ const title = text.replace(/\s+/g, ' ').trim();
1271
+ return { title: title || rawTitle, priority, tags, assignee, due, dependsOn };
1272
+ }
1273
+
1229
1274
  function cmdCreate(rawArgs) {
1230
1275
  const { kandownDir } = ensureKandownDir(rawArgs);
1231
1276
  const args = taskParseArgs(rawArgs);
1232
- const title = args.positional.join(' ').trim();
1233
- if (!title) {
1234
- err('Usage: kandown create "title" [-p priority] [-a assignee] [-t tag] [--to status]');
1277
+ const rawTitle = args.positional[0];
1278
+ if (!rawTitle) {
1279
+ err('Usage: kandown create "<title>" [-p priority] [-a assignee] [-t tag ...] [--to status]');
1235
1280
  process.exit(1);
1236
1281
  }
1237
1282
  const config = readKandownConfig(kandownDir);
1238
- const defaultStatus = (config && config.board && config.board.columns && config.board.columns[0]) || 'Backlog';
1239
- const targetStatus = args.flags.to ? resolveStatusArg(config, args.flags.to) : defaultStatus;
1240
- if (args.flags.to && !targetStatus) {
1241
- err(`Unknown status: ${args.flags.to}`);
1242
- process.exit(1);
1243
- }
1283
+ const targetStatus = resolveStatusArg(config, args.flags.to) || (config && config.board && config.board.columns ? config.board.columns[0] : 'Backlog');
1244
1284
  const id = args.flags.id || nextTaskId(kandownDir);
1245
- if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
1246
- err(`Invalid task id: ${id}`);
1247
- process.exit(1);
1248
- }
1249
1285
  const tasksDir = getTasksDir(kandownDir);
1250
1286
  if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
1251
1287
  const targetPath = join(tasksDir, `${id}.md`);
@@ -1253,6 +1289,8 @@ function cmdCreate(rawArgs) {
1253
1289
  err(`Task already exists: ${id}`);
1254
1290
  process.exit(1);
1255
1291
  }
1292
+ const parsedInline = parseInlineQuickAdd(rawTitle);
1293
+ const title = parsedInline.title;
1256
1294
  const fm = {
1257
1295
  id,
1258
1296
  title,
@@ -1883,11 +1921,37 @@ function handleCors(res) {
1883
1921
  * daemon token; otherwise answers 401 and returns false.
1884
1922
  */
1885
1923
  function requireToken(req, res) {
1886
- if (req.headers['x-kandown-token'] === DAEMON_TOKEN) return true;
1924
+ const tokenHeader = req.headers['x-kandown-token'];
1925
+ const requestUrl = new URL(req.url || '/', 'http://localhost');
1926
+ const tokenQuery = requestUrl.searchParams.get('token');
1927
+ if (tokenHeader === DAEMON_TOKEN || tokenQuery === DAEMON_TOKEN) return true;
1887
1928
  writeJson(res, 401, { error: 'missing or invalid X-Kandown-Token' });
1888
1929
  return false;
1889
1930
  }
1890
1931
 
1932
+ const sseClients = new Set();
1933
+
1934
+ function broadcastSseEvent(eventData) {
1935
+ const payload = `data: ${JSON.stringify(eventData)}\n\n`;
1936
+ for (const client of sseClients) {
1937
+ try {
1938
+ client.write(payload);
1939
+ } catch {
1940
+ sseClients.delete(client);
1941
+ }
1942
+ }
1943
+ }
1944
+
1945
+ setInterval(() => {
1946
+ for (const client of sseClients) {
1947
+ try {
1948
+ client.write(':\n\n');
1949
+ } catch {
1950
+ sseClients.delete(client);
1951
+ }
1952
+ }
1953
+ }, 15000);
1954
+
1891
1955
  function writeJson(res, status, body) {
1892
1956
  const headers = { ...apiHeaders(), 'Content-Type': 'application/json' };
1893
1957
  res.writeHead(status, headers);
@@ -2309,6 +2373,44 @@ function handleApi(req, res, url, kandownDir) {
2309
2373
  }
2310
2374
  }
2311
2375
 
2376
+ if (resource === 'events') {
2377
+ if (req.method === 'GET') {
2378
+ res.writeHead(200, {
2379
+ 'Content-Type': 'text/event-stream',
2380
+ 'Cache-Control': 'no-cache',
2381
+ 'Connection': 'keep-alive',
2382
+ 'Access-Control-Allow-Origin': '*',
2383
+ });
2384
+ res.write('data: {"type":"connected"}\n\n');
2385
+ sseClients.add(res);
2386
+ req.on('close', () => {
2387
+ sseClients.delete(res);
2388
+ });
2389
+ return;
2390
+ }
2391
+ }
2392
+
2393
+ if (resource === 'git' && parts[1] === 'history') {
2394
+ const taskId = url.searchParams.get('id');
2395
+ if (!taskId) return writeJson(res, 400, { error: 'Missing task id' });
2396
+ try {
2397
+ const taskPath = findTaskPath(kandownDir, taskId);
2398
+ if (!taskPath) return writeJson(res, 404, { error: 'Task not found' });
2399
+ const relativePath = relative(getProjectRoot(kandownDir), taskPath);
2400
+ const output = execFileSync('git', ['log', '-n', '10', '--follow', '--format=%h|%an|%ar|%s', '--', relativePath], {
2401
+ cwd: getProjectRoot(kandownDir),
2402
+ encoding: 'utf8',
2403
+ }).trim();
2404
+ const commits = output ? output.split('\n').map(line => {
2405
+ const [hash, author, date, message] = line.split('|');
2406
+ return { hash, author, date, message };
2407
+ }) : [];
2408
+ return writeJson(res, 200, { commits });
2409
+ } catch {
2410
+ return writeJson(res, 200, { commits: [] });
2411
+ }
2412
+ }
2413
+
2312
2414
  if (resource === 'config') {
2313
2415
  if (req.method === 'GET') return getConfig(res, kandownDir);
2314
2416
  if (req.method === 'PUT') return putConfig(req, res, kandownDir);
@@ -2367,6 +2469,21 @@ function serveApp(res, kandownDir) {
2367
2469
  * follow-up REST task, keeping this refactor limited to server bootstrapping.
2368
2470
  */
2369
2471
  function createServeServer(kandownDir) {
2472
+ try {
2473
+ const tasksDir = getTasksDir(kandownDir);
2474
+ const configPath = join(kandownDir, 'kandown.json');
2475
+ const watcher = watchFs([join(tasksDir, '*.md'), configPath], {
2476
+ ignoreInitial: true,
2477
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
2478
+ });
2479
+ watcher.on('all', (_event, filePath) => {
2480
+ const taskId = filePath.replace(/\\/g, '/').split('/').pop()?.replace(/\.md$/, '') || '';
2481
+ broadcastSseEvent({ type: 'change', taskId });
2482
+ });
2483
+ } catch (e) {
2484
+ console.error('[daemon] File watcher init warning:', e.message);
2485
+ }
2486
+
2370
2487
  return createServer((req, res) => {
2371
2488
  const requestUrl = new URL(req.url || '/', 'http://localhost');
2372
2489
  if (req.method === 'OPTIONS') return handleCors(res);
@@ -2690,21 +2807,266 @@ const skipUpdate = process.argv.slice(2).includes('--no-update-check');
2690
2807
  // children, status probes) must never pay a network round-trip nor risk a
2691
2808
  // mid-pipeline respawn. Inside checkForUpdate there are further guards: 24h
2692
2809
  // throttle, TTY-only, and the KANDOWN_NO_UPDATE=1 opt-out.
2810
+ async function cmdDoctor(rawArgs) {
2811
+ const { kandownDir } = ensureKandownDir(rawArgs);
2812
+ const fix = rawArgs.includes('--fix');
2813
+ log(`${c.bold}kandown doctor${c.reset} ${c.dim}— environment & board diagnostic${c.reset}\n`);
2814
+
2815
+ let errors = 0;
2816
+ let warnings = 0;
2817
+
2818
+ const cliVer = getCurrentVersion();
2819
+ log(` ${c.cyan}CLI Version:${c.reset} ${cliVer}`);
2820
+
2821
+ const configPath = join(kandownDir, 'kandown.json');
2822
+ if (existsSync(configPath)) {
2823
+ try {
2824
+ JSON.parse(readFileSync(configPath, 'utf8'));
2825
+ log(` ${c.green}✓${c.reset} kandown.json valid`);
2826
+ } catch (e) {
2827
+ log(` ${c.red}✗${c.reset} kandown.json invalid JSON: ${e.message}`);
2828
+ errors++;
2829
+ }
2830
+ } else {
2831
+ log(` ${c.red}✗${c.reset} kandown.json missing`);
2832
+ errors++;
2833
+ }
2834
+
2835
+ const daemon = readDaemonMetadata(kandownDir);
2836
+ if (daemon) {
2837
+ const alive = isProcessAlive(daemon.pid);
2838
+ if (alive) {
2839
+ log(` ${c.green}✓${c.reset} Daemon running on port ${daemon.port} (PID ${daemon.pid})`);
2840
+ } else {
2841
+ log(` ${c.yellow}⚠${c.reset} Daemon metadata stale (PID ${daemon.pid} dead)`);
2842
+ warnings++;
2843
+ if (fix) {
2844
+ removeDaemonMetadata(kandownDir);
2845
+ log(` ${c.green}└─ Fixed: removed stale daemon.json${c.reset}`);
2846
+ }
2847
+ }
2848
+ } else {
2849
+ log(` ${c.dim}ℹ Daemon not running${c.reset}`);
2850
+ }
2851
+
2852
+ const tasksDir = getTasksDir(kandownDir);
2853
+ if (existsSync(tasksDir)) {
2854
+ const activeFiles = readdirSync(tasksDir).filter(f => f.endsWith('.md'));
2855
+ const archiveDir = join(tasksDir, 'archive');
2856
+ const archiveFiles = existsSync(archiveDir) ? readdirSync(archiveDir).filter(f => f.endsWith('.md')) : [];
2857
+
2858
+ log(` ${c.cyan}Tasks:${c.reset} ${activeFiles.length} active, ${archiveFiles.length} archived`);
2859
+
2860
+ const activeSet = new Set(activeFiles);
2861
+ const duplicates = archiveFiles.filter(f => activeSet.has(f));
2862
+ if (duplicates.length > 0) {
2863
+ log(` ${c.red}✗${c.reset} Found ${duplicates.length} duplicate file(s) in tasks/ and archive/: ${duplicates.join(', ')}`);
2864
+ errors++;
2865
+ if (fix) {
2866
+ for (const dup of duplicates) {
2867
+ unlinkSync(join(archiveDir, dup));
2868
+ }
2869
+ log(` ${c.green}└─ Fixed: removed duplicate archived files${c.reset}`);
2870
+ }
2871
+ } else {
2872
+ log(` ${c.green}✓${c.reset} No duplicate task files`);
2873
+ }
2874
+
2875
+ let invalidFm = 0;
2876
+ for (const f of activeFiles) {
2877
+ try {
2878
+ const content = readFileSync(join(tasksDir, f), 'utf8');
2879
+ parseFrontmatter(content);
2880
+ } catch {
2881
+ invalidFm++;
2882
+ }
2883
+ }
2884
+ if (invalidFm > 0) {
2885
+ log(` ${c.yellow}⚠${c.reset} ${invalidFm} task file(s) have invalid frontmatter formatting`);
2886
+ warnings++;
2887
+ } else {
2888
+ log(` ${c.green}✓${c.reset} Task frontmatters valid`);
2889
+ }
2890
+ }
2891
+
2892
+ log('');
2893
+ if (errors === 0 && warnings === 0) {
2894
+ success('Everything looks good!');
2895
+ } else {
2896
+ info(`Doctor summary: ${errors} error(s), ${warnings} warning(s). ${fix ? '' : 'Run with --fix to resolve automatically.'}`);
2897
+ }
2898
+ }
2899
+
2900
+ function cmdUndo(rawArgs) {
2901
+ const { kandownDir } = ensureKandownDir(rawArgs);
2902
+ const logPath = join(kandownDir, '.undo', 'log.json');
2903
+ if (!existsSync(logPath)) {
2904
+ info('No actions to undo');
2905
+ return;
2906
+ }
2907
+ try {
2908
+ const list = JSON.parse(readFileSync(logPath, 'utf8'));
2909
+ if (!list || list.length === 0) {
2910
+ info('No actions to undo');
2911
+ return;
2912
+ }
2913
+ const record = list.shift();
2914
+ writeFileSync(logPath, JSON.stringify(list, null, 2), 'utf8');
2915
+ if (record.previousContent === null) {
2916
+ if (existsSync(record.path)) unlinkSync(record.path);
2917
+ } else {
2918
+ const dir = dirname(record.path);
2919
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2920
+ writeFileSync(record.path, record.previousContent, 'utf8');
2921
+ if (record.newContent !== null && record.path.includes('/archive/')) {
2922
+ const activePath = record.path.replace('/archive/', '/');
2923
+ if (existsSync(activePath)) unlinkSync(activePath);
2924
+ }
2925
+ }
2926
+ success(`Undid last action (${record.type} ${record.taskId})`);
2927
+ } catch (e) {
2928
+ err(`Undo failed: ${e.message}`);
2929
+ }
2930
+ }
2931
+
2932
+ async function cmdProjects(rawArgs) {
2933
+ const isJson = rawArgs.includes('--json');
2934
+ const running = [];
2935
+
2936
+ for (let port = START_PORT_RANGE; port <= END_PORT_RANGE; port++) {
2937
+ if (isBrowserUnsafePort(port)) continue;
2938
+ try {
2939
+ const controller = new AbortController();
2940
+ const timeout = setTimeout(() => controller.abort(), 200);
2941
+ const res = await fetch(`http://127.0.0.1:${port}/api/daemon`, { signal: controller.signal });
2942
+ clearTimeout(timeout);
2943
+ if (res.ok) {
2944
+ const data = await res.json();
2945
+ if (data && data.ok) {
2946
+ running.push({ port, pid: data.pid, kandownDir: data.kandownDir, startedAt: data.startedAt, version: data.version });
2947
+ }
2948
+ }
2949
+ } catch {}
2950
+ }
2951
+
2952
+ if (isJson) {
2953
+ out(JSON.stringify(running, null, 2));
2954
+ return;
2955
+ }
2956
+
2957
+ if (running.length === 0) {
2958
+ info('No active kandown web daemons running on this machine');
2959
+ return;
2960
+ }
2961
+
2962
+ log(`${c.bold}Active Kandown Daemons (${running.length})${c.reset}\n`);
2963
+ for (const d of running) {
2964
+ log(` ${c.green}●${c.reset} Port ${c.cyan}${d.port}${c.reset} PID ${d.pid} ${c.dim}${d.kandownDir}${c.reset}`);
2965
+ }
2966
+ function cmdExport(rawArgs) {
2967
+ const { kandownDir } = ensureKandownDir(rawArgs);
2968
+ const isCsv = rawArgs.includes('--csv');
2969
+ const board = readBoard(kandownDir);
2970
+
2971
+ if (isCsv) {
2972
+ let csv = 'id,title,status,priority,assignee,tags,created\n';
2973
+ for (const col of board.columns) {
2974
+ for (const t of col.tasks) {
2975
+ const task = readTask(kandownDir, t.id);
2976
+ const tags = (t.tags || []).join(';');
2977
+ csv += `"${t.id}","${t.title.replace(/"/g, '""')}","${col.name}","${t.priority || ''}","${t.assignee || ''}","${tags}","${task.frontmatter.created || ''}"\n`;
2978
+ }
2979
+ }
2980
+ out(csv);
2981
+ } else {
2982
+ const data = [];
2983
+ for (const col of board.columns) {
2984
+ for (const t of col.tasks) {
2985
+ const task = readTask(kandownDir, t.id);
2986
+ data.push({ ...task, status: col.name });
2987
+ }
2988
+ }
2989
+ out(JSON.stringify(data, null, 2));
2990
+ }
2991
+ }
2992
+
2993
+ function cmdImport(rawArgs) {
2994
+ const { kandownDir } = ensureKandownDir(rawArgs);
2995
+ const fileIdx = rawArgs.findIndex(a => a.endsWith('.json') || a.endsWith('.md'));
2996
+ if (fileIdx === -1 || !existsSync(rawArgs[fileIdx])) {
2997
+ err('Usage: kandown import <file.json | file.md>');
2998
+ process.exit(1);
2999
+ }
3000
+ const filePath = rawArgs[fileIdx];
3001
+ const content = readFileSync(filePath, 'utf8');
3002
+ let count = 0;
3003
+
3004
+ if (filePath.endsWith('.json')) {
3005
+ try {
3006
+ const parsed = JSON.parse(content);
3007
+ const cards = Array.isArray(parsed) ? parsed : (parsed.cards || []);
3008
+ for (const c of cards) {
3009
+ const title = c.name || c.title || 'Imported Task';
3010
+ createTaskInBoard(kandownDir, title, c.status || 'Backlog');
3011
+ count++;
3012
+ }
3013
+ } catch (e) {
3014
+ err(`Import failed: ${e.message}`);
3015
+ process.exit(1);
3016
+ }
3017
+ } else {
3018
+ const lines = content.split('\n');
3019
+ for (const line of lines) {
3020
+ const m = line.match(/^#{1,3}\s+(.+)$/);
3021
+ if (m) {
3022
+ createTaskInBoard(kandownDir, m[1].trim(), 'Backlog');
3023
+ count++;
3024
+ }
3025
+ }
3026
+ }
3027
+
3028
+ success(`Imported ${count} tasks into board`);
3029
+ }
3030
+
2693
3031
  const SCRIPTED_COMMANDS = new Set([
2694
3032
  'list', 'ls', 'show', 'create', 'new', 'move', 'assign', 'commit', 'tasks', 'work', 'daemon',
3033
+ 'doctor', 'undo', 'projects', 'export', 'import',
2695
3034
  ]);
2696
3035
  if (!skipUpdate && !SCRIPTED_COMMANDS.has(cmd)) await checkForUpdate(process.argv);
2697
3036
 
2698
- // 📖 Catches breaking-change notices for upgrades that bypassed the
2699
- // auto-updater above (manual `npm install -g kandown`, pnpm/yarn/bun, a
2700
- // respawned child, etc). See checkVersionSeenNotices' doc comment.
2701
3037
  if (!SCRIPTED_COMMANDS.has(cmd)) checkVersionSeenNotices();
2702
3038
 
2703
3039
  switch (cmd) {
3040
+ case 'export':
3041
+ cmdExport(rest);
3042
+ break;
3043
+
3044
+ case 'import':
3045
+ cmdImport(rest);
3046
+ break;
2704
3047
  case 'init':
2705
3048
  cmdInit(rest);
2706
3049
  break;
2707
3050
 
3051
+ case 'doctor':
3052
+ await cmdDoctor(rest);
3053
+ break;
3054
+
3055
+ case 'undo':
3056
+ cmdUndo(rest);
3057
+ break;
3058
+
3059
+ case 'mcp': {
3060
+ const { kandownDir } = ensureKandownDir(rest);
3061
+ const { startMcpServer } = await import('../src/cli/lib/mcp.js');
3062
+ startMcpServer(kandownDir);
3063
+ break;
3064
+ }
3065
+
3066
+ case 'projects':
3067
+ await cmdProjects(rest);
3068
+ break;
3069
+
2708
3070
  case 'board':
2709
3071
  // 📖 kandown board — open the interactive kanban board TUI only
2710
3072
  await cmdTui('board', rest);