kandown 0.19.1 → 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 +388 -32
- package/bin/tui.js +492 -21
- package/dist/index.html +137 -122
- package/package.json +1 -1
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
|
|
@@ -621,7 +622,7 @@ function appendAgentReference(cwd, agentsFile, kandownPath) {
|
|
|
621
622
|
${marker}
|
|
622
623
|
## Task management
|
|
623
624
|
|
|
624
|
-
This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md
|
|
625
|
+
This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md\`.)
|
|
625
626
|
`;
|
|
626
627
|
|
|
627
628
|
try {
|
|
@@ -642,7 +643,7 @@ function createAgentsFileIfMissing(cwd, kandownPath) {
|
|
|
642
643
|
<!-- kandown:agent-ref -->
|
|
643
644
|
## Task management
|
|
644
645
|
|
|
645
|
-
This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md
|
|
646
|
+
This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md\`.)
|
|
646
647
|
`;
|
|
647
648
|
writeFileSync(agentsPath, content, 'utf8');
|
|
648
649
|
return true;
|
|
@@ -830,11 +831,14 @@ function doInit(args, cwd, kandownPath, kandownDir) {
|
|
|
830
831
|
copyFileSync(htmlSrc, htmlDest);
|
|
831
832
|
success('kandown.html');
|
|
832
833
|
|
|
834
|
+
// 📖 AGENT.md and AGENT_KANDOWN.md are intentionally NOT copied into new
|
|
835
|
+
// projects anymore. Both `kandown work` and the TUI's agent launcher (`a`)
|
|
836
|
+
// now read the rules straight from this package's templates/AGENT_KANDOWN.md
|
|
837
|
+
// at call time — a per-project copy would only go stale the moment the
|
|
838
|
+
// package updates, with nothing to keep it in sync. Existing projects with
|
|
839
|
+
// an old copy are left alone (never auto-deleted); customization now goes
|
|
840
|
+
// in `.kandown/instructions.md` / `~/.kandown/instructions.md` instead.
|
|
833
841
|
const templatesDir = join(PKG_ROOT, 'templates');
|
|
834
|
-
if (!existsSync(join(kandownDir, 'AGENT.md'))) {
|
|
835
|
-
copyFileSync(join(templatesDir, 'AGENT.md'), join(kandownDir, 'AGENT.md'));
|
|
836
|
-
success('AGENT.md');
|
|
837
|
-
}
|
|
838
842
|
if (!existsSync(join(kandownDir, 'README.md'))) {
|
|
839
843
|
copyFileSync(join(templatesDir, 'README.md'), join(kandownDir, 'README.md'));
|
|
840
844
|
success('README.md');
|
|
@@ -871,15 +875,6 @@ function doInit(args, cwd, kandownPath, kandownDir) {
|
|
|
871
875
|
success('.gitignore (daemon runtime metadata ignored)');
|
|
872
876
|
}
|
|
873
877
|
|
|
874
|
-
const agentKandownSrc = join(templatesDir, 'AGENT_KANDOWN.md');
|
|
875
|
-
const agentKandownDest = join(kandownDir, 'AGENT_KANDOWN.md');
|
|
876
|
-
if (!existsSync(agentKandownDest)) {
|
|
877
|
-
copyFileSync(agentKandownSrc, agentKandownDest);
|
|
878
|
-
success('AGENT_KANDOWN.md');
|
|
879
|
-
} else {
|
|
880
|
-
info('AGENT_KANDOWN.md already exists (kept)');
|
|
881
|
-
}
|
|
882
|
-
|
|
883
878
|
if (!args.noAgents) {
|
|
884
879
|
log('');
|
|
885
880
|
const existingAgents = findAgentsFile(cwd);
|
|
@@ -892,6 +887,13 @@ function doInit(args, cwd, kandownPath, kandownDir) {
|
|
|
892
887
|
}
|
|
893
888
|
}
|
|
894
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
|
+
|
|
895
897
|
return true;
|
|
896
898
|
}
|
|
897
899
|
|
|
@@ -1232,26 +1234,54 @@ function depIsResolved(kandownDir, id, terminalLower) {
|
|
|
1232
1234
|
return isArch || (parsed.frontmatter.status || '').toLowerCase() === terminalLower;
|
|
1233
1235
|
}
|
|
1234
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
|
+
|
|
1235
1274
|
function cmdCreate(rawArgs) {
|
|
1236
1275
|
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
1237
1276
|
const args = taskParseArgs(rawArgs);
|
|
1238
|
-
const
|
|
1239
|
-
if (!
|
|
1240
|
-
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]');
|
|
1241
1280
|
process.exit(1);
|
|
1242
1281
|
}
|
|
1243
1282
|
const config = readKandownConfig(kandownDir);
|
|
1244
|
-
const
|
|
1245
|
-
const targetStatus = args.flags.to ? resolveStatusArg(config, args.flags.to) : defaultStatus;
|
|
1246
|
-
if (args.flags.to && !targetStatus) {
|
|
1247
|
-
err(`Unknown status: ${args.flags.to}`);
|
|
1248
|
-
process.exit(1);
|
|
1249
|
-
}
|
|
1283
|
+
const targetStatus = resolveStatusArg(config, args.flags.to) || (config && config.board && config.board.columns ? config.board.columns[0] : 'Backlog');
|
|
1250
1284
|
const id = args.flags.id || nextTaskId(kandownDir);
|
|
1251
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
|
|
1252
|
-
err(`Invalid task id: ${id}`);
|
|
1253
|
-
process.exit(1);
|
|
1254
|
-
}
|
|
1255
1285
|
const tasksDir = getTasksDir(kandownDir);
|
|
1256
1286
|
if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
|
|
1257
1287
|
const targetPath = join(tasksDir, `${id}.md`);
|
|
@@ -1259,6 +1289,8 @@ function cmdCreate(rawArgs) {
|
|
|
1259
1289
|
err(`Task already exists: ${id}`);
|
|
1260
1290
|
process.exit(1);
|
|
1261
1291
|
}
|
|
1292
|
+
const parsedInline = parseInlineQuickAdd(rawTitle);
|
|
1293
|
+
const title = parsedInline.title;
|
|
1262
1294
|
const fm = {
|
|
1263
1295
|
id,
|
|
1264
1296
|
title,
|
|
@@ -1889,11 +1921,37 @@ function handleCors(res) {
|
|
|
1889
1921
|
* daemon token; otherwise answers 401 and returns false.
|
|
1890
1922
|
*/
|
|
1891
1923
|
function requireToken(req, res) {
|
|
1892
|
-
|
|
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;
|
|
1893
1928
|
writeJson(res, 401, { error: 'missing or invalid X-Kandown-Token' });
|
|
1894
1929
|
return false;
|
|
1895
1930
|
}
|
|
1896
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
|
+
|
|
1897
1955
|
function writeJson(res, status, body) {
|
|
1898
1956
|
const headers = { ...apiHeaders(), 'Content-Type': 'application/json' };
|
|
1899
1957
|
res.writeHead(status, headers);
|
|
@@ -2315,6 +2373,44 @@ function handleApi(req, res, url, kandownDir) {
|
|
|
2315
2373
|
}
|
|
2316
2374
|
}
|
|
2317
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
|
+
|
|
2318
2414
|
if (resource === 'config') {
|
|
2319
2415
|
if (req.method === 'GET') return getConfig(res, kandownDir);
|
|
2320
2416
|
if (req.method === 'PUT') return putConfig(req, res, kandownDir);
|
|
@@ -2373,6 +2469,21 @@ function serveApp(res, kandownDir) {
|
|
|
2373
2469
|
* follow-up REST task, keeping this refactor limited to server bootstrapping.
|
|
2374
2470
|
*/
|
|
2375
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
|
+
|
|
2376
2487
|
return createServer((req, res) => {
|
|
2377
2488
|
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
2378
2489
|
if (req.method === 'OPTIONS') return handleCors(res);
|
|
@@ -2696,21 +2807,266 @@ const skipUpdate = process.argv.slice(2).includes('--no-update-check');
|
|
|
2696
2807
|
// children, status probes) must never pay a network round-trip nor risk a
|
|
2697
2808
|
// mid-pipeline respawn. Inside checkForUpdate there are further guards: 24h
|
|
2698
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
|
+
|
|
2699
3031
|
const SCRIPTED_COMMANDS = new Set([
|
|
2700
3032
|
'list', 'ls', 'show', 'create', 'new', 'move', 'assign', 'commit', 'tasks', 'work', 'daemon',
|
|
3033
|
+
'doctor', 'undo', 'projects', 'export', 'import',
|
|
2701
3034
|
]);
|
|
2702
3035
|
if (!skipUpdate && !SCRIPTED_COMMANDS.has(cmd)) await checkForUpdate(process.argv);
|
|
2703
3036
|
|
|
2704
|
-
// 📖 Catches breaking-change notices for upgrades that bypassed the
|
|
2705
|
-
// auto-updater above (manual `npm install -g kandown`, pnpm/yarn/bun, a
|
|
2706
|
-
// respawned child, etc). See checkVersionSeenNotices' doc comment.
|
|
2707
3037
|
if (!SCRIPTED_COMMANDS.has(cmd)) checkVersionSeenNotices();
|
|
2708
3038
|
|
|
2709
3039
|
switch (cmd) {
|
|
3040
|
+
case 'export':
|
|
3041
|
+
cmdExport(rest);
|
|
3042
|
+
break;
|
|
3043
|
+
|
|
3044
|
+
case 'import':
|
|
3045
|
+
cmdImport(rest);
|
|
3046
|
+
break;
|
|
2710
3047
|
case 'init':
|
|
2711
3048
|
cmdInit(rest);
|
|
2712
3049
|
break;
|
|
2713
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
|
+
|
|
2714
3070
|
case 'board':
|
|
2715
3071
|
// 📖 kandown board — open the interactive kanban board TUI only
|
|
2716
3072
|
await cmdTui('board', rest);
|