kandown 0.7.4 → 0.8.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/README.md CHANGED
@@ -64,10 +64,12 @@ This creates a `.kandown/` folder with:
64
64
  kandown
65
65
  ```
66
66
 
67
- This opens:
67
+ This starts a per-project local web daemon, then opens:
68
68
  - A **web UI** in your browser (board view, list view, task editor)
69
69
  - A **terminal TUI** for keyboard-driven workflow (works over SSH, no browser needed)
70
70
 
71
+ The web daemon stays alive after you quit the TUI so the browser keeps working. Stop or restart it anytime from the TUI with `d`, or from the CLI with `kandown daemon stop`.
72
+
71
73
  ### CLI Commands
72
74
 
73
75
  | Command | Description |
@@ -76,6 +78,9 @@ This opens:
76
78
  | `kandown init` | Initialize in current project |
77
79
  | `kandown board` | TUI only (no browser) |
78
80
  | `kandown settings` | Terminal settings editor |
81
+ | `kandown daemon status` | Show this project's web daemon status |
82
+ | `kandown daemon start` | Start/reconnect this project's web daemon |
83
+ | `kandown daemon stop` | Stop this project's web daemon |
79
84
  | `kandown update` | Update `kandown.html` to latest |
80
85
  | `kandown help` | CLI help |
81
86
 
@@ -142,6 +147,15 @@ Press `a` in the board TUI to launch an AI agent on any task. Supported agents:
142
147
  | `⌘S` / `Ctrl+S` | Save task |
143
148
  | `⌘⌫` / `Ctrl+Backspace` | Delete task (with confirmation) |
144
149
 
150
+ ### TUI extras
151
+
152
+ | Shortcut / gesture | Action |
153
+ |---|---|
154
+ | `d` | Start/stop the per-project web daemon |
155
+ | Mouse drag task | Move a task between columns in the terminal |
156
+ | `m` | Open the focused task context menu |
157
+ | `r` | Reload board from disk |
158
+
145
159
  ### Data Model
146
160
 
147
161
  Each task is a standalone Markdown file:
package/bin/kandown.js CHANGED
@@ -23,7 +23,11 @@
23
23
  * → cmdUpdate — refreshes installed kandown.html
24
24
  * → injectServerRoot — injects the CLI server root into single-file HTML
25
25
  * → createServeServer — creates the local zero-dependency HTTP server
26
- * → cmdServeopens the web UI over localhost and launches the board TUI
26
+ * → readDaemonMetadatareads per-project daemon status metadata
27
+ * → startDaemon — starts/reconnects the per-project web daemon
28
+ * → stopDaemon — stops the per-project web daemon
29
+ * → cmdDaemon — daemon lifecycle command router
30
+ * → cmdServe — starts/reconnects the daemon, opens web UI, and launches the board TUI
27
31
  * → main — dispatches CLI commands
28
32
  *
29
33
  * @exports none
@@ -63,6 +67,7 @@ const DEFAULT_SERVE_PORT = 2048;
63
67
  const MAX_SERVE_PORT = 2060;
64
68
  const START_PORT_RANGE = 2048;
65
69
  const END_PORT_RANGE = 2060;
70
+ const DAEMON_FILE = 'daemon.json';
66
71
 
67
72
  // 📖 Get current CLI version from package.json at PKG_ROOT
68
73
  function getCurrentVersion() {
@@ -290,6 +295,7 @@ ${c.bold}Commands:${c.reset}
290
295
  ${c.cyan}board${c.reset} Open the interactive kanban board in the terminal
291
296
  ${c.cyan}init${c.reset} Initialize .kandown/ in the current directory
292
297
  ${c.cyan}settings${c.reset} Open the settings TUI
298
+ ${c.cyan}daemon${c.reset} Manage the per-project web daemon (start|stop|status)
293
299
  ${c.cyan}update${c.reset} Update kandown.html to the latest version
294
300
  ${c.cyan}help${c.reset} Show this help
295
301
 
@@ -300,6 +306,7 @@ ${c.bold}Examples:${c.reset}
300
306
  ${c.dim}$${c.reset} npx kandown ${c.dim}# local web server + board TUI${c.reset}
301
307
  ${c.dim}$${c.reset} npx kandown --port 3000 ${c.dim}# use a specific web UI port${c.reset}
302
308
  ${c.dim}$${c.reset} npx kandown board ${c.dim}# board TUI only${c.reset}
309
+ ${c.dim}$${c.reset} npx kandown daemon stop ${c.dim}# stop this project's web daemon${c.reset}
303
310
  ${c.dim}$${c.reset} npx kandown init
304
311
  ${c.dim}$${c.reset} npx kandown init --path docs/kanban
305
312
  ${c.dim}$${c.reset} npx kandown init --no-agents
@@ -448,6 +455,12 @@ function doInit(args, cwd, kandownPath, kandownDir) {
448
455
  info('kandown.json already exists (kept)');
449
456
  }
450
457
 
458
+ const kandownGitignore = join(kandownDir, '.gitignore');
459
+ if (!existsSync(kandownGitignore)) {
460
+ writeFileSync(kandownGitignore, `${DAEMON_FILE}\n`, 'utf8');
461
+ success('.gitignore (daemon runtime metadata ignored)');
462
+ }
463
+
451
464
  const agentKandownSrc = join(templatesDir, 'AGENT_KANDOWN.md');
452
465
  const agentKandownDest = join(kandownDir, 'AGENT_KANDOWN.md');
453
466
  if (!existsSync(agentKandownDest)) {
@@ -541,6 +554,151 @@ function parsePort(value) {
541
554
  return port;
542
555
  }
543
556
 
557
+ function daemonMetadataPath(kandownDir) {
558
+ return join(kandownDir, DAEMON_FILE);
559
+ }
560
+
561
+ function readDaemonMetadata(kandownDir) {
562
+ const metadataPath = daemonMetadataPath(kandownDir);
563
+ if (!existsSync(metadataPath)) return null;
564
+ try {
565
+ const raw = JSON.parse(readFileSync(metadataPath, 'utf8'));
566
+ if (!raw || typeof raw !== 'object') return null;
567
+ if (!Number.isInteger(raw.pid) || !Number.isInteger(raw.port)) return null;
568
+ if (typeof raw.url !== 'string' || typeof raw.kandownDir !== 'string') return null;
569
+ return raw;
570
+ } catch {
571
+ return null;
572
+ }
573
+ }
574
+
575
+ function writeDaemonMetadata(kandownDir, metadata) {
576
+ writeFileSync(daemonMetadataPath(kandownDir), JSON.stringify(metadata, null, 2) + '\n', 'utf8');
577
+ }
578
+
579
+ function removeDaemonMetadata(kandownDir) {
580
+ try {
581
+ if (existsSync(daemonMetadataPath(kandownDir))) unlinkSync(daemonMetadataPath(kandownDir));
582
+ } catch { /* ignore cleanup failure */ }
583
+ }
584
+
585
+ function ensureDaemonGitignore(kandownDir) {
586
+ const gitignorePath = join(kandownDir, '.gitignore');
587
+ try {
588
+ if (!existsSync(gitignorePath)) {
589
+ writeFileSync(gitignorePath, `${DAEMON_FILE}\n`, 'utf8');
590
+ return;
591
+ }
592
+ const existing = readFileSync(gitignorePath, 'utf8');
593
+ if (!existing.split(/\r?\n/).includes(DAEMON_FILE)) {
594
+ writeFileSync(gitignorePath, `${existing.trimEnd()}\n${DAEMON_FILE}\n`, 'utf8');
595
+ }
596
+ } catch { /* ignore gitignore best-effort failure */ }
597
+ }
598
+
599
+ function isProcessAlive(pid) {
600
+ if (!Number.isInteger(pid) || pid <= 0) return false;
601
+ try {
602
+ process.kill(pid, 0);
603
+ return true;
604
+ } catch {
605
+ return false;
606
+ }
607
+ }
608
+
609
+ async function fetchDaemonInfo(port) {
610
+ try {
611
+ const res = await fetch(`http://127.0.0.1:${port}/api/daemon`, {
612
+ signal: AbortSignal.timeout(700),
613
+ });
614
+ if (!res.ok) return null;
615
+ return await res.json();
616
+ } catch {
617
+ return null;
618
+ }
619
+ }
620
+
621
+ async function getDaemonStatus(kandownDir) {
622
+ const metadata = readDaemonMetadata(kandownDir);
623
+ if (!metadata) return { running: false, metadata: null };
624
+ if (!isProcessAlive(metadata.pid)) {
625
+ removeDaemonMetadata(kandownDir);
626
+ return { running: false, metadata: null };
627
+ }
628
+ const remote = await fetchDaemonInfo(metadata.port);
629
+ if (!remote || remote.kandownDir !== kandownDir || remote.pid !== metadata.pid) {
630
+ removeDaemonMetadata(kandownDir);
631
+ return { running: false, metadata: null };
632
+ }
633
+ return { running: true, metadata };
634
+ }
635
+
636
+ function refreshKandownHtml(kandownDir) {
637
+ const htmlDest = join(kandownDir, 'kandown.html');
638
+ const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
639
+ if (existsSync(htmlDest) && existsSync(htmlSrc)) {
640
+ copyFileSync(htmlSrc, htmlDest);
641
+ return true;
642
+ }
643
+ return false;
644
+ }
645
+
646
+ async function waitForDaemon(kandownDir, timeoutMs = 5000) {
647
+ const started = Date.now();
648
+ while (Date.now() - started < timeoutMs) {
649
+ const status = await getDaemonStatus(kandownDir);
650
+ if (status.running) return status;
651
+ await new Promise(r => setTimeout(r, 150));
652
+ }
653
+ return { running: false, metadata: null };
654
+ }
655
+
656
+ async function startDaemon(kandownDir, preferredPort) {
657
+ const current = await getDaemonStatus(kandownDir);
658
+ if (current.running) return current;
659
+
660
+ removeDaemonMetadata(kandownDir);
661
+ ensureDaemonGitignore(kandownDir);
662
+ const daemonArgs = [
663
+ process.argv[1],
664
+ '--no-update-check',
665
+ 'daemon',
666
+ 'run',
667
+ '--path',
668
+ kandownDir,
669
+ ];
670
+ if (preferredPort !== null) daemonArgs.push('--port', String(preferredPort));
671
+
672
+ const child = spawn(process.execPath, daemonArgs, {
673
+ cwd: dirname(kandownDir),
674
+ detached: true,
675
+ stdio: 'ignore',
676
+ env: { ...process.env, KANDOWN_DAEMON: '1' },
677
+ });
678
+ child.unref();
679
+
680
+ return waitForDaemon(kandownDir);
681
+ }
682
+
683
+ async function stopDaemon(kandownDir) {
684
+ const status = await getDaemonStatus(kandownDir);
685
+ if (!status.running || !status.metadata) {
686
+ removeDaemonMetadata(kandownDir);
687
+ return false;
688
+ }
689
+
690
+ try {
691
+ process.kill(status.metadata.pid, 'SIGTERM');
692
+ } catch { /* already stopped */ }
693
+
694
+ const started = Date.now();
695
+ while (Date.now() - started < 2500 && isProcessAlive(status.metadata.pid)) {
696
+ await new Promise(r => setTimeout(r, 100));
697
+ }
698
+ removeDaemonMetadata(kandownDir);
699
+ return true;
700
+ }
701
+
544
702
  function apiHeaders() {
545
703
  return {
546
704
  'Access-Control-Allow-Origin': '*',
@@ -720,6 +878,18 @@ function handleApi(req, res, url, kandownDir) {
720
878
  const resource = parts[0];
721
879
  const id = parts[1];
722
880
 
881
+ if (resource === 'daemon') {
882
+ if (req.method === 'GET') {
883
+ return writeJson(res, 200, {
884
+ ok: true,
885
+ pid: process.pid,
886
+ kandownDir,
887
+ version: getCurrentVersion(),
888
+ startedAt: daemonStartedAt,
889
+ });
890
+ }
891
+ }
892
+
723
893
  if (resource === 'config') {
724
894
  if (req.method === 'GET') return getConfig(res, kandownDir);
725
895
  if (req.method === 'PUT') return putConfig(req, res, kandownDir);
@@ -740,6 +910,8 @@ function handleApi(req, res, url, kandownDir) {
740
910
  writeJson(res, 404, { error: 'Not found' });
741
911
  }
742
912
 
913
+ const daemonStartedAt = new Date().toISOString();
914
+
743
915
  function serveApp(res, kandownDir) {
744
916
  const htmlPath = join(kandownDir, 'kandown.html');
745
917
  if (!existsSync(htmlPath)) {
@@ -915,47 +1087,96 @@ function detectStaleKandown(port, currentKandownDir) {
915
1087
  };
916
1088
  }
917
1089
 
1090
+ async function cmdDaemon(rawArgs) {
1091
+ const [subcommand = 'status', ...rest] = rawArgs;
1092
+ const { kandownDir } = ensureKandownDir(rest);
1093
+ const preferredPort = parsePort(parseArgs(rest).port);
1094
+
1095
+ if (subcommand === 'run') {
1096
+ const { server, port } = await listenOnAvailablePort(kandownDir, preferredPort);
1097
+ const url = `http://localhost:${port}`;
1098
+ ensureDaemonGitignore(kandownDir);
1099
+ writeDaemonMetadata(kandownDir, {
1100
+ pid: process.pid,
1101
+ port,
1102
+ url,
1103
+ kandownDir,
1104
+ startedAt: daemonStartedAt,
1105
+ version: getCurrentVersion(),
1106
+ });
1107
+
1108
+ const shutdown = () => {
1109
+ server.close(() => {
1110
+ removeDaemonMetadata(kandownDir);
1111
+ process.exit(0);
1112
+ });
1113
+ };
1114
+ process.once('SIGINT', shutdown);
1115
+ process.once('SIGTERM', shutdown);
1116
+ await new Promise(() => {});
1117
+ return;
1118
+ }
1119
+
1120
+ if (subcommand === 'start') {
1121
+ refreshKandownHtml(kandownDir);
1122
+ const status = await startDaemon(kandownDir, preferredPort);
1123
+ if (!status.running || !status.metadata) {
1124
+ err('Daemon failed to start');
1125
+ process.exit(1);
1126
+ }
1127
+ success(`Daemon running: ${status.metadata.url}`);
1128
+ return;
1129
+ }
1130
+
1131
+ if (subcommand === 'stop') {
1132
+ const stopped = await stopDaemon(kandownDir);
1133
+ if (stopped) success('Daemon stopped');
1134
+ else info('Daemon already stopped');
1135
+ return;
1136
+ }
1137
+
1138
+ if (subcommand === 'status') {
1139
+ const status = await getDaemonStatus(kandownDir);
1140
+ if (status.running && status.metadata) {
1141
+ success(`Daemon ON ${status.metadata.url} PID ${status.metadata.pid}`);
1142
+ } else {
1143
+ info('Daemon OFF');
1144
+ }
1145
+ return;
1146
+ }
1147
+
1148
+ err(`Unknown daemon command: ${subcommand}`);
1149
+ log(` Use ${c.cyan}kandown daemon start|stop|status${c.reset}`);
1150
+ process.exit(1);
1151
+ }
1152
+
918
1153
  /**
919
- * 📖 Starts the local web UI server, opens it in the browser, then hands the
920
- * terminal to the board TUI. The server intentionally stays in this process so
921
- * the browser can keep talking to localhost while the terminal board is active.
1154
+ * 📖 Starts/reconnects the per-project web daemon, opens it in the browser,
1155
+ * then hands the terminal to the board TUI. The daemon intentionally survives
1156
+ * TUI exit so the web UI keeps working until the user stops it.
922
1157
  */
923
1158
  async function cmdServe(rawArgs) {
924
1159
  const { kandownDir } = ensureKandownDir(rawArgs);
925
1160
 
926
- // 📖 Auto-refresh kandown.html if it already exists — ensures CLI upgrades
927
- // propagate to the web UI without requiring a separate `kandown update`.
928
- const htmlDest = join(kandownDir, 'kandown.html');
929
- const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
930
- if (existsSync(htmlDest) && existsSync(htmlSrc)) {
931
- try {
932
- copyFileSync(htmlSrc, htmlDest);
1161
+ try {
1162
+ if (refreshKandownHtml(kandownDir)) {
933
1163
  info(`Refreshed kandown.html (CLI v${getCurrentVersion()})`);
934
- } catch (e) {
935
- warn(`Could not refresh kandown.html: ${e.message}`);
936
1164
  }
937
- } else {
938
- info(`kandown.html not refreshed: dest=${existsSync(htmlDest)}, src=${existsSync(htmlSrc)}, PKG_ROOT=${PKG_ROOT}`);
1165
+ } catch (e) {
1166
+ warn(`Could not refresh kandown.html: ${e.message}`);
939
1167
  }
940
1168
 
941
1169
  const preferredPort = parsePort(parseArgs(rawArgs).port);
942
- const { server, port } = await listenOnAvailablePort(kandownDir, preferredPort);
943
- const url = `http://localhost:${port}`;
944
-
945
- const shutdown = () => {
946
- server.close(() => process.exit(0));
947
- };
948
- process.once('SIGINT', shutdown);
949
- process.once('SIGTERM', shutdown);
1170
+ const status = await startDaemon(kandownDir, preferredPort);
1171
+ if (!status.running || !status.metadata) {
1172
+ err('Failed to start web daemon');
1173
+ process.exit(1);
1174
+ }
950
1175
 
951
- success(`Web UI: ${url}`);
1176
+ success(`Web daemon: ${status.metadata.url}`);
952
1177
  info(`Project: ${kandownDir}`);
953
- openInBrowser(url);
954
- try {
955
- await cmdTui('board', rawArgs);
956
- } finally {
957
- server.close();
958
- }
1178
+ openInBrowser(status.metadata.url);
1179
+ await cmdTui('board', rawArgs);
959
1180
  }
960
1181
 
961
1182
  /**
@@ -1044,6 +1265,10 @@ switch (cmd) {
1044
1265
  await cmdTui('settings', rest);
1045
1266
  break;
1046
1267
 
1268
+ case 'daemon':
1269
+ await cmdDaemon(rest);
1270
+ break;
1271
+
1047
1272
  case 'update':
1048
1273
  cmdUpdate(rest);
1049
1274
  break;