kandown 0.7.5 → 0.9.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 +15 -1
- package/bin/kandown.js +301 -42
- package/bin/tui.js +248 -41
- package/dist/index.html +375 -1904
- package/package.json +1 -3
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
|
-
* →
|
|
26
|
+
* → readDaemonMetadata — reads 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
|
|
@@ -52,6 +56,7 @@ import {
|
|
|
52
56
|
readdirSync,
|
|
53
57
|
statSync,
|
|
54
58
|
unlinkSync,
|
|
59
|
+
renameSync,
|
|
55
60
|
} from 'node:fs';
|
|
56
61
|
import { spawnSync, spawn, execSync } from 'node:child_process';
|
|
57
62
|
|
|
@@ -63,6 +68,7 @@ const DEFAULT_SERVE_PORT = 2048;
|
|
|
63
68
|
const MAX_SERVE_PORT = 2060;
|
|
64
69
|
const START_PORT_RANGE = 2048;
|
|
65
70
|
const END_PORT_RANGE = 2060;
|
|
71
|
+
const DAEMON_FILE = 'daemon.json';
|
|
66
72
|
|
|
67
73
|
// 📖 Get current CLI version from package.json at PKG_ROOT
|
|
68
74
|
function getCurrentVersion() {
|
|
@@ -290,6 +296,7 @@ ${c.bold}Commands:${c.reset}
|
|
|
290
296
|
${c.cyan}board${c.reset} Open the interactive kanban board in the terminal
|
|
291
297
|
${c.cyan}init${c.reset} Initialize .kandown/ in the current directory
|
|
292
298
|
${c.cyan}settings${c.reset} Open the settings TUI
|
|
299
|
+
${c.cyan}daemon${c.reset} Manage the per-project web daemon (start|stop|status)
|
|
293
300
|
${c.cyan}update${c.reset} Update kandown.html to the latest version
|
|
294
301
|
${c.cyan}help${c.reset} Show this help
|
|
295
302
|
|
|
@@ -300,6 +307,7 @@ ${c.bold}Examples:${c.reset}
|
|
|
300
307
|
${c.dim}$${c.reset} npx kandown ${c.dim}# local web server + board TUI${c.reset}
|
|
301
308
|
${c.dim}$${c.reset} npx kandown --port 3000 ${c.dim}# use a specific web UI port${c.reset}
|
|
302
309
|
${c.dim}$${c.reset} npx kandown board ${c.dim}# board TUI only${c.reset}
|
|
310
|
+
${c.dim}$${c.reset} npx kandown daemon stop ${c.dim}# stop this project's web daemon${c.reset}
|
|
303
311
|
${c.dim}$${c.reset} npx kandown init
|
|
304
312
|
${c.dim}$${c.reset} npx kandown init --path docs/kanban
|
|
305
313
|
${c.dim}$${c.reset} npx kandown init --no-agents
|
|
@@ -448,6 +456,12 @@ function doInit(args, cwd, kandownPath, kandownDir) {
|
|
|
448
456
|
info('kandown.json already exists (kept)');
|
|
449
457
|
}
|
|
450
458
|
|
|
459
|
+
const kandownGitignore = join(kandownDir, '.gitignore');
|
|
460
|
+
if (!existsSync(kandownGitignore)) {
|
|
461
|
+
writeFileSync(kandownGitignore, `${DAEMON_FILE}\n`, 'utf8');
|
|
462
|
+
success('.gitignore (daemon runtime metadata ignored)');
|
|
463
|
+
}
|
|
464
|
+
|
|
451
465
|
const agentKandownSrc = join(templatesDir, 'AGENT_KANDOWN.md');
|
|
452
466
|
const agentKandownDest = join(kandownDir, 'AGENT_KANDOWN.md');
|
|
453
467
|
if (!existsSync(agentKandownDest)) {
|
|
@@ -541,6 +555,151 @@ function parsePort(value) {
|
|
|
541
555
|
return port;
|
|
542
556
|
}
|
|
543
557
|
|
|
558
|
+
function daemonMetadataPath(kandownDir) {
|
|
559
|
+
return join(kandownDir, DAEMON_FILE);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function readDaemonMetadata(kandownDir) {
|
|
563
|
+
const metadataPath = daemonMetadataPath(kandownDir);
|
|
564
|
+
if (!existsSync(metadataPath)) return null;
|
|
565
|
+
try {
|
|
566
|
+
const raw = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
567
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
568
|
+
if (!Number.isInteger(raw.pid) || !Number.isInteger(raw.port)) return null;
|
|
569
|
+
if (typeof raw.url !== 'string' || typeof raw.kandownDir !== 'string') return null;
|
|
570
|
+
return raw;
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function writeDaemonMetadata(kandownDir, metadata) {
|
|
577
|
+
writeFileSync(daemonMetadataPath(kandownDir), JSON.stringify(metadata, null, 2) + '\n', 'utf8');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function removeDaemonMetadata(kandownDir) {
|
|
581
|
+
try {
|
|
582
|
+
if (existsSync(daemonMetadataPath(kandownDir))) unlinkSync(daemonMetadataPath(kandownDir));
|
|
583
|
+
} catch { /* ignore cleanup failure */ }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function ensureDaemonGitignore(kandownDir) {
|
|
587
|
+
const gitignorePath = join(kandownDir, '.gitignore');
|
|
588
|
+
try {
|
|
589
|
+
if (!existsSync(gitignorePath)) {
|
|
590
|
+
writeFileSync(gitignorePath, `${DAEMON_FILE}\n`, 'utf8');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const existing = readFileSync(gitignorePath, 'utf8');
|
|
594
|
+
if (!existing.split(/\r?\n/).includes(DAEMON_FILE)) {
|
|
595
|
+
writeFileSync(gitignorePath, `${existing.trimEnd()}\n${DAEMON_FILE}\n`, 'utf8');
|
|
596
|
+
}
|
|
597
|
+
} catch { /* ignore gitignore best-effort failure */ }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function isProcessAlive(pid) {
|
|
601
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
602
|
+
try {
|
|
603
|
+
process.kill(pid, 0);
|
|
604
|
+
return true;
|
|
605
|
+
} catch {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function fetchDaemonInfo(port) {
|
|
611
|
+
try {
|
|
612
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/daemon`, {
|
|
613
|
+
signal: AbortSignal.timeout(700),
|
|
614
|
+
});
|
|
615
|
+
if (!res.ok) return null;
|
|
616
|
+
return await res.json();
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function getDaemonStatus(kandownDir) {
|
|
623
|
+
const metadata = readDaemonMetadata(kandownDir);
|
|
624
|
+
if (!metadata) return { running: false, metadata: null };
|
|
625
|
+
if (!isProcessAlive(metadata.pid)) {
|
|
626
|
+
removeDaemonMetadata(kandownDir);
|
|
627
|
+
return { running: false, metadata: null };
|
|
628
|
+
}
|
|
629
|
+
const remote = await fetchDaemonInfo(metadata.port);
|
|
630
|
+
if (!remote || remote.kandownDir !== kandownDir || remote.pid !== metadata.pid) {
|
|
631
|
+
removeDaemonMetadata(kandownDir);
|
|
632
|
+
return { running: false, metadata: null };
|
|
633
|
+
}
|
|
634
|
+
return { running: true, metadata };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function refreshKandownHtml(kandownDir) {
|
|
638
|
+
const htmlDest = join(kandownDir, 'kandown.html');
|
|
639
|
+
const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
|
|
640
|
+
if (existsSync(htmlDest) && existsSync(htmlSrc)) {
|
|
641
|
+
copyFileSync(htmlSrc, htmlDest);
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function waitForDaemon(kandownDir, timeoutMs = 5000) {
|
|
648
|
+
const started = Date.now();
|
|
649
|
+
while (Date.now() - started < timeoutMs) {
|
|
650
|
+
const status = await getDaemonStatus(kandownDir);
|
|
651
|
+
if (status.running) return status;
|
|
652
|
+
await new Promise(r => setTimeout(r, 150));
|
|
653
|
+
}
|
|
654
|
+
return { running: false, metadata: null };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function startDaemon(kandownDir, preferredPort) {
|
|
658
|
+
const current = await getDaemonStatus(kandownDir);
|
|
659
|
+
if (current.running) return current;
|
|
660
|
+
|
|
661
|
+
removeDaemonMetadata(kandownDir);
|
|
662
|
+
ensureDaemonGitignore(kandownDir);
|
|
663
|
+
const daemonArgs = [
|
|
664
|
+
process.argv[1],
|
|
665
|
+
'--no-update-check',
|
|
666
|
+
'daemon',
|
|
667
|
+
'run',
|
|
668
|
+
'--path',
|
|
669
|
+
kandownDir,
|
|
670
|
+
];
|
|
671
|
+
if (preferredPort !== null) daemonArgs.push('--port', String(preferredPort));
|
|
672
|
+
|
|
673
|
+
const child = spawn(process.execPath, daemonArgs, {
|
|
674
|
+
cwd: dirname(kandownDir),
|
|
675
|
+
detached: true,
|
|
676
|
+
stdio: 'ignore',
|
|
677
|
+
env: { ...process.env, KANDOWN_DAEMON: '1' },
|
|
678
|
+
});
|
|
679
|
+
child.unref();
|
|
680
|
+
|
|
681
|
+
return waitForDaemon(kandownDir);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function stopDaemon(kandownDir) {
|
|
685
|
+
const status = await getDaemonStatus(kandownDir);
|
|
686
|
+
if (!status.running || !status.metadata) {
|
|
687
|
+
removeDaemonMetadata(kandownDir);
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
try {
|
|
692
|
+
process.kill(status.metadata.pid, 'SIGTERM');
|
|
693
|
+
} catch { /* already stopped */ }
|
|
694
|
+
|
|
695
|
+
const started = Date.now();
|
|
696
|
+
while (Date.now() - started < 2500 && isProcessAlive(status.metadata.pid)) {
|
|
697
|
+
await new Promise(r => setTimeout(r, 100));
|
|
698
|
+
}
|
|
699
|
+
removeDaemonMetadata(kandownDir);
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
|
|
544
703
|
function apiHeaders() {
|
|
545
704
|
return {
|
|
546
705
|
'Access-Control-Allow-Origin': '*',
|
|
@@ -632,16 +791,39 @@ function putBoard(req, res, kandownDir) {
|
|
|
632
791
|
});
|
|
633
792
|
}
|
|
634
793
|
|
|
794
|
+
/**
|
|
795
|
+
* 📖 Resolves the on-disk path of a task id, searching the active tasks dir
|
|
796
|
+
* first, then the archive subfolder. Returns null when the id exists in
|
|
797
|
+
* neither location. Used by every CRUD handler so archived tasks stay
|
|
798
|
+
* reachable at their real location.
|
|
799
|
+
*/
|
|
800
|
+
function findTaskPath(kandownDir, id) {
|
|
801
|
+
const inTasks = join(kandownDir, 'tasks', `${id}.md`);
|
|
802
|
+
if (existsSync(inTasks)) return inTasks;
|
|
803
|
+
const inArchive = join(kandownDir, 'tasks', 'archive', `${id}.md`);
|
|
804
|
+
if (existsSync(inArchive)) return inArchive;
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
|
|
635
808
|
function getTasks(res, kandownDir) {
|
|
636
809
|
const tasksDir = join(kandownDir, 'tasks');
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
810
|
+
const archiveDir = join(tasksDir, 'archive');
|
|
811
|
+
const ids = new Set();
|
|
641
812
|
try {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
813
|
+
if (existsSync(tasksDir)) {
|
|
814
|
+
for (const f of readdirSync(tasksDir).filter(f => f.endsWith('.md'))) {
|
|
815
|
+
ids.add(f.replace(/\.md$/, ''));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// 📖 Also surface archived tasks so the UI can list them in the archive
|
|
819
|
+
// view. The archived flag in frontmatter (not the folder) is the source of
|
|
820
|
+
// truth for hiding them from the active board.
|
|
821
|
+
if (existsSync(archiveDir)) {
|
|
822
|
+
for (const f of readdirSync(archiveDir).filter(f => f.endsWith('.md'))) {
|
|
823
|
+
ids.add(f.replace(/\.md$/, ''));
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
writeJson(res, 200, [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
|
|
645
827
|
} catch (e) {
|
|
646
828
|
writeJson(res, 500, { error: `Failed to list tasks: ${e.message}` });
|
|
647
829
|
}
|
|
@@ -652,8 +834,8 @@ function getTask(res, kandownDir, id) {
|
|
|
652
834
|
writeText(res, 400, 'Invalid task id');
|
|
653
835
|
return;
|
|
654
836
|
}
|
|
655
|
-
const taskPath =
|
|
656
|
-
if (!
|
|
837
|
+
const taskPath = findTaskPath(kandownDir, id);
|
|
838
|
+
if (!taskPath) {
|
|
657
839
|
writeText(res, 404, 'Task not found');
|
|
658
840
|
return;
|
|
659
841
|
}
|
|
@@ -673,7 +855,13 @@ function putTask(req, res, kandownDir, id) {
|
|
|
673
855
|
readBody(req).then(body => {
|
|
674
856
|
const tasksDir = join(kandownDir, 'tasks');
|
|
675
857
|
if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
|
|
676
|
-
|
|
858
|
+
// 📖 Write in place: keep an archived task inside archive/ on save so the
|
|
859
|
+
// file location never drifts from its archived flag.
|
|
860
|
+
const existing = findTaskPath(kandownDir, id);
|
|
861
|
+
const archiveDir = join(tasksDir, 'archive');
|
|
862
|
+
const inArchive = existing && existing.startsWith(archiveDir);
|
|
863
|
+
const targetDir = inArchive ? archiveDir : tasksDir;
|
|
864
|
+
const taskPath = join(targetDir, `${id}.md`);
|
|
677
865
|
writeFileSync(taskPath, body, 'utf8');
|
|
678
866
|
writeJson(res, 200, { ok: true });
|
|
679
867
|
}).catch(e => {
|
|
@@ -686,8 +874,8 @@ function deleteTask(res, kandownDir, id) {
|
|
|
686
874
|
writeText(res, 400, 'Invalid task id');
|
|
687
875
|
return;
|
|
688
876
|
}
|
|
689
|
-
const taskPath =
|
|
690
|
-
if (!
|
|
877
|
+
const taskPath = findTaskPath(kandownDir, id);
|
|
878
|
+
if (!taskPath) {
|
|
691
879
|
writeJson(res, 404, { error: 'Task not found' });
|
|
692
880
|
return;
|
|
693
881
|
}
|
|
@@ -720,6 +908,18 @@ function handleApi(req, res, url, kandownDir) {
|
|
|
720
908
|
const resource = parts[0];
|
|
721
909
|
const id = parts[1];
|
|
722
910
|
|
|
911
|
+
if (resource === 'daemon') {
|
|
912
|
+
if (req.method === 'GET') {
|
|
913
|
+
return writeJson(res, 200, {
|
|
914
|
+
ok: true,
|
|
915
|
+
pid: process.pid,
|
|
916
|
+
kandownDir,
|
|
917
|
+
version: getCurrentVersion(),
|
|
918
|
+
startedAt: daemonStartedAt,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
723
923
|
if (resource === 'config') {
|
|
724
924
|
if (req.method === 'GET') return getConfig(res, kandownDir);
|
|
725
925
|
if (req.method === 'PUT') return putConfig(req, res, kandownDir);
|
|
@@ -735,11 +935,17 @@ function handleApi(req, res, url, kandownDir) {
|
|
|
735
935
|
if (req.method === 'GET' && id) return getTask(res, kandownDir, id);
|
|
736
936
|
if (req.method === 'PUT' && id) return putTask(req, res, kandownDir, id);
|
|
737
937
|
if (req.method === 'DELETE' && id) return deleteTask(res, kandownDir, id);
|
|
938
|
+
// parts[2] is the sub-resource: 'archive' or 'unarchive'. The body carries
|
|
939
|
+
// the full task file content with the archived flag already toggled.
|
|
940
|
+
if (req.method === 'POST' && id && parts[2] === 'archive') return archiveTask(req, res, kandownDir, id);
|
|
941
|
+
if (req.method === 'POST' && id && parts[2] === 'unarchive') return unarchiveTask(req, res, kandownDir, id);
|
|
738
942
|
}
|
|
739
943
|
|
|
740
944
|
writeJson(res, 404, { error: 'Not found' });
|
|
741
945
|
}
|
|
742
946
|
|
|
947
|
+
const daemonStartedAt = new Date().toISOString();
|
|
948
|
+
|
|
743
949
|
function serveApp(res, kandownDir) {
|
|
744
950
|
const htmlPath = join(kandownDir, 'kandown.html');
|
|
745
951
|
if (!existsSync(htmlPath)) {
|
|
@@ -915,47 +1121,96 @@ function detectStaleKandown(port, currentKandownDir) {
|
|
|
915
1121
|
};
|
|
916
1122
|
}
|
|
917
1123
|
|
|
1124
|
+
async function cmdDaemon(rawArgs) {
|
|
1125
|
+
const [subcommand = 'status', ...rest] = rawArgs;
|
|
1126
|
+
const { kandownDir } = ensureKandownDir(rest);
|
|
1127
|
+
const preferredPort = parsePort(parseArgs(rest).port);
|
|
1128
|
+
|
|
1129
|
+
if (subcommand === 'run') {
|
|
1130
|
+
const { server, port } = await listenOnAvailablePort(kandownDir, preferredPort);
|
|
1131
|
+
const url = `http://localhost:${port}`;
|
|
1132
|
+
ensureDaemonGitignore(kandownDir);
|
|
1133
|
+
writeDaemonMetadata(kandownDir, {
|
|
1134
|
+
pid: process.pid,
|
|
1135
|
+
port,
|
|
1136
|
+
url,
|
|
1137
|
+
kandownDir,
|
|
1138
|
+
startedAt: daemonStartedAt,
|
|
1139
|
+
version: getCurrentVersion(),
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
const shutdown = () => {
|
|
1143
|
+
server.close(() => {
|
|
1144
|
+
removeDaemonMetadata(kandownDir);
|
|
1145
|
+
process.exit(0);
|
|
1146
|
+
});
|
|
1147
|
+
};
|
|
1148
|
+
process.once('SIGINT', shutdown);
|
|
1149
|
+
process.once('SIGTERM', shutdown);
|
|
1150
|
+
await new Promise(() => {});
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (subcommand === 'start') {
|
|
1155
|
+
refreshKandownHtml(kandownDir);
|
|
1156
|
+
const status = await startDaemon(kandownDir, preferredPort);
|
|
1157
|
+
if (!status.running || !status.metadata) {
|
|
1158
|
+
err('Daemon failed to start');
|
|
1159
|
+
process.exit(1);
|
|
1160
|
+
}
|
|
1161
|
+
success(`Daemon running: ${status.metadata.url}`);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (subcommand === 'stop') {
|
|
1166
|
+
const stopped = await stopDaemon(kandownDir);
|
|
1167
|
+
if (stopped) success('Daemon stopped');
|
|
1168
|
+
else info('Daemon already stopped');
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (subcommand === 'status') {
|
|
1173
|
+
const status = await getDaemonStatus(kandownDir);
|
|
1174
|
+
if (status.running && status.metadata) {
|
|
1175
|
+
success(`Daemon ON ${status.metadata.url} PID ${status.metadata.pid}`);
|
|
1176
|
+
} else {
|
|
1177
|
+
info('Daemon OFF');
|
|
1178
|
+
}
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
err(`Unknown daemon command: ${subcommand}`);
|
|
1183
|
+
log(` Use ${c.cyan}kandown daemon start|stop|status${c.reset}`);
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
918
1187
|
/**
|
|
919
|
-
* 📖 Starts the
|
|
920
|
-
* terminal to the board TUI. The
|
|
921
|
-
*
|
|
1188
|
+
* 📖 Starts/reconnects the per-project web daemon, opens it in the browser,
|
|
1189
|
+
* then hands the terminal to the board TUI. The daemon intentionally survives
|
|
1190
|
+
* TUI exit so the web UI keeps working until the user stops it.
|
|
922
1191
|
*/
|
|
923
1192
|
async function cmdServe(rawArgs) {
|
|
924
1193
|
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
925
1194
|
|
|
926
|
-
|
|
927
|
-
|
|
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);
|
|
1195
|
+
try {
|
|
1196
|
+
if (refreshKandownHtml(kandownDir)) {
|
|
933
1197
|
info(`Refreshed kandown.html (CLI v${getCurrentVersion()})`);
|
|
934
|
-
} catch (e) {
|
|
935
|
-
warn(`Could not refresh kandown.html: ${e.message}`);
|
|
936
1198
|
}
|
|
937
|
-
}
|
|
938
|
-
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
warn(`Could not refresh kandown.html: ${e.message}`);
|
|
939
1201
|
}
|
|
940
1202
|
|
|
941
1203
|
const preferredPort = parsePort(parseArgs(rawArgs).port);
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
};
|
|
948
|
-
process.once('SIGINT', shutdown);
|
|
949
|
-
process.once('SIGTERM', shutdown);
|
|
1204
|
+
const status = await startDaemon(kandownDir, preferredPort);
|
|
1205
|
+
if (!status.running || !status.metadata) {
|
|
1206
|
+
err('Failed to start web daemon');
|
|
1207
|
+
process.exit(1);
|
|
1208
|
+
}
|
|
950
1209
|
|
|
951
|
-
success(`Web
|
|
1210
|
+
success(`Web daemon: ${status.metadata.url}`);
|
|
952
1211
|
info(`Project: ${kandownDir}`);
|
|
953
|
-
openInBrowser(url);
|
|
954
|
-
|
|
955
|
-
await cmdTui('board', rawArgs);
|
|
956
|
-
} finally {
|
|
957
|
-
server.close();
|
|
958
|
-
}
|
|
1212
|
+
openInBrowser(status.metadata.url);
|
|
1213
|
+
await cmdTui('board', rawArgs);
|
|
959
1214
|
}
|
|
960
1215
|
|
|
961
1216
|
/**
|
|
@@ -1044,6 +1299,10 @@ switch (cmd) {
|
|
|
1044
1299
|
await cmdTui('settings', rest);
|
|
1045
1300
|
break;
|
|
1046
1301
|
|
|
1302
|
+
case 'daemon':
|
|
1303
|
+
await cmdDaemon(rest);
|
|
1304
|
+
break;
|
|
1305
|
+
|
|
1047
1306
|
case 'update':
|
|
1048
1307
|
cmdUpdate(rest);
|
|
1049
1308
|
break;
|