kandown 0.3.5 → 0.6.1
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 +90 -378
- package/bin/kandown.js +212 -44
- package/dist/index.html +559 -4225
- package/package.json +5 -1
package/bin/kandown.js
CHANGED
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
* → appendAgentReference — injects a Kandown task-management reference
|
|
17
17
|
* → createAgentsFileIfMissing — creates AGENTS.md when none exists
|
|
18
18
|
* → parseArgs — parses shared CLI flags
|
|
19
|
+
* → resolveKandownBin — resolves the global kandown binary path for respawn
|
|
20
|
+
* → semverGt — compares two semver strings
|
|
21
|
+
* → checkForUpdate — non-blocking auto-updater with lock file and graceful fallback
|
|
19
22
|
* → cmdInit — installs `.kandown`
|
|
20
23
|
* → cmdUpdate — refreshes installed kandown.html
|
|
24
|
+
* → injectServerRoot — injects the CLI server root into single-file HTML
|
|
21
25
|
* → createServeServer — creates the local zero-dependency HTTP server
|
|
22
26
|
* → cmdServe — opens the web UI over localhost and launches the board TUI
|
|
23
27
|
* → main — dispatches CLI commands
|
|
@@ -68,50 +72,191 @@ function getCurrentVersion() {
|
|
|
68
72
|
} catch { return null; }
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
/**
|
|
76
|
+
* 📖 Resolves the kandown binary path for respawning after an update.
|
|
77
|
+
* Tries, in order: npm global bin → pnpm global bin → process.execPath fallback.
|
|
78
|
+
* @returns {string|null} Absolute path to the kandown binary, or null.
|
|
79
|
+
*/
|
|
80
|
+
function resolveKandownBin() {
|
|
81
|
+
try {
|
|
82
|
+
const npmBin = String(execSync('npm config get prefix 2>/dev/null', {
|
|
83
|
+
timeout: 3000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
84
|
+
})).trim();
|
|
85
|
+
if (existsSync(join(npmBin, 'bin', 'kandown'))) return join(npmBin, 'bin', 'kandown');
|
|
86
|
+
} catch { /* npm not available */ }
|
|
87
|
+
try {
|
|
88
|
+
const pnpmBin = String(execSync('pnpm config get prefix 2>/dev/null', {
|
|
89
|
+
timeout: 3000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
90
|
+
})).trim();
|
|
91
|
+
if (existsSync(join(pnpmBin, 'bin', 'kandown'))) return join(pnpmBin, 'bin', 'kandown');
|
|
92
|
+
} catch { /* pnpm not available */ }
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 📖 Compares two semver strings (major.minor.patch).
|
|
98
|
+
* @returns {number} 1 if a > b, -1 if a < b, 0 if equal.
|
|
99
|
+
*/
|
|
100
|
+
function semverGt(a, b) {
|
|
101
|
+
const pa = a.replace(/^v/, '').split('.').map(Number);
|
|
102
|
+
const pb = b.replace(/^v/, '').split('.').map(Number);
|
|
103
|
+
for (let i = 0; i < 3; i++) {
|
|
104
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
105
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
106
|
+
}
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 📖 Check npm for a newer version and auto-update if outdated.
|
|
112
|
+
*
|
|
113
|
+
* Design principles:
|
|
114
|
+
* - 🚀 Non-blocking: spawns npm check as a background child process.
|
|
115
|
+
* - 🔒 Lock file: prevents concurrent update races when multiple kandown
|
|
116
|
+
* instances start simultaneously.
|
|
117
|
+
* - 🛡️ Resilient: if the update fails for any reason (network, permissions,
|
|
118
|
+
* npm registry downtime), the current version continues normally.
|
|
119
|
+
* - 🔄 Respawn: after a successful update, re-spawns the CLI with the same
|
|
120
|
+
* arguments so the user gets the new version immediately.
|
|
121
|
+
* - 📦 Package manager agnostic: tries npm, then pnpm, for both the update
|
|
122
|
+
* and the binary resolution.
|
|
123
|
+
*
|
|
124
|
+
* Only activates when running from an installed npm package
|
|
125
|
+
* (not local dev source, where `src/` exists in PKG_ROOT).
|
|
126
|
+
*/
|
|
75
127
|
async function checkForUpdate(argv = process.argv) {
|
|
76
|
-
|
|
128
|
+
// 📖 Local dev — skip entirely
|
|
129
|
+
if (existsSync(join(PKG_ROOT, 'src'))) return;
|
|
130
|
+
|
|
77
131
|
const current = getCurrentVersion();
|
|
78
132
|
if (!current) return;
|
|
133
|
+
|
|
134
|
+
// 📖 Skip if a lock file exists — another kandown instance is already updating.
|
|
135
|
+
// The lock auto-expires after 60 seconds to handle stale locks from crashed processes.
|
|
136
|
+
const lockFile = join(PKG_ROOT, '.update.lock');
|
|
137
|
+
const now = Date.now();
|
|
79
138
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
139
|
+
if (existsSync(lockFile)) {
|
|
140
|
+
const lockAge = now - statSync(lockFile).mtimeMs;
|
|
141
|
+
if (lockAge < 60_000) return; // another process is handling the update
|
|
142
|
+
unlinkSync(lockFile); // stale lock — remove it
|
|
143
|
+
}
|
|
144
|
+
} catch { /* ignore lock errors */ }
|
|
145
|
+
|
|
146
|
+
// 📖 Step 1: Check latest version on npm registry (non-blocking).
|
|
147
|
+
// We use `npm view` in a spawned child process with a short timeout.
|
|
148
|
+
const latest = await new Promise((resolve) => {
|
|
149
|
+
const child = spawn('npm', ['view', 'kandown', 'version'], {
|
|
150
|
+
timeout: 6000,
|
|
151
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
152
|
+
env: { ...process.env },
|
|
153
|
+
// 📖 Detach so we can kill cleanly on timeout
|
|
154
|
+
detached: false,
|
|
155
|
+
});
|
|
156
|
+
let stdout = '';
|
|
157
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
158
|
+
child.stderr.on('data', () => {}); // silence stderr
|
|
159
|
+
child.on('error', () => resolve(null));
|
|
160
|
+
child.on('close', (code) => {
|
|
161
|
+
if (code !== 0) return resolve(null);
|
|
162
|
+
const v = stdout.trim().replace(/^"|"$/g, '');
|
|
163
|
+
resolve(v || null);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
85
166
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
167
|
+
if (!latest || semverGt(current, latest) >= 0) return; // up to date or offline
|
|
168
|
+
|
|
169
|
+
log('');
|
|
170
|
+
log(`${c.yellow}⚡ Update available:${c.reset} kandown ${c.dim}${current}${c.reset} → ${c.green}${latest}${c.reset}`);
|
|
171
|
+
info('Auto-updating…');
|
|
172
|
+
|
|
173
|
+
// 📖 Step 2: Create lock file to prevent concurrent updates.
|
|
174
|
+
try { writeFileSync(lockFile, `${process.pid}\n${now}`, 'utf8'); } catch { /* ignore */ }
|
|
175
|
+
|
|
176
|
+
// 📖 Step 3: Run the update via npm or pnpm.
|
|
177
|
+
// Try npm first, fall back to pnpm.
|
|
178
|
+
const updateOk = await new Promise((resolve) => {
|
|
179
|
+
const tryInstall = (cmd) => {
|
|
180
|
+
return new Promise((res) => {
|
|
181
|
+
const child = spawn(cmd, ['install', '-g', 'kandown'], {
|
|
182
|
+
timeout: 45000,
|
|
183
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
184
|
+
env: { ...process.env },
|
|
185
|
+
detached: false,
|
|
186
|
+
});
|
|
187
|
+
child.stderr.on('data', () => {}); // silence npm noise
|
|
188
|
+
child.stdout.on('data', () => {});
|
|
189
|
+
child.on('error', () => res(false));
|
|
190
|
+
child.on('close', (code) => res(code === 0));
|
|
90
191
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
192
|
+
};
|
|
193
|
+
tryInstall('npm').then((ok) => {
|
|
194
|
+
if (ok) resolve(true);
|
|
195
|
+
else tryInstall('pnpm').then(resolve);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// 📖 Clean up lock file regardless of outcome.
|
|
200
|
+
try { if (existsSync(lockFile)) unlinkSync(lockFile); } catch { /* ignore */ }
|
|
201
|
+
|
|
202
|
+
if (!updateOk) {
|
|
203
|
+
warn('Auto-update failed — continuing with current version');
|
|
204
|
+
log(` Run ${c.cyan}npm install -g kandown${c.reset} to upgrade manually`);
|
|
205
|
+
log('');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 📖 Step 4: Verify the update actually landed.
|
|
210
|
+
const postVersion = await new Promise((resolve) => {
|
|
211
|
+
const child = spawn('npm', ['view', 'kandown', 'version'], {
|
|
212
|
+
timeout: 5000,
|
|
213
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
214
|
+
detached: false,
|
|
215
|
+
});
|
|
216
|
+
let stdout = '';
|
|
217
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
218
|
+
child.stderr.on('data', () => {});
|
|
219
|
+
child.on('error', () => resolve(null));
|
|
220
|
+
child.on('close', (code) => {
|
|
221
|
+
if (code !== 0) return resolve(null);
|
|
222
|
+
resolve(stdout.trim().replace(/^"|"$/g, '') || null);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (!postVersion || semverGt(postVersion, latest) < 0) {
|
|
227
|
+
// Install claimed success but version didn't change — probably a permissions issue.
|
|
228
|
+
warn('Update did not apply — continuing with current version');
|
|
229
|
+
log(` Run ${c.cyan}npm install -g kandown${c.reset} to upgrade manually`);
|
|
230
|
+
log('');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
success(`Updated to v${postVersion} — restarting…`);
|
|
235
|
+
log('');
|
|
236
|
+
|
|
237
|
+
// 📖 Step 5: Respawn with the new version.
|
|
238
|
+
// Pass --no-update-check to the child so it doesn't try to update again.
|
|
239
|
+
const bin = resolveKandownBin();
|
|
240
|
+
const childArgs = ['--no-update-check', ...argv.slice(2)];
|
|
241
|
+
|
|
242
|
+
if (bin) {
|
|
243
|
+
const child = spawn(bin, childArgs, {
|
|
244
|
+
detached: true,
|
|
245
|
+
stdio: 'inherit',
|
|
246
|
+
env: { ...process.env },
|
|
247
|
+
});
|
|
248
|
+
child.unref();
|
|
249
|
+
} else {
|
|
250
|
+
// 📖 Fallback: re-use the current binary path (works for npx).
|
|
251
|
+
const child = spawn(process.argv[0], [process.argv[1], ...childArgs], {
|
|
252
|
+
detached: true,
|
|
253
|
+
stdio: 'inherit',
|
|
254
|
+
env: { ...process.env },
|
|
255
|
+
});
|
|
256
|
+
child.unref();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
process.exit(0);
|
|
115
260
|
}
|
|
116
261
|
|
|
117
262
|
const c = {
|
|
@@ -333,6 +478,11 @@ function cmdInit(rawArgs) {
|
|
|
333
478
|
const kandownPath = args.path;
|
|
334
479
|
const kandownDir = resolve(cwd, kandownPath);
|
|
335
480
|
|
|
481
|
+
if (existsSync(join(cwd, '.kandown', 'board.md'))) {
|
|
482
|
+
err(`Kandown already initialized in this directory. To reinitialize, remove the .kandown folder first.`);
|
|
483
|
+
process.exit(1);
|
|
484
|
+
}
|
|
485
|
+
|
|
336
486
|
log('');
|
|
337
487
|
info(`Installing kandown in ${c.bold}${kandownPath}/${c.reset}`);
|
|
338
488
|
log('');
|
|
@@ -549,6 +699,22 @@ function deleteTask(res, kandownDir, id) {
|
|
|
549
699
|
}
|
|
550
700
|
}
|
|
551
701
|
|
|
702
|
+
/**
|
|
703
|
+
* 📖 The single-file Vite bundle can contain literal strings such as
|
|
704
|
+
* `</head>` from HTML parser libraries. Use the last closing head tag so the
|
|
705
|
+
* CLI does not inject server-mode globals into bundled JavaScript text.
|
|
706
|
+
*/
|
|
707
|
+
function injectServerRoot(html, kandownDir) {
|
|
708
|
+
const marker = '</head>';
|
|
709
|
+
const markerIndex = html.toLowerCase().lastIndexOf(marker);
|
|
710
|
+
const safeRoot = JSON.stringify(kandownDir).replace(/</g, '\\u003c');
|
|
711
|
+
const script = `<script>window.__KANDOWN_ROOT__ = ${safeRoot};</script>\n`;
|
|
712
|
+
|
|
713
|
+
if (markerIndex === -1) return script + html;
|
|
714
|
+
|
|
715
|
+
return html.slice(0, markerIndex) + script + html.slice(markerIndex);
|
|
716
|
+
}
|
|
717
|
+
|
|
552
718
|
function handleApi(req, res, url, kandownDir) {
|
|
553
719
|
const parts = url.pathname.replace('/api/', '').split('/');
|
|
554
720
|
const resource = parts[0];
|
|
@@ -583,10 +749,7 @@ function serveApp(res, kandownDir) {
|
|
|
583
749
|
|
|
584
750
|
try {
|
|
585
751
|
const html = readFileSync(htmlPath, 'utf8');
|
|
586
|
-
const injected = html
|
|
587
|
-
'</head>',
|
|
588
|
-
`<script>window.__KANDOWN_ROOT__ = ${JSON.stringify(kandownDir)};</script>\n</head>`,
|
|
589
|
-
);
|
|
752
|
+
const injected = injectServerRoot(html, kandownDir);
|
|
590
753
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
591
754
|
res.end(injected);
|
|
592
755
|
} catch (e) {
|
|
@@ -745,7 +908,8 @@ async function cmdTui(screen, rawArgs) {
|
|
|
745
908
|
}
|
|
746
909
|
}
|
|
747
910
|
|
|
748
|
-
const
|
|
911
|
+
const rawArgs = process.argv.slice(2).filter((a) => a !== '--no-update-check');
|
|
912
|
+
const [cmd, ...rest] = rawArgs;
|
|
749
913
|
|
|
750
914
|
// 📖 Handle --version / -v before any command logic
|
|
751
915
|
if (cmd === '--version' || cmd === '-v') {
|
|
@@ -754,9 +918,13 @@ if (cmd === '--version' || cmd === '-v') {
|
|
|
754
918
|
process.exit(0);
|
|
755
919
|
}
|
|
756
920
|
|
|
921
|
+
// 📖 Skip auto-update if this is a respawned child after an update.
|
|
922
|
+
// The parent passes --no-update-check to prevent an infinite update loop.
|
|
923
|
+
const skipUpdate = process.argv.slice(2).includes('--no-update-check');
|
|
924
|
+
|
|
757
925
|
// 📖 Auto-update check runs before EVERY command (except --version).
|
|
758
926
|
// Uses a short timeout so startup is not noticeably slower.
|
|
759
|
-
await checkForUpdate(
|
|
927
|
+
if (!skipUpdate) await checkForUpdate(process.argv);
|
|
760
928
|
|
|
761
929
|
switch (cmd) {
|
|
762
930
|
case 'init':
|