kandown 0.32.0 → 0.32.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/CHANGELOG.md +6 -0
- package/bin/kandown.js +880 -3355
- package/bin/tui.js +1 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
package/bin/kandown.js
CHANGED
|
@@ -1,3555 +1,1080 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* @file Kandown CLI entrypoint
|
|
4
|
-
* @description Implements `kandown`, `kandown board`, `kandown init`,
|
|
5
|
-
* `kandown update`, and `kandown settings` for serving the local web UI,
|
|
6
|
-
* launching the terminal board, installing templates, and managing project
|
|
7
|
-
* configuration.
|
|
8
|
-
*
|
|
9
|
-
* 📖 The CLI is intentionally dependency-light: it copies built artifacts and
|
|
10
|
-
* markdown templates, then wires agent docs into the host project when possible.
|
|
11
|
-
*
|
|
12
|
-
* @functions
|
|
13
|
-
* → help — prints CLI usage
|
|
14
|
-
* → copyRecursive — copies template directories
|
|
15
|
-
* → findAgentsFile — finds existing AI-agent instruction files
|
|
16
|
-
* → appendAgentReference — injects a Kandown task-management reference
|
|
17
|
-
* → createAgentsFileIfMissing — creates AGENTS.md when none exists
|
|
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
|
|
22
|
-
* → getProjectRoot — returns the project root (parent of .kandown/)
|
|
23
|
-
* → getTasksDir — returns the project-root tasks/ path
|
|
24
|
-
* → migrateTasksToTopLevel — silently moves legacy .kandown/tasks/ → ./tasks/
|
|
25
|
-
* → cmdInit — installs `.kandown` and top-level `tasks/`
|
|
26
|
-
* → cmdUpdate — refreshes installed kandown.html
|
|
27
|
-
* → injectServerRoot — injects the CLI server root into single-file HTML
|
|
28
|
-
* → createServeServer — creates the local zero-dependency HTTP server
|
|
29
|
-
* → readDaemonMetadata — reads per-project daemon status metadata
|
|
30
|
-
* → startDaemon — starts/reconnects the per-project web daemon
|
|
31
|
-
* → stopDaemon — stops the per-project web daemon
|
|
32
|
-
* → cmdDaemon — daemon lifecycle command router
|
|
33
|
-
* → cmdServe — starts/reconnects the daemon, opens web UI, and launches the board TUI
|
|
34
|
-
* → main — dispatches CLI commands
|
|
35
|
-
*
|
|
36
|
-
* @exports none
|
|
37
|
-
*/
|
|
38
|
-
/* eslint-disable no-console */
|
|
39
|
-
// 📖 DEV=false prevents Ink from loading react-devtools-core (CJS-only, breaks ESM).
|
|
40
|
-
// Must be set BEFORE any imports because ESM hoists all import statements.
|
|
41
|
-
process.env.DEV = 'false';
|
|
42
|
-
// 📖 Polyfill browser globals that some bundled modules expect.
|
|
43
|
-
if (typeof globalThis.self === 'undefined') Object.defineProperty(globalThis, 'self', { value: globalThis });
|
|
44
|
-
if (typeof globalThis.window === 'undefined') Object.defineProperty(globalThis, 'window', { value: globalThis });
|
|
45
|
-
// 📖 Make require() available in this ESM module so bundled __require() shims work.
|
|
46
|
-
// tsup's __require checks `typeof require !== "undefined"` — this makes it truthy.
|
|
47
|
-
import { createRequire } from 'node:module';
|
|
48
|
-
globalThis.require = createRequire(import.meta.url);
|
|
49
2
|
|
|
50
|
-
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
55
|
-
existsSync,
|
|
56
|
-
mkdirSync,
|
|
57
|
-
copyFileSync,
|
|
58
|
-
readFileSync,
|
|
59
|
-
writeFileSync,
|
|
60
|
-
readdirSync,
|
|
61
|
-
statSync,
|
|
62
|
-
unlinkSync,
|
|
63
|
-
renameSync,
|
|
64
|
-
rmSync,
|
|
65
|
-
openSync,
|
|
66
|
-
writeSync,
|
|
67
|
-
closeSync,
|
|
68
|
-
} from 'node:fs';
|
|
69
|
-
import { spawn, execSync } from 'node:child_process';
|
|
70
|
-
import { createConnection } from 'node:net';
|
|
71
|
-
import { randomBytes } from 'node:crypto';
|
|
72
|
-
import { watch as watchFs } from 'chokidar';
|
|
3
|
+
// src/cli/cli.ts
|
|
4
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, copyFileSync } from "fs";
|
|
5
|
+
import { join as join5, resolve as resolve2, basename } from "path";
|
|
6
|
+
import { spawn as spawn3 } from "child_process";
|
|
73
7
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
80
|
-
console.error(`\x1b[31m✗\x1b[0m kandown ${kind}: ${msg}`);
|
|
81
|
-
if (process.env.KANDOWN_DEBUG && e instanceof Error) console.error(e.stack);
|
|
82
|
-
if (process.env.KANDOWN_DAEMON !== '1') process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
process.on('uncaughtException', (e) => handleFatal('crashed', e));
|
|
85
|
-
process.on('unhandledRejection', (e) => handleFatal('internal error', e));
|
|
86
|
-
|
|
87
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
88
|
-
const __dirname = dirname(__filename);
|
|
89
|
-
const PKG_ROOT = resolve(__dirname, '..');
|
|
90
|
-
// 📖 Default localhost range for the zero-config `kandown` web UI server.
|
|
91
|
-
// 📖 Single source of truth for the daemon port range. Each kandown project
|
|
92
|
-
// gets its own daemon on the first free port in this range, so multiple
|
|
93
|
-
// projects can run in parallel (A=2048, B=2050, C=2051, ...).
|
|
94
|
-
const START_PORT_RANGE = 2048;
|
|
95
|
-
const END_PORT_RANGE = 2150;
|
|
96
|
-
const DAEMON_FILE = 'daemon.json';
|
|
8
|
+
// src/cli/lib/updater.ts
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, statSync } from "fs";
|
|
10
|
+
import { join, resolve } from "path";
|
|
11
|
+
import { spawn, execSync } from "child_process";
|
|
12
|
+
import { homedir } from "os";
|
|
97
13
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* These are reserved for well-known services (NFS, ssh, smtp, X11, ...).
|
|
101
|
-
* Browsers block navigation to them with no recourse, so a daemon listening
|
|
102
|
-
* on one of these appears dead in the browser even though it serves fine via
|
|
103
|
-
* curl. We MUST skip them when allocating ports. The most common victim in
|
|
104
|
-
* our 2048+ range is 2049 (NFS), which silently broke the 2nd concurrent
|
|
105
|
-
* kandown project. Sourced from Chromium's net/base/port_util.cc restricted
|
|
106
|
-
* ports list (stable, updated rarely).
|
|
107
|
-
*/
|
|
108
|
-
const BROWSER_UNSAFE_PORTS = new Set([
|
|
109
|
-
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77,
|
|
110
|
-
79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123,
|
|
111
|
-
135, 139, 143, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
|
|
112
|
-
540, 548, 554, 556, 563, 587, 601, 636, 993, 995, 1720, 1723, 2049, 3659,
|
|
113
|
-
4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080,
|
|
114
|
-
]);
|
|
115
|
-
function isBrowserUnsafePort(port) {
|
|
116
|
-
return BROWSER_UNSAFE_PORTS.has(port);
|
|
117
|
-
}
|
|
14
|
+
// src/lib/version.ts
|
|
15
|
+
var KANDOWN_VERSION = "0.32.1";
|
|
118
16
|
|
|
119
|
-
//
|
|
17
|
+
// src/cli/lib/updater.ts
|
|
18
|
+
var PKG_ROOT = resolve(import.meta.url ? new URL("../../..", import.meta.url).pathname : process.cwd());
|
|
19
|
+
var UPDATE_CHECK_CACHE = join(PKG_ROOT, ".update-check.json");
|
|
20
|
+
var UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1e3;
|
|
120
21
|
function getCurrentVersion() {
|
|
121
22
|
try {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
23
|
+
const pkgPath = join(PKG_ROOT, "package.json");
|
|
24
|
+
if (existsSync(pkgPath)) {
|
|
25
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
26
|
+
if (pkg.version) return pkg.version;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
return KANDOWN_VERSION || "0.32.0";
|
|
31
|
+
}
|
|
32
|
+
function semverGt(a, b) {
|
|
33
|
+
const parse = (v) => {
|
|
34
|
+
const [core, ...pre] = String(v).replace(/^v/, "").split("-");
|
|
35
|
+
return { nums: core.split(".").map((n) => Number(n) || 0), pre: pre.length > 0 };
|
|
36
|
+
};
|
|
37
|
+
const pa = parse(a);
|
|
38
|
+
const pb = parse(b);
|
|
39
|
+
for (let i = 0; i < 3; i++) {
|
|
40
|
+
if ((pa.nums[i] || 0) > (pb.nums[i] || 0)) return 1;
|
|
41
|
+
if ((pa.nums[i] || 0) < (pb.nums[i] || 0)) return -1;
|
|
42
|
+
}
|
|
43
|
+
if (!pa.pre && pb.pre) return 1;
|
|
44
|
+
if (pa.pre && !pb.pre) return -1;
|
|
45
|
+
return 0;
|
|
125
46
|
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* 📖 Resolves the kandown binary path for respawning after an update.
|
|
129
|
-
* Tries, in order: npm global bin → pnpm global bin → process.execPath fallback.
|
|
130
|
-
* @returns {string|null} Absolute path to the kandown binary, or null.
|
|
131
|
-
*/
|
|
132
47
|
function resolveKandownBin() {
|
|
133
48
|
try {
|
|
134
|
-
const whichBin = String(execSync(
|
|
135
|
-
timeout:
|
|
49
|
+
const whichBin = String(execSync("which kandown 2>/dev/null || command -v kandown 2>/dev/null", {
|
|
50
|
+
timeout: 3e3,
|
|
51
|
+
encoding: "utf8",
|
|
52
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
136
53
|
})).trim();
|
|
137
54
|
if (whichBin && existsSync(whichBin)) return whichBin;
|
|
138
|
-
} catch {
|
|
139
|
-
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
const localBin = join(homedir(), ".local", "bin", "kandown");
|
|
140
58
|
if (existsSync(localBin)) return localBin;
|
|
141
59
|
try {
|
|
142
|
-
const npmBin = String(execSync(
|
|
143
|
-
timeout:
|
|
60
|
+
const npmBin = String(execSync("npm config get prefix 2>/dev/null", {
|
|
61
|
+
timeout: 3e3,
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
144
64
|
})).trim();
|
|
145
|
-
if (existsSync(join(npmBin,
|
|
146
|
-
if (existsSync(join(npmBin,
|
|
147
|
-
} catch {
|
|
65
|
+
if (existsSync(join(npmBin, "bin", "kandown"))) return join(npmBin, "bin", "kandown");
|
|
66
|
+
if (existsSync(join(npmBin, "kandown"))) return join(npmBin, "kandown");
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
148
69
|
try {
|
|
149
|
-
const pnpmBin = String(execSync(
|
|
150
|
-
timeout:
|
|
70
|
+
const pnpmBin = String(execSync("pnpm config get prefix 2>/dev/null", {
|
|
71
|
+
timeout: 3e3,
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
151
74
|
})).trim();
|
|
152
|
-
if (existsSync(join(pnpmBin,
|
|
153
|
-
if (existsSync(join(pnpmBin,
|
|
154
|
-
} catch {
|
|
75
|
+
if (existsSync(join(pnpmBin, "bin", "kandown"))) return join(pnpmBin, "bin", "kandown");
|
|
76
|
+
if (existsSync(join(pnpmBin, "kandown"))) return join(pnpmBin, "kandown");
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
155
79
|
return null;
|
|
156
80
|
}
|
|
157
|
-
|
|
158
81
|
async function readInstalledKandownVersion(targetVersion) {
|
|
159
82
|
const localVersion = getCurrentVersion();
|
|
160
83
|
if (localVersion && semverGt(localVersion, targetVersion) >= 0) return localVersion;
|
|
161
|
-
|
|
162
84
|
const bin = resolveKandownBin();
|
|
163
85
|
if (!bin) return localVersion;
|
|
164
|
-
|
|
165
86
|
return await new Promise((resolveVersion) => {
|
|
166
|
-
const child = spawn(bin, [
|
|
167
|
-
timeout:
|
|
168
|
-
stdio: [
|
|
169
|
-
env: { ...process.env, KANDOWN_NO_UPDATE:
|
|
170
|
-
detached: false
|
|
87
|
+
const child = spawn(bin, ["--version"], {
|
|
88
|
+
timeout: 5e3,
|
|
89
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
90
|
+
env: { ...process.env, KANDOWN_NO_UPDATE: "1" },
|
|
91
|
+
detached: false
|
|
92
|
+
});
|
|
93
|
+
let stdout = "";
|
|
94
|
+
child.stdout.on("data", (d) => {
|
|
95
|
+
stdout += d;
|
|
171
96
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
child.
|
|
175
|
-
child.on(
|
|
176
|
-
child.on('close', (code) => {
|
|
97
|
+
child.stderr.on("data", () => {
|
|
98
|
+
});
|
|
99
|
+
child.on("error", () => resolveVersion(localVersion));
|
|
100
|
+
child.on("close", (code) => {
|
|
177
101
|
if (code !== 0) return resolveVersion(localVersion);
|
|
178
102
|
const match = stdout.trim().match(/v?(\d+\.\d+\.\d+(?:-[\w.-]+)?)/);
|
|
179
103
|
resolveVersion(match ? match[1] : localVersion);
|
|
180
104
|
});
|
|
181
105
|
});
|
|
182
106
|
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* 📖 Compares two semver strings (major.minor.patch, optional -prerelease).
|
|
186
|
-
* Prerelease-safe: "0.18.0-beta.1" no longer parses as NaN — the numeric
|
|
187
|
-
* triple is compared first, and on a tie a release outranks a prerelease.
|
|
188
|
-
* @returns {number} 1 if a > b, -1 if a < b, 0 if equal.
|
|
189
|
-
*/
|
|
190
|
-
function semverGt(a, b) {
|
|
191
|
-
const parse = (v) => {
|
|
192
|
-
const [core, ...pre] = String(v).replace(/^v/, '').split('-');
|
|
193
|
-
return { nums: core.split('.').map(n => Number(n) || 0), pre: pre.length > 0 };
|
|
194
|
-
};
|
|
195
|
-
const pa = parse(a);
|
|
196
|
-
const pb = parse(b);
|
|
197
|
-
for (let i = 0; i < 3; i++) {
|
|
198
|
-
if ((pa.nums[i] || 0) > (pb.nums[i] || 0)) return 1;
|
|
199
|
-
if ((pa.nums[i] || 0) < (pb.nums[i] || 0)) return -1;
|
|
200
|
-
}
|
|
201
|
-
if (!pa.pre && pb.pre) return 1;
|
|
202
|
-
if (pa.pre && !pb.pre) return -1;
|
|
203
|
-
return 0;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* 📖 Update-check throttle: remembers the last successful registry check in a
|
|
208
|
-
* small cache file next to the package. The network check runs at most once
|
|
209
|
-
* per 24h — `kandown` stays fast (and fully offline-silent) the rest of the
|
|
210
|
-
* time. Cache write failures are ignored (read-only installs just check more
|
|
211
|
-
* often, which is the pre-throttle behavior).
|
|
212
|
-
*/
|
|
213
|
-
const UPDATE_CHECK_CACHE = join(PKG_ROOT, '.update-check.json');
|
|
214
|
-
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
215
|
-
|
|
216
107
|
function updateCheckedRecently() {
|
|
217
108
|
try {
|
|
218
|
-
const raw = JSON.parse(readFileSync(UPDATE_CHECK_CACHE,
|
|
109
|
+
const raw = JSON.parse(readFileSync(UPDATE_CHECK_CACHE, "utf8"));
|
|
219
110
|
return Number.isFinite(raw?.lastCheck) && Date.now() - raw.lastCheck < UPDATE_CHECK_INTERVAL_MS;
|
|
220
111
|
} catch {
|
|
221
112
|
return false;
|
|
222
113
|
}
|
|
223
114
|
}
|
|
224
|
-
|
|
225
115
|
function rememberUpdateCheck() {
|
|
226
116
|
try {
|
|
227
|
-
writeFileSync(UPDATE_CHECK_CACHE, JSON.stringify({ lastCheck: Date.now() }),
|
|
228
|
-
} catch {
|
|
117
|
+
writeFileSync(UPDATE_CHECK_CACHE, JSON.stringify({ lastCheck: Date.now() }), "utf8");
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
229
120
|
}
|
|
230
121
|
async function performGlobalPackageUpdate(packageSpec) {
|
|
231
122
|
const cleanEnv = { ...process.env };
|
|
232
123
|
for (const k of Object.keys(cleanEnv)) {
|
|
233
|
-
if (k.startsWith(
|
|
124
|
+
if (k.startsWith("npm_config_") || k.startsWith("npm_") || k === "INIT_CWD") {
|
|
234
125
|
delete cleanEnv[k];
|
|
235
126
|
}
|
|
236
127
|
}
|
|
237
|
-
|
|
238
128
|
const tryPkgCmd = (cmd, args) => {
|
|
239
129
|
return new Promise((res) => {
|
|
240
130
|
const child = spawn(cmd, args, {
|
|
241
|
-
timeout:
|
|
242
|
-
stdio: [
|
|
131
|
+
timeout: 6e4,
|
|
132
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
243
133
|
env: cleanEnv,
|
|
244
|
-
detached: false
|
|
134
|
+
detached: false
|
|
135
|
+
});
|
|
136
|
+
child.stderr.on("data", () => {
|
|
245
137
|
});
|
|
246
|
-
child.
|
|
247
|
-
|
|
248
|
-
child.on(
|
|
249
|
-
child.on(
|
|
138
|
+
child.stdout.on("data", () => {
|
|
139
|
+
});
|
|
140
|
+
child.on("error", () => res(false));
|
|
141
|
+
child.on("close", (code) => res(code === 0));
|
|
250
142
|
});
|
|
251
143
|
};
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
144
|
+
const currentBin = resolveKandownBin() || "";
|
|
145
|
+
const isPnpmInstall = currentBin.includes("pnpm");
|
|
146
|
+
if (isPnpmInstall) {
|
|
147
|
+
if (await tryPkgCmd("pnpm", ["add", "-g", packageSpec])) return true;
|
|
148
|
+
if (await tryPkgCmd("npm", ["install", "-g", packageSpec, "--force"])) return true;
|
|
149
|
+
} else {
|
|
150
|
+
if (await tryPkgCmd("npm", ["install", "-g", packageSpec, "--force"])) return true;
|
|
151
|
+
if (await tryPkgCmd("pnpm", ["add", "-g", packageSpec])) return true;
|
|
152
|
+
}
|
|
153
|
+
if (await tryPkgCmd("yarn", ["global", "add", packageSpec])) return true;
|
|
154
|
+
return await tryPkgCmd("bun", ["add", "-g", packageSpec]);
|
|
257
155
|
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* 📖 Check npm for a newer version and auto-update if outdated.
|
|
261
|
-
*
|
|
262
|
-
* Design principles:
|
|
263
|
-
* - 🚀 Non-blocking: spawns npm check as a background child process.
|
|
264
|
-
* - 🔒 Lock file: prevents concurrent update races when multiple kandown
|
|
265
|
-
* instances start simultaneously.
|
|
266
|
-
* - 🛡️ Resilient: if the update fails for any reason (network, permissions,
|
|
267
|
-
* npm registry downtime), the current version continues normally.
|
|
268
|
-
* - 🔄 Respawn: after a successful update, re-spawns the CLI with the same
|
|
269
|
-
* arguments so the user gets the new version immediately.
|
|
270
|
-
* - 📦 Package manager agnostic: tries npm, then pnpm, for both the update
|
|
271
|
-
* and the binary resolution.
|
|
272
|
-
*
|
|
273
|
-
* Only activates when running from an installed npm package
|
|
274
|
-
* (not local dev source, where `src/` exists in PKG_ROOT).
|
|
275
|
-
*/
|
|
276
156
|
async function checkForUpdate(argv = process.argv) {
|
|
277
|
-
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
// 📖 Opt-out for CI / controlled environments.
|
|
281
|
-
if (process.env.KANDOWN_NO_UPDATE === '1') return;
|
|
282
|
-
|
|
283
|
-
// 📖 Script context (piped/captured output): never surprise-update — a
|
|
284
|
-
// respawn mid-pipeline would interleave output from two versions.
|
|
157
|
+
if (existsSync(join(PKG_ROOT, "src")) && !process.env.KANDOWN_TEST_UPDATE) return;
|
|
158
|
+
if (process.env.KANDOWN_NO_UPDATE === "1") return;
|
|
285
159
|
if (!process.stdout.isTTY) return;
|
|
286
|
-
|
|
287
|
-
// 📖 Throttle: the registry round-trip runs at most once per 24h, so the
|
|
288
|
-
// command stays instant (and silent offline) the rest of the time.
|
|
289
160
|
if (updateCheckedRecently()) return;
|
|
290
|
-
|
|
291
161
|
const current = getCurrentVersion();
|
|
292
162
|
if (!current) return;
|
|
293
|
-
|
|
294
|
-
// 📖 Skip if a lock file exists — another kandown instance is already updating.
|
|
295
|
-
// The lock auto-expires after 60 seconds to handle stale locks from crashed processes.
|
|
296
|
-
const lockFile = join(PKG_ROOT, '.update.lock');
|
|
163
|
+
const lockFile = join(PKG_ROOT, ".update.lock");
|
|
297
164
|
const now = Date.now();
|
|
298
165
|
try {
|
|
299
166
|
if (existsSync(lockFile)) {
|
|
300
167
|
const lockAge = now - statSync(lockFile).mtimeMs;
|
|
301
|
-
if (lockAge <
|
|
302
|
-
unlinkSync(lockFile);
|
|
168
|
+
if (lockAge < 6e4) return;
|
|
169
|
+
unlinkSync(lockFile);
|
|
303
170
|
}
|
|
304
|
-
} catch {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
timeout: 6000,
|
|
311
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
const latest = await new Promise((resolve3) => {
|
|
174
|
+
const child2 = spawn("npm", ["view", "kandown", "version"], {
|
|
175
|
+
timeout: 6e3,
|
|
176
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
312
177
|
env: { ...process.env },
|
|
313
|
-
|
|
314
|
-
|
|
178
|
+
detached: false
|
|
179
|
+
});
|
|
180
|
+
let stdout = "";
|
|
181
|
+
child2.stdout.on("data", (d) => {
|
|
182
|
+
stdout += d;
|
|
315
183
|
});
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
resolve(v || null);
|
|
184
|
+
child2.stderr.on("data", () => {
|
|
185
|
+
});
|
|
186
|
+
child2.on("error", () => resolve3(null));
|
|
187
|
+
child2.on("close", (code) => {
|
|
188
|
+
if (code !== 0) return resolve3(null);
|
|
189
|
+
const v = stdout.trim().replace(/^"|"$/g, "");
|
|
190
|
+
resolve3(v || null);
|
|
324
191
|
});
|
|
325
192
|
});
|
|
326
|
-
|
|
327
|
-
if (!latest) return; // offline / registry-down — retry on the next interactive run
|
|
328
|
-
|
|
193
|
+
if (!latest) return;
|
|
329
194
|
if (semverGt(current, latest) >= 0) {
|
|
330
|
-
// 📖 Up-to-date registry answers can be throttled. Failed update attempts
|
|
331
|
-
// below are intentionally NOT cached, otherwise one broken install would
|
|
332
|
-
// silence auto-update retries for 24h across every open project.
|
|
333
195
|
rememberUpdateCheck();
|
|
334
196
|
return;
|
|
335
197
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
// 📖 Step 3: Run the update via npm or pnpm with animated progress.
|
|
343
|
-
tuiProgress(`Updating to ${latest}…`, 25);
|
|
198
|
+
console.log(`\u26A1 Update available: kandown ${current} \u2192 ${latest}`);
|
|
199
|
+
try {
|
|
200
|
+
writeFileSync(lockFile, `${process.pid}
|
|
201
|
+
${now}`, "utf8");
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
344
204
|
const updateOk = await performGlobalPackageUpdate(`kandown@${latest}`);
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
205
|
+
try {
|
|
206
|
+
if (existsSync(lockFile)) unlinkSync(lockFile);
|
|
207
|
+
} catch {
|
|
208
|
+
}
|
|
349
209
|
if (!updateOk) {
|
|
350
|
-
|
|
351
|
-
log(` Run ${c.cyan}pnpm add -g kandown@latest${c.reset} or ${c.cyan}npm install -g kandown@latest${c.reset} to upgrade manually`);
|
|
352
|
-
log('');
|
|
210
|
+
console.log(`\u2717 Auto-update failed \u2014 continuing with current version`);
|
|
353
211
|
return;
|
|
354
212
|
}
|
|
355
|
-
|
|
356
|
-
// 📖 Step 4: Verify the installed CLI, not just the npm registry, because
|
|
357
|
-
// `npm view kandown version` only proves a version exists remotely.
|
|
358
213
|
const postVersion = await readInstalledKandownVersion(latest);
|
|
359
|
-
|
|
360
|
-
if (!postVersion || semverGt(postVersion, latest) < 0) {
|
|
361
|
-
tuiDone('✗', `${c.yellow}Update did not apply${c.reset} — continuing with current version`);
|
|
362
|
-
log(` Run ${c.cyan}npm install -g kandown${c.reset} to upgrade manually`);
|
|
363
|
-
log('');
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
214
|
+
if (!postVersion || semverGt(postVersion, latest) < 0) return;
|
|
367
215
|
rememberUpdateCheck();
|
|
368
|
-
|
|
369
|
-
// 📖 If the running package directory has been replaced in-place (normal
|
|
370
|
-
// global install), refresh every currently open project daemon, not only the
|
|
371
|
-
// project that triggered the update. This keeps two simultaneously open
|
|
372
|
-
// Kandown boards from getting stuck on different kandown.html versions.
|
|
373
|
-
const localVersionAfterInstall = getCurrentVersion();
|
|
374
|
-
if (localVersionAfterInstall && semverGt(localVersionAfterInstall, latest) >= 0) {
|
|
375
|
-
const refreshed = await refreshRunningProjectHtml();
|
|
376
|
-
if (refreshed > 0) info(`Refreshed ${refreshed} open project${refreshed === 1 ? '' : 's'}`);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
tuiDone('✓', `${c.green}Updated to v${postVersion}${c.reset} — restarting…`);
|
|
380
|
-
printVersionChangelog(postVersion);
|
|
381
|
-
printBreakingChangeNotices(current, postVersion);
|
|
382
|
-
log('');
|
|
383
|
-
|
|
384
|
-
// 📖 Step 5: Respawn with the new version.
|
|
385
|
-
// Pass --no-update-check to the child so it doesn't try to update again.
|
|
216
|
+
console.log(`\u2713 Updated to v${postVersion} \u2014 restarting\u2026`);
|
|
386
217
|
const bin = resolveKandownBin();
|
|
387
|
-
const childArgs = [
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
});
|
|
395
|
-
child.unref();
|
|
396
|
-
} else {
|
|
397
|
-
// 📖 Fallback: re-use the current binary path (works for npx).
|
|
398
|
-
const child = spawn(process.argv[0], [process.argv[1], ...childArgs], {
|
|
399
|
-
detached: true,
|
|
400
|
-
stdio: 'inherit',
|
|
401
|
-
env: { ...process.env },
|
|
402
|
-
});
|
|
403
|
-
child.unref();
|
|
404
|
-
}
|
|
405
|
-
|
|
218
|
+
const childArgs = ["--no-update-check", ...argv.slice(2)];
|
|
219
|
+
const child = spawn(bin || process.argv[0], bin ? childArgs : [process.argv[1], ...childArgs], {
|
|
220
|
+
detached: true,
|
|
221
|
+
stdio: "inherit",
|
|
222
|
+
env: { ...process.env }
|
|
223
|
+
});
|
|
224
|
+
child.unref();
|
|
406
225
|
process.exit(0);
|
|
407
226
|
}
|
|
408
227
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
blue: '\x1b[34m',
|
|
416
|
-
red: '\x1b[31m',
|
|
417
|
-
cyan: '\x1b[36m',
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* 📖 One-time migration notices shown right after a successful auto-update,
|
|
422
|
-
* when the jump crosses a release that changed user-facing behavior (i.e.
|
|
423
|
-
* `fromVersion < notice.version <= toVersion`). Keeps upgraders from being
|
|
424
|
-
* silently surprised by a removed command or a changed convention — full
|
|
425
|
-
* details always live in CHANGELOG.md. Add an entry here for any future
|
|
426
|
-
* breaking release; harmless no-op for anyone already past that version.
|
|
427
|
-
*/
|
|
428
|
-
const BREAKING_CHANGE_NOTICES = [
|
|
429
|
-
{
|
|
430
|
-
version: '0.18.0',
|
|
431
|
-
lines: [
|
|
432
|
-
`${c.yellow}⚠ Breaking change in v0.18.0:${c.reset}`,
|
|
433
|
-
` ${c.dim}•${c.reset} ${c.cyan}kandown shell <cmd>${c.reset} is gone — commands are now top-level: ${c.cyan}kandown list/show/create/move/assign/commit${c.reset}`,
|
|
434
|
-
` ${c.dim}•${c.reset} ${c.cyan}kandown init${c.reset} now injects one line into AGENTS.md/CLAUDE.md pointing agents at ${c.cyan}kandown work${c.reset} — existing projects keep their old block until you re-run ${c.cyan}kandown init${c.reset}`,
|
|
435
|
-
` ${c.dim}•${c.reset} New: ${c.cyan}kandown work${c.reset} prints the agent rules + a live board digest — the recommended way to brief an AI agent`,
|
|
436
|
-
` ${c.dim}Full changelog:${c.reset} https://github.com/vava-nessa/kandown/blob/main/CHANGELOG.md`,
|
|
437
|
-
],
|
|
438
|
-
},
|
|
439
|
-
];
|
|
228
|
+
// src/cli/lib/board-reader.ts
|
|
229
|
+
import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, mkdirSync, unlinkSync as unlinkSync3 } from "fs";
|
|
230
|
+
import { dirname, join as join3 } from "path";
|
|
231
|
+
import { fileURLToPath } from "url";
|
|
232
|
+
import { homedir as homedir2 } from "os";
|
|
233
|
+
import { execFileSync } from "child_process";
|
|
440
234
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
235
|
+
// src/cli/lib/atomic-write.ts
|
|
236
|
+
import { renameSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
237
|
+
function atomicWriteFileSync(path, content) {
|
|
238
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
239
|
+
try {
|
|
240
|
+
writeFileSync2(tmp, content, "utf8");
|
|
241
|
+
renameSync(tmp, path);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
try {
|
|
244
|
+
unlinkSync2(tmp);
|
|
245
|
+
} catch {
|
|
446
246
|
}
|
|
247
|
+
throw e;
|
|
447
248
|
}
|
|
448
249
|
}
|
|
449
250
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
* DON'T go through checkForUpdate's own auto-update flow — a manual
|
|
453
|
-
* `npm install -g kandown`, pnpm/yarn/bun global installs, or any future
|
|
454
|
-
* package manager. Records the last version this install printed notices for
|
|
455
|
-
* in a small cache file next to the package; on the next interactive launch,
|
|
456
|
-
* any BREAKING_CHANGE_NOTICES entry crossed since then is shown once.
|
|
457
|
-
* TTY-only and skipped for scripted commands — never fires in a script/CI.
|
|
458
|
-
* First run after this mechanism ships has no prior cache, so it seeds
|
|
459
|
-
* silently rather than guessing; the checkForUpdate path covers that specific
|
|
460
|
-
* transition for auto-updaters, and every version from here on is covered.
|
|
461
|
-
*/
|
|
462
|
-
function getChangelogForVersion(version) {
|
|
463
|
-
const changelogPath = join(PKG_ROOT, 'CHANGELOG.md');
|
|
464
|
-
if (!existsSync(changelogPath)) return null;
|
|
465
|
-
try {
|
|
466
|
-
const text = readFileSync(changelogPath, 'utf8');
|
|
467
|
-
const escapedVer = version.replace(/\./g, '\\.');
|
|
468
|
-
const regex = new RegExp(`##\\s+${escapedVer}[\\s\\S]*?(?=\\n##\\s+|$)`, 'i');
|
|
469
|
-
const match = text.match(regex);
|
|
470
|
-
if (!match) return null;
|
|
471
|
-
return match[0].trim();
|
|
472
|
-
} catch {
|
|
473
|
-
return null;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
251
|
+
// src/lib/types.ts
|
|
252
|
+
var DEFAULT_COLUMNS = ["Backlog", "Todo", "In Progress", "Review", "Done"];
|
|
476
253
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (
|
|
488
|
-
|
|
254
|
+
// src/lib/parser.ts
|
|
255
|
+
function parseSimpleYaml(yaml) {
|
|
256
|
+
const obj = {};
|
|
257
|
+
if (!yaml || typeof yaml !== "string") return obj;
|
|
258
|
+
const lines = yaml.split("\n");
|
|
259
|
+
for (let i = 0; i < lines.length; i++) {
|
|
260
|
+
const line = lines[i] ?? "";
|
|
261
|
+
const m = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
|
|
262
|
+
if (!m) continue;
|
|
263
|
+
const key = m[1];
|
|
264
|
+
if (!key) continue;
|
|
265
|
+
let val = m[2]?.trim() ?? "";
|
|
266
|
+
if (val === "|") {
|
|
267
|
+
const block = [];
|
|
268
|
+
i++;
|
|
269
|
+
while (i < lines.length && (/^\s+/.test(lines[i] ?? "") || (lines[i] ?? "") === "")) {
|
|
270
|
+
block.push((lines[i] ?? "").replace(/^ /, ""));
|
|
271
|
+
i++;
|
|
272
|
+
}
|
|
273
|
+
i--;
|
|
274
|
+
obj[key] = block.join("\n").trimEnd();
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (typeof val !== "string") val = "";
|
|
278
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
279
|
+
const arr = val.slice(1, -1).split(",").map((s) => s && typeof s === "string" ? s.trim().replace(/^["']|["']$/g, "") : "").filter(Boolean);
|
|
280
|
+
obj[key] = arr;
|
|
489
281
|
} else {
|
|
490
|
-
|
|
282
|
+
obj[key] = typeof val === "string" ? val.replace(/^["']|["']$/g, "") : val;
|
|
491
283
|
}
|
|
492
284
|
}
|
|
493
|
-
|
|
285
|
+
return obj;
|
|
494
286
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
function checkVersionSeenNotices() {
|
|
499
|
-
if (!process.stdout.isTTY) return;
|
|
500
|
-
const current = getCurrentVersion();
|
|
501
|
-
if (!current) return;
|
|
502
|
-
|
|
503
|
-
let lastSeen = null;
|
|
504
|
-
try {
|
|
505
|
-
const raw = JSON.parse(readFileSync(VERSION_SEEN_CACHE, 'utf8'));
|
|
506
|
-
if (typeof raw?.lastSeen === 'string') lastSeen = raw.lastSeen;
|
|
507
|
-
} catch { /* first run, or corrupted cache — treat as unknown */ }
|
|
508
|
-
|
|
509
|
-
if (lastSeen !== current) {
|
|
510
|
-
printVersionChangelog(current);
|
|
511
|
-
if (lastSeen) printBreakingChangeNotices(lastSeen, current);
|
|
512
|
-
try { writeFileSync(VERSION_SEEN_CACHE, JSON.stringify({ lastSeen: current })); } catch { /* best-effort */ }
|
|
287
|
+
function parseTaskFile(md) {
|
|
288
|
+
if (!md || typeof md !== "string") {
|
|
289
|
+
return { frontmatter: { id: "", title: "" }, body: "" };
|
|
513
290
|
}
|
|
291
|
+
const lines = md.split("\n");
|
|
292
|
+
if (lines[0] && lines[0].trim() === "---") {
|
|
293
|
+
const fmLines = [];
|
|
294
|
+
let i = 1;
|
|
295
|
+
while (i < lines.length && lines[i].trim() !== "---") {
|
|
296
|
+
fmLines.push(lines[i]);
|
|
297
|
+
i++;
|
|
298
|
+
}
|
|
299
|
+
const body = lines.slice(i + 1).join("\n").trimStart();
|
|
300
|
+
const fm = parseSimpleYaml(fmLines.join("\n"));
|
|
301
|
+
return { frontmatter: fm, body };
|
|
302
|
+
}
|
|
303
|
+
return { frontmatter: { id: "", title: "" }, body: md };
|
|
304
|
+
}
|
|
305
|
+
function normalizeStatus(status) {
|
|
306
|
+
const value = typeof status === "string" ? status.trim() : "";
|
|
307
|
+
return value || "Backlog";
|
|
308
|
+
}
|
|
309
|
+
function normalizePriority(priority) {
|
|
310
|
+
if (typeof priority !== "string") return null;
|
|
311
|
+
const value = priority.toUpperCase();
|
|
312
|
+
return /^(P1|P2|P3|P4)$/.test(value) ? value : null;
|
|
313
|
+
}
|
|
314
|
+
function normalizeOwnerType(ownerType) {
|
|
315
|
+
if (typeof ownerType !== "string") return "";
|
|
316
|
+
const value = ownerType.toLowerCase();
|
|
317
|
+
return value === "human" || value === "ai" ? value : "";
|
|
318
|
+
}
|
|
319
|
+
function taskOrder(task) {
|
|
320
|
+
const value = task.frontmatter.order;
|
|
321
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
322
|
+
if (typeof value === "string" && value.trim()) {
|
|
323
|
+
const parsed = Number(value);
|
|
324
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
325
|
+
}
|
|
326
|
+
return Number.MAX_SAFE_INTEGER;
|
|
327
|
+
}
|
|
328
|
+
function taskToBoardTask(task) {
|
|
329
|
+
const { frontmatter, body } = task;
|
|
330
|
+
const { subtasks } = extractSubtasks(body);
|
|
331
|
+
const done = subtasks.filter((s) => s.done).length;
|
|
332
|
+
const total = subtasks.length;
|
|
333
|
+
const status = normalizeStatus(frontmatter.status);
|
|
334
|
+
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0) : [];
|
|
335
|
+
const { id: _id, title: _title, status: _status, order: _order, created: _created, archived: _archived, report: _report, ...metadata } = frontmatter;
|
|
336
|
+
return {
|
|
337
|
+
id: frontmatter.id || "",
|
|
338
|
+
title: frontmatter.title || frontmatter.id || "Untitled task",
|
|
339
|
+
checked: /done|termin|closed|complet/i.test(status),
|
|
340
|
+
tags,
|
|
341
|
+
assignee: typeof frontmatter.assignee === "string" && frontmatter.assignee ? frontmatter.assignee : null,
|
|
342
|
+
priority: normalizePriority(frontmatter.priority),
|
|
343
|
+
ownerType: normalizeOwnerType(frontmatter.ownerType),
|
|
344
|
+
progress: total > 0 ? { done, total } : null,
|
|
345
|
+
dependsOn: Array.isArray(frontmatter.depends_on) ? frontmatter.depends_on.filter((d) => typeof d === "string" && d.trim().length > 0) : [],
|
|
346
|
+
frontmatter: metadata
|
|
347
|
+
};
|
|
514
348
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
349
|
+
function buildColumnsFromTasks(tasks, configuredColumns = DEFAULT_COLUMNS) {
|
|
350
|
+
const columnNames = configuredColumns.length > 0 ? configuredColumns : DEFAULT_COLUMNS;
|
|
351
|
+
const columnsByName = /* @__PURE__ */ new Map();
|
|
352
|
+
const configured = columnNames.map((name) => ({ name, tasks: [] }));
|
|
353
|
+
for (const column of configured) columnsByName.set(column.name.toLowerCase(), column);
|
|
354
|
+
const unknownColumns = [];
|
|
355
|
+
const sortedTasks = [...tasks].filter((task) => Boolean(task.frontmatter.id)).filter((task) => !isArchived(task)).sort((a, b) => {
|
|
356
|
+
const byOrder = taskOrder(a) - taskOrder(b);
|
|
357
|
+
if (byOrder !== 0) return byOrder;
|
|
358
|
+
return a.frontmatter.id.localeCompare(b.frontmatter.id, void 0, { numeric: true });
|
|
359
|
+
});
|
|
360
|
+
for (const task of sortedTasks) {
|
|
361
|
+
const status = normalizeStatus(task.frontmatter.status);
|
|
362
|
+
let column = columnsByName.get(status.toLowerCase());
|
|
363
|
+
if (!column) {
|
|
364
|
+
column = { name: status, tasks: [] };
|
|
365
|
+
columnsByName.set(status.toLowerCase(), column);
|
|
366
|
+
unknownColumns.push(column);
|
|
367
|
+
}
|
|
368
|
+
column.tasks.push(taskToBoardTask(task));
|
|
369
|
+
}
|
|
370
|
+
return [...unknownColumns, ...configured];
|
|
371
|
+
}
|
|
372
|
+
function isArchived(task) {
|
|
373
|
+
return String(task.frontmatter.archived) === "true";
|
|
374
|
+
}
|
|
375
|
+
function extractSubtasks(body) {
|
|
376
|
+
const subtasks = [];
|
|
377
|
+
if (!body || typeof body !== "string") return { subtasks, bodyWithoutSubtasks: body ?? "" };
|
|
378
|
+
const lines = body.split("\n");
|
|
379
|
+
const kept = [];
|
|
380
|
+
let inSubtaskSection = false;
|
|
381
|
+
for (const line of lines) {
|
|
382
|
+
if (/^#{1,6}\s+(subtasks?|sous[- ]t[âa]ches?|crit[èe]res?)/i.test(line)) {
|
|
383
|
+
inSubtaskSection = true;
|
|
384
|
+
kept.push(line);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (/^#{1,6}\s+/.test(line) && inSubtaskSection) {
|
|
388
|
+
inSubtaskSection = false;
|
|
389
|
+
kept.push(line);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const m = line.match(/^\s*-\s+\[([ xX])\]\s+(.+)$/);
|
|
393
|
+
if (m && inSubtaskSection) {
|
|
394
|
+
const text = m[2]?.trim() ?? "";
|
|
395
|
+
subtasks.push({ done: (m[1]?.toLowerCase() ?? "") === "x", text });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const descMatch = line.match(/^\s*\[DESC\]\s*(.*)$/);
|
|
399
|
+
if (descMatch && subtasks.length > 0) {
|
|
400
|
+
subtasks[subtasks.length - 1].description = descMatch[1];
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const reportMatch = line.match(/^\s*\[REPORT\]\s*(.*)$/);
|
|
404
|
+
if (reportMatch && subtasks.length > 0) {
|
|
405
|
+
subtasks[subtasks.length - 1].report = reportMatch[1];
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const legacyDescMatch = line.match(/^\s+description:\s*(.+)$/);
|
|
409
|
+
if (legacyDescMatch && inSubtaskSection && subtasks.length > 0) {
|
|
410
|
+
subtasks[subtasks.length - 1].description = legacyDescMatch[1].trim();
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const legacyReportMatch = line.match(/^\s+report:\s*(.+)$/);
|
|
414
|
+
if (legacyReportMatch && inSubtaskSection && subtasks.length > 0) {
|
|
415
|
+
subtasks[subtasks.length - 1].report = legacyReportMatch[1].trim();
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
kept.push(line);
|
|
419
|
+
}
|
|
420
|
+
return { subtasks, bodyWithoutSubtasks: kept.join("\n") };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/lib/serializer.ts
|
|
424
|
+
function serializeTaskFile(frontmatter, body) {
|
|
425
|
+
const lines = ["---"];
|
|
426
|
+
if (frontmatter && typeof frontmatter === "object") {
|
|
427
|
+
for (const [k, v] of Object.entries(frontmatter)) {
|
|
428
|
+
if (v === null || v === void 0 || v === "") continue;
|
|
429
|
+
if (Array.isArray(v)) {
|
|
430
|
+
if (v.length === 0) continue;
|
|
431
|
+
lines.push(`${k}: [${v.join(", ")}]`);
|
|
432
|
+
} else if (typeof v === "string" && v.includes("\n")) {
|
|
433
|
+
lines.push(`${k}: |`);
|
|
434
|
+
lines.push(...v.split("\n").map((line) => line === "" ? "" : ` ${line}`));
|
|
435
|
+
} else if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
436
|
+
lines.push(`${k}: ${v}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
lines.push("---");
|
|
441
|
+
lines.push("");
|
|
442
|
+
lines.push((body ?? "").trim());
|
|
443
|
+
lines.push("");
|
|
444
|
+
return lines.join("\n");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/cli/lib/config.ts
|
|
448
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
449
|
+
import { join as join2 } from "path";
|
|
450
|
+
var DEFAULT_CONFIG = {
|
|
451
|
+
ui: { language: "en", theme: "auto", skin: "kandown", font: "inter" },
|
|
452
|
+
agent: { suggestFollowUp: false, maxSuggestions: 3 },
|
|
453
|
+
board: {
|
|
454
|
+
columns: ["Backlog", "Todo", "In Progress", "Review", "Done"],
|
|
455
|
+
defaultPriority: "P3",
|
|
456
|
+
defaultOwnerType: "human",
|
|
457
|
+
stackDefaultState: "collapsed"
|
|
458
|
+
},
|
|
459
|
+
fields: {
|
|
460
|
+
priority: false,
|
|
461
|
+
assignee: false,
|
|
462
|
+
tags: false,
|
|
463
|
+
dueDate: false,
|
|
464
|
+
ownerType: false,
|
|
465
|
+
tools: false
|
|
466
|
+
},
|
|
467
|
+
notifications: {
|
|
468
|
+
browser: false,
|
|
469
|
+
sound: false,
|
|
470
|
+
soundId: "soft",
|
|
471
|
+
statusChanges: true,
|
|
472
|
+
taskEdits: true,
|
|
473
|
+
subtaskCompletions: true,
|
|
474
|
+
editDebounceMs: 2e3
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
function loadConfig(kandownDir) {
|
|
478
|
+
const configPath = join2(kandownDir, "kandown.json");
|
|
479
|
+
if (!existsSync2(configPath)) return structuredClone(DEFAULT_CONFIG);
|
|
480
|
+
let raw;
|
|
523
481
|
try {
|
|
524
|
-
|
|
525
|
-
renameSync(tmp, path);
|
|
482
|
+
raw = JSON.parse(readFileSync2(configPath, "utf8"));
|
|
526
483
|
} catch (e) {
|
|
527
|
-
|
|
528
|
-
|
|
484
|
+
const err2 = e;
|
|
485
|
+
if (err2.code === "ENOENT") return structuredClone(DEFAULT_CONFIG);
|
|
486
|
+
console.warn(`[kandown] kandown.json is corrupted, using defaults: ${e.message}`);
|
|
487
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
488
|
+
}
|
|
489
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
490
|
+
console.warn("[kandown] kandown.json must be a JSON object, using defaults.");
|
|
491
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
492
|
+
}
|
|
493
|
+
const obj = raw;
|
|
494
|
+
const safeObj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
495
|
+
const boardRaw = safeObj(obj.board);
|
|
496
|
+
const merged = {
|
|
497
|
+
ui: { ...DEFAULT_CONFIG.ui, ...safeObj(obj.ui) },
|
|
498
|
+
agent: { ...DEFAULT_CONFIG.agent, ...safeObj(obj.agent) },
|
|
499
|
+
board: {
|
|
500
|
+
...DEFAULT_CONFIG.board,
|
|
501
|
+
...boardRaw,
|
|
502
|
+
columns: Array.isArray(boardRaw.columns) && boardRaw.columns.length > 0 ? boardRaw.columns.filter((name) => typeof name === "string" && name.trim().length > 0) : DEFAULT_CONFIG.board.columns
|
|
503
|
+
},
|
|
504
|
+
fields: { ...DEFAULT_CONFIG.fields, ...safeObj(obj.fields) },
|
|
505
|
+
notifications: { ...DEFAULT_CONFIG.notifications, ...safeObj(obj.notifications) }
|
|
506
|
+
};
|
|
507
|
+
if (obj.agents && typeof obj.agents === "object") {
|
|
508
|
+
merged.agents = obj.agents;
|
|
529
509
|
}
|
|
510
|
+
return merged;
|
|
530
511
|
}
|
|
531
512
|
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
/** Data output → stdout. Use ONLY for content the command was asked to produce. */
|
|
536
|
-
const out = (msg) => console.log(msg);
|
|
537
|
-
/** Decoration output → stderr (status, hints, banners). */
|
|
538
|
-
const log = (msg) => console.error(msg);
|
|
539
|
-
const success = (msg) => log(`${c.green}✓${c.reset} ${msg}`);
|
|
540
|
-
const info = (msg) => log(`${c.cyan}→${c.reset} ${msg}`);
|
|
541
|
-
const warn = (msg) => log(`${c.yellow}⚠${c.reset} ${msg}`);
|
|
542
|
-
const err = (msg) => log(`${c.red}✗${c.reset} ${msg}`);
|
|
543
|
-
|
|
544
|
-
// ─── TUI spinner & progress bar ─────────────────────────────────────────────
|
|
545
|
-
// 📖 Lightweight terminal animation helpers for the auto-updater.
|
|
546
|
-
// Uses ANSI escape codes (carriage return + clear line) to redraw in-place.
|
|
547
|
-
// Only animate when stdout is a TTY; otherwise fall back to plain text.
|
|
548
|
-
|
|
549
|
-
const _SP_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
550
|
-
let _spTimer = null;
|
|
551
|
-
let _spIdx = 0;
|
|
552
|
-
// 📖 Animations are decorations → stderr, and only when stderr is a TTY.
|
|
553
|
-
const _isTTY = process.stderr.isTTY;
|
|
554
|
-
|
|
555
|
-
function _tuiClear() {
|
|
556
|
-
process.stderr.write('\r\x1b[K');
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* 📖 Start a time-estimated progress bar + spinner.
|
|
561
|
-
* Shows a filling bar with percentage based on elapsed time.
|
|
562
|
-
* Caps at 95% until the caller calls tuiDone().
|
|
563
|
-
*/
|
|
564
|
-
function tuiProgress(text, estimateSec = 25, barWidth = 15) {
|
|
565
|
-
if (_spTimer) clearInterval(_spTimer);
|
|
566
|
-
_spIdx = 0;
|
|
567
|
-
_tuiClear();
|
|
568
|
-
if (!_isTTY) { process.stderr.write(` ${text}\n`); return; }
|
|
569
|
-
const start = Date.now();
|
|
570
|
-
_spTimer = setInterval(() => {
|
|
571
|
-
const elapsed = (Date.now() - start) / 1000;
|
|
572
|
-
const pct = Math.min(Math.round((elapsed / estimateSec) * 100), 95);
|
|
573
|
-
const filled = Math.round(barWidth * pct / 100);
|
|
574
|
-
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
|
|
575
|
-
process.stderr.write(`\r\x1b[K${_SP_FRAMES[_spIdx % _SP_FRAMES.length]} ${text} ${bar} ${pct}%`);
|
|
576
|
-
_spIdx++;
|
|
577
|
-
}, 120);
|
|
513
|
+
// src/cli/lib/board-reader.ts
|
|
514
|
+
function getProjectRoot(kandownDir) {
|
|
515
|
+
return dirname(kandownDir);
|
|
578
516
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
function tuiDone(symbol, text) {
|
|
582
|
-
if (_spTimer) { clearInterval(_spTimer); _spTimer = null; }
|
|
583
|
-
if (_isTTY) {
|
|
584
|
-
_tuiClear();
|
|
585
|
-
process.stderr.write(`${symbol} ${text}\n`);
|
|
586
|
-
} else {
|
|
587
|
-
log(`${symbol} ${text}`);
|
|
588
|
-
}
|
|
517
|
+
function getTasksDir(kandownDir) {
|
|
518
|
+
return join3(getProjectRoot(kandownDir), "tasks");
|
|
589
519
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
${c.bold}kandown${c.reset} ${c.dim}· file-based kanban backed by markdown${c.reset}
|
|
595
|
-
${c.dim}v${v}${c.reset}
|
|
596
|
-
|
|
597
|
-
${c.bold}Usage:${c.reset}
|
|
598
|
-
npx kandown [command]
|
|
599
|
-
|
|
600
|
-
${c.bold}Commands:${c.reset}
|
|
601
|
-
${c.cyan}(none)${c.reset} Start local web UI server + open the board TUI
|
|
602
|
-
${c.cyan}board${c.reset} Open the interactive kanban board in the terminal
|
|
603
|
-
${c.cyan}init${c.reset} Initialize .kandown/ in the current directory
|
|
604
|
-
${c.cyan}settings${c.reset} Open the settings TUI
|
|
605
|
-
${c.cyan}daemon${c.reset} Manage web daemons (start|stop|status|refresh-all)
|
|
606
|
-
${c.cyan}update${c.reset} Update kandown.html to the latest version
|
|
607
|
-
${c.cyan}list${c.reset} List tasks ${c.dim}(-s status, -a assignee, -t tag, -p priority, --json)${c.reset}
|
|
608
|
-
${c.cyan}show${c.reset} Print a task's raw file content
|
|
609
|
-
${c.cyan}create${c.reset} Create a task ${c.dim}(-p priority, -a assignee, -t tag, --to status)${c.reset}
|
|
610
|
-
${c.cyan}move${c.reset} Move a task to a column (or "archived")
|
|
611
|
-
${c.cyan}assign${c.reset} Assign / unassign a task
|
|
612
|
-
${c.cyan}commit${c.reset} git add + commit the task files
|
|
613
|
-
${c.cyan}tasks${c.reset} Full help for the commands above
|
|
614
|
-
${c.cyan}work${c.reset} ${c.bold}For AI agents:${c.reset} print the agent rules + live board digest
|
|
615
|
-
${c.cyan}help${c.reset} Show this help
|
|
616
|
-
|
|
617
|
-
${c.bold}Options:${c.reset}
|
|
618
|
-
${c.cyan}--port <n>${c.reset} Preferred local HTTP port for ${c.cyan}kandown${c.reset} (default: ${START_PORT_RANGE}, auto-increments to ${END_PORT_RANGE})
|
|
619
|
-
|
|
620
|
-
${c.bold}Examples:${c.reset}
|
|
621
|
-
${c.dim}$${c.reset} npx kandown ${c.dim}# local web server + board TUI${c.reset}
|
|
622
|
-
${c.dim}$${c.reset} npx kandown --port 3000 ${c.dim}# use a specific web UI port${c.reset}
|
|
623
|
-
${c.dim}$${c.reset} npx kandown board ${c.dim}# board TUI only${c.reset}
|
|
624
|
-
${c.dim}$${c.reset} npx kandown daemon stop ${c.dim}# stop this project's web daemon${c.reset}
|
|
625
|
-
${c.dim}$${c.reset} npx kandown daemon refresh-all ${c.dim}# refresh/restart outdated open daemons${c.reset}
|
|
626
|
-
${c.dim}$${c.reset} npx kandown init
|
|
627
|
-
${c.dim}$${c.reset} npx kandown init --path docs/kanban
|
|
628
|
-
${c.dim}$${c.reset} npx kandown init --no-agents
|
|
629
|
-
${c.dim}$${c.reset} npx kandown work ${c.dim}# what an AI agent should run first${c.reset}
|
|
630
|
-
${c.dim}$${c.reset} npx kandown list --json
|
|
631
|
-
${c.dim}$${c.reset} npx kandown create "Refactor auth" -p P1
|
|
632
|
-
${c.dim}$${c.reset} npx kandown commit -m "tasks: refactor auth"
|
|
633
|
-
`);
|
|
520
|
+
function listTaskIds(kandownDir) {
|
|
521
|
+
const tasksDir = getTasksDir(kandownDir);
|
|
522
|
+
if (!existsSync3(tasksDir)) return [];
|
|
523
|
+
return readdirSync(tasksDir).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
|
|
634
524
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
* about what went wrong — the caller reports each failure.
|
|
641
|
-
*/
|
|
642
|
-
function copyRecursive(src, dest, errors = []) {
|
|
643
|
-
if (!existsSync(src)) return errors;
|
|
644
|
-
try {
|
|
645
|
-
mkdirSync(dest, { recursive: true });
|
|
646
|
-
} catch (e) {
|
|
647
|
-
errors.push(`Failed to create directory ${dest}: ${e.message}`);
|
|
648
|
-
return errors;
|
|
649
|
-
}
|
|
650
|
-
let entries;
|
|
651
|
-
try {
|
|
652
|
-
entries = readdirSync(src);
|
|
653
|
-
} catch (e) {
|
|
654
|
-
errors.push(`Failed to read ${src}: ${e.message}`);
|
|
655
|
-
return errors;
|
|
656
|
-
}
|
|
657
|
-
for (const entry of entries) {
|
|
658
|
-
const srcPath = join(src, entry);
|
|
659
|
-
const destPath = join(dest, entry);
|
|
525
|
+
function readBoard(kandownDir) {
|
|
526
|
+
const config = loadConfig(kandownDir);
|
|
527
|
+
const ids = listTaskIds(kandownDir);
|
|
528
|
+
const tasks = [];
|
|
529
|
+
for (const id of ids) {
|
|
660
530
|
try {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
531
|
+
const task = readTask(kandownDir, id);
|
|
532
|
+
tasks.push({
|
|
533
|
+
...task,
|
|
534
|
+
frontmatter: {
|
|
535
|
+
...task.frontmatter,
|
|
536
|
+
id: task.frontmatter.id || id,
|
|
537
|
+
status: task.frontmatter.status || "Backlog"
|
|
538
|
+
}
|
|
539
|
+
});
|
|
666
540
|
} catch (e) {
|
|
667
|
-
|
|
668
|
-
// record the error and keep copying the rest (t113).
|
|
669
|
-
errors.push(`Failed to copy ${srcPath}: ${e.message}`);
|
|
541
|
+
console.error(`[kandown] Failed to read task ${id}:`, e.message);
|
|
670
542
|
}
|
|
671
543
|
}
|
|
672
|
-
return
|
|
544
|
+
return {
|
|
545
|
+
frontmatter: null,
|
|
546
|
+
title: "Project Kanban",
|
|
547
|
+
columns: buildColumnsFromTasks(tasks, config.board.columns)
|
|
548
|
+
};
|
|
673
549
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
550
|
+
function readTask(kandownDir, taskId) {
|
|
551
|
+
const taskPath = join3(getTasksDir(kandownDir), `${taskId}.md`);
|
|
552
|
+
if (!existsSync3(taskPath)) {
|
|
553
|
+
return {
|
|
554
|
+
frontmatter: { id: taskId, title: `Task ${taskId}`, status: "Backlog" },
|
|
555
|
+
body: ""
|
|
556
|
+
};
|
|
680
557
|
}
|
|
681
|
-
|
|
558
|
+
const content = readFileSync3(taskPath, "utf8");
|
|
559
|
+
const parsed = parseTaskFile(content);
|
|
560
|
+
return {
|
|
561
|
+
...parsed,
|
|
562
|
+
frontmatter: {
|
|
563
|
+
...parsed.frontmatter,
|
|
564
|
+
id: parsed.frontmatter.id || taskId,
|
|
565
|
+
status: parsed.frontmatter.status || "Backlog"
|
|
566
|
+
}
|
|
567
|
+
};
|
|
682
568
|
}
|
|
683
|
-
|
|
684
|
-
function
|
|
685
|
-
const
|
|
686
|
-
const marker = '<!-- kandown:agent-ref -->';
|
|
687
|
-
let existing;
|
|
569
|
+
var PKG_ROOT2 = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
570
|
+
function readAgentDoc(kandownDir) {
|
|
571
|
+
const sections = [];
|
|
688
572
|
try {
|
|
689
|
-
|
|
573
|
+
sections.push(readFileSync3(join3(PKG_ROOT2, "templates", "AGENT_KANDOWN.md"), "utf8").trim());
|
|
690
574
|
} catch (e) {
|
|
691
|
-
|
|
692
|
-
// Warn and skip rather than crashing init (t113).
|
|
693
|
-
warn(`Could not read ${agentsFile} (${e.message}); skipping agent reference.`);
|
|
694
|
-
return false;
|
|
575
|
+
console.warn("[kandown] Could not read base agent rules:", e.message);
|
|
695
576
|
}
|
|
577
|
+
const globalPath = join3(homedir2(), ".kandown", "instructions.md");
|
|
578
|
+
if (existsSync3(globalPath)) {
|
|
579
|
+
try {
|
|
580
|
+
sections.push(`## Global instructions
|
|
696
581
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
582
|
+
${readFileSync3(globalPath, "utf8").trim()}`);
|
|
583
|
+
} catch (e) {
|
|
584
|
+
console.warn(`[kandown] Could not read ${globalPath}:`, e.message);
|
|
585
|
+
}
|
|
700
586
|
}
|
|
587
|
+
const projectPath = join3(kandownDir, "instructions.md");
|
|
588
|
+
if (existsSync3(projectPath)) {
|
|
589
|
+
try {
|
|
590
|
+
sections.push(`## Project-specific instructions
|
|
701
591
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
${
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
592
|
+
${readFileSync3(projectPath, "utf8").trim()}`);
|
|
593
|
+
} catch (e) {
|
|
594
|
+
console.warn(`[kandown] Could not read ${projectPath}:`, e.message);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
const root = getProjectRoot(kandownDir);
|
|
599
|
+
const gitLog = execFileSync("git", ["log", "-n", "5", "--oneline", "--", "tasks/"], { cwd: root, encoding: "utf8" }).trim();
|
|
600
|
+
if (gitLog) {
|
|
601
|
+
sections.push(`## Recent Task Activity (Git History)
|
|
709
602
|
|
|
603
|
+
\`\`\`
|
|
604
|
+
${gitLog}
|
|
605
|
+
\`\`\``);
|
|
606
|
+
}
|
|
607
|
+
} catch {
|
|
608
|
+
}
|
|
609
|
+
return sections.filter(Boolean).join("\n\n---\n\n");
|
|
610
|
+
}
|
|
611
|
+
function moveTaskToColumn(kandownDir, taskId, targetColumn) {
|
|
612
|
+
const taskPath = join3(getTasksDir(kandownDir), `${taskId}.md`);
|
|
613
|
+
if (!existsSync3(taskPath)) return false;
|
|
710
614
|
try {
|
|
711
|
-
|
|
615
|
+
const prevContent = readFileSync3(taskPath, "utf8");
|
|
616
|
+
const parsed = readTask(kandownDir, taskId);
|
|
617
|
+
const newContent = serializeTaskFile({
|
|
618
|
+
...parsed.frontmatter,
|
|
619
|
+
id: taskId,
|
|
620
|
+
status: targetColumn
|
|
621
|
+
}, parsed.body);
|
|
622
|
+
atomicWriteFileSync(taskPath, newContent);
|
|
623
|
+
pushUndo(kandownDir, {
|
|
624
|
+
type: "move",
|
|
625
|
+
taskId,
|
|
626
|
+
path: taskPath,
|
|
627
|
+
previousContent: prevContent,
|
|
628
|
+
newContent,
|
|
629
|
+
timestamp: Date.now()
|
|
630
|
+
});
|
|
712
631
|
return true;
|
|
713
632
|
} catch (e) {
|
|
714
|
-
|
|
633
|
+
console.error(`[kandown] Failed to move task ${taskId} to ${targetColumn}:`, e.message);
|
|
715
634
|
return false;
|
|
716
635
|
}
|
|
717
636
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const args = { path: '.kandown', noAgents: false, force: false, port: null };
|
|
736
|
-
for (let i = 0; i < argv.length; i++) {
|
|
737
|
-
const a = argv[i];
|
|
738
|
-
if (a === '--path') args.path = argv[++i];
|
|
739
|
-
else if (a === '--port') args.port = argv[++i];
|
|
740
|
-
else if (a === '--no-agents') args.noAgents = true;
|
|
741
|
-
else if (a === '--force' || a === '-f') args.force = true;
|
|
637
|
+
function pushUndo(kandownDir, record) {
|
|
638
|
+
try {
|
|
639
|
+
const undoDir = join3(kandownDir, ".undo");
|
|
640
|
+
if (!existsSync3(undoDir)) mkdirSync(undoDir, { recursive: true });
|
|
641
|
+
const logPath = join3(undoDir, "log.json");
|
|
642
|
+
let list = [];
|
|
643
|
+
if (existsSync3(logPath)) {
|
|
644
|
+
try {
|
|
645
|
+
list = JSON.parse(readFileSync3(logPath, "utf8"));
|
|
646
|
+
} catch {
|
|
647
|
+
list = [];
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
list.unshift(record);
|
|
651
|
+
if (list.length > 50) list = list.slice(0, 50);
|
|
652
|
+
atomicWriteFileSync(logPath, JSON.stringify(list, null, 2));
|
|
653
|
+
} catch {
|
|
742
654
|
}
|
|
743
|
-
return args;
|
|
744
655
|
}
|
|
745
656
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
function getProjectRoot(kandownDir) {
|
|
754
|
-
return dirname(kandownDir);
|
|
657
|
+
// src/cli/lib/daemon.ts
|
|
658
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync as unlinkSync4 } from "fs";
|
|
659
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
660
|
+
import { execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
|
|
661
|
+
import { createConnection } from "net";
|
|
662
|
+
function metadataPath(kandownDir) {
|
|
663
|
+
return join4(kandownDir, "daemon.json");
|
|
755
664
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
* 📖 Returns the absolute path of the tasks directory at the project root.
|
|
759
|
-
* Mirrors the File System Access layout: `./tasks/` is a sibling of `.kandown/`.
|
|
760
|
-
* @param {string} kandownDir absolute path to the kandown config dir
|
|
761
|
-
* @returns {string} absolute path to `./tasks/`
|
|
762
|
-
*/
|
|
763
|
-
function getTasksDir(kandownDir) {
|
|
764
|
-
return join(getProjectRoot(kandownDir), 'tasks');
|
|
665
|
+
function isRecord(value) {
|
|
666
|
+
return typeof value === "object" && value !== null;
|
|
765
667
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
if (!existsSync(oldDir)) return result;
|
|
785
|
-
|
|
786
|
-
// 📖 Check FIRST whether the legacy dir actually has anything to migrate
|
|
787
|
-
// (top-level .md files or an archive/ subfolder with .md files). A stray
|
|
788
|
-
// empty `.kandown/tasks/` — common on old/incomplete installs — has
|
|
789
|
-
// nothing worth protecting, so it should just get cleaned up regardless
|
|
790
|
-
// of what's in `./tasks/`, instead of being misreported as a "conflict".
|
|
791
|
-
const oldMdFiles = readdirSync(oldDir, { withFileTypes: true })
|
|
792
|
-
.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
793
|
-
const oldArchiveDir = join(oldDir, 'archive');
|
|
794
|
-
const oldArchiveHasMd = existsSync(oldArchiveDir)
|
|
795
|
-
&& readdirSync(oldArchiveDir, { withFileTypes: true }).some(e => e.isFile() && e.name.endsWith('.md'));
|
|
796
|
-
|
|
797
|
-
if (oldMdFiles.length === 0 && !oldArchiveHasMd) {
|
|
798
|
-
return cleanupLegacyTasksDir(kandownDir, result);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
if (!existsSync(newDir)) mkdirSync(newDir, { recursive: true });
|
|
802
|
-
|
|
803
|
-
// 📖 If the new location already has any .md file, don't touch anything.
|
|
804
|
-
// The user is in a hybrid state and we should not clobber their work.
|
|
805
|
-
const existingNew = existsSync(newDir)
|
|
806
|
-
? readdirSync(newDir).filter(n => n.endsWith('.md'))
|
|
807
|
-
: [];
|
|
808
|
-
if (existingNew.length > 0) {
|
|
809
|
-
result.skipped = true;
|
|
810
|
-
return result;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
for (const entry of oldMdFiles) {
|
|
814
|
-
try {
|
|
815
|
-
renameSync(join(oldDir, entry.name), join(newDir, entry.name));
|
|
816
|
-
result.moved += 1;
|
|
817
|
-
} catch (e) {
|
|
818
|
-
warn(`migrate: could not move ${entry.name} (${e.code ?? e.message})`);
|
|
819
|
-
}
|
|
668
|
+
function parseMetadata(value) {
|
|
669
|
+
if (!isRecord(value)) return null;
|
|
670
|
+
const { pid, port, url, kandownDir, startedAt, version, token } = value;
|
|
671
|
+
if (typeof pid !== "number" || !Number.isInteger(pid)) return null;
|
|
672
|
+
if (typeof port !== "number" || !Number.isInteger(port)) return null;
|
|
673
|
+
if (typeof url !== "string" || typeof kandownDir !== "string") return null;
|
|
674
|
+
if (typeof startedAt !== "string") return null;
|
|
675
|
+
if (version !== null && typeof version !== "string" && version !== void 0) return null;
|
|
676
|
+
if (token !== null && typeof token !== "string" && token !== void 0) return null;
|
|
677
|
+
return { pid, port, url, kandownDir, startedAt, version: version ?? null, token: typeof token === "string" ? token : null };
|
|
678
|
+
}
|
|
679
|
+
function readDaemonMetadata(kandownDir) {
|
|
680
|
+
const path = metadataPath(kandownDir);
|
|
681
|
+
if (!existsSync4(path)) return null;
|
|
682
|
+
try {
|
|
683
|
+
return parseMetadata(JSON.parse(readFileSync4(path, "utf8")));
|
|
684
|
+
} catch {
|
|
685
|
+
return null;
|
|
820
686
|
}
|
|
821
|
-
|
|
822
|
-
return migrateArchive(kandownDir, result);
|
|
823
687
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
function migrateArchive(kandownDir, result) {
|
|
830
|
-
const oldArchive = join(kandownDir, 'tasks', 'archive');
|
|
831
|
-
const newArchive = join(getTasksDir(kandownDir), 'archive');
|
|
832
|
-
if (!existsSync(oldArchive)) return cleanupLegacyTasksDir(kandownDir, result);
|
|
833
|
-
|
|
834
|
-
if (!existsSync(newArchive)) mkdirSync(newArchive, { recursive: true });
|
|
835
|
-
for (const entry of readdirSync(oldArchive, { withFileTypes: true })) {
|
|
836
|
-
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
837
|
-
try {
|
|
838
|
-
renameSync(join(oldArchive, entry.name), join(newArchive, entry.name));
|
|
839
|
-
result.moved += 1;
|
|
840
|
-
} catch (e) {
|
|
841
|
-
warn(`migrate: could not move archive/${entry.name} (${e.code ?? e.message})`);
|
|
842
|
-
}
|
|
843
|
-
}
|
|
688
|
+
function removeDaemonMetadata(kandownDir) {
|
|
689
|
+
try {
|
|
690
|
+
const path = metadataPath(kandownDir);
|
|
691
|
+
if (existsSync4(path)) unlinkSync4(path);
|
|
692
|
+
} catch {
|
|
844
693
|
}
|
|
845
|
-
|
|
846
|
-
return cleanupLegacyTasksDir(kandownDir, result);
|
|
847
694
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
function cleanupLegacyTasksDir(kandownDir, result) {
|
|
855
|
-
const oldDir = join(kandownDir, 'tasks');
|
|
856
|
-
if (!existsSync(oldDir)) return result;
|
|
857
|
-
const remaining = readdirSync(oldDir);
|
|
858
|
-
if (remaining.length === 0) {
|
|
859
|
-
try {
|
|
860
|
-
// 📖 fs.rmSync requires { recursive: true } to remove a directory at
|
|
861
|
-
// all — even an already-confirmed-empty one — or it throws EISDIR.
|
|
862
|
-
// `remaining.length === 0` just verified above makes this safe.
|
|
863
|
-
rmSync(oldDir, { recursive: true });
|
|
864
|
-
result.cleanedUp = true;
|
|
865
|
-
} catch { /* directory not empty or platform limitation — leave it */ }
|
|
695
|
+
function isProcessAlive(pid) {
|
|
696
|
+
try {
|
|
697
|
+
process.kill(pid, 0);
|
|
698
|
+
return true;
|
|
699
|
+
} catch {
|
|
700
|
+
return false;
|
|
866
701
|
}
|
|
867
|
-
return result;
|
|
868
702
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
* the project root `./tasks/` on first access of a legacy project.
|
|
875
|
-
*/
|
|
876
|
-
function ensureKandownDir(rawArgs) {
|
|
877
|
-
const args = parseArgs(rawArgs);
|
|
878
|
-
const cwd = process.cwd();
|
|
879
|
-
const kandownDir = resolve(cwd, args.path);
|
|
880
|
-
|
|
881
|
-
if (existsSync(kandownDir)) {
|
|
882
|
-
// 📖 Silent one-time migration: move any legacy `.kandown/tasks/*.md` to
|
|
883
|
-
// the project-root `./tasks/`. Idempotent — safe to call on every startup.
|
|
884
|
-
const migration = migrateTasksToTopLevel(kandownDir);
|
|
885
|
-
if (migration.moved > 0) {
|
|
886
|
-
success(`Migrated ${migration.moved} task${migration.moved === 1 ? '' : 's'} from .kandown/tasks/ to ./tasks/`);
|
|
887
|
-
if (migration.cleanedUp) info('Removed empty .kandown/tasks/ folder');
|
|
888
|
-
} else if (migration.skipped) {
|
|
889
|
-
info('Both .kandown/tasks/ and ./tasks/ have files — leaving both in place');
|
|
890
|
-
}
|
|
891
|
-
syncKandownAgentDoc(kandownDir);
|
|
892
|
-
return { kandownDir, alreadyExisted: true };
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
log('');
|
|
896
|
-
info(`No .kandown/ found — auto-installing...`);
|
|
897
|
-
doInit(args, cwd, args.path, kandownDir);
|
|
898
|
-
return { kandownDir, alreadyExisted: false };
|
|
703
|
+
function parseRemoteDaemonInfo(value) {
|
|
704
|
+
if (!isRecord(value)) return null;
|
|
705
|
+
const { ok, pid, kandownDir } = value;
|
|
706
|
+
if (ok !== true || typeof pid !== "number" || !Number.isInteger(pid) || typeof kandownDir !== "string") return null;
|
|
707
|
+
return { ok, pid, kandownDir };
|
|
899
708
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
const htmlDest = join(kandownDir, 'kandown.html');
|
|
910
|
-
if (!existsSync(htmlSrc)) {
|
|
911
|
-
err(`Missing build output at ${htmlSrc}. Did you run 'npm run build'?`);
|
|
912
|
-
return false;
|
|
709
|
+
async function fetchDaemonInfo(port) {
|
|
710
|
+
try {
|
|
711
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/daemon`, {
|
|
712
|
+
signal: AbortSignal.timeout(700)
|
|
713
|
+
});
|
|
714
|
+
if (!response.ok) return null;
|
|
715
|
+
return parseRemoteDaemonInfo(await response.json());
|
|
716
|
+
} catch {
|
|
717
|
+
return null;
|
|
913
718
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
719
|
+
}
|
|
720
|
+
function isPortListening(port, timeoutMs = 400) {
|
|
721
|
+
return new Promise((resolve3) => {
|
|
722
|
+
const socket = createConnection({ port, host: "127.0.0.1" }, () => {
|
|
723
|
+
socket.destroy();
|
|
724
|
+
resolve3(true);
|
|
725
|
+
});
|
|
726
|
+
socket.on("error", () => resolve3(false));
|
|
727
|
+
socket.setTimeout(timeoutMs);
|
|
728
|
+
socket.on("timeout", () => {
|
|
729
|
+
socket.destroy();
|
|
730
|
+
resolve3(false);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
async function getDaemonStatus(kandownDir) {
|
|
735
|
+
const metadata = readDaemonMetadata(kandownDir);
|
|
736
|
+
if (!metadata) return { running: false, metadata: null };
|
|
737
|
+
if (!isProcessAlive(metadata.pid)) {
|
|
738
|
+
removeDaemonMetadata(kandownDir);
|
|
739
|
+
return { running: false, metadata: null };
|
|
928
740
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const tasksSrc = join(templatesDir, 'tasks');
|
|
933
|
-
const tasksDest = getTasksDir(kandownDir);
|
|
934
|
-
if (!existsSync(tasksDest)) {
|
|
935
|
-
mkdirSync(tasksDest, { recursive: true });
|
|
936
|
-
// 📖 copyRecursive now returns an errors[] array — report any partial
|
|
937
|
-
// failures instead of letting them crash the whole init (t113).
|
|
938
|
-
const copyErrors = copyRecursive(tasksSrc, tasksDest);
|
|
939
|
-
if (copyErrors.length > 0) {
|
|
940
|
-
warn(`Some task template files could not be copied:`);
|
|
941
|
-
for (const msg of copyErrors) warn(` - ${msg}`);
|
|
942
|
-
}
|
|
943
|
-
success('./tasks/ (with welcome example)');
|
|
944
|
-
} else {
|
|
945
|
-
info('./tasks/ already exists (kept)');
|
|
741
|
+
const remote = await fetchDaemonInfo(metadata.port);
|
|
742
|
+
if (!remote) {
|
|
743
|
+
return { running: false, metadata: null };
|
|
946
744
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
success('kandown.json');
|
|
951
|
-
} else {
|
|
952
|
-
info('kandown.json already exists (kept)');
|
|
745
|
+
if (remote.pid !== metadata.pid || remote.kandownDir !== kandownDir) {
|
|
746
|
+
removeDaemonMetadata(kandownDir);
|
|
747
|
+
return { running: false, metadata: null };
|
|
953
748
|
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
749
|
+
return { running: true, metadata };
|
|
750
|
+
}
|
|
751
|
+
async function waitForDaemon(kandownDir, timeoutMs = 8e3) {
|
|
752
|
+
const started = Date.now();
|
|
753
|
+
while (Date.now() - started < timeoutMs) {
|
|
754
|
+
const metadata = readDaemonMetadata(kandownDir);
|
|
755
|
+
if (metadata && isProcessAlive(metadata.pid) && await isPortListening(metadata.port)) {
|
|
756
|
+
return { running: true, metadata };
|
|
757
|
+
}
|
|
758
|
+
await new Promise((resolve3) => setTimeout(resolve3, 120));
|
|
959
759
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
760
|
+
return { running: false, metadata: null };
|
|
761
|
+
}
|
|
762
|
+
async function startProjectDaemon(kandownDir, preferredPort) {
|
|
763
|
+
const current = await getDaemonStatus(kandownDir);
|
|
764
|
+
if (current.running) return current;
|
|
765
|
+
const cliPath = process.argv[1];
|
|
766
|
+
if (!cliPath) throw new Error("Cannot locate kandown CLI entrypoint");
|
|
767
|
+
const args = [
|
|
768
|
+
cliPath,
|
|
769
|
+
"--no-update-check",
|
|
770
|
+
"daemon",
|
|
771
|
+
"run",
|
|
772
|
+
"--path",
|
|
773
|
+
kandownDir
|
|
774
|
+
];
|
|
775
|
+
if (typeof preferredPort === "number" && Number.isInteger(preferredPort)) {
|
|
776
|
+
args.push("--port", String(preferredPort));
|
|
777
|
+
}
|
|
778
|
+
const child = spawn2(process.execPath, args, {
|
|
779
|
+
cwd: dirname2(kandownDir),
|
|
780
|
+
detached: true,
|
|
781
|
+
stdio: "ignore",
|
|
782
|
+
env: { ...process.env, KANDOWN_DAEMON: "1" }
|
|
783
|
+
});
|
|
784
|
+
child.unref();
|
|
785
|
+
return waitForDaemon(kandownDir);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// src/cli/cli.ts
|
|
789
|
+
var c = {
|
|
790
|
+
reset: "\x1B[0m",
|
|
791
|
+
bold: "\x1B[1m",
|
|
792
|
+
dim: "\x1B[2m",
|
|
793
|
+
red: "\x1B[31m",
|
|
794
|
+
green: "\x1B[32m",
|
|
795
|
+
yellow: "\x1B[33m",
|
|
796
|
+
blue: "\x1B[34m",
|
|
797
|
+
cyan: "\x1B[36m"
|
|
798
|
+
};
|
|
799
|
+
function log(msg) {
|
|
800
|
+
console.log(msg);
|
|
801
|
+
}
|
|
802
|
+
function info(msg) {
|
|
803
|
+
console.log(`${c.blue}\u2139${c.reset} ${msg}`);
|
|
804
|
+
}
|
|
805
|
+
function success(msg) {
|
|
806
|
+
console.log(`${c.green}\u2713${c.reset} ${msg}`);
|
|
807
|
+
}
|
|
808
|
+
function err(msg) {
|
|
809
|
+
console.error(`${c.red}\u2717${c.reset} ${msg}`);
|
|
810
|
+
}
|
|
811
|
+
function parseArgs(args) {
|
|
812
|
+
const flags = {};
|
|
813
|
+
const positional = [];
|
|
814
|
+
for (let i = 0; i < args.length; i++) {
|
|
815
|
+
const a = args[i];
|
|
816
|
+
if (a.startsWith("--")) {
|
|
817
|
+
const key = a.slice(2);
|
|
818
|
+
const next = args[i + 1];
|
|
819
|
+
if (next && !next.startsWith("-")) {
|
|
820
|
+
flags[key] = next;
|
|
821
|
+
i++;
|
|
822
|
+
} else {
|
|
823
|
+
flags[key] = true;
|
|
824
|
+
}
|
|
825
|
+
} else if (a.startsWith("-")) {
|
|
826
|
+
flags[a.slice(1)] = true;
|
|
967
827
|
} else {
|
|
968
|
-
|
|
969
|
-
if (created) success(`Created ${c.bold}AGENTS.md${c.reset} with kandown reference`);
|
|
828
|
+
positional.push(a);
|
|
970
829
|
}
|
|
971
830
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
info(` Tip: Run ${c.cyan}git init${c.reset} and track ${c.bold}./tasks/${c.reset} for git history & portability!`);
|
|
831
|
+
return { flags, positional, path: typeof flags.path === "string" ? flags.path : ".kandown" };
|
|
832
|
+
}
|
|
833
|
+
function resolveKandownDir(pathArg = ".kandown", cwd = process.cwd()) {
|
|
834
|
+
if (basename(cwd) === ".kandown" || existsSync5(join5(cwd, "kandown.json"))) {
|
|
835
|
+
return cwd;
|
|
978
836
|
}
|
|
979
|
-
|
|
980
|
-
return true;
|
|
837
|
+
return resolve2(cwd, pathArg);
|
|
981
838
|
}
|
|
982
|
-
|
|
983
|
-
function cmdInit(rawArgs) {
|
|
839
|
+
function ensureKandownDir(rawArgs) {
|
|
984
840
|
const args = parseArgs(rawArgs);
|
|
985
841
|
const cwd = process.cwd();
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
err(`Kandown already initialized in this directory. To reinitialize, remove the .kandown folder first.`);
|
|
991
|
-
process.exit(1);
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
log('');
|
|
995
|
-
info(`Installing kandown in ${c.bold}${kandownPath}/${c.reset}`);
|
|
996
|
-
log('');
|
|
997
|
-
|
|
998
|
-
if (existsSync(kandownDir) && !args.force) {
|
|
999
|
-
err(`Directory ${c.bold}${kandownPath}/${c.reset} already exists.`);
|
|
1000
|
-
log(` Use ${c.cyan}--force${c.reset} to overwrite or ${c.cyan}--path <dir>${c.reset} for another location.`);
|
|
842
|
+
const kandownDir = resolveKandownDir(args.path, cwd);
|
|
843
|
+
if (!existsSync5(kandownDir)) {
|
|
844
|
+
err(`No Kandown installation found at ${c.bold}${kandownDir}${c.reset}`);
|
|
845
|
+
log(` Run ${c.cyan}npx kandown init${c.reset} to create one.`);
|
|
1001
846
|
process.exit(1);
|
|
1002
847
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
log(
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
848
|
+
return { kandownDir, cwd };
|
|
849
|
+
}
|
|
850
|
+
function help() {
|
|
851
|
+
const current = getCurrentVersion();
|
|
852
|
+
log(`
|
|
853
|
+
${c.bold}Kandown CLI${c.reset} v${current} \u2014 file-based Kanban backed by Markdown
|
|
854
|
+
|
|
855
|
+
${c.bold}USAGE:${c.reset}
|
|
856
|
+
kandown [command] [options]
|
|
857
|
+
|
|
858
|
+
${c.bold}COMMANDS:${c.reset}
|
|
859
|
+
(none) Start web server & launch TUI
|
|
860
|
+
work Output agent rules + live board digest
|
|
861
|
+
list List tasks (alias: ls)
|
|
862
|
+
show <id> Display task details
|
|
863
|
+
create "<title>" Create new task (alias: new)
|
|
864
|
+
move <id> <status> Move task column
|
|
865
|
+
assign <id> <user> Assign task
|
|
866
|
+
commit Commit task changes to git
|
|
867
|
+
update Update kandown CLI to latest version (alias: upgrade)
|
|
868
|
+
doctor Run environment & board diagnostics
|
|
869
|
+
daemon Manage background daemon (status, start, stop, restart)
|
|
870
|
+
projects List open kandown projects
|
|
871
|
+
export Export board tasks to JSON
|
|
872
|
+
import <file> Import tasks from JSON/Markdown
|
|
873
|
+
mcp Start Model Context Protocol (MCP) server
|
|
874
|
+
help Show help screen
|
|
875
|
+
|
|
876
|
+
${c.bold}OPTIONS:${c.reset}
|
|
877
|
+
--path <dir> Path to .kandown folder (default: .kandown)
|
|
878
|
+
--port <number> Server port (default: 2050)
|
|
879
|
+
--no-open Don't open browser automatically
|
|
880
|
+
--help, -h Show help screen
|
|
881
|
+
`);
|
|
1022
882
|
}
|
|
1023
|
-
|
|
1024
883
|
async function cmdUpdate(rawArgs) {
|
|
1025
884
|
const current = getCurrentVersion();
|
|
1026
|
-
log(`${c.bold}kandown update${c.reset} ${c.dim}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
timeout: 6000,
|
|
1032
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
885
|
+
log(`${c.bold}kandown update${c.reset} ${c.dim}\u2014 v${current}${c.reset}`);
|
|
886
|
+
const latest = await new Promise((resolve3) => {
|
|
887
|
+
const child = spawn3("npm", ["view", "kandown", "version"], {
|
|
888
|
+
timeout: 6e3,
|
|
889
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1033
890
|
env: { ...process.env },
|
|
1034
|
-
detached: false
|
|
891
|
+
detached: false
|
|
892
|
+
});
|
|
893
|
+
let stdout = "";
|
|
894
|
+
child.stdout.on("data", (d) => {
|
|
895
|
+
stdout += d;
|
|
1035
896
|
});
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
child.
|
|
1039
|
-
child.on(
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
resolve(v || null);
|
|
897
|
+
child.stderr.on("data", () => {
|
|
898
|
+
});
|
|
899
|
+
child.on("error", () => resolve3(null));
|
|
900
|
+
child.on("close", (code) => {
|
|
901
|
+
if (code !== 0) return resolve3(null);
|
|
902
|
+
const v = stdout.trim().replace(/^"|"$/g, "");
|
|
903
|
+
resolve3(v || null);
|
|
1044
904
|
});
|
|
1045
905
|
});
|
|
1046
|
-
|
|
1047
906
|
if (latest && semverGt(latest, current) > 0) {
|
|
1048
|
-
|
|
907
|
+
info(`Updating kandown package v${current} \u2192 v${latest}\u2026`);
|
|
1049
908
|
const updateOk = await performGlobalPackageUpdate(`kandown@${latest}`);
|
|
1050
909
|
if (updateOk) {
|
|
1051
|
-
|
|
1052
|
-
printVersionChangelog(latest);
|
|
910
|
+
success(`Successfully upgraded kandown to v${latest}`);
|
|
1053
911
|
} else {
|
|
1054
|
-
|
|
912
|
+
err(`Global CLI update failed \u2014 try: pnpm add -g kandown@latest or npm install -g kandown@latest`);
|
|
1055
913
|
}
|
|
1056
914
|
} else {
|
|
1057
|
-
printVersionChangelog(current);
|
|
1058
915
|
info(`kandown CLI is already up to date (v${current}).`);
|
|
1059
916
|
}
|
|
1060
|
-
|
|
1061
917
|
const args = parseArgs(rawArgs);
|
|
1062
918
|
const cwd = process.cwd();
|
|
1063
|
-
const kandownDir =
|
|
1064
|
-
const htmlDest =
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
if (existsSync(htmlSrc)) {
|
|
919
|
+
const kandownDir = resolve2(cwd, args.path);
|
|
920
|
+
const htmlDest = join5(kandownDir, "kandown.html");
|
|
921
|
+
if (existsSync5(htmlDest)) {
|
|
922
|
+
const htmlSrc = resolve2(import.meta.url ? new URL("../..", import.meta.url).pathname : process.cwd(), "dist", "index.html");
|
|
923
|
+
if (existsSync5(htmlSrc)) {
|
|
1069
924
|
copyFileSync(htmlSrc, htmlDest);
|
|
1070
925
|
success(`Refreshed ${args.path}/kandown.html`);
|
|
1071
926
|
}
|
|
1072
927
|
}
|
|
1073
928
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
*/
|
|
1088
|
-
|
|
1089
|
-
function parseFrontmatter(content) {
|
|
1090
|
-
const out = { frontmatter: {}, body: content || '' };
|
|
1091
|
-
if (!content || !content.startsWith('---\n')) return out;
|
|
1092
|
-
const end = content.indexOf('\n---\n', 4);
|
|
1093
|
-
if (end === -1) {
|
|
1094
|
-
out.body = content;
|
|
1095
|
-
return out;
|
|
1096
|
-
}
|
|
1097
|
-
const yaml = content.slice(4, end);
|
|
1098
|
-
out.body = content.slice(end + 5).replace(/^\n+/, '');
|
|
1099
|
-
|
|
1100
|
-
const lines = yaml.split('\n');
|
|
1101
|
-
let i = 0;
|
|
1102
|
-
while (i < lines.length) {
|
|
1103
|
-
const line = lines[i];
|
|
1104
|
-
if (!line.trim() || line.trim().startsWith('#')) {
|
|
1105
|
-
i++;
|
|
1106
|
-
continue;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
const blockMatch = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*\|\s*$/);
|
|
1110
|
-
if (blockMatch) {
|
|
1111
|
-
const key = blockMatch[1];
|
|
1112
|
-
const valLines = [];
|
|
1113
|
-
i++;
|
|
1114
|
-
while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) {
|
|
1115
|
-
if (lines[i].startsWith(' ')) {
|
|
1116
|
-
valLines.push(lines[i].slice(2));
|
|
1117
|
-
} else if (lines[i].startsWith(' ')) {
|
|
1118
|
-
valLines.push(lines[i].slice(1));
|
|
1119
|
-
} else {
|
|
1120
|
-
valLines.push(lines[i]);
|
|
1121
|
-
}
|
|
1122
|
-
i++;
|
|
1123
|
-
}
|
|
1124
|
-
out.frontmatter[key] = valLines.join('\n');
|
|
1125
|
-
continue;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
const m = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
|
|
1129
|
-
if (!m) {
|
|
1130
|
-
i++;
|
|
1131
|
-
continue;
|
|
1132
|
-
}
|
|
1133
|
-
const key = m[1];
|
|
1134
|
-
let val = (m[2] || '').trim();
|
|
1135
|
-
if (val.startsWith('[') && val.endsWith(']')) {
|
|
1136
|
-
val = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
1137
|
-
} else if (val === 'true' || val === 'false') {
|
|
1138
|
-
val = val === 'true';
|
|
1139
|
-
} else if (val === 'null' || val === '') {
|
|
1140
|
-
val = null;
|
|
1141
|
-
} else if (/^-?\d+(\.\d+)?$/.test(val)) {
|
|
1142
|
-
val = Number(val);
|
|
1143
|
-
} else {
|
|
1144
|
-
val = val.replace(/^["']|["']$/g, '');
|
|
1145
|
-
}
|
|
1146
|
-
out.frontmatter[key] = val;
|
|
1147
|
-
i++;
|
|
1148
|
-
}
|
|
1149
|
-
return out;
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
function serializeFrontmatter(fm, body) {
|
|
1153
|
-
const lines = ['---'];
|
|
1154
|
-
for (const [k, v] of Object.entries(fm || {})) {
|
|
1155
|
-
if (v === null || v === undefined || v === '') continue;
|
|
1156
|
-
if (Array.isArray(v)) {
|
|
1157
|
-
if (v.length === 0) continue;
|
|
1158
|
-
lines.push(`${k}: [${v.join(', ')}]`);
|
|
1159
|
-
} else if (typeof v === 'string' && v.includes('\n')) {
|
|
1160
|
-
lines.push(`${k}: |`);
|
|
1161
|
-
lines.push(...v.split('\n').map(l => (l === '' ? '' : ` ${l}`)));
|
|
1162
|
-
} else {
|
|
1163
|
-
lines.push(`${k}: ${v}`);
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
lines.push('---', '', (body || '').replace(/^\n+/, '').replace(/\n+$/, '') + '\n');
|
|
1167
|
-
return lines.join('\n');
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
function readKandownConfig(kandownDir) {
|
|
1171
|
-
const configPath = join(kandownDir, 'kandown.json');
|
|
1172
|
-
if (!existsSync(configPath)) return null;
|
|
1173
|
-
try {
|
|
1174
|
-
return JSON.parse(readFileSync(configPath, 'utf8'));
|
|
1175
|
-
} catch {
|
|
1176
|
-
return null;
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
function findTaskFile(kandownDir, id) {
|
|
1181
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return null;
|
|
1182
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
1183
|
-
const inTasks = join(tasksDir, `${id}.md`);
|
|
1184
|
-
if (existsSync(inTasks)) return inTasks;
|
|
1185
|
-
const inArchive = join(tasksDir, 'archive', `${id}.md`);
|
|
1186
|
-
if (existsSync(inArchive)) return inArchive;
|
|
1187
|
-
return null;
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
function listAllTaskIds(kandownDir) {
|
|
1191
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
1192
|
-
const ids = new Set();
|
|
1193
|
-
if (existsSync(tasksDir)) {
|
|
1194
|
-
for (const f of readdirSync(tasksDir).filter(f => f.endsWith('.md'))) {
|
|
1195
|
-
ids.add(f.replace(/\.md$/, ''));
|
|
1196
|
-
}
|
|
1197
|
-
const archive = join(tasksDir, 'archive');
|
|
1198
|
-
if (existsSync(archive)) {
|
|
1199
|
-
for (const f of readdirSync(archive).filter(f => f.endsWith('.md'))) {
|
|
1200
|
-
ids.add(f.replace(/\.md$/, ''));
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
return [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
function nextTaskId(kandownDir) {
|
|
1208
|
-
const ids = listAllTaskIds(kandownDir);
|
|
1209
|
-
let maxN = 0;
|
|
1210
|
-
for (const id of ids) {
|
|
1211
|
-
const m = id.match(/^t(\d+)$/i);
|
|
1212
|
-
if (m) {
|
|
1213
|
-
const n = parseInt(m[1], 10);
|
|
1214
|
-
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
929
|
+
async function cmdDoctor(rawArgs) {
|
|
930
|
+
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
931
|
+
const currentVersion = getCurrentVersion();
|
|
932
|
+
log(`${c.bold}kandown doctor${c.reset} ${c.dim}\u2014 environment & board diagnostic${c.reset}
|
|
933
|
+
`);
|
|
934
|
+
log(` CLI Version: ${currentVersion}`);
|
|
935
|
+
const configPath = join5(kandownDir, "kandown.json");
|
|
936
|
+
if (existsSync5(configPath)) {
|
|
937
|
+
try {
|
|
938
|
+
JSON.parse(readFileSync5(configPath, "utf8"));
|
|
939
|
+
success("kandown.json valid");
|
|
940
|
+
} catch (e) {
|
|
941
|
+
err(`kandown.json invalid: ${e.message}`);
|
|
1215
942
|
}
|
|
943
|
+
} else {
|
|
944
|
+
err("Missing kandown.json");
|
|
1216
945
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
// 📖 Only truncate when the string is strictly LONGER than the column —
|
|
1223
|
-
// an exact fit must render as-is (`>=` chopped the last char of e.g. the
|
|
1224
|
-
// longest task id, displaying "t99" as "t9").
|
|
1225
|
-
if (s.length > len) return s.slice(0, Math.max(0, len - 1)) + '…';
|
|
1226
|
-
return s + ' '.repeat(len - s.length);
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
function taskParseArgs(argv) {
|
|
1230
|
-
// 📖 Minimal flag parser for the task commands. Stops at the first
|
|
1231
|
-
// positional arg so `kandown create "Some title with -dash"` works.
|
|
1232
|
-
const out = { positional: [], flags: {} };
|
|
1233
|
-
for (let i = 0; i < argv.length; i++) {
|
|
1234
|
-
const a = argv[i];
|
|
1235
|
-
if (a === '--json') out.flags.json = true;
|
|
1236
|
-
else if (a === '--archived') out.flags.archived = true;
|
|
1237
|
-
else if (a === '-s' || a === '--status') out.flags.status = argv[++i];
|
|
1238
|
-
else if (a === '-a' || a === '--assignee') out.flags.assignee = argv[++i];
|
|
1239
|
-
else if (a === '-p' || a === '--priority') out.flags.priority = argv[++i];
|
|
1240
|
-
else if (a === '-t' || a === '--tag') out.flags.tags = (out.flags.tags || []).concat([argv[++i]]);
|
|
1241
|
-
else if (a === '-d' || a === '--depends-on') out.flags.dependsOn = (out.flags.dependsOn || []).concat([argv[++i]]);
|
|
1242
|
-
else if (a === '-m' || a === '--message') out.flags.message = argv[++i];
|
|
1243
|
-
else if (a === '--to') out.flags.to = argv[++i];
|
|
1244
|
-
else if (a === '--id') out.flags.id = argv[++i];
|
|
1245
|
-
else out.positional.push(a);
|
|
946
|
+
const daemonStatus = await getDaemonStatus(kandownDir);
|
|
947
|
+
if (daemonStatus.running && daemonStatus.metadata) {
|
|
948
|
+
success(`Daemon running on port ${daemonStatus.metadata.port} (PID ${daemonStatus.metadata.pid})`);
|
|
949
|
+
} else {
|
|
950
|
+
info("Daemon not running");
|
|
1246
951
|
}
|
|
1247
|
-
|
|
952
|
+
const taskIds = listTaskIds(kandownDir);
|
|
953
|
+
success(`Tasks: ${taskIds.length} active task files`);
|
|
954
|
+
log(`
|
|
955
|
+
${c.green}\u2713 Everything looks good!${c.reset}
|
|
956
|
+
`);
|
|
1248
957
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
958
|
+
async function cmdWork(rawArgs) {
|
|
959
|
+
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
960
|
+
const doc = readAgentDoc(kandownDir);
|
|
961
|
+
const board = readBoard(kandownDir);
|
|
962
|
+
log(doc);
|
|
963
|
+
log("\n---\n");
|
|
964
|
+
log(`## Current Board Digest
|
|
965
|
+
`);
|
|
966
|
+
const allTasksCount = board.columns.reduce((sum, col) => sum + col.tasks.length, 0);
|
|
967
|
+
log(`Tasks total: ${allTasksCount}`);
|
|
968
|
+
for (const col of board.columns) {
|
|
969
|
+
log(`- **${col.name}** (${col.tasks.length}): ${col.tasks.map((t) => `${t.id} ${t.title}`).join(", ") || "empty"}`);
|
|
1259
970
|
}
|
|
1260
|
-
return null;
|
|
1261
971
|
}
|
|
1262
|
-
|
|
1263
972
|
function cmdList(rawArgs) {
|
|
1264
973
|
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
1265
|
-
const
|
|
1266
|
-
const
|
|
1267
|
-
const statusFilter =
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
if (!path) continue;
|
|
1277
|
-
const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
|
|
1278
|
-
const fm = parsed.frontmatter;
|
|
1279
|
-
const isArchived = fm.archived === true || fm.archived === 'true' || path.includes('/archive/');
|
|
1280
|
-
if (args.flags.archived ? !isArchived : isArchived) continue;
|
|
1281
|
-
if (statusFilter && statusFilter !== 'archived' && (fm.status || '').toLowerCase() !== statusFilter.toLowerCase()) continue;
|
|
1282
|
-
if (args.flags.assignee && fm.assignee !== args.flags.assignee) continue;
|
|
1283
|
-
if (args.flags.priority && (fm.priority || '').toLowerCase() !== args.flags.priority.toLowerCase()) continue;
|
|
1284
|
-
if (args.flags.tags && args.flags.tags.length > 0) {
|
|
1285
|
-
const taskTags = Array.isArray(fm.tags) ? fm.tags : [];
|
|
1286
|
-
const wanted = new Set(args.flags.tags);
|
|
1287
|
-
let ok = true;
|
|
1288
|
-
for (const t of wanted) { if (!taskTags.includes(t)) { ok = false; break; } }
|
|
1289
|
-
if (!ok) continue;
|
|
974
|
+
const board = readBoard(kandownDir);
|
|
975
|
+
const { flags } = parseArgs(rawArgs);
|
|
976
|
+
const statusFilter = typeof flags.status === "string" ? flags.status.toLowerCase() : null;
|
|
977
|
+
const priorityFilter = typeof flags.priority === "string" ? flags.priority.toUpperCase() : null;
|
|
978
|
+
for (const col of board.columns) {
|
|
979
|
+
if (statusFilter && col.name.toLowerCase() !== statusFilter) continue;
|
|
980
|
+
log(`
|
|
981
|
+
${c.bold}${col.name}${c.reset} (${col.tasks.length})`);
|
|
982
|
+
for (const t of col.tasks) {
|
|
983
|
+
if (priorityFilter && t.priority !== priorityFilter) continue;
|
|
984
|
+
log(` ${c.cyan}${t.id}${c.reset} [${t.priority || "P2"}] ${t.title}`);
|
|
1290
985
|
}
|
|
1291
|
-
rows.push({ id, fm, body: parsed.body });
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
if (args.flags.json) {
|
|
1295
|
-
process.stdout.write(JSON.stringify(rows.map(r => ({
|
|
1296
|
-
id: r.id, ...r.fm, archived: r.fm.archived === true || r.fm.archived === 'true',
|
|
1297
|
-
})), null, 2) + '\n');
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
if (rows.length === 0) {
|
|
1302
|
-
// 📖 Placeholder goes to stderr — an empty result on stdout stays empty
|
|
1303
|
-
// so `[ -z "$(kandown list ...)" ]` style checks behave.
|
|
1304
|
-
log(c.dim + '(no tasks)' + c.reset);
|
|
1305
|
-
return;
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
const idW = Math.max(2, ...rows.map(r => r.id.length));
|
|
1309
|
-
out(`${c.dim}${padCell('ID', idW)} ${padCell('STATUS', 14)} ${padCell('PRI', 4)} ${padCell('ASSIGNEE', 12)} TITLE${c.reset}`);
|
|
1310
|
-
for (const r of rows) {
|
|
1311
|
-
const status = (r.fm.status || 'Backlog') + (r.fm.archived === true || r.fm.archived === 'true' ? ' (archived)' : '');
|
|
1312
|
-
const pri = r.fm.priority || '';
|
|
1313
|
-
const assignee = r.fm.assignee || '';
|
|
1314
|
-
const title = (r.fm.title || '(untitled)').replace(/\n/g, ' ');
|
|
1315
|
-
out(`${padCell(r.id, idW)} ${padCell(status, 14)} ${padCell(pri, 4)} ${padCell(assignee, 12)} ${title}`);
|
|
1316
986
|
}
|
|
987
|
+
log("");
|
|
1317
988
|
}
|
|
1318
|
-
|
|
1319
989
|
function cmdShow(rawArgs) {
|
|
1320
990
|
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
1321
|
-
const
|
|
1322
|
-
const id = args.positional[0];
|
|
991
|
+
const id = rawArgs.find((a) => !a.startsWith("-"));
|
|
1323
992
|
if (!id) {
|
|
1324
|
-
err(
|
|
1325
|
-
process.exit(1);
|
|
1326
|
-
}
|
|
1327
|
-
const path = findTaskFile(kandownDir, id);
|
|
1328
|
-
if (!path) {
|
|
1329
|
-
err(`Task not found: ${id}`);
|
|
1330
|
-
process.exit(1);
|
|
1331
|
-
}
|
|
1332
|
-
process.stdout.write(readFileSync(path, 'utf8'));
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
/**
|
|
1336
|
-
* 📖 Resolves a task id to whether it is in a "blocking" state (i.e. still
|
|
1337
|
-
* pending). Used by the move gate. Mirrors the web store's logic: a dep is
|
|
1338
|
-
* resolved when it lives in the terminal column OR is archived. Unknown
|
|
1339
|
-
* ids and self-references never block.
|
|
1340
|
-
*/
|
|
1341
|
-
function depIsResolved(kandownDir, id, terminalLower) {
|
|
1342
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return true; // unknown / invalid → don't block
|
|
1343
|
-
const path = findTaskFile(kandownDir, id);
|
|
1344
|
-
if (!path) return true; // unknown id → don't block
|
|
1345
|
-
const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
|
|
1346
|
-
const isArch = parsed.frontmatter.archived === true || parsed.frontmatter.archived === 'true';
|
|
1347
|
-
return isArch || (parsed.frontmatter.status || '').toLowerCase() === terminalLower;
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
function parseInlineQuickAdd(rawTitle) {
|
|
1351
|
-
let text = (rawTitle || '').trim();
|
|
1352
|
-
let priority;
|
|
1353
|
-
const tags = [];
|
|
1354
|
-
let assignee;
|
|
1355
|
-
let due;
|
|
1356
|
-
const dependsOn = [];
|
|
1357
|
-
|
|
1358
|
-
text = text.replace(/(?:^|\s)p([1-4])(?:\s|$)/i, (_, level) => {
|
|
1359
|
-
priority = `P${level}`;
|
|
1360
|
-
return ' ';
|
|
1361
|
-
});
|
|
1362
|
-
|
|
1363
|
-
text = text.replace(/(?:^|\s)#([a-zA-Z0-9_-]+)/g, (_, tag) => {
|
|
1364
|
-
tags.push(tag.toLowerCase());
|
|
1365
|
-
return ' ';
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
text = text.replace(/(?:^|\s)@([a-zA-Z0-9_-]+)/g, (_, user) => {
|
|
1369
|
-
assignee = user;
|
|
1370
|
-
return ' ';
|
|
1371
|
-
});
|
|
1372
|
-
|
|
1373
|
-
text = text.replace(/(?:^|\s)due:([^\s]+)/i, (_, dateStr) => {
|
|
1374
|
-
due = dateStr;
|
|
1375
|
-
return ' ';
|
|
1376
|
-
});
|
|
1377
|
-
|
|
1378
|
-
text = text.replace(/(?:^|\s)\+([a-zA-Z0-9_-]+)/g, (_, depId) => {
|
|
1379
|
-
dependsOn.push(depId);
|
|
1380
|
-
return ' ';
|
|
1381
|
-
});
|
|
1382
|
-
|
|
1383
|
-
const title = text.replace(/\s+/g, ' ').trim();
|
|
1384
|
-
return { title: title || rawTitle, priority, tags, assignee, due, dependsOn };
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
function cmdCreate(rawArgs) {
|
|
1388
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
1389
|
-
const args = taskParseArgs(rawArgs);
|
|
1390
|
-
const rawTitle = args.positional[0];
|
|
1391
|
-
if (!rawTitle) {
|
|
1392
|
-
err('Usage: kandown create "<title>" [-p priority] [-a assignee] [-t tag ...] [--to status]');
|
|
1393
|
-
process.exit(1);
|
|
1394
|
-
}
|
|
1395
|
-
const config = readKandownConfig(kandownDir);
|
|
1396
|
-
const targetStatus = resolveStatusArg(config, args.flags.to) || (config && config.board && config.board.columns ? config.board.columns[0] : 'Backlog');
|
|
1397
|
-
const id = args.flags.id || nextTaskId(kandownDir);
|
|
1398
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
1399
|
-
if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
|
|
1400
|
-
const targetPath = join(tasksDir, `${id}.md`);
|
|
1401
|
-
if (existsSync(targetPath)) {
|
|
1402
|
-
err(`Task already exists: ${id}`);
|
|
993
|
+
err("Usage: kandown show <task-id>");
|
|
1403
994
|
process.exit(1);
|
|
1404
995
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
if (args.flags.priority) fm.priority = args.flags.priority;
|
|
1414
|
-
if (args.flags.assignee) fm.assignee = args.flags.assignee;
|
|
1415
|
-
if (args.flags.tags && args.flags.tags.length > 0) fm.tags = args.flags.tags;
|
|
1416
|
-
if (args.flags.dependsOn && args.flags.dependsOn.length > 0) fm.depends_on = args.flags.dependsOn;
|
|
1417
|
-
const content = serializeFrontmatter(fm, '');
|
|
1418
|
-
atomicWriteFileSync(targetPath, content);
|
|
1419
|
-
process.stderr.write(`${c.green}✓${c.reset} Created ${c.bold}${id}${c.reset} → ${targetStatus}\n`);
|
|
1420
|
-
if (args.flags.json) {
|
|
1421
|
-
process.stdout.write(JSON.stringify({ id, ...fm }, null, 2) + '\n');
|
|
1422
|
-
} else {
|
|
1423
|
-
// 📖 Print the id on stdout (last line) so scripts can do
|
|
1424
|
-
// `ID=$(kandown create "...")` without parsing the colored status line.
|
|
1425
|
-
process.stdout.write(id + '\n');
|
|
996
|
+
try {
|
|
997
|
+
const task = readTask(kandownDir, id);
|
|
998
|
+
log(`${c.bold}${task.frontmatter.id}: ${task.frontmatter.title}${c.reset}`);
|
|
999
|
+
log(`Status: ${task.frontmatter.status} | Priority: ${task.frontmatter.priority || "P2"} | Assignee: ${task.frontmatter.assignee || "none"}
|
|
1000
|
+
`);
|
|
1001
|
+
log(task.body);
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
err(`Could not read task ${id}: ${e.message}`);
|
|
1426
1004
|
}
|
|
1427
1005
|
}
|
|
1428
|
-
|
|
1429
1006
|
function cmdMove(rawArgs) {
|
|
1430
1007
|
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
1431
|
-
const
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
if (!id || !targetStatus) {
|
|
1435
|
-
err('Usage: kandown move <id> <status>');
|
|
1436
|
-
process.exit(1);
|
|
1437
|
-
}
|
|
1438
|
-
const config = readKandownConfig(kandownDir);
|
|
1439
|
-
const resolved = resolveStatusArg(config, targetStatus);
|
|
1440
|
-
if (!resolved) {
|
|
1441
|
-
err(`Unknown status: ${targetStatus}`);
|
|
1008
|
+
const [id, newStatus] = rawArgs.filter((a) => !a.startsWith("-"));
|
|
1009
|
+
if (!id || !newStatus) {
|
|
1010
|
+
err("Usage: kandown move <task-id> <status>");
|
|
1442
1011
|
process.exit(1);
|
|
1443
1012
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1013
|
+
try {
|
|
1014
|
+
moveTaskToColumn(kandownDir, id, newStatus);
|
|
1015
|
+
success(`Moved ${id} \u2192 "${newStatus}"`);
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
err(`Move failed: ${e.message}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
async function main() {
|
|
1021
|
+
const args = process.argv.slice(2);
|
|
1022
|
+
const cmd = args[0];
|
|
1023
|
+
const rest = args.slice(1);
|
|
1024
|
+
const skipUpdate = args.includes("--no-update-check") || process.env.KANDOWN_NO_UPDATE === "1";
|
|
1025
|
+
const SCRIPTED = /* @__PURE__ */ new Set(["list", "ls", "show", "create", "new", "move", "assign", "commit", "work", "doctor", "projects", "export", "import"]);
|
|
1026
|
+
if (!skipUpdate && !SCRIPTED.has(cmd)) {
|
|
1027
|
+
await checkForUpdate(process.argv);
|
|
1028
|
+
}
|
|
1029
|
+
switch (cmd) {
|
|
1030
|
+
case "update":
|
|
1031
|
+
case "upgrade":
|
|
1032
|
+
await cmdUpdate(rest);
|
|
1033
|
+
break;
|
|
1034
|
+
case "doctor":
|
|
1035
|
+
await cmdDoctor(rest);
|
|
1036
|
+
break;
|
|
1037
|
+
case "work":
|
|
1038
|
+
await cmdWork(rest);
|
|
1039
|
+
break;
|
|
1040
|
+
case "list":
|
|
1041
|
+
case "ls":
|
|
1042
|
+
cmdList(rest);
|
|
1043
|
+
break;
|
|
1044
|
+
case "show":
|
|
1045
|
+
cmdShow(rest);
|
|
1046
|
+
break;
|
|
1047
|
+
case "move":
|
|
1048
|
+
cmdMove(rest);
|
|
1049
|
+
break;
|
|
1050
|
+
case "help":
|
|
1051
|
+
case "--help":
|
|
1052
|
+
case "-h":
|
|
1053
|
+
help();
|
|
1054
|
+
break;
|
|
1055
|
+
case void 0: {
|
|
1056
|
+
const { kandownDir } = ensureKandownDir(rest);
|
|
1057
|
+
const status = await getDaemonStatus(kandownDir);
|
|
1058
|
+
if (!status.running) {
|
|
1059
|
+
await startProjectDaemon(kandownDir);
|
|
1461
1060
|
}
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
process.exit(
|
|
1061
|
+
const tuiPath = resolve2(import.meta.url ? new URL("../..", import.meta.url).pathname : process.cwd(), "bin", "tui.js");
|
|
1062
|
+
if (existsSync5(tuiPath)) {
|
|
1063
|
+
const child = spawn3("node", [tuiPath, ...rest], { stdio: "inherit" });
|
|
1064
|
+
child.on("close", (code) => process.exit(code || 0));
|
|
1065
|
+
} else {
|
|
1066
|
+
info("Kandown daemon running. Open kandown.html in browser.");
|
|
1466
1067
|
}
|
|
1068
|
+
break;
|
|
1467
1069
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
1476
|
-
atomicWriteFileSync(join(archiveDir, `${id}.md`), serializeFrontmatter(parsed.frontmatter, parsed.body));
|
|
1477
|
-
try { unlinkSync(path); } catch { /* already absent */ }
|
|
1478
|
-
} else {
|
|
1479
|
-
const normalTasksDir = getTasksDir(kandownDir);
|
|
1480
|
-
const normalPath = join(normalTasksDir, `${id}.md`);
|
|
1481
|
-
atomicWriteFileSync(normalPath, serializeFrontmatter(parsed.frontmatter, parsed.body));
|
|
1482
|
-
if (path !== normalPath) {
|
|
1483
|
-
try { unlinkSync(path); } catch { /* already absent */ }
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
log(`${c.green}✓${c.reset} ${c.bold}${id}${c.reset} → ${resolved}`);
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
function cmdAssign(rawArgs) {
|
|
1490
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
1491
|
-
const args = taskParseArgs(rawArgs);
|
|
1492
|
-
const [id, name] = args.positional;
|
|
1493
|
-
if (!id) {
|
|
1494
|
-
err('Usage: kandown assign <id> [name] (omit name to unassign)');
|
|
1495
|
-
process.exit(1);
|
|
1496
|
-
}
|
|
1497
|
-
const path = findTaskFile(kandownDir, id);
|
|
1498
|
-
if (!path) {
|
|
1499
|
-
err(`Task not found: ${id}`);
|
|
1500
|
-
process.exit(1);
|
|
1501
|
-
}
|
|
1502
|
-
const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
|
|
1503
|
-
if (name) parsed.frontmatter.assignee = name;
|
|
1504
|
-
else delete parsed.frontmatter.assignee;
|
|
1505
|
-
atomicWriteFileSync(path, serializeFrontmatter(parsed.frontmatter, parsed.body));
|
|
1506
|
-
log(`${c.green}✓${c.reset} ${c.bold}${id}${c.reset} → ${name ? c.cyan + name : c.dim + '(unassigned)'}${c.reset}`);
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
function cmdCommit(rawArgs) {
|
|
1510
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
1511
|
-
const args = taskParseArgs(rawArgs);
|
|
1512
|
-
const projectRoot = getProjectRoot(kandownDir);
|
|
1513
|
-
// 📖 Refuse to commit if not inside a git repo — silently failing here would
|
|
1514
|
-
// let the user believe they versioned their tasks when they didn't.
|
|
1515
|
-
try {
|
|
1516
|
-
execSync('git rev-parse --is-inside-work-tree', { cwd: projectRoot, stdio: 'pipe' });
|
|
1517
|
-
} catch {
|
|
1518
|
-
err('Not a git repository — kandown commit only stages and commits in a git repo.');
|
|
1519
|
-
err(' Run `git init` first or commit manually.');
|
|
1520
|
-
process.exit(1);
|
|
1521
|
-
}
|
|
1522
|
-
const tasksRel = 'tasks';
|
|
1523
|
-
const configRel = '.kandown/kandown.json';
|
|
1524
|
-
const staged = [];
|
|
1525
|
-
for (const rel of [tasksRel, configRel]) {
|
|
1526
|
-
const abs = join(projectRoot, rel);
|
|
1527
|
-
if (!existsSync(abs)) continue;
|
|
1528
|
-
try {
|
|
1529
|
-
execSync(`git add -A -- "${rel}"`, { cwd: projectRoot, stdio: 'pipe' });
|
|
1530
|
-
staged.push(rel);
|
|
1531
|
-
} catch (e) {
|
|
1532
|
-
err(`git add failed for ${rel}: ${e.message}`);
|
|
1070
|
+
default:
|
|
1071
|
+
if (cmd.startsWith("-")) {
|
|
1072
|
+
help();
|
|
1073
|
+
break;
|
|
1074
|
+
}
|
|
1075
|
+
err(`Unknown command: ${cmd}`);
|
|
1076
|
+
help();
|
|
1533
1077
|
process.exit(1);
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
if (staged.length === 0) {
|
|
1537
|
-
log(c.dim + 'Nothing to commit.' + c.reset);
|
|
1538
|
-
return;
|
|
1539
|
-
}
|
|
1540
|
-
const message = args.flags.message || `chore(kandown): update tasks`;
|
|
1541
|
-
try {
|
|
1542
|
-
execSync(`git commit -m ${JSON.stringify(message)}`, { cwd: projectRoot, stdio: 'inherit' });
|
|
1543
|
-
success(`Committed ${staged.length} path${staged.length === 1 ? '' : 's'}: ${staged.join(', ')}`);
|
|
1544
|
-
} catch (e) {
|
|
1545
|
-
// 📖 git commit exits non-zero when there's nothing to commit (after the
|
|
1546
|
-
// add). Treat that as a no-op so the user doesn't think they broke
|
|
1547
|
-
// anything.
|
|
1548
|
-
if (e && e.status === 1) {
|
|
1549
|
-
log(c.dim + 'Nothing to commit (working tree clean).' + c.reset);
|
|
1550
|
-
return;
|
|
1551
|
-
}
|
|
1552
|
-
err(`git commit failed: ${e.message}`);
|
|
1553
|
-
process.exit(1);
|
|
1554
1078
|
}
|
|
1555
1079
|
}
|
|
1556
|
-
|
|
1557
|
-
/**
|
|
1558
|
-
* 📖 Prints the one-shot task-command cheatsheet. Reachable as `kandown tasks`
|
|
1559
|
-
* (or `kandown tasks help`) — the commands themselves (list/show/create/move/
|
|
1560
|
-
* assign/commit) are top-level, not nested under a "shell" prefix, since
|
|
1561
|
-
* they're the most basic operations of the product and scripts/agents should
|
|
1562
|
-
* never need to remember a wrapper word to reach them.
|
|
1563
|
-
*/
|
|
1564
|
-
function printTaskCommandsHelp() {
|
|
1565
|
-
out(`
|
|
1566
|
-
${c.bold}kandown${c.reset} ${c.dim}· one-shot task commands (scriptable, agent-friendly)${c.reset}
|
|
1567
|
-
|
|
1568
|
-
${c.bold}Commands:${c.reset}
|
|
1569
|
-
${c.cyan}list${c.reset} [-s status] [-a assignee] [-t tag] [-p priority] [--archived] [--json]
|
|
1570
|
-
${c.cyan}show${c.reset} <id>
|
|
1571
|
-
${c.cyan}create${c.reset} "title" [-p priority] [-a assignee] [-t tag ...] [--to status] [--id custom-id] [--json]
|
|
1572
|
-
${c.cyan}move${c.reset} <id> <status> (status is a column name or "archived")
|
|
1573
|
-
${c.cyan}assign${c.reset} <id> [name] (omit name to unassign)
|
|
1574
|
-
${c.cyan}commit${c.reset} [-m "message"] (git add tasks/ + .kandown/kandown.json + git commit)
|
|
1575
|
-
|
|
1576
|
-
${c.bold}Examples:${c.reset}
|
|
1577
|
-
${c.dim}$${c.reset} kandown list --json | jq '.[] | select(.priority=="P1")'
|
|
1578
|
-
${c.dim}$${c.reset} kandown create "Refactor auth middleware" -p P1 -t backend
|
|
1579
|
-
${c.dim}$${c.reset} kandown move t42 Done
|
|
1580
|
-
${c.dim}$${c.reset} kandown assign t42 alice
|
|
1581
|
-
${c.dim}$${c.reset} kandown commit -m "tasks: add auth refactor"
|
|
1582
|
-
`);
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
/* ═════════════ kandown work — agent entrypoint ═════════════ */
|
|
1586
|
-
/**
|
|
1587
|
-
* 📖 `kandown work` replaces the "paste the rules into AGENTS.md/CLAUDE.md"
|
|
1588
|
-
* pattern: instead of a stale copy embedded in the project's agent file at
|
|
1589
|
-
* init time, the CLI serves the rules fresh on every invocation — always in
|
|
1590
|
-
* sync with the installed version — plus a live digest of the board so the
|
|
1591
|
-
* agent gets its marching orders and its context in one call.
|
|
1592
|
-
*
|
|
1593
|
-
* Layering (base → global → project), each optional except the base:
|
|
1594
|
-
* 1. Base rules — templates/AGENT_KANDOWN.md, shipped with the package.
|
|
1595
|
-
* 2. Global — ~/.kandown/instructions.md, applies to every project
|
|
1596
|
-
* on this machine (team conventions, personal style).
|
|
1597
|
-
* 3. Project — .kandown/instructions.md, this project only.
|
|
1598
|
-
* Later layers are appended, not merged — the agent reads all of them.
|
|
1599
|
-
*/
|
|
1600
|
-
|
|
1601
|
-
const PRIORITY_RANK = { P1: 0, P2: 1, P3: 2, P4: 3 };
|
|
1602
|
-
function priorityRank(p) {
|
|
1603
|
-
return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, p) ? PRIORITY_RANK[p] : 4;
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
const WORK_OUTPUT_SECTION_IDS = ['baseRules', 'projectInstructions', 'boardDigest'];
|
|
1607
|
-
const OPTIMIZED_AGENT_RULES = `# Kandown agent rules
|
|
1608
|
-
|
|
1609
|
-
## CLI Commands
|
|
1610
|
-
- \`kandown list\` (list tasks) · \`kandown show <id>\` (view task)
|
|
1611
|
-
- \`kandown create "<title>"\` (create task) · \`kandown move <id> <status>\` (move task column)
|
|
1612
|
-
- \`kandown assign <id> <user>\` · \`kandown commit\` (commit board to git)
|
|
1613
|
-
|
|
1614
|
-
## Rules
|
|
1615
|
-
- Task state lives in project-root \`tasks/*.md\`; do not maintain a separate board index.
|
|
1616
|
-
- Before work, read the relevant task file and keep it updated while you progress.
|
|
1617
|
-
- Move tasks by editing frontmatter \`status:\`; complete work by setting \`status: Done\` and adding a markdown \`report:\` summary.
|
|
1618
|
-
- Update subtasks in-place: \`- [ ]\` → \`- [x]\`, with a short \`report:\` line for meaningful progress.
|
|
1619
|
-
- Board columns live in \`.kandown/kandown.json\` under \`board.columns\`; project instructions live in \`.kandown/instructions.md\`.
|
|
1620
|
-
- Never put Kandown task data inside \`.kandown/\`; tasks belong in \`./tasks/\`.`;
|
|
1621
|
-
|
|
1622
|
-
const CAVEMAN_AGENT_RULES = `# Kandown agent rules
|
|
1623
|
-
|
|
1624
|
-
CLI: kandown list | kandown show <id> | kandown create "<title>" | kandown move <id> <status> | kandown assign <id> <user> | kandown commit
|
|
1625
|
-
RULES: TASKS IN ./tasks/*.md. READ TASK BEFORE WORK. UPDATE SUBTASKS - [ ] -> - [x] + REPORT. MOVE: EDIT status:. DONE: status: Done + report:.`;
|
|
1626
|
-
|
|
1627
|
-
const CONCISE_AGENT_RULES = OPTIMIZED_AGENT_RULES;
|
|
1628
|
-
|
|
1629
|
-
const DEFAULT_WORK_OUTPUT = {
|
|
1630
|
-
mode: 'blocks',
|
|
1631
|
-
includeBaseRules: true,
|
|
1632
|
-
baseRulesMode: 'verbose',
|
|
1633
|
-
includeProjectInstructions: true,
|
|
1634
|
-
includeBoardDigest: true,
|
|
1635
|
-
sectionOrder: WORK_OUTPUT_SECTION_IDS,
|
|
1636
|
-
rawTemplate: '{{baseRules}}\n\n---\n\n{{projectInstructions}}\n\n---\n\n{{boardDigest}}',
|
|
1637
|
-
boardDigest: {
|
|
1638
|
-
showColumnCounts: true,
|
|
1639
|
-
showTasks: true,
|
|
1640
|
-
showPriority: true,
|
|
1641
|
-
showAssignee: true,
|
|
1642
|
-
showBlockedBy: true,
|
|
1643
|
-
showNextActionable: true,
|
|
1644
|
-
},
|
|
1645
|
-
};
|
|
1646
|
-
|
|
1647
|
-
function safePlainObject(value) {
|
|
1648
|
-
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
function normalizeWorkOutputConfig(config) {
|
|
1652
|
-
const raw = safePlainObject(safePlainObject(config?.agent).workOutput);
|
|
1653
|
-
const digest = safePlainObject(raw.boardDigest);
|
|
1654
|
-
const sectionOrder = Array.isArray(raw.sectionOrder)
|
|
1655
|
-
? raw.sectionOrder.filter(id => WORK_OUTPUT_SECTION_IDS.includes(id))
|
|
1656
|
-
: DEFAULT_WORK_OUTPUT.sectionOrder;
|
|
1657
|
-
const order = sectionOrder.length > 0 ? sectionOrder : DEFAULT_WORK_OUTPUT.sectionOrder;
|
|
1658
|
-
const validBaseModes = ['verbose', 'optimized', 'caveman', 'full', 'concise'];
|
|
1659
|
-
const baseMode = validBaseModes.includes(raw.baseRulesMode) ? raw.baseRulesMode : DEFAULT_WORK_OUTPUT.baseRulesMode;
|
|
1660
|
-
return {
|
|
1661
|
-
...DEFAULT_WORK_OUTPUT,
|
|
1662
|
-
...raw,
|
|
1663
|
-
mode: raw.mode === 'raw' ? 'raw' : 'blocks',
|
|
1664
|
-
baseRulesMode: baseMode,
|
|
1665
|
-
sectionOrder: order,
|
|
1666
|
-
rawTemplate: typeof raw.rawTemplate === 'string' && raw.rawTemplate.trim()
|
|
1667
|
-
? raw.rawTemplate
|
|
1668
|
-
: DEFAULT_WORK_OUTPUT.rawTemplate,
|
|
1669
|
-
boardDigest: { ...DEFAULT_WORK_OUTPUT.boardDigest, ...digest },
|
|
1670
|
-
};
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
function readBaseAgentRules(mode = 'verbose') {
|
|
1674
|
-
if (mode === 'caveman') return CAVEMAN_AGENT_RULES;
|
|
1675
|
-
if (mode === 'optimized' || mode === 'concise') return OPTIMIZED_AGENT_RULES;
|
|
1676
|
-
try {
|
|
1677
|
-
return readFileSync(join(PKG_ROOT, 'templates', 'AGENT_KANDOWN.md'), 'utf8').trim();
|
|
1678
|
-
} catch (e) {
|
|
1679
|
-
warn(`Could not read base rules (${e.message})`);
|
|
1680
|
-
return '';
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
function syncKandownAgentDoc(kandownDir) {
|
|
1685
|
-
const source = join(PKG_ROOT, 'templates', 'AGENT_KANDOWN.md');
|
|
1686
|
-
const target = join(kandownDir, 'AGENT_KANDOWN.md');
|
|
1687
|
-
if (!existsSync(source)) return false;
|
|
1688
|
-
const expected = readFileSync(source, 'utf8');
|
|
1689
|
-
const existing = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
1690
|
-
const isMissing = existing === null;
|
|
1691
|
-
const isMalformed = existing !== null && !existing.includes('# Kandown') && !existing.includes('## The System');
|
|
1692
|
-
const isStaleGeneratedDoc = existing !== null && existing.includes('# Kandown') && existing !== expected;
|
|
1693
|
-
|
|
1694
|
-
if (isMissing || isMalformed || isStaleGeneratedDoc) {
|
|
1695
|
-
atomicWriteFileSync(target, expected.endsWith('\n') ? expected : `${expected}\n`);
|
|
1696
|
-
return true;
|
|
1697
|
-
}
|
|
1698
|
-
return false;
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
function applyWorkRawTemplate(template, blocks) {
|
|
1702
|
-
return template
|
|
1703
|
-
.replaceAll('{{baseRules}}', blocks.baseRules)
|
|
1704
|
-
.replaceAll('{{projectInstructions}}', blocks.projectInstructions)
|
|
1705
|
-
.replaceAll('{{boardDigest}}', blocks.boardDigest)
|
|
1706
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
1707
|
-
.trim();
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
/**
|
|
1711
|
-
* 📖 Builds the "Current board" section: column counts, tasks per column
|
|
1712
|
-
* (with blocked-by annotations), and a "Next actionable task" pick — the
|
|
1713
|
-
* task in the most-advanced non-terminal column (closest to done, so
|
|
1714
|
-
* in-flight work finishes before new work starts) with no unresolved
|
|
1715
|
-
* `depends_on`, tie-broken by priority. Mirrors the same gate logic used by
|
|
1716
|
-
* `move`/the TUI/the web store.
|
|
1717
|
-
*/
|
|
1718
|
-
function buildBoardDigest(kandownDir, options = DEFAULT_WORK_OUTPUT.boardDigest) {
|
|
1719
|
-
const digestOptions = { ...DEFAULT_WORK_OUTPUT.boardDigest, ...safePlainObject(options) };
|
|
1720
|
-
const config = readKandownConfig(kandownDir);
|
|
1721
|
-
const columns = (config && config.board && Array.isArray(config.board.columns) && config.board.columns.length > 0)
|
|
1722
|
-
? config.board.columns
|
|
1723
|
-
: ['Backlog', 'Todo', 'In Progress', 'Review', 'Done'];
|
|
1724
|
-
const terminalCol = columns[columns.length - 1];
|
|
1725
|
-
const terminalLower = terminalCol.toLowerCase();
|
|
1726
|
-
|
|
1727
|
-
const rows = [];
|
|
1728
|
-
for (const id of listAllTaskIds(kandownDir)) {
|
|
1729
|
-
const path = findTaskFile(kandownDir, id);
|
|
1730
|
-
if (!path) continue;
|
|
1731
|
-
let content;
|
|
1732
|
-
try { content = readFileSync(path, 'utf8'); } catch { continue; }
|
|
1733
|
-
const parsed = parseFrontmatter(content);
|
|
1734
|
-
const fm = parsed.frontmatter;
|
|
1735
|
-
const isArchived = fm.archived === true || fm.archived === 'true' || path.includes('/archive/');
|
|
1736
|
-
if (isArchived) continue;
|
|
1737
|
-
rows.push({ id, fm });
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
if (rows.length === 0) {
|
|
1741
|
-
return `## Current board\n\n(no tasks yet)`;
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
const resolved = new Map();
|
|
1745
|
-
for (const r of rows) {
|
|
1746
|
-
const isArch = r.fm.archived === true || r.fm.archived === 'true';
|
|
1747
|
-
resolved.set(r.id, isArch || (r.fm.status || '').toLowerCase() === terminalLower);
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
const byColumn = new Map(columns.map(col => [col, []]));
|
|
1751
|
-
for (const r of rows) {
|
|
1752
|
-
const status = r.fm.status || columns[0];
|
|
1753
|
-
const col = columns.find(c => c.toLowerCase() === String(status).toLowerCase()) || columns[0];
|
|
1754
|
-
const deps = Array.isArray(r.fm.depends_on) ? r.fm.depends_on : [];
|
|
1755
|
-
const blocked = deps.filter(d => typeof d === 'string' && d.trim() && d !== r.id && !resolved.get(d));
|
|
1756
|
-
// 📖 `col` always resolves to one of `columns` (falls back to columns[0]),
|
|
1757
|
-
// which the Map was seeded with above — the entry always exists.
|
|
1758
|
-
byColumn.get(col).push({ ...r, blocked });
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
const lines = ['## Current board', ''];
|
|
1762
|
-
if (digestOptions.showColumnCounts) {
|
|
1763
|
-
lines.push(`**Columns:** ${columns.map(col => `${col} (${(byColumn.get(col) || []).length})`).join(' · ')}`);
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
if (digestOptions.showTasks) {
|
|
1767
|
-
for (const col of columns) {
|
|
1768
|
-
const tasks = (byColumn.get(col) || []).sort((a, b) => priorityRank(a.fm.priority) - priorityRank(b.fm.priority));
|
|
1769
|
-
if (tasks.length === 0) continue;
|
|
1770
|
-
lines.push('', `### ${col}`);
|
|
1771
|
-
for (const t of tasks) {
|
|
1772
|
-
const pri = digestOptions.showPriority && t.fm.priority ? `[${t.fm.priority}] ` : '';
|
|
1773
|
-
const assignee = digestOptions.showAssignee && t.fm.assignee ? ` (@${t.fm.assignee})` : '';
|
|
1774
|
-
const blockedStr = digestOptions.showBlockedBy && t.blocked.length > 0 ? ` ⛔ blocked by ${t.blocked.join(', ')}` : '';
|
|
1775
|
-
lines.push(`- ${t.id} ${pri}${t.fm.title || '(untitled)'}${assignee}${blockedStr}`);
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
const actionable = rows.filter(r => {
|
|
1781
|
-
const status = r.fm.status || columns[0];
|
|
1782
|
-
if (status.toLowerCase() === terminalLower) return false;
|
|
1783
|
-
const deps = Array.isArray(r.fm.depends_on) ? r.fm.depends_on : [];
|
|
1784
|
-
return !deps.some(d => typeof d === 'string' && d.trim() && d !== r.id && !resolved.get(d));
|
|
1785
|
-
});
|
|
1786
|
-
const next = actionable.sort((a, b) => {
|
|
1787
|
-
const idxA = columns.findIndex(col => col.toLowerCase() === (a.fm.status || columns[0]).toLowerCase());
|
|
1788
|
-
const idxB = columns.findIndex(col => col.toLowerCase() === (b.fm.status || columns[0]).toLowerCase());
|
|
1789
|
-
if (idxA !== idxB) return idxB - idxA; // most-advanced non-terminal column first
|
|
1790
|
-
return priorityRank(a.fm.priority) - priorityRank(b.fm.priority);
|
|
1791
|
-
})[0];
|
|
1792
|
-
|
|
1793
|
-
if (digestOptions.showNextActionable) {
|
|
1794
|
-
lines.push('', '### Next actionable task');
|
|
1795
|
-
lines.push(next
|
|
1796
|
-
? `→ **${next.id}** — ${next.fm.title || '(untitled)'} (${next.fm.priority || 'no priority'}, ${next.fm.status || columns[0]})`
|
|
1797
|
-
: 'None — every task is done, archived, or blocked.');
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
return lines.join('\n');
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
async function cmdWork(rawArgs) {
|
|
1804
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
1805
|
-
const config = readKandownConfig(kandownDir);
|
|
1806
|
-
const workOutput = normalizeWorkOutputConfig(config);
|
|
1807
|
-
syncKandownAgentDoc(kandownDir);
|
|
1808
|
-
|
|
1809
|
-
const baseRules = readBaseAgentRules(workOutput.baseRulesMode);
|
|
1810
|
-
|
|
1811
|
-
let projectInstructions = '';
|
|
1812
|
-
const projectPath = join(kandownDir, 'instructions.md');
|
|
1813
|
-
if (existsSync(projectPath)) {
|
|
1814
|
-
try { projectInstructions = readFileSync(projectPath, 'utf8').trim(); }
|
|
1815
|
-
catch (e) { warn(`Could not read project instructions (${e.message})`); }
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
const blocks = {
|
|
1819
|
-
baseRules,
|
|
1820
|
-
projectInstructions: projectInstructions ? `## Project-specific instructions\n\n${projectInstructions}` : '',
|
|
1821
|
-
boardDigest: buildBoardDigest(kandownDir, workOutput.boardDigest),
|
|
1822
|
-
};
|
|
1823
|
-
|
|
1824
|
-
if (workOutput.mode === 'raw') {
|
|
1825
|
-
out(applyWorkRawTemplate(workOutput.rawTemplate, blocks));
|
|
1826
|
-
return;
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
const sections = [];
|
|
1830
|
-
for (const sectionId of workOutput.sectionOrder) {
|
|
1831
|
-
if (sectionId === 'baseRules' && workOutput.includeBaseRules) sections.push(blocks.baseRules);
|
|
1832
|
-
if (sectionId === 'projectInstructions' && workOutput.includeProjectInstructions) sections.push(blocks.projectInstructions);
|
|
1833
|
-
if (sectionId === 'boardDigest' && workOutput.includeBoardDigest) sections.push(blocks.boardDigest);
|
|
1834
|
-
}
|
|
1835
|
-
|
|
1836
|
-
out(sections.filter(Boolean).join('\n\n---\n\n'));
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
function parsePort(value) {
|
|
1840
|
-
if (value === null) return null;
|
|
1841
|
-
const port = Number(value);
|
|
1842
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1843
|
-
err(`Invalid port: ${c.bold}${value}${c.reset}`);
|
|
1844
|
-
log(` Use ${c.cyan}--port <1-65535>${c.reset}.`);
|
|
1845
|
-
process.exit(1);
|
|
1846
|
-
}
|
|
1847
|
-
return port;
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
function daemonMetadataPath(kandownDir) {
|
|
1851
|
-
return join(kandownDir, DAEMON_FILE);
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
function readDaemonMetadata(kandownDir) {
|
|
1855
|
-
const metadataPath = daemonMetadataPath(kandownDir);
|
|
1856
|
-
if (!existsSync(metadataPath)) return null;
|
|
1857
|
-
try {
|
|
1858
|
-
const raw = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
1859
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
1860
|
-
if (!Number.isInteger(raw.pid) || !Number.isInteger(raw.port)) return null;
|
|
1861
|
-
if (typeof raw.url !== 'string' || typeof raw.kandownDir !== 'string') return null;
|
|
1862
|
-
return raw;
|
|
1863
|
-
} catch {
|
|
1864
|
-
return null;
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
function writeDaemonMetadata(kandownDir, metadata) {
|
|
1869
|
-
atomicWriteFileSync(daemonMetadataPath(kandownDir), JSON.stringify(metadata, null, 2) + '\n');
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
function removeDaemonMetadata(kandownDir) {
|
|
1873
|
-
try {
|
|
1874
|
-
if (existsSync(daemonMetadataPath(kandownDir))) unlinkSync(daemonMetadataPath(kandownDir));
|
|
1875
|
-
} catch { /* ignore cleanup failure */ }
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
function ensureDaemonGitignore(kandownDir) {
|
|
1879
|
-
const gitignorePath = join(kandownDir, '.gitignore');
|
|
1880
|
-
// 📖 Runtime files that must never be committed: daemon metadata + spawn lock.
|
|
1881
|
-
const runtimeEntries = [DAEMON_FILE, 'daemon.lock'];
|
|
1882
|
-
try {
|
|
1883
|
-
if (!existsSync(gitignorePath)) {
|
|
1884
|
-
writeFileSync(gitignorePath, runtimeEntries.join('\n') + '\n', 'utf8');
|
|
1885
|
-
return;
|
|
1886
|
-
}
|
|
1887
|
-
const existing = readFileSync(gitignorePath, 'utf8');
|
|
1888
|
-
const lines = existing.split(/\r?\n/);
|
|
1889
|
-
const missing = runtimeEntries.filter(entry => !lines.includes(entry));
|
|
1890
|
-
if (missing.length > 0) {
|
|
1891
|
-
writeFileSync(gitignorePath, `${existing.trimEnd()}\n${missing.join('\n')}\n`, 'utf8');
|
|
1892
|
-
}
|
|
1893
|
-
} catch { /* ignore gitignore best-effort failure */ }
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
function isProcessAlive(pid) {
|
|
1897
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1898
|
-
try {
|
|
1899
|
-
process.kill(pid, 0);
|
|
1900
|
-
return true;
|
|
1901
|
-
} catch {
|
|
1902
|
-
return false;
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
async function fetchDaemonInfo(port) {
|
|
1907
|
-
try {
|
|
1908
|
-
const res = await fetch(`http://127.0.0.1:${port}/api/daemon`, {
|
|
1909
|
-
signal: AbortSignal.timeout(700),
|
|
1910
|
-
});
|
|
1911
|
-
if (!res.ok) return null;
|
|
1912
|
-
return await res.json();
|
|
1913
|
-
} catch {
|
|
1914
|
-
return null;
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
/**
|
|
1919
|
-
* 📖 Fast, dependency-free TCP probe. Returns true the instant a port accepts
|
|
1920
|
-
* a connection — i.e. the moment the daemon has actually bound its socket.
|
|
1921
|
-
* Used instead of an HTTP fetch to detect startup, because Node's fetch
|
|
1922
|
-
* (undici) can fail to recover from the initial ECONNREFUSED window (fetches
|
|
1923
|
-
* sent before the server binds) and report a healthy local server as down for
|
|
1924
|
-
* seconds, which orphaned freshly-started multi-project daemons. A raw TCP
|
|
1925
|
-
* connect has no connection-pool state to poison, so it reliably tracks the
|
|
1926
|
-
* real liveness of the socket.
|
|
1927
|
-
*/
|
|
1928
|
-
function isPortListening(port, timeoutMs = 400) {
|
|
1929
|
-
return new Promise((resolve) => {
|
|
1930
|
-
const socket = createConnection({ port, host: '127.0.0.1' }, () => {
|
|
1931
|
-
socket.destroy();
|
|
1932
|
-
resolve(true);
|
|
1933
|
-
});
|
|
1934
|
-
socket.on('error', () => resolve(false));
|
|
1935
|
-
socket.setTimeout(timeoutMs);
|
|
1936
|
-
socket.on('timeout', () => {
|
|
1937
|
-
socket.destroy();
|
|
1938
|
-
resolve(false);
|
|
1939
|
-
});
|
|
1940
|
-
});
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
|
-
async function getDaemonStatus(kandownDir) {
|
|
1944
|
-
const metadata = readDaemonMetadata(kandownDir);
|
|
1945
|
-
if (!metadata) return { running: false, metadata: null };
|
|
1946
|
-
if (!isProcessAlive(metadata.pid)) {
|
|
1947
|
-
removeDaemonMetadata(kandownDir);
|
|
1948
|
-
return { running: false, metadata: null };
|
|
1949
|
-
}
|
|
1950
|
-
const remote = await fetchDaemonInfo(metadata.port);
|
|
1951
|
-
if (!remote) {
|
|
1952
|
-
// 📖 Transient fetch failure (server still warming up, undici connection
|
|
1953
|
-
// hiccup, high load). The owning process IS alive (checked above), so we
|
|
1954
|
-
// must NOT destroy the metadata here — otherwise starting a 2nd kandown
|
|
1955
|
-
// project races the just-written metadata and orphans a healthy daemon.
|
|
1956
|
-
// Return non-running so callers retry; the metadata stays intact.
|
|
1957
|
-
return { running: false, metadata: null };
|
|
1958
|
-
}
|
|
1959
|
-
if (remote.kandownDir !== kandownDir || remote.pid !== metadata.pid) {
|
|
1960
|
-
// 📖 Real conflict: the port is owned by a DIFFERENT kandown (another
|
|
1961
|
-
// project, or a reincarnated PID). That is a genuine stale entry — remove.
|
|
1962
|
-
removeDaemonMetadata(kandownDir);
|
|
1963
|
-
return { running: false, metadata: null };
|
|
1964
|
-
}
|
|
1965
|
-
return { running: true, metadata };
|
|
1966
|
-
}
|
|
1967
|
-
|
|
1968
|
-
function refreshKandownHtml(kandownDir) {
|
|
1969
|
-
const htmlDest = join(kandownDir, 'kandown.html');
|
|
1970
|
-
const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
|
|
1971
|
-
if (existsSync(htmlDest) && existsSync(htmlSrc)) {
|
|
1972
|
-
copyFileSync(htmlSrc, htmlDest);
|
|
1973
|
-
return true;
|
|
1974
|
-
}
|
|
1975
|
-
return false;
|
|
1976
|
-
}
|
|
1977
|
-
|
|
1978
|
-
function daemonScanPorts() {
|
|
1979
|
-
const ports = [];
|
|
1980
|
-
for (let port = START_PORT_RANGE; port <= END_PORT_RANGE; port++) {
|
|
1981
|
-
if (!isBrowserUnsafePort(port)) ports.push(port);
|
|
1982
|
-
}
|
|
1983
|
-
return ports;
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
async function listRunningDaemons() {
|
|
1987
|
-
const daemons = await Promise.all(daemonScanPorts().map(port => fetchDaemonInfo(port)));
|
|
1988
|
-
const byDir = new Map();
|
|
1989
|
-
for (const daemon of daemons) {
|
|
1990
|
-
if (!daemon || typeof daemon.kandownDir !== 'string' || daemon.kandownDir.length === 0) continue;
|
|
1991
|
-
if (!byDir.has(daemon.kandownDir)) byDir.set(daemon.kandownDir, daemon);
|
|
1992
|
-
}
|
|
1993
|
-
return [...byDir.values()];
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
async function refreshRunningDaemons({ forceRestart = false } = {}) {
|
|
1997
|
-
const daemons = await listRunningDaemons();
|
|
1998
|
-
const currentVersion = getCurrentVersion();
|
|
1999
|
-
const results = {
|
|
2000
|
-
found: daemons.length,
|
|
2001
|
-
refreshed: 0,
|
|
2002
|
-
restarted: 0,
|
|
2003
|
-
skipped: 0,
|
|
2004
|
-
failed: [],
|
|
2005
|
-
};
|
|
2006
|
-
|
|
2007
|
-
for (const daemon of daemons) {
|
|
2008
|
-
const kandownDir = daemon.kandownDir;
|
|
2009
|
-
try {
|
|
2010
|
-
if (refreshKandownHtml(kandownDir)) results.refreshed++;
|
|
2011
|
-
const current = await getDaemonStatus(kandownDir);
|
|
2012
|
-
const daemonVersion = daemon.version || current.metadata?.version || null;
|
|
2013
|
-
const shouldRestart = forceRestart || !daemonVersion || (currentVersion && semverGt(currentVersion, daemonVersion) > 0);
|
|
2014
|
-
if (!shouldRestart) {
|
|
2015
|
-
results.skipped++;
|
|
2016
|
-
continue;
|
|
2017
|
-
}
|
|
2018
|
-
const preferredPort = daemon.port || current.metadata?.port || null;
|
|
2019
|
-
await stopDaemon(kandownDir);
|
|
2020
|
-
const restarted = await startDaemon(kandownDir, preferredPort);
|
|
2021
|
-
if (!restarted.running) throw new Error('daemon failed to restart');
|
|
2022
|
-
results.restarted++;
|
|
2023
|
-
} catch (e) {
|
|
2024
|
-
results.failed.push({ kandownDir, error: e.message });
|
|
2025
|
-
}
|
|
2026
|
-
}
|
|
2027
|
-
|
|
2028
|
-
return results;
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
async function refreshRunningProjectHtml() {
|
|
2032
|
-
const results = await refreshRunningDaemons({ forceRestart: false });
|
|
2033
|
-
return results.refreshed;
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
async function waitForDaemon(kandownDir, timeoutMs = 8000) {
|
|
2037
|
-
// 📖 Detect daemon startup via TCP probe + process liveness rather than HTTP
|
|
2038
|
-
// fetch. The freshly spawned child is known to be ours, so once its process
|
|
2039
|
-
// is alive AND its port accepts a TCP connection, the daemon is up — no need
|
|
2040
|
-
// to wait for an HTTP round-trip that undici may not serve reliably during
|
|
2041
|
-
// the bind window. The dir/pid ownership check still happens later via
|
|
2042
|
-
// getDaemonStatus() for the status/stop commands.
|
|
2043
|
-
const started = Date.now();
|
|
2044
|
-
while (Date.now() - started < timeoutMs) {
|
|
2045
|
-
const metadata = readDaemonMetadata(kandownDir);
|
|
2046
|
-
if (metadata && isProcessAlive(metadata.pid)) {
|
|
2047
|
-
if (await isPortListening(metadata.port)) {
|
|
2048
|
-
return { running: true, metadata };
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
await new Promise(r => setTimeout(r, 120));
|
|
2052
|
-
}
|
|
2053
|
-
return { running: false, metadata: null };
|
|
2054
|
-
}
|
|
2055
|
-
|
|
2056
|
-
/**
|
|
2057
|
-
* 📖 Spawn lock (M7): two `kandown` processes started at the same moment in
|
|
2058
|
-
* the same project must not BOTH spawn a daemon (the loser's daemon.json
|
|
2059
|
-
* write would orphan the winner's daemon). O_EXCL file creation is the mutex;
|
|
2060
|
-
* the loser just waits for the winner's daemon to come up. A lock older than
|
|
2061
|
-
* 15s is stale (crashed spawner) and gets stolen.
|
|
2062
|
-
*/
|
|
2063
|
-
function acquireDaemonSpawnLock(kandownDir) {
|
|
2064
|
-
const lockPath = join(kandownDir, 'daemon.lock');
|
|
2065
|
-
try {
|
|
2066
|
-
const fd = openSync(lockPath, 'wx');
|
|
2067
|
-
writeSync(fd, String(process.pid));
|
|
2068
|
-
closeSync(fd);
|
|
2069
|
-
return lockPath;
|
|
2070
|
-
} catch (e) {
|
|
2071
|
-
if (e.code !== 'EEXIST') return null;
|
|
2072
|
-
try {
|
|
2073
|
-
if (Date.now() - statSync(lockPath).mtimeMs > 15_000) {
|
|
2074
|
-
unlinkSync(lockPath);
|
|
2075
|
-
return acquireDaemonSpawnLock(kandownDir);
|
|
2076
|
-
}
|
|
2077
|
-
} catch { /* lock vanished — racing is fine, caller waits */ }
|
|
2078
|
-
return null;
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
function releaseDaemonSpawnLock(lockPath) {
|
|
2083
|
-
try { unlinkSync(lockPath); } catch { /* already gone */ }
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
|
-
async function startDaemon(kandownDir, preferredPort) {
|
|
2087
|
-
const current = await getDaemonStatus(kandownDir);
|
|
2088
|
-
if (current.running) {
|
|
2089
|
-
const currentVersion = getCurrentVersion();
|
|
2090
|
-
const daemonVersion = current.metadata?.version || null;
|
|
2091
|
-
// 📖 Deep-link routes and other daemon-owned behavior live in the long-running
|
|
2092
|
-
// daemon process, not in kandown.html. If the CLI updated while a daemon was
|
|
2093
|
-
// already running, reconnecting to the old process keeps old routing alive
|
|
2094
|
-
// (e.g. refreshing `/058?p=suzu` returned 404). Restart only when the local
|
|
2095
|
-
// CLI is newer; newer/dev daemons are left untouched.
|
|
2096
|
-
if (!daemonVersion || semverGt(currentVersion, daemonVersion) > 0) {
|
|
2097
|
-
warn(`Restarting daemon v${daemonVersion || 'unknown'} → v${currentVersion}...`);
|
|
2098
|
-
await stopDaemon(kandownDir);
|
|
2099
|
-
} else {
|
|
2100
|
-
return current;
|
|
2101
|
-
}
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
const lock = acquireDaemonSpawnLock(kandownDir);
|
|
2105
|
-
if (!lock) {
|
|
2106
|
-
// 📖 Another process is spawning the daemon right now — just wait for it.
|
|
2107
|
-
return waitForDaemon(kandownDir);
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
try {
|
|
2111
|
-
// Double-checked: the daemon may have come up while we acquired the lock.
|
|
2112
|
-
const recheck = await getDaemonStatus(kandownDir);
|
|
2113
|
-
if (recheck.running) return recheck;
|
|
2114
|
-
|
|
2115
|
-
removeDaemonMetadata(kandownDir);
|
|
2116
|
-
ensureDaemonGitignore(kandownDir);
|
|
2117
|
-
const daemonArgs = [
|
|
2118
|
-
process.argv[1],
|
|
2119
|
-
'--no-update-check',
|
|
2120
|
-
'daemon',
|
|
2121
|
-
'run',
|
|
2122
|
-
'--path',
|
|
2123
|
-
kandownDir,
|
|
2124
|
-
];
|
|
2125
|
-
if (preferredPort !== null) daemonArgs.push('--port', String(preferredPort));
|
|
2126
|
-
|
|
2127
|
-
const child = spawn(process.execPath, daemonArgs, {
|
|
2128
|
-
cwd: dirname(kandownDir),
|
|
2129
|
-
detached: true,
|
|
2130
|
-
stdio: 'ignore',
|
|
2131
|
-
env: { ...process.env, KANDOWN_DAEMON: '1' },
|
|
2132
|
-
});
|
|
2133
|
-
child.unref();
|
|
2134
|
-
|
|
2135
|
-
return await waitForDaemon(kandownDir);
|
|
2136
|
-
} finally {
|
|
2137
|
-
releaseDaemonSpawnLock(lock);
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
/**
|
|
2142
|
-
* 📖 Guard against PID reuse before killing: the PID is only "ours" if the
|
|
2143
|
-
* daemon API confirms ownership, or — when the API is unreachable (wedged /
|
|
2144
|
-
* still starting) — the OS process table shows a kandown process launched for
|
|
2145
|
-
* THIS project. Without this, stale metadata left by a crash could point at a
|
|
2146
|
-
* recycled PID and stopDaemon would SIGKILL an unrelated process.
|
|
2147
|
-
*/
|
|
2148
|
-
async function isOwnedKandownDaemon(pid, port, kandownDir) {
|
|
2149
|
-
const remote = await fetchDaemonInfo(port);
|
|
2150
|
-
if (remote) return remote.pid === pid && remote.kandownDir === kandownDir;
|
|
2151
|
-
try {
|
|
2152
|
-
const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', timeout: 2000 }).trim();
|
|
2153
|
-
return /kandown/.test(cmd) && cmd.includes(kandownDir);
|
|
2154
|
-
} catch {
|
|
2155
|
-
return false;
|
|
2156
|
-
}
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
async function stopDaemon(kandownDir) {
|
|
2160
|
-
const metadata = readDaemonMetadata(kandownDir);
|
|
2161
|
-
if (!metadata) return false;
|
|
2162
|
-
|
|
2163
|
-
const pid = metadata.pid;
|
|
2164
|
-
if (isProcessAlive(pid)) {
|
|
2165
|
-
if (!(await isOwnedKandownDaemon(pid, metadata.port, kandownDir))) {
|
|
2166
|
-
// Alive PID that isn't our daemon (recycled PID / stale metadata) — never kill.
|
|
2167
|
-
removeDaemonMetadata(kandownDir);
|
|
2168
|
-
return false;
|
|
2169
|
-
}
|
|
2170
|
-
try {
|
|
2171
|
-
process.kill(pid, 'SIGTERM');
|
|
2172
|
-
} catch { /* already stopped */ }
|
|
2173
|
-
|
|
2174
|
-
const started = Date.now();
|
|
2175
|
-
let killed = false;
|
|
2176
|
-
while (Date.now() - started < 2500) {
|
|
2177
|
-
if (!isProcessAlive(pid)) {
|
|
2178
|
-
killed = true;
|
|
2179
|
-
break;
|
|
2180
|
-
}
|
|
2181
|
-
await new Promise(r => setTimeout(r, 100));
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
if (!killed && isProcessAlive(pid)) {
|
|
2185
|
-
try {
|
|
2186
|
-
process.kill(pid, 'SIGKILL');
|
|
2187
|
-
} catch {}
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
|
-
|
|
2191
|
-
removeDaemonMetadata(kandownDir);
|
|
2192
|
-
return true;
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
/**
|
|
2196
|
-
* 📖 API auth token (M5). Generated fresh at every daemon start, stored in
|
|
2197
|
-
* daemon.json (CLI/TUI read it there) and injected into the served HTML as
|
|
2198
|
-
* `window.__KANDOWN_TOKEN__`. Every API route except `GET /api/daemon`
|
|
2199
|
-
* requires it via the `X-Kandown-Token` header — a drive-by web page scanning
|
|
2200
|
-
* localhost ports can no longer read or write the task files.
|
|
2201
|
-
*/
|
|
2202
|
-
const DAEMON_TOKEN = randomBytes(24).toString('hex');
|
|
2203
|
-
|
|
2204
|
-
function apiHeaders() {
|
|
2205
|
-
// 📖 No Access-Control-Allow-Origin (M5): the web UI is served same-origin
|
|
2206
|
-
// by this daemon, so cross-origin browser access is intentionally blocked.
|
|
2207
|
-
// Non-browser clients (CLI, TUI, curl) are unaffected by CORS.
|
|
2208
|
-
return {
|
|
2209
|
-
'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE, OPTIONS',
|
|
2210
|
-
'Access-Control-Allow-Headers': 'Content-Type, X-Kandown-Token',
|
|
2211
|
-
};
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
|
-
function handleCors(res) {
|
|
2215
|
-
res.writeHead(204, apiHeaders());
|
|
2216
|
-
res.end();
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
/**
|
|
2220
|
-
* 📖 Token gate for API routes. Returns true when the request carries the
|
|
2221
|
-
* daemon token; otherwise answers 401 and returns false.
|
|
2222
|
-
*/
|
|
2223
|
-
function requireToken(req, res) {
|
|
2224
|
-
const tokenHeader = req.headers['x-kandown-token'];
|
|
2225
|
-
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
2226
|
-
const tokenQuery = requestUrl.searchParams.get('token');
|
|
2227
|
-
if (tokenHeader === DAEMON_TOKEN || tokenQuery === DAEMON_TOKEN) return true;
|
|
2228
|
-
writeJson(res, 401, { error: 'missing or invalid X-Kandown-Token' });
|
|
2229
|
-
return false;
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
const sseClients = new Set();
|
|
2233
|
-
|
|
2234
|
-
function broadcastSseEvent(eventData) {
|
|
2235
|
-
const payload = `data: ${JSON.stringify(eventData)}\n\n`;
|
|
2236
|
-
for (const client of sseClients) {
|
|
2237
|
-
try {
|
|
2238
|
-
client.write(payload);
|
|
2239
|
-
} catch {
|
|
2240
|
-
sseClients.delete(client);
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
|
|
2245
|
-
setInterval(() => {
|
|
2246
|
-
for (const client of sseClients) {
|
|
2247
|
-
try {
|
|
2248
|
-
client.write(':\n\n');
|
|
2249
|
-
} catch {
|
|
2250
|
-
sseClients.delete(client);
|
|
2251
|
-
}
|
|
2252
|
-
}
|
|
2253
|
-
}, 15000);
|
|
2254
|
-
|
|
2255
|
-
function writeJson(res, status, body) {
|
|
2256
|
-
const headers = { ...apiHeaders(), 'Content-Type': 'application/json' };
|
|
2257
|
-
res.writeHead(status, headers);
|
|
2258
|
-
res.end(JSON.stringify(body));
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
function writeText(res, status, body, contentType = 'text/plain; charset=utf-8') {
|
|
2262
|
-
res.writeHead(status, { ...apiHeaders(), 'Content-Type': contentType });
|
|
2263
|
-
res.end(body);
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
// 📖 Body size cap: task files and configs are small — anything above 10 MB
|
|
2267
|
-
// is a bug or abuse, and buffering it would balloon the daemon's memory.
|
|
2268
|
-
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
2269
|
-
|
|
2270
|
-
function readBody(req) {
|
|
2271
|
-
return new Promise((resolve, reject) => {
|
|
2272
|
-
const chunks = [];
|
|
2273
|
-
let total = 0;
|
|
2274
|
-
req.on('data', chunk => {
|
|
2275
|
-
total += chunk.length;
|
|
2276
|
-
if (total > MAX_BODY_BYTES) {
|
|
2277
|
-
const e = new Error('Request body too large (max 10 MB)');
|
|
2278
|
-
e.statusCode = 413;
|
|
2279
|
-
req.destroy();
|
|
2280
|
-
reject(e);
|
|
2281
|
-
return;
|
|
2282
|
-
}
|
|
2283
|
-
chunks.push(chunk);
|
|
2284
|
-
});
|
|
2285
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
2286
|
-
req.on('error', reject);
|
|
2287
|
-
});
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
/**
|
|
2291
|
-
* 📖 Resolves the agent hook configuration from environment variables.
|
|
2292
|
-
*
|
|
2293
|
-
* Strictly opt-in: if KANDOWN_AGENT_HOOK_URL is not set, returns null and no
|
|
2294
|
-
* agent-related UI surfaces in the web app. This is the only check the UI
|
|
2295
|
-
* uses to decide whether to show the "Send to Agent" button.
|
|
2296
|
-
*
|
|
2297
|
-
* Env vars (all optional except URL):
|
|
2298
|
-
* - KANDOWN_AGENT_HOOK_URL target URL (POST receives `{action, task, context}`)
|
|
2299
|
-
* - KANDOWN_AGENT_HOOK_LABEL button label (default: "Agent")
|
|
2300
|
-
* - KANDOWN_AGENT_HOOK_HEADERS JSON object of extra headers (default: {})
|
|
2301
|
-
*
|
|
2302
|
-
* Malformed HEADERS are silently ignored so a typo never disables the hook.
|
|
2303
|
-
*/
|
|
2304
|
-
function loadAgentHook() {
|
|
2305
|
-
const url = process.env.KANDOWN_AGENT_HOOK_URL;
|
|
2306
|
-
if (!url) return null;
|
|
2307
|
-
const label = process.env.KANDOWN_AGENT_HOOK_LABEL || 'Agent';
|
|
2308
|
-
const headers = {};
|
|
2309
|
-
const raw = process.env.KANDOWN_AGENT_HOOK_HEADERS;
|
|
2310
|
-
if (raw) {
|
|
2311
|
-
try {
|
|
2312
|
-
const parsed = JSON.parse(raw);
|
|
2313
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
2314
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
2315
|
-
if (typeof v === 'string') headers[k] = v;
|
|
2316
|
-
}
|
|
2317
|
-
}
|
|
2318
|
-
} catch {
|
|
2319
|
-
// Malformed JSON — ignore and ship with the default empty headers.
|
|
2320
|
-
}
|
|
2321
|
-
}
|
|
2322
|
-
return { url, label, headers };
|
|
2323
|
-
}
|
|
2324
|
-
|
|
2325
|
-
/**
|
|
2326
|
-
* 📖 Forwards a task to the configured agent hook. Returns a status object
|
|
2327
|
-
* the HTTP handler converts to a JSON response. Failures (network, non-2xx,
|
|
2328
|
-
* timeout) are surfaced as 502 so the UI can show a useful error.
|
|
2329
|
-
*/
|
|
2330
|
-
async function postAgentTask(hook, taskMarkdown, id, kandownDir) {
|
|
2331
|
-
const controller = new AbortController();
|
|
2332
|
-
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
2333
|
-
try {
|
|
2334
|
-
const res = await fetch(hook.url, {
|
|
2335
|
-
method: 'POST',
|
|
2336
|
-
headers: {
|
|
2337
|
-
'Content-Type': 'application/json',
|
|
2338
|
-
...hook.headers,
|
|
2339
|
-
},
|
|
2340
|
-
body: JSON.stringify({
|
|
2341
|
-
action: 'agent',
|
|
2342
|
-
task: { id, content: taskMarkdown },
|
|
2343
|
-
context: {
|
|
2344
|
-
tasksDir: getTasksDir(kandownDir),
|
|
2345
|
-
cwd: getProjectRoot(kandownDir),
|
|
2346
|
-
schema: 'kandown',
|
|
2347
|
-
},
|
|
2348
|
-
}),
|
|
2349
|
-
signal: controller.signal,
|
|
2350
|
-
});
|
|
2351
|
-
const body = await res.text().catch(() => '');
|
|
2352
|
-
return { ok: res.ok, status: res.status, body };
|
|
2353
|
-
} catch (e) {
|
|
2354
|
-
const message = e && e.name === 'AbortError' ? 'agent hook timed out (8s)' : `agent hook failed: ${e.message}`;
|
|
2355
|
-
return { ok: false, status: 502, body: message };
|
|
2356
|
-
} finally {
|
|
2357
|
-
clearTimeout(timeout);
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
function postTaskToAgent(req, res, kandownDir, id) {
|
|
2362
|
-
if (!isValidTaskId(id)) {
|
|
2363
|
-
return writeText(res, 400, 'Invalid task id');
|
|
2364
|
-
}
|
|
2365
|
-
const hook = loadAgentHook();
|
|
2366
|
-
if (!hook) {
|
|
2367
|
-
return writeJson(res, 501, { error: 'agent hook not configured (set KANDOWN_AGENT_HOOK_URL)' });
|
|
2368
|
-
}
|
|
2369
|
-
const taskPath = findTaskPath(kandownDir, id);
|
|
2370
|
-
if (!taskPath) {
|
|
2371
|
-
return writeJson(res, 404, { error: 'task not found' });
|
|
2372
|
-
}
|
|
2373
|
-
let taskMarkdown;
|
|
2374
|
-
try {
|
|
2375
|
-
taskMarkdown = readFileSync(taskPath, 'utf8');
|
|
2376
|
-
} catch (e) {
|
|
2377
|
-
return writeJson(res, 500, { error: `failed to read task: ${e.message}` });
|
|
2378
|
-
}
|
|
2379
|
-
postAgentTask(hook, taskMarkdown, id, kandownDir).then(result => {
|
|
2380
|
-
if (!result.ok) {
|
|
2381
|
-
return writeJson(res, 502, { error: result.body || `agent hook returned ${result.status}` });
|
|
2382
|
-
}
|
|
2383
|
-
return writeJson(res, 200, { ok: true });
|
|
2384
|
-
}).catch(e => {
|
|
2385
|
-
writeJson(res, 500, { error: e.message });
|
|
2386
|
-
});
|
|
2387
|
-
}
|
|
2388
|
-
|
|
2389
|
-
function isValidTaskId(id) {
|
|
2390
|
-
return /^[a-zA-Z0-9_-]+$/.test(id);
|
|
2391
|
-
}
|
|
2392
|
-
|
|
2393
|
-
function getConfig(res, kandownDir) {
|
|
2394
|
-
const configPath = join(kandownDir, 'kandown.json');
|
|
2395
|
-
if (!existsSync(configPath)) {
|
|
2396
|
-
writeJson(res, 404, { error: 'kandown.json not found' });
|
|
2397
|
-
return;
|
|
2398
|
-
}
|
|
2399
|
-
try {
|
|
2400
|
-
const content = readFileSync(configPath, 'utf8');
|
|
2401
|
-
const config = JSON.parse(content);
|
|
2402
|
-
writeJson(res, 200, config);
|
|
2403
|
-
} catch (e) {
|
|
2404
|
-
writeJson(res, 500, { error: `Failed to read config: ${e.message}` });
|
|
2405
|
-
}
|
|
2406
|
-
}
|
|
2407
|
-
|
|
2408
|
-
/**
|
|
2409
|
-
* 📖 Shape validation for kandown.json writes: "is valid JSON" is not enough —
|
|
2410
|
-
* a stray `[]` or `null` body would overwrite the config and silently reset
|
|
2411
|
-
* every setting on the next load. Requires a plain object, and when `board`
|
|
2412
|
-
* or `board.columns` are present they must have the right shape.
|
|
2413
|
-
*/
|
|
2414
|
-
function isValidConfigShape(parsed) {
|
|
2415
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
2416
|
-
if ('board' in parsed) {
|
|
2417
|
-
const board = parsed.board;
|
|
2418
|
-
if (!board || typeof board !== 'object' || Array.isArray(board)) return false;
|
|
2419
|
-
if ('columns' in board) {
|
|
2420
|
-
if (!Array.isArray(board.columns)) return false;
|
|
2421
|
-
if (!board.columns.every(col => typeof col === 'string' && col.trim().length > 0)) return false;
|
|
2422
|
-
}
|
|
2423
|
-
}
|
|
2424
|
-
return true;
|
|
2425
|
-
}
|
|
2426
|
-
|
|
2427
|
-
function putConfig(req, res, kandownDir) {
|
|
2428
|
-
readBody(req).then(body => {
|
|
2429
|
-
let parsed;
|
|
2430
|
-
try {
|
|
2431
|
-
parsed = JSON.parse(body);
|
|
2432
|
-
} catch {
|
|
2433
|
-
return writeJson(res, 400, { error: 'Invalid JSON' });
|
|
2434
|
-
}
|
|
2435
|
-
if (!isValidConfigShape(parsed)) {
|
|
2436
|
-
return writeJson(res, 400, { error: 'Invalid config shape: expected an object (board.columns must be a non-empty string array)' });
|
|
2437
|
-
}
|
|
2438
|
-
const configPath = join(kandownDir, 'kandown.json');
|
|
2439
|
-
atomicWriteFileSync(configPath, body);
|
|
2440
|
-
writeJson(res, 200, { ok: true });
|
|
2441
|
-
}).catch(e => {
|
|
2442
|
-
writeJson(res, e.statusCode || 500, { error: `Failed to read body: ${e.message}` });
|
|
2443
|
-
});
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
function getInstructions(res, kandownDir) {
|
|
2447
|
-
const instructionsPath = join(kandownDir, 'instructions.md');
|
|
2448
|
-
if (!existsSync(instructionsPath)) {
|
|
2449
|
-
writeText(res, 200, '');
|
|
2450
|
-
return;
|
|
2451
|
-
}
|
|
2452
|
-
try {
|
|
2453
|
-
writeText(res, 200, readFileSync(instructionsPath, 'utf8'));
|
|
2454
|
-
} catch (e) {
|
|
2455
|
-
writeText(res, 500, `Failed to read instructions: ${e.message}`);
|
|
2456
|
-
}
|
|
2457
|
-
}
|
|
2458
|
-
|
|
2459
|
-
function putInstructions(req, res, kandownDir) {
|
|
2460
|
-
readBody(req).then(body => {
|
|
2461
|
-
const instructionsPath = join(kandownDir, 'instructions.md');
|
|
2462
|
-
const normalized = body.trim() ? body.replace(/\s+$/, '') + '\n' : '';
|
|
2463
|
-
atomicWriteFileSync(instructionsPath, normalized);
|
|
2464
|
-
writeJson(res, 200, { ok: true });
|
|
2465
|
-
}).catch(e => {
|
|
2466
|
-
writeJson(res, e.statusCode || 500, { error: `Failed to write instructions: ${e.message}` });
|
|
2467
|
-
});
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
function getBoard(res, kandownDir) {
|
|
2471
|
-
const boardPath = join(kandownDir, 'board.md');
|
|
2472
|
-
if (!existsSync(boardPath)) {
|
|
2473
|
-
writeText(res, 404, 'board.md not found');
|
|
2474
|
-
return;
|
|
2475
|
-
}
|
|
2476
|
-
try {
|
|
2477
|
-
const content = readFileSync(boardPath, 'utf8');
|
|
2478
|
-
writeText(res, 200, content);
|
|
2479
|
-
} catch (e) {
|
|
2480
|
-
writeText(res, 500, `Failed to read board: ${e.message}`);
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
function putBoard(req, res, kandownDir) {
|
|
2485
|
-
readBody(req).then(body => {
|
|
2486
|
-
const boardPath = join(kandownDir, 'board.md');
|
|
2487
|
-
atomicWriteFileSync(boardPath, body);
|
|
2488
|
-
writeJson(res, 200, { ok: true });
|
|
2489
|
-
}).catch(e => {
|
|
2490
|
-
writeJson(res, 500, { error: `Failed to write board: ${e.message}` });
|
|
2491
|
-
});
|
|
2492
|
-
}
|
|
2493
|
-
|
|
2494
|
-
/**
|
|
2495
|
-
* 📖 Resolves the on-disk path of a task id, searching the active tasks dir
|
|
2496
|
-
* first, then the archive subfolder. Returns null when the id exists in
|
|
2497
|
-
* neither location. Used by every CRUD handler so archived tasks stay
|
|
2498
|
-
* reachable at their real location.
|
|
2499
|
-
*
|
|
2500
|
-
* Tasks live at the project root in `./tasks/` (sibling of `.kandown/`),
|
|
2501
|
-
* not inside `.kandown/tasks/`.
|
|
2502
|
-
*/
|
|
2503
|
-
function findTaskPath(kandownDir, id) {
|
|
2504
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
2505
|
-
const inTasks = join(tasksDir, `${id}.md`);
|
|
2506
|
-
if (existsSync(inTasks)) return inTasks;
|
|
2507
|
-
const inArchive = join(tasksDir, 'archive', `${id}.md`);
|
|
2508
|
-
if (existsSync(inArchive)) return inArchive;
|
|
2509
|
-
return null;
|
|
2510
|
-
}
|
|
2511
|
-
|
|
2512
|
-
function getTasks(res, kandownDir) {
|
|
2513
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
2514
|
-
const archiveDir = join(tasksDir, 'archive');
|
|
2515
|
-
const ids = new Set();
|
|
2516
|
-
try {
|
|
2517
|
-
if (existsSync(tasksDir)) {
|
|
2518
|
-
for (const f of readdirSync(tasksDir).filter(f => f.endsWith('.md'))) {
|
|
2519
|
-
ids.add(f.replace(/\.md$/, ''));
|
|
2520
|
-
}
|
|
2521
|
-
}
|
|
2522
|
-
// 📖 Also surface archived tasks so the UI can list them in the archive
|
|
2523
|
-
// view. The archived flag in frontmatter (not the folder) is the source of
|
|
2524
|
-
// truth for hiding them from the active board.
|
|
2525
|
-
if (existsSync(archiveDir)) {
|
|
2526
|
-
for (const f of readdirSync(archiveDir).filter(f => f.endsWith('.md'))) {
|
|
2527
|
-
ids.add(f.replace(/\.md$/, ''));
|
|
2528
|
-
}
|
|
2529
|
-
}
|
|
2530
|
-
writeJson(res, 200, [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
|
|
2531
|
-
} catch (e) {
|
|
2532
|
-
writeJson(res, 500, { error: `Failed to list tasks: ${e.message}` });
|
|
2533
|
-
}
|
|
2534
|
-
}
|
|
2535
|
-
|
|
2536
|
-
function getTask(res, kandownDir, id) {
|
|
2537
|
-
if (!isValidTaskId(id)) {
|
|
2538
|
-
writeText(res, 400, 'Invalid task id');
|
|
2539
|
-
return;
|
|
2540
|
-
}
|
|
2541
|
-
const taskPath = findTaskPath(kandownDir, id);
|
|
2542
|
-
if (!taskPath) {
|
|
2543
|
-
writeText(res, 404, 'Task not found');
|
|
2544
|
-
return;
|
|
2545
|
-
}
|
|
2546
|
-
try {
|
|
2547
|
-
const content = readFileSync(taskPath, 'utf8');
|
|
2548
|
-
writeText(res, 200, content);
|
|
2549
|
-
} catch (e) {
|
|
2550
|
-
writeText(res, 500, `Failed to read task: ${e.message}`);
|
|
2551
|
-
}
|
|
2552
|
-
}
|
|
2553
|
-
|
|
2554
|
-
function putTask(req, res, kandownDir, id) {
|
|
2555
|
-
if (!isValidTaskId(id)) {
|
|
2556
|
-
writeText(res, 400, 'Invalid task id');
|
|
2557
|
-
return;
|
|
2558
|
-
}
|
|
2559
|
-
readBody(req).then(body => {
|
|
2560
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
2561
|
-
if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
|
|
2562
|
-
// 📖 Write in place: keep an archived task inside archive/ on save so the
|
|
2563
|
-
// file location never drifts from its archived flag.
|
|
2564
|
-
const existing = findTaskPath(kandownDir, id);
|
|
2565
|
-
const archiveDir = join(tasksDir, 'archive');
|
|
2566
|
-
const inArchive = existing && existing.startsWith(archiveDir);
|
|
2567
|
-
const targetDir = inArchive ? archiveDir : tasksDir;
|
|
2568
|
-
const taskPath = join(targetDir, `${id}.md`);
|
|
2569
|
-
atomicWriteFileSync(taskPath, body);
|
|
2570
|
-
writeJson(res, 200, { ok: true });
|
|
2571
|
-
}).catch(e => {
|
|
2572
|
-
writeJson(res, 500, { error: `Failed to write task: ${e.message}` });
|
|
2573
|
-
});
|
|
2574
|
-
}
|
|
2575
|
-
|
|
2576
|
-
function deleteTask(res, kandownDir, id) {
|
|
2577
|
-
if (!isValidTaskId(id)) {
|
|
2578
|
-
writeText(res, 400, 'Invalid task id');
|
|
2579
|
-
return;
|
|
2580
|
-
}
|
|
2581
|
-
const taskPath = findTaskPath(kandownDir, id);
|
|
2582
|
-
if (!taskPath) {
|
|
2583
|
-
writeJson(res, 404, { error: 'Task not found' });
|
|
2584
|
-
return;
|
|
2585
|
-
}
|
|
2586
|
-
try {
|
|
2587
|
-
unlinkSync(taskPath);
|
|
2588
|
-
writeJson(res, 200, { ok: true });
|
|
2589
|
-
} catch (e) {
|
|
2590
|
-
writeJson(res, 500, { error: `Failed to delete task: ${e.message}` });
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
|
|
2594
|
-
/**
|
|
2595
|
-
* 📖 Archives a task: writes the (already flag-updated) content into
|
|
2596
|
-
* `tasks/archive/<id>.md` and removes the active `tasks/<id>.md` copy.
|
|
2597
|
-
* The body comes pre-flagged from the web client (which knows frontmatter).
|
|
2598
|
-
*/
|
|
2599
|
-
function archiveTask(req, res, kandownDir, id) {
|
|
2600
|
-
if (!isValidTaskId(id)) {
|
|
2601
|
-
writeText(res, 400, 'Invalid task id');
|
|
2602
|
-
return;
|
|
2603
|
-
}
|
|
2604
|
-
readBody(req).then(body => {
|
|
2605
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
2606
|
-
const archiveDir = join(tasksDir, 'archive');
|
|
2607
|
-
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
2608
|
-
atomicWriteFileSync(join(archiveDir, `${id}.md`), body);
|
|
2609
|
-
try {
|
|
2610
|
-
unlinkSync(join(tasksDir, `${id}.md`));
|
|
2611
|
-
} catch { /* already absent */ }
|
|
2612
|
-
writeJson(res, 200, { ok: true });
|
|
2613
|
-
}).catch(e => {
|
|
2614
|
-
writeJson(res, 500, { error: `Failed to archive task: ${e.message}` });
|
|
2615
|
-
});
|
|
2616
|
-
}
|
|
2617
|
-
|
|
2618
|
-
/**
|
|
2619
|
-
* 📖 Unarchives a task: writes the content back into `tasks/<id>.md` and
|
|
2620
|
-
* removes the archived copy. Mirror of archiveTask.
|
|
2621
|
-
*/
|
|
2622
|
-
function unarchiveTask(req, res, kandownDir, id) {
|
|
2623
|
-
if (!isValidTaskId(id)) {
|
|
2624
|
-
writeText(res, 400, 'Invalid task id');
|
|
2625
|
-
return;
|
|
2626
|
-
}
|
|
2627
|
-
readBody(req).then(body => {
|
|
2628
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
2629
|
-
atomicWriteFileSync(join(tasksDir, `${id}.md`), body);
|
|
2630
|
-
try {
|
|
2631
|
-
unlinkSync(join(tasksDir, 'archive', `${id}.md`));
|
|
2632
|
-
} catch { /* already absent */ }
|
|
2633
|
-
writeJson(res, 200, { ok: true });
|
|
2634
|
-
}).catch(e => {
|
|
2635
|
-
writeJson(res, 500, { error: `Failed to unarchive task: ${e.message}` });
|
|
2636
|
-
});
|
|
2637
|
-
}
|
|
2638
|
-
|
|
2639
|
-
/**
|
|
2640
|
-
* 📖 REST endpoint to trigger the silent one-time task migration. The web
|
|
2641
|
-
* client (server mode) can call this on startup to perform the same
|
|
2642
|
-
* `.kandown/tasks/` → `./tasks/` move the CLI does on first access.
|
|
2643
|
-
* Safe to call multiple times — idempotent.
|
|
2644
|
-
*/
|
|
2645
|
-
function postMigrateTasks(res, kandownDir) {
|
|
2646
|
-
try {
|
|
2647
|
-
const result = migrateTasksToTopLevel(kandownDir);
|
|
2648
|
-
writeJson(res, 200, { ok: true, ...result });
|
|
2649
|
-
} catch (e) {
|
|
2650
|
-
writeJson(res, 500, { error: `Migration failed: ${e.message}` });
|
|
2651
|
-
}
|
|
2652
|
-
}
|
|
2653
|
-
|
|
2654
|
-
/**
|
|
2655
|
-
* 📖 The single-file Vite bundle can contain literal strings such as
|
|
2656
|
-
* `</head>` from HTML parser libraries. Use the last closing head tag so the
|
|
2657
|
-
* CLI does not inject server-mode globals into bundled JavaScript text.
|
|
2658
|
-
*/
|
|
2659
|
-
function injectServerRoot(html, kandownDir) {
|
|
2660
|
-
const marker = '</head>';
|
|
2661
|
-
const markerIndex = html.toLowerCase().lastIndexOf(marker);
|
|
2662
|
-
const safeRoot = JSON.stringify(kandownDir).replace(/</g, '\\u003c');
|
|
2663
|
-
// 📖 The token rides along with the root: same-origin page → full API access;
|
|
2664
|
-
// any other page (drive-by localhost scan) never sees it (M5).
|
|
2665
|
-
const safeToken = JSON.stringify(DAEMON_TOKEN).replace(/</g, '\\u003c');
|
|
2666
|
-
const script = `<script>window.__KANDOWN_ROOT__ = ${safeRoot}; window.__KANDOWN_TOKEN__ = ${safeToken};</script>\n`;
|
|
2667
|
-
|
|
2668
|
-
if (markerIndex === -1) return script + html;
|
|
2669
|
-
|
|
2670
|
-
return html.slice(0, markerIndex) + script + html.slice(markerIndex);
|
|
2671
|
-
}
|
|
2672
|
-
|
|
2673
|
-
function handleApi(req, res, url, kandownDir) {
|
|
2674
|
-
const parts = url.pathname.replace('/api/', '').split('/');
|
|
2675
|
-
const resource = parts[0];
|
|
2676
|
-
const id = parts[1];
|
|
2677
|
-
|
|
2678
|
-
// 📖 Token gate (M5): every API route requires X-Kandown-Token, except
|
|
2679
|
-
// GET /api/daemon which stays open — it is the identity endpoint the CLI
|
|
2680
|
-
// and sibling daemons use to verify ownership before they have the token,
|
|
2681
|
-
// and it never exposes the token itself.
|
|
2682
|
-
if (!(resource === 'daemon' && req.method === 'GET')) {
|
|
2683
|
-
if (!requireToken(req, res)) return;
|
|
2684
|
-
}
|
|
2685
|
-
|
|
2686
|
-
if (resource === 'daemon') {
|
|
2687
|
-
if (req.method === 'GET') {
|
|
2688
|
-
const hook = loadAgentHook();
|
|
2689
|
-
return writeJson(res, 200, {
|
|
2690
|
-
ok: true,
|
|
2691
|
-
pid: process.pid,
|
|
2692
|
-
kandownDir,
|
|
2693
|
-
version: getCurrentVersion(),
|
|
2694
|
-
startedAt: daemonStartedAt,
|
|
2695
|
-
agentHook: hook ? { enabled: true, label: hook.label } : null,
|
|
2696
|
-
});
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
if (resource === 'events') {
|
|
2701
|
-
if (req.method === 'GET') {
|
|
2702
|
-
res.writeHead(200, {
|
|
2703
|
-
'Content-Type': 'text/event-stream',
|
|
2704
|
-
'Cache-Control': 'no-cache',
|
|
2705
|
-
'Connection': 'keep-alive',
|
|
2706
|
-
'Access-Control-Allow-Origin': '*',
|
|
2707
|
-
});
|
|
2708
|
-
res.write('data: {"type":"connected"}\n\n');
|
|
2709
|
-
sseClients.add(res);
|
|
2710
|
-
req.on('close', () => {
|
|
2711
|
-
sseClients.delete(res);
|
|
2712
|
-
});
|
|
2713
|
-
return;
|
|
2714
|
-
}
|
|
2715
|
-
}
|
|
2716
|
-
|
|
2717
|
-
if (resource === 'git' && parts[1] === 'history') {
|
|
2718
|
-
const taskId = url.searchParams.get('id');
|
|
2719
|
-
if (!taskId) return writeJson(res, 400, { error: 'Missing task id' });
|
|
2720
|
-
try {
|
|
2721
|
-
const taskPath = findTaskPath(kandownDir, taskId);
|
|
2722
|
-
if (!taskPath) return writeJson(res, 404, { error: 'Task not found' });
|
|
2723
|
-
const relativePath = relative(getProjectRoot(kandownDir), taskPath);
|
|
2724
|
-
const output = execFileSync('git', ['log', '-n', '10', '--follow', '--format=%h|%an|%ar|%s', '--', relativePath], {
|
|
2725
|
-
cwd: getProjectRoot(kandownDir),
|
|
2726
|
-
encoding: 'utf8',
|
|
2727
|
-
}).trim();
|
|
2728
|
-
const commits = output ? output.split('\n').map(line => {
|
|
2729
|
-
const [hash, author, date, message] = line.split('|');
|
|
2730
|
-
return { hash, author, date, message };
|
|
2731
|
-
}) : [];
|
|
2732
|
-
return writeJson(res, 200, { commits });
|
|
2733
|
-
} catch {
|
|
2734
|
-
return writeJson(res, 200, { commits: [] });
|
|
2735
|
-
}
|
|
2736
|
-
}
|
|
2737
|
-
|
|
2738
|
-
if (resource === 'config') {
|
|
2739
|
-
if (req.method === 'GET') return getConfig(res, kandownDir);
|
|
2740
|
-
if (req.method === 'PUT') return putConfig(req, res, kandownDir);
|
|
2741
|
-
}
|
|
2742
|
-
|
|
2743
|
-
if (resource === 'instructions') {
|
|
2744
|
-
if (req.method === 'GET') return getInstructions(res, kandownDir);
|
|
2745
|
-
if (req.method === 'PUT') return putInstructions(req, res, kandownDir);
|
|
2746
|
-
}
|
|
2747
|
-
|
|
2748
|
-
if (resource === 'board') {
|
|
2749
|
-
if (req.method === 'GET') return getBoard(res, kandownDir);
|
|
2750
|
-
if (req.method === 'PUT') return putBoard(req, res, kandownDir);
|
|
2751
|
-
}
|
|
2752
|
-
|
|
2753
|
-
if (resource === 'tasks') {
|
|
2754
|
-
if (req.method === 'GET' && !id) return getTasks(res, kandownDir);
|
|
2755
|
-
if (req.method === 'GET' && id) return getTask(res, kandownDir, id);
|
|
2756
|
-
if (req.method === 'PUT' && id) return putTask(req, res, kandownDir, id);
|
|
2757
|
-
if (req.method === 'DELETE' && id) return deleteTask(res, kandownDir, id);
|
|
2758
|
-
// parts[2] is the sub-resource: 'archive' or 'unarchive'. The body carries
|
|
2759
|
-
// the full task file content with the archived flag already toggled.
|
|
2760
|
-
if (req.method === 'POST' && id && parts[2] === 'archive') return archiveTask(req, res, kandownDir, id);
|
|
2761
|
-
if (req.method === 'POST' && id && parts[2] === 'unarchive') return unarchiveTask(req, res, kandownDir, id);
|
|
2762
|
-
if (req.method === 'POST' && id && parts[2] === 'agent') return postTaskToAgent(req, res, kandownDir, id);
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
|
-
// 📖 Migration endpoint: `POST /api/migrate-tasks` with no id. Idempotent.
|
|
2766
|
-
if (resource === 'migrate-tasks' && req.method === 'POST' && !id) {
|
|
2767
|
-
return postMigrateTasks(res, kandownDir);
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2770
|
-
writeJson(res, 404, { error: 'Not found' });
|
|
2771
|
-
}
|
|
2772
|
-
|
|
2773
|
-
const daemonStartedAt = new Date().toISOString();
|
|
2774
|
-
|
|
2775
|
-
function serveApp(res, kandownDir) {
|
|
2776
|
-
const htmlPath = join(kandownDir, 'kandown.html');
|
|
2777
|
-
if (!existsSync(htmlPath)) {
|
|
2778
|
-
writeText(res, 404, 'kandown.html not found');
|
|
2779
|
-
return;
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
try {
|
|
2783
|
-
const html = readFileSync(htmlPath, 'utf8');
|
|
2784
|
-
const injected = injectServerRoot(html, kandownDir);
|
|
2785
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2786
|
-
res.end(injected);
|
|
2787
|
-
} catch (e) {
|
|
2788
|
-
// 📖 Don't leak internal paths / error details to HTTP clients — log the
|
|
2789
|
-
// full error server-side, send a generic message to the browser (t113).
|
|
2790
|
-
console.error(`[serve] Error serving ${htmlPath}:`, e);
|
|
2791
|
-
writeText(res, 500, 'Internal server error — check the terminal where kandown is running.');
|
|
2792
|
-
}
|
|
2793
|
-
}
|
|
2794
|
-
|
|
2795
|
-
/**
|
|
2796
|
-
* 📖 Creates the local HTTP server used by `kandown` with no arguments.
|
|
2797
|
-
* It serves the single-file web app and exposes placeholder API routes for the
|
|
2798
|
-
* follow-up REST task, keeping this refactor limited to server bootstrapping.
|
|
2799
|
-
*/
|
|
2800
|
-
const ASSET_MIME_TYPES = {
|
|
2801
|
-
'.svg': 'image/svg+xml',
|
|
2802
|
-
'.png': 'image/png',
|
|
2803
|
-
'.ico': 'image/x-icon',
|
|
2804
|
-
'.json': 'application/json',
|
|
2805
|
-
'.webmanifest': 'application/manifest+json',
|
|
2806
|
-
};
|
|
2807
|
-
|
|
2808
|
-
function serveStaticAsset(req, res, pathname) {
|
|
2809
|
-
const fileBasename = pathname.replace(/^\//, '');
|
|
2810
|
-
if (!fileBasename || fileBasename.includes('..')) return false;
|
|
2811
|
-
const assetPath = join(PKG_ROOT, 'public', fileBasename);
|
|
2812
|
-
if (existsSync(assetPath) && statSync(assetPath).isFile()) {
|
|
2813
|
-
const ext = extname(assetPath).toLowerCase();
|
|
2814
|
-
const contentType = ASSET_MIME_TYPES[ext] || 'application/octet-stream';
|
|
2815
|
-
try {
|
|
2816
|
-
handleCors(res);
|
|
2817
|
-
const content = readFileSync(assetPath);
|
|
2818
|
-
if (req.method === 'HEAD') {
|
|
2819
|
-
res.writeHead(200, {
|
|
2820
|
-
'Content-Type': contentType,
|
|
2821
|
-
'Content-Length': content.length,
|
|
2822
|
-
'Cache-Control': 'public, max-age=86400',
|
|
2823
|
-
});
|
|
2824
|
-
res.end();
|
|
2825
|
-
return true;
|
|
2826
|
-
}
|
|
2827
|
-
res.writeHead(200, {
|
|
2828
|
-
'Content-Type': contentType,
|
|
2829
|
-
'Content-Length': content.length,
|
|
2830
|
-
'Cache-Control': 'public, max-age=86400',
|
|
2831
|
-
});
|
|
2832
|
-
res.end(content);
|
|
2833
|
-
return true;
|
|
2834
|
-
} catch {
|
|
2835
|
-
return false;
|
|
2836
|
-
}
|
|
2837
|
-
}
|
|
2838
|
-
return false;
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
function isTaskDeepLinkPath(pathname) {
|
|
2842
|
-
try {
|
|
2843
|
-
const cleaned = decodeURIComponent(pathname).replace(/^\/+|\/+$/g, '');
|
|
2844
|
-
return !!cleaned && !cleaned.includes('/') && !cleaned.includes('.') && /^[A-Za-z0-9_-]+$/.test(cleaned);
|
|
2845
|
-
} catch {
|
|
2846
|
-
return false;
|
|
2847
|
-
}
|
|
2848
|
-
}
|
|
2849
|
-
|
|
2850
|
-
function createServeServer(kandownDir) {
|
|
2851
|
-
try {
|
|
2852
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
2853
|
-
const configPath = join(kandownDir, 'kandown.json');
|
|
2854
|
-
const watcher = watchFs([join(tasksDir, '*.md'), configPath], {
|
|
2855
|
-
ignoreInitial: true,
|
|
2856
|
-
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
|
2857
|
-
});
|
|
2858
|
-
watcher.on('all', (_event, filePath) => {
|
|
2859
|
-
const taskId = filePath.replace(/\\/g, '/').split('/').pop()?.replace(/\.md$/, '') || '';
|
|
2860
|
-
broadcastSseEvent({ type: 'change', taskId });
|
|
2861
|
-
});
|
|
2862
|
-
} catch (e) {
|
|
2863
|
-
console.error('[daemon] File watcher init warning:', e.message);
|
|
2864
|
-
}
|
|
2865
|
-
|
|
2866
|
-
return createServer((req, res) => {
|
|
2867
|
-
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
2868
|
-
if (req.method === 'OPTIONS') return handleCors(res);
|
|
2869
|
-
if (requestUrl.pathname === '/') return serveApp(res, kandownDir);
|
|
2870
|
-
if (requestUrl.pathname.startsWith('/api/')) {
|
|
2871
|
-
return handleApi(req, res, requestUrl, kandownDir);
|
|
2872
|
-
}
|
|
2873
|
-
if (serveStaticAsset(req, res, requestUrl.pathname)) return;
|
|
2874
|
-
if (req.method === 'GET' && isTaskDeepLinkPath(requestUrl.pathname)) return serveApp(res, kandownDir);
|
|
2875
|
-
return writeText(res, 404, 'Not found');
|
|
2876
|
-
});
|
|
2877
|
-
}
|
|
2878
|
-
|
|
2879
|
-
function listen(server, port) {
|
|
2880
|
-
return new Promise((resolveListen, rejectListen) => {
|
|
2881
|
-
const onError = (e) => {
|
|
2882
|
-
server.off('listening', onListening);
|
|
2883
|
-
rejectListen(e);
|
|
2884
|
-
};
|
|
2885
|
-
const onListening = () => {
|
|
2886
|
-
server.off('error', onError);
|
|
2887
|
-
resolveListen();
|
|
2888
|
-
};
|
|
2889
|
-
server.once('error', onError);
|
|
2890
|
-
server.once('listening', onListening);
|
|
2891
|
-
server.listen(port, '127.0.0.1');
|
|
2892
|
-
});
|
|
2893
|
-
}
|
|
2894
|
-
|
|
2895
|
-
async function listenOnAvailablePort(kandownDir, preferredPort) {
|
|
2896
|
-
const port = preferredPort ?? START_PORT_RANGE;
|
|
2897
|
-
const isSinglePort = preferredPort !== null;
|
|
2898
|
-
|
|
2899
|
-
// 📖 Check if the target port is already occupied by a stale kandown process
|
|
2900
|
-
// from the SAME project. A stale process is one whose TUI exited but the HTTP
|
|
2901
|
-
// server is still alive. We ONLY reclaim same-project daemons here — a daemon
|
|
2902
|
-
// belonging to a DIFFERENT project (reason === null) must never be touched;
|
|
2903
|
-
// it is handled by the scan loop below (which skips it).
|
|
2904
|
-
const staleInfo = detectStaleKandown(port, kandownDir);
|
|
2905
|
-
if (staleInfo && staleInfo.reason) {
|
|
2906
|
-
warn(staleInfo.reason);
|
|
2907
|
-
try {
|
|
2908
|
-
process.kill(staleInfo.pid, 'SIGTERM');
|
|
2909
|
-
// Give it a moment to clean up
|
|
2910
|
-
await new Promise(r => setTimeout(r, 300));
|
|
2911
|
-
info(`Reclaimed port ${c.cyan}${port}${c.reset} (killed stale kandown PID ${staleInfo.pid})`);
|
|
2912
|
-
} catch {
|
|
2913
|
-
// Process already dead
|
|
2914
|
-
}
|
|
2915
|
-
}
|
|
2916
|
-
|
|
2917
|
-
// If user specified a specific port, only try that one
|
|
2918
|
-
if (isSinglePort) {
|
|
2919
|
-
if (isBrowserUnsafePort(port)) {
|
|
2920
|
-
err(`Port ${c.bold}${port}${c.reset} is reserved for a well-known service and ${c.bold}blocked by all browsers${c.reset} (net::ERR_UNSAFE_PORT).`);
|
|
2921
|
-
log(` Pick another port with ${c.cyan}--port${c.reset}.`);
|
|
2922
|
-
process.exit(1);
|
|
2923
|
-
}
|
|
2924
|
-
const server = createServeServer(kandownDir);
|
|
2925
|
-
try {
|
|
2926
|
-
await listen(server, port);
|
|
2927
|
-
return { server, port };
|
|
2928
|
-
} catch (e) {
|
|
2929
|
-
if (e.code === 'EADDRINUSE') {
|
|
2930
|
-
err(`Port ${c.bold}${port}${c.reset} is in use by another application.`);
|
|
2931
|
-
process.exit(1);
|
|
2932
|
-
}
|
|
2933
|
-
throw e;
|
|
2934
|
-
}
|
|
2935
|
-
}
|
|
2936
|
-
|
|
2937
|
-
// 📖 Scan range — try ports until one works
|
|
2938
|
-
for (let p = START_PORT_RANGE; p <= END_PORT_RANGE; p++) {
|
|
2939
|
-
// 📖 Skip browser-blocked ports (e.g. 2049 = NFS). A daemon here would
|
|
2940
|
-
// start fine and answer curl, but the browser refuses with
|
|
2941
|
-
// net::ERR_UNSAFE_PORT, so the web UI looks dead. Move to the next port.
|
|
2942
|
-
if (isBrowserUnsafePort(p)) continue;
|
|
2943
|
-
// Skip port if occupied by a stale kandown from a DIFFERENT project
|
|
2944
|
-
const stale = detectStaleKandown(p, kandownDir);
|
|
2945
|
-
if (stale && !stale.reason) {
|
|
2946
|
-
// reason is null → different project, skip this port
|
|
2947
|
-
continue;
|
|
2948
|
-
}
|
|
2949
|
-
if (stale) {
|
|
2950
|
-
// Same project — already killed above for the first port, but handle edge case
|
|
2951
|
-
try { process.kill(stale.pid, 'SIGTERM'); } catch {}
|
|
2952
|
-
await new Promise(r => setTimeout(r, 200));
|
|
2953
|
-
}
|
|
2954
|
-
|
|
2955
|
-
const server = createServeServer(kandownDir);
|
|
2956
|
-
try {
|
|
2957
|
-
await listen(server, p);
|
|
2958
|
-
return { server, port: p };
|
|
2959
|
-
} catch (e) {
|
|
2960
|
-
// 📖 EADDRINUSE → try the next port. EACCES (privileged port / blocked
|
|
2961
|
-
// by OS) → also try the next port instead of crashing (t113). Anything
|
|
2962
|
-
// else is unexpected and bubbles up.
|
|
2963
|
-
if (e.code !== 'EADDRINUSE' && e.code !== 'EACCES') throw e;
|
|
2964
|
-
}
|
|
2965
|
-
}
|
|
2966
|
-
|
|
2967
|
-
err(`No free port available in ${c.bold}${START_PORT_RANGE}-${END_PORT_RANGE}${c.reset}.`);
|
|
2968
|
-
process.exit(1);
|
|
2969
|
-
}
|
|
2970
|
-
|
|
2971
|
-
/**
|
|
2972
|
-
* 📖 Detects if a port is occupied by a stale/zombie kandown process.
|
|
2973
|
-
* A "stale" kandown is one whose TUI has exited but the HTTP server is still alive.
|
|
2974
|
-
* Returns { pid, cwd, reason } or null if the port is free or used by something else.
|
|
2975
|
-
*/
|
|
2976
|
-
function detectStaleKandown(port, currentKandownDir) {
|
|
2977
|
-
let pid;
|
|
2978
|
-
try {
|
|
2979
|
-
pid = execSync(`lsof -ti :${port} -sTCP:LISTEN`, { encoding: 'utf8', timeout: 2000 }).trim();
|
|
2980
|
-
} catch {
|
|
2981
|
-
return null; // Port is free
|
|
2982
|
-
}
|
|
2983
|
-
if (!pid) return null;
|
|
2984
|
-
|
|
2985
|
-
const pids = pid.split('\n').filter(Boolean);
|
|
2986
|
-
if (pids.length === 0) return null;
|
|
2987
|
-
pid = parseInt(pids[0], 10);
|
|
2988
|
-
if (isNaN(pid) || pid === process.pid) return null;
|
|
2989
|
-
|
|
2990
|
-
// Check if it's a kandown process
|
|
2991
|
-
let cmdline;
|
|
2992
|
-
try {
|
|
2993
|
-
cmdline = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', timeout: 2000 }).trim();
|
|
2994
|
-
} catch {
|
|
2995
|
-
return null; // Process gone
|
|
2996
|
-
}
|
|
2997
|
-
|
|
2998
|
-
if (!/[/\\]kandown\b|^kandown\b|\skandown\b/.test(cmdline)) return null; // Not a kandown process
|
|
2999
|
-
|
|
3000
|
-
// Get the working directory of the existing process
|
|
3001
|
-
let cwd;
|
|
3002
|
-
try {
|
|
3003
|
-
if (process.platform === 'linux') {
|
|
3004
|
-
cwd = execSync(`readlink -f /proc/${pid}/cwd`, { encoding: 'utf8', timeout: 2000 }).trim();
|
|
3005
|
-
} else {
|
|
3006
|
-
// macOS
|
|
3007
|
-
cwd = execSync(`lsof -p ${pid} -Fn -a -d cwd 2>/dev/null | grep '^n' | cut -c2-`, { encoding: 'utf8', timeout: 2000, shell: true }).trim();
|
|
3008
|
-
}
|
|
3009
|
-
} catch {
|
|
3010
|
-
cwd = null;
|
|
3011
|
-
}
|
|
3012
|
-
|
|
3013
|
-
// 📖 Compare with our project root (parent of .kandown/), not process.cwd().
|
|
3014
|
-
// process.cwd() could be a subdirectory; the project root is stable.
|
|
3015
|
-
const ourProjectRoot = currentKandownDir ? dirname(currentKandownDir) : process.cwd();
|
|
3016
|
-
const isSameProject = cwd && (cwd === ourProjectRoot || cwd === process.cwd());
|
|
3017
|
-
|
|
3018
|
-
// 📖 If it's our own process, skip (we're checking our own port)
|
|
3019
|
-
if (pid === process.pid) return null;
|
|
3020
|
-
|
|
3021
|
-
// 📖 Different project — don't touch, just skip the port
|
|
3022
|
-
if (!isSameProject) {
|
|
3023
|
-
return { pid, cwd: cwd || 'unknown', reason: null };
|
|
3024
|
-
}
|
|
3025
|
-
|
|
3026
|
-
// 📖 Same project — stale/zombie or legitimate, kill it either way.
|
|
3027
|
-
// The user is explicitly launching kandown, so they want a fresh instance.
|
|
3028
|
-
return {
|
|
3029
|
-
pid,
|
|
3030
|
-
cwd: cwd || 'unknown',
|
|
3031
|
-
reason: `${c.yellow}Existing kandown found on port ${c.cyan}${port}${c.yellow} (PID ${pid}, same project). Reconnecting...${c.reset}`,
|
|
3032
|
-
};
|
|
3033
|
-
}
|
|
3034
|
-
|
|
3035
|
-
async function cmdDaemon(rawArgs) {
|
|
3036
|
-
const [subcommand = 'status', ...rest] = rawArgs;
|
|
3037
|
-
|
|
3038
|
-
if (subcommand === 'refresh-all' || subcommand === 'restart-all') {
|
|
3039
|
-
const forceRestart = rest.includes('--force') || subcommand === 'restart-all';
|
|
3040
|
-
const results = await refreshRunningDaemons({ forceRestart });
|
|
3041
|
-
success(`Scanned ${results.found} running daemon${results.found === 1 ? '' : 's'}`);
|
|
3042
|
-
info(`Refreshed ${results.refreshed} kandown.html bundle${results.refreshed === 1 ? '' : 's'}`);
|
|
3043
|
-
info(`${forceRestart ? 'Restarted' : 'Restarted outdated'} ${results.restarted} daemon${results.restarted === 1 ? '' : 's'}`);
|
|
3044
|
-
if (!forceRestart && results.skipped > 0) info(`Skipped ${results.skipped} already-current daemon${results.skipped === 1 ? '' : 's'}`);
|
|
3045
|
-
if (results.failed.length > 0) {
|
|
3046
|
-
for (const failure of results.failed) warn(`${failure.kandownDir}: ${failure.error}`);
|
|
3047
|
-
process.exit(1);
|
|
3048
|
-
}
|
|
3049
|
-
return;
|
|
3050
|
-
}
|
|
3051
|
-
|
|
3052
|
-
const { kandownDir } = ensureKandownDir(rest);
|
|
3053
|
-
const preferredPort = parsePort(parseArgs(rest).port);
|
|
3054
|
-
|
|
3055
|
-
if (subcommand === 'run') {
|
|
3056
|
-
const { server, port } = await listenOnAvailablePort(kandownDir, preferredPort);
|
|
3057
|
-
const url = `http://localhost:${port}`;
|
|
3058
|
-
ensureDaemonGitignore(kandownDir);
|
|
3059
|
-
writeDaemonMetadata(kandownDir, {
|
|
3060
|
-
pid: process.pid,
|
|
3061
|
-
port,
|
|
3062
|
-
url,
|
|
3063
|
-
kandownDir,
|
|
3064
|
-
startedAt: daemonStartedAt,
|
|
3065
|
-
version: getCurrentVersion(),
|
|
3066
|
-
// 📖 Local-only secret (M5): daemon.json is chmod-protected by the
|
|
3067
|
-
// user's umask and gitignored; the CLI/TUI read the token here to call
|
|
3068
|
-
// the API. Never exposed by GET /api/daemon.
|
|
3069
|
-
token: DAEMON_TOKEN,
|
|
3070
|
-
});
|
|
3071
|
-
|
|
3072
|
-
const shutdown = () => {
|
|
3073
|
-
server.close(() => {
|
|
3074
|
-
removeDaemonMetadata(kandownDir);
|
|
3075
|
-
process.exit(0);
|
|
3076
|
-
});
|
|
3077
|
-
};
|
|
3078
|
-
process.once('SIGINT', shutdown);
|
|
3079
|
-
process.once('SIGTERM', shutdown);
|
|
3080
|
-
await new Promise(() => {});
|
|
3081
|
-
return;
|
|
3082
|
-
}
|
|
3083
|
-
|
|
3084
|
-
if (subcommand === 'start') {
|
|
3085
|
-
refreshKandownHtml(kandownDir);
|
|
3086
|
-
const status = await startDaemon(kandownDir, preferredPort);
|
|
3087
|
-
if (!status.running || !status.metadata) {
|
|
3088
|
-
err('Daemon failed to start');
|
|
3089
|
-
process.exit(1);
|
|
3090
|
-
}
|
|
3091
|
-
success(`Daemon running: ${status.metadata.url}`);
|
|
3092
|
-
return;
|
|
3093
|
-
}
|
|
3094
|
-
|
|
3095
|
-
if (subcommand === 'stop') {
|
|
3096
|
-
const stopped = await stopDaemon(kandownDir);
|
|
3097
|
-
if (stopped) success('Daemon stopped');
|
|
3098
|
-
else info('Daemon already stopped');
|
|
3099
|
-
return;
|
|
3100
|
-
}
|
|
3101
|
-
|
|
3102
|
-
if (subcommand === 'status') {
|
|
3103
|
-
const status = await getDaemonStatus(kandownDir);
|
|
3104
|
-
if (status.running && status.metadata) {
|
|
3105
|
-
success(`Daemon ON ${status.metadata.url} PID ${status.metadata.pid}`);
|
|
3106
|
-
} else {
|
|
3107
|
-
info('Daemon OFF');
|
|
3108
|
-
}
|
|
3109
|
-
return;
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
|
-
err(`Unknown daemon command: ${subcommand}`);
|
|
3113
|
-
log(` Use ${c.cyan}kandown daemon start|stop|status|refresh-all${c.reset}`);
|
|
3114
|
-
process.exit(1);
|
|
3115
|
-
}
|
|
3116
|
-
|
|
3117
|
-
/**
|
|
3118
|
-
* 📖 Starts/reconnects the per-project web daemon, opens it in the browser,
|
|
3119
|
-
* then hands the terminal to the board TUI. The daemon intentionally survives
|
|
3120
|
-
* TUI exit so the web UI keeps working until the user stops it.
|
|
3121
|
-
*/
|
|
3122
|
-
async function cmdServe(rawArgs) {
|
|
3123
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
3124
|
-
|
|
3125
|
-
try {
|
|
3126
|
-
if (refreshKandownHtml(kandownDir)) {
|
|
3127
|
-
info(`Refreshed kandown.html (CLI v${getCurrentVersion()})`);
|
|
3128
|
-
}
|
|
3129
|
-
} catch (e) {
|
|
3130
|
-
warn(`Could not refresh kandown.html: ${e.message}`);
|
|
3131
|
-
}
|
|
3132
|
-
|
|
3133
|
-
const preferredPort = parsePort(parseArgs(rawArgs).port);
|
|
3134
|
-
const status = await startDaemon(kandownDir, preferredPort);
|
|
3135
|
-
if (!status.running || !status.metadata) {
|
|
3136
|
-
err('Failed to start web daemon');
|
|
3137
|
-
process.exit(1);
|
|
3138
|
-
}
|
|
3139
|
-
|
|
3140
|
-
success(`Web daemon: ${status.metadata.url}`);
|
|
3141
|
-
info(`Project: ${kandownDir}`);
|
|
3142
|
-
openInBrowser(status.metadata.url);
|
|
3143
|
-
await cmdTui('board', rawArgs);
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
|
-
/**
|
|
3147
|
-
* 📖 Opens a URL in the system default browser after confirming the server is ready.
|
|
3148
|
-
* Non-blocking — spawns the opener and returns immediately.
|
|
3149
|
-
* macOS: open, Linux: xdg-open, Windows: start (via cmd.exe).
|
|
3150
|
-
*/
|
|
3151
|
-
async function openInBrowser(url) {
|
|
3152
|
-
// 📖 Wait for server to be truly ready (up to 2s) before opening browser.
|
|
3153
|
-
// This prevents ERR_UNSAFE_PORT and similar race conditions.
|
|
3154
|
-
for (let i = 0; i < 10; i++) {
|
|
3155
|
-
try {
|
|
3156
|
-
const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(500) });
|
|
3157
|
-
if (res.ok || res.status === 404) break; // server is up (404 means serving HTML)
|
|
3158
|
-
} catch { /* server not ready yet */ }
|
|
3159
|
-
await new Promise(r => setTimeout(r, 200));
|
|
3160
|
-
}
|
|
3161
|
-
|
|
3162
|
-
const opener = process.platform === 'darwin'
|
|
3163
|
-
? 'open'
|
|
3164
|
-
: process.platform === 'win32'
|
|
3165
|
-
? 'cmd'
|
|
3166
|
-
: 'xdg-open';
|
|
3167
|
-
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
3168
|
-
const child = spawn(opener, args, { detached: true, stdio: 'ignore' });
|
|
3169
|
-
child.on('error', (e) => warn(`Could not open browser automatically: ${e.message}`));
|
|
3170
|
-
child.unref();
|
|
3171
|
-
}
|
|
3172
|
-
|
|
3173
|
-
// 📖 Launches the fullscreen TUI for a given screen (settings, board, etc.)
|
|
3174
|
-
async function cmdTui(screen, rawArgs) {
|
|
3175
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
3176
|
-
const version = getCurrentVersion();
|
|
3177
|
-
|
|
3178
|
-
try {
|
|
3179
|
-
const { run } = await import(new URL('./tui.js', import.meta.url).href);
|
|
3180
|
-
await run(screen, kandownDir, version);
|
|
3181
|
-
} catch (e) {
|
|
3182
|
-
err(`Failed to launch TUI: ${e.message}`);
|
|
3183
|
-
process.exit(1);
|
|
3184
|
-
}
|
|
3185
|
-
}
|
|
3186
|
-
|
|
3187
|
-
const rawArgs = process.argv.slice(2).filter((a) => a !== '--no-update-check');
|
|
3188
|
-
const [cmd, ...rest] = rawArgs;
|
|
3189
|
-
|
|
3190
|
-
// 📖 Handle --version / -v before any command logic
|
|
3191
|
-
if (cmd === '--version' || cmd === '-v') {
|
|
3192
|
-
const v = getCurrentVersion() ?? 'unknown';
|
|
3193
|
-
out(`kandown v${v}`);
|
|
3194
|
-
process.exit(0);
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
// 📖 Skip auto-update if this is a respawned child after an update.
|
|
3198
|
-
// The parent passes --no-update-check to prevent an infinite update loop.
|
|
3199
|
-
const skipUpdate = process.argv.slice(2).includes('--no-update-check');
|
|
3200
|
-
|
|
3201
|
-
// 📖 Update policy (M1): the check only runs for INTERACTIVE commands.
|
|
3202
|
-
// The one-shot task commands (scripts/agents) and `daemon` (spawned
|
|
3203
|
-
// children, status probes) must never pay a network round-trip nor risk a
|
|
3204
|
-
// mid-pipeline respawn. Inside checkForUpdate there are further guards: 24h
|
|
3205
|
-
// throttle, TTY-only, and the KANDOWN_NO_UPDATE=1 opt-out.
|
|
3206
|
-
async function cmdDoctor(rawArgs) {
|
|
3207
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
3208
|
-
const fix = rawArgs.includes('--fix');
|
|
3209
|
-
log(`${c.bold}kandown doctor${c.reset} ${c.dim}— environment & board diagnostic${c.reset}\n`);
|
|
3210
|
-
|
|
3211
|
-
let errors = 0;
|
|
3212
|
-
let warnings = 0;
|
|
3213
|
-
|
|
3214
|
-
const cliVer = getCurrentVersion();
|
|
3215
|
-
log(` ${c.cyan}CLI Version:${c.reset} ${cliVer}`);
|
|
3216
|
-
|
|
3217
|
-
const configPath = join(kandownDir, 'kandown.json');
|
|
3218
|
-
if (existsSync(configPath)) {
|
|
3219
|
-
try {
|
|
3220
|
-
JSON.parse(readFileSync(configPath, 'utf8'));
|
|
3221
|
-
log(` ${c.green}✓${c.reset} kandown.json valid`);
|
|
3222
|
-
} catch (e) {
|
|
3223
|
-
log(` ${c.red}✗${c.reset} kandown.json invalid JSON: ${e.message}`);
|
|
3224
|
-
errors++;
|
|
3225
|
-
}
|
|
3226
|
-
} else {
|
|
3227
|
-
log(` ${c.red}✗${c.reset} kandown.json missing`);
|
|
3228
|
-
errors++;
|
|
3229
|
-
}
|
|
3230
|
-
|
|
3231
|
-
const daemon = readDaemonMetadata(kandownDir);
|
|
3232
|
-
if (daemon) {
|
|
3233
|
-
const alive = isProcessAlive(daemon.pid);
|
|
3234
|
-
if (alive) {
|
|
3235
|
-
log(` ${c.green}✓${c.reset} Daemon running on port ${daemon.port} (PID ${daemon.pid})`);
|
|
3236
|
-
} else {
|
|
3237
|
-
log(` ${c.yellow}⚠${c.reset} Daemon metadata stale (PID ${daemon.pid} dead)`);
|
|
3238
|
-
warnings++;
|
|
3239
|
-
if (fix) {
|
|
3240
|
-
removeDaemonMetadata(kandownDir);
|
|
3241
|
-
log(` ${c.green}└─ Fixed: removed stale daemon.json${c.reset}`);
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
3244
|
-
} else {
|
|
3245
|
-
log(` ${c.dim}ℹ Daemon not running${c.reset}`);
|
|
3246
|
-
}
|
|
3247
|
-
|
|
3248
|
-
const tasksDir = getTasksDir(kandownDir);
|
|
3249
|
-
if (existsSync(tasksDir)) {
|
|
3250
|
-
const activeFiles = readdirSync(tasksDir).filter(f => f.endsWith('.md'));
|
|
3251
|
-
const archiveDir = join(tasksDir, 'archive');
|
|
3252
|
-
const archiveFiles = existsSync(archiveDir) ? readdirSync(archiveDir).filter(f => f.endsWith('.md')) : [];
|
|
3253
|
-
|
|
3254
|
-
log(` ${c.cyan}Tasks:${c.reset} ${activeFiles.length} active, ${archiveFiles.length} archived`);
|
|
3255
|
-
|
|
3256
|
-
const activeSet = new Set(activeFiles);
|
|
3257
|
-
const duplicates = archiveFiles.filter(f => activeSet.has(f));
|
|
3258
|
-
if (duplicates.length > 0) {
|
|
3259
|
-
log(` ${c.red}✗${c.reset} Found ${duplicates.length} duplicate file(s) in tasks/ and archive/: ${duplicates.join(', ')}`);
|
|
3260
|
-
errors++;
|
|
3261
|
-
if (fix) {
|
|
3262
|
-
for (const dup of duplicates) {
|
|
3263
|
-
unlinkSync(join(archiveDir, dup));
|
|
3264
|
-
}
|
|
3265
|
-
log(` ${c.green}└─ Fixed: removed duplicate archived files${c.reset}`);
|
|
3266
|
-
}
|
|
3267
|
-
} else {
|
|
3268
|
-
log(` ${c.green}✓${c.reset} No duplicate task files`);
|
|
3269
|
-
}
|
|
3270
|
-
|
|
3271
|
-
let invalidFm = 0;
|
|
3272
|
-
for (const f of activeFiles) {
|
|
3273
|
-
try {
|
|
3274
|
-
const content = readFileSync(join(tasksDir, f), 'utf8');
|
|
3275
|
-
parseFrontmatter(content);
|
|
3276
|
-
} catch {
|
|
3277
|
-
invalidFm++;
|
|
3278
|
-
}
|
|
3279
|
-
}
|
|
3280
|
-
if (invalidFm > 0) {
|
|
3281
|
-
log(` ${c.yellow}⚠${c.reset} ${invalidFm} task file(s) have invalid frontmatter formatting`);
|
|
3282
|
-
warnings++;
|
|
3283
|
-
} else {
|
|
3284
|
-
log(` ${c.green}✓${c.reset} Task frontmatters valid`);
|
|
3285
|
-
}
|
|
3286
|
-
}
|
|
3287
|
-
|
|
3288
|
-
log('');
|
|
3289
|
-
if (errors === 0 && warnings === 0) {
|
|
3290
|
-
success('Everything looks good!');
|
|
3291
|
-
} else {
|
|
3292
|
-
info(`Doctor summary: ${errors} error(s), ${warnings} warning(s). ${fix ? '' : 'Run with --fix to resolve automatically.'}`);
|
|
3293
|
-
}
|
|
3294
|
-
}
|
|
3295
|
-
|
|
3296
|
-
function cmdUndo(rawArgs) {
|
|
3297
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
3298
|
-
const logPath = join(kandownDir, '.undo', 'log.json');
|
|
3299
|
-
if (!existsSync(logPath)) {
|
|
3300
|
-
info('No actions to undo');
|
|
3301
|
-
return;
|
|
3302
|
-
}
|
|
3303
|
-
try {
|
|
3304
|
-
const list = JSON.parse(readFileSync(logPath, 'utf8'));
|
|
3305
|
-
if (!list || list.length === 0) {
|
|
3306
|
-
info('No actions to undo');
|
|
3307
|
-
return;
|
|
3308
|
-
}
|
|
3309
|
-
const record = list.shift();
|
|
3310
|
-
writeFileSync(logPath, JSON.stringify(list, null, 2), 'utf8');
|
|
3311
|
-
if (record.previousContent === null) {
|
|
3312
|
-
if (existsSync(record.path)) unlinkSync(record.path);
|
|
3313
|
-
} else {
|
|
3314
|
-
const dir = dirname(record.path);
|
|
3315
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
3316
|
-
writeFileSync(record.path, record.previousContent, 'utf8');
|
|
3317
|
-
if (record.newContent !== null && record.path.includes('/archive/')) {
|
|
3318
|
-
const activePath = record.path.replace('/archive/', '/');
|
|
3319
|
-
if (existsSync(activePath)) unlinkSync(activePath);
|
|
3320
|
-
}
|
|
3321
|
-
}
|
|
3322
|
-
success(`Undid last action (${record.type} ${record.taskId})`);
|
|
3323
|
-
} catch (e) {
|
|
3324
|
-
err(`Undo failed: ${e.message}`);
|
|
3325
|
-
}
|
|
3326
|
-
}
|
|
3327
|
-
|
|
3328
|
-
async function cmdProjects(rawArgs) {
|
|
3329
|
-
const isJson = rawArgs.includes('--json');
|
|
3330
|
-
const running = [];
|
|
3331
|
-
|
|
3332
|
-
for (let port = START_PORT_RANGE; port <= END_PORT_RANGE; port++) {
|
|
3333
|
-
if (isBrowserUnsafePort(port)) continue;
|
|
3334
|
-
try {
|
|
3335
|
-
const controller = new AbortController();
|
|
3336
|
-
const timeout = setTimeout(() => controller.abort(), 200);
|
|
3337
|
-
const res = await fetch(`http://127.0.0.1:${port}/api/daemon`, { signal: controller.signal });
|
|
3338
|
-
clearTimeout(timeout);
|
|
3339
|
-
if (res.ok) {
|
|
3340
|
-
const data = await res.json();
|
|
3341
|
-
if (data && data.ok) {
|
|
3342
|
-
running.push({ port, pid: data.pid, kandownDir: data.kandownDir, startedAt: data.startedAt, version: data.version });
|
|
3343
|
-
}
|
|
3344
|
-
}
|
|
3345
|
-
} catch {}
|
|
3346
|
-
}
|
|
3347
|
-
|
|
3348
|
-
if (isJson) {
|
|
3349
|
-
out(JSON.stringify(running, null, 2));
|
|
3350
|
-
return;
|
|
3351
|
-
}
|
|
3352
|
-
|
|
3353
|
-
if (running.length === 0) {
|
|
3354
|
-
info('No active kandown web daemons running on this machine');
|
|
3355
|
-
return;
|
|
3356
|
-
}
|
|
3357
|
-
|
|
3358
|
-
log(`${c.bold}Active Kandown Daemons (${running.length})${c.reset}\n`);
|
|
3359
|
-
for (const d of running) {
|
|
3360
|
-
log(` ${c.green}●${c.reset} Port ${c.cyan}${d.port}${c.reset} PID ${d.pid} ${c.dim}${d.kandownDir}${c.reset}`);
|
|
3361
|
-
}
|
|
3362
|
-
}
|
|
3363
|
-
|
|
3364
|
-
function cmdExport(rawArgs) {
|
|
3365
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
3366
|
-
const isCsv = rawArgs.includes('--csv');
|
|
3367
|
-
const board = readBoard(kandownDir);
|
|
3368
|
-
|
|
3369
|
-
if (isCsv) {
|
|
3370
|
-
let csv = 'id,title,status,priority,assignee,tags,created\n';
|
|
3371
|
-
for (const col of board.columns) {
|
|
3372
|
-
for (const t of col.tasks) {
|
|
3373
|
-
const task = readTask(kandownDir, t.id);
|
|
3374
|
-
const tags = (t.tags || []).join(';');
|
|
3375
|
-
csv += `"${t.id}","${t.title.replace(/"/g, '""')}","${col.name}","${t.priority || ''}","${t.assignee || ''}","${tags}","${task.frontmatter.created || ''}"\n`;
|
|
3376
|
-
}
|
|
3377
|
-
}
|
|
3378
|
-
out(csv);
|
|
3379
|
-
} else {
|
|
3380
|
-
const data = [];
|
|
3381
|
-
for (const col of board.columns) {
|
|
3382
|
-
for (const t of col.tasks) {
|
|
3383
|
-
const task = readTask(kandownDir, t.id);
|
|
3384
|
-
data.push({ ...task, status: col.name });
|
|
3385
|
-
}
|
|
3386
|
-
}
|
|
3387
|
-
out(JSON.stringify(data, null, 2));
|
|
3388
|
-
}
|
|
3389
|
-
}
|
|
3390
|
-
|
|
3391
|
-
function cmdImport(rawArgs) {
|
|
3392
|
-
const { kandownDir } = ensureKandownDir(rawArgs);
|
|
3393
|
-
const fileIdx = rawArgs.findIndex(a => a.endsWith('.json') || a.endsWith('.md'));
|
|
3394
|
-
if (fileIdx === -1 || !existsSync(rawArgs[fileIdx])) {
|
|
3395
|
-
err('Usage: kandown import <file.json | file.md>');
|
|
3396
|
-
process.exit(1);
|
|
3397
|
-
}
|
|
3398
|
-
const filePath = rawArgs[fileIdx];
|
|
3399
|
-
const content = readFileSync(filePath, 'utf8');
|
|
3400
|
-
let count = 0;
|
|
3401
|
-
|
|
3402
|
-
if (filePath.endsWith('.json')) {
|
|
3403
|
-
try {
|
|
3404
|
-
const parsed = JSON.parse(content);
|
|
3405
|
-
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || []);
|
|
3406
|
-
for (const c of cards) {
|
|
3407
|
-
const title = c.name || c.title || 'Imported Task';
|
|
3408
|
-
createTaskInBoard(kandownDir, title, c.status || 'Backlog');
|
|
3409
|
-
count++;
|
|
3410
|
-
}
|
|
3411
|
-
} catch (e) {
|
|
3412
|
-
err(`Import failed: ${e.message}`);
|
|
3413
|
-
process.exit(1);
|
|
3414
|
-
}
|
|
3415
|
-
} else {
|
|
3416
|
-
const lines = content.split('\n');
|
|
3417
|
-
for (const line of lines) {
|
|
3418
|
-
const m = line.match(/^#{1,3}\s+(.+)$/);
|
|
3419
|
-
if (m) {
|
|
3420
|
-
createTaskInBoard(kandownDir, m[1].trim(), 'Backlog');
|
|
3421
|
-
count++;
|
|
3422
|
-
}
|
|
3423
|
-
}
|
|
3424
|
-
}
|
|
3425
|
-
|
|
3426
|
-
success(`Imported ${count} tasks into board`);
|
|
3427
|
-
}
|
|
3428
|
-
|
|
3429
|
-
const SCRIPTED_COMMANDS = new Set([
|
|
3430
|
-
'list', 'ls', 'show', 'create', 'new', 'move', 'assign', 'commit', 'tasks', 'work', 'daemon',
|
|
3431
|
-
'doctor', 'undo', 'projects', 'export', 'import',
|
|
3432
|
-
]);
|
|
3433
|
-
if (!skipUpdate && !SCRIPTED_COMMANDS.has(cmd)) await checkForUpdate(process.argv);
|
|
3434
|
-
|
|
3435
|
-
if (!SCRIPTED_COMMANDS.has(cmd)) checkVersionSeenNotices();
|
|
3436
|
-
|
|
3437
|
-
switch (cmd) {
|
|
3438
|
-
case 'export':
|
|
3439
|
-
cmdExport(rest);
|
|
3440
|
-
break;
|
|
3441
|
-
|
|
3442
|
-
case 'import':
|
|
3443
|
-
cmdImport(rest);
|
|
3444
|
-
break;
|
|
3445
|
-
case 'init':
|
|
3446
|
-
cmdInit(rest);
|
|
3447
|
-
break;
|
|
3448
|
-
|
|
3449
|
-
case 'doctor':
|
|
3450
|
-
await cmdDoctor(rest);
|
|
3451
|
-
break;
|
|
3452
|
-
|
|
3453
|
-
case 'undo':
|
|
3454
|
-
cmdUndo(rest);
|
|
3455
|
-
break;
|
|
3456
|
-
|
|
3457
|
-
case 'mcp': {
|
|
3458
|
-
const { kandownDir } = ensureKandownDir(rest);
|
|
3459
|
-
const { startMcpServer } = await import('../src/cli/lib/mcp.js');
|
|
3460
|
-
startMcpServer(kandownDir);
|
|
3461
|
-
break;
|
|
3462
|
-
}
|
|
3463
|
-
|
|
3464
|
-
case 'projects':
|
|
3465
|
-
await cmdProjects(rest);
|
|
3466
|
-
break;
|
|
3467
|
-
|
|
3468
|
-
case 'board':
|
|
3469
|
-
// 📖 kandown board — open the interactive kanban board TUI only
|
|
3470
|
-
await cmdTui('board', rest);
|
|
3471
|
-
break;
|
|
3472
|
-
|
|
3473
|
-
case 'settings':
|
|
3474
|
-
await cmdTui('settings', rest);
|
|
3475
|
-
break;
|
|
3476
|
-
|
|
3477
|
-
case 'daemon':
|
|
3478
|
-
await cmdDaemon(rest);
|
|
3479
|
-
break;
|
|
3480
|
-
|
|
3481
|
-
case 'update':
|
|
3482
|
-
await cmdUpdate(rest);
|
|
3483
|
-
break;
|
|
3484
|
-
|
|
3485
|
-
// 📖 One-shot task commands — top-level, no "shell" wrapper. These are the
|
|
3486
|
-
// most basic operations of the product (list/read/create/move/assign a
|
|
3487
|
-
// task, commit the board to git); nesting them under a prefix only added
|
|
3488
|
-
// friction for scripts and AI agents, which is exactly who these are for.
|
|
3489
|
-
case 'list':
|
|
3490
|
-
case 'ls':
|
|
3491
|
-
cmdList(rest);
|
|
3492
|
-
break;
|
|
3493
|
-
|
|
3494
|
-
case 'show':
|
|
3495
|
-
cmdShow(rest);
|
|
3496
|
-
break;
|
|
3497
|
-
|
|
3498
|
-
case 'create':
|
|
3499
|
-
case 'new':
|
|
3500
|
-
cmdCreate(rest);
|
|
3501
|
-
break;
|
|
3502
|
-
|
|
3503
|
-
case 'move':
|
|
3504
|
-
cmdMove(rest);
|
|
3505
|
-
break;
|
|
3506
|
-
|
|
3507
|
-
case 'assign':
|
|
3508
|
-
cmdAssign(rest);
|
|
3509
|
-
break;
|
|
3510
|
-
|
|
3511
|
-
case 'commit':
|
|
3512
|
-
cmdCommit(rest);
|
|
3513
|
-
break;
|
|
3514
|
-
|
|
3515
|
-
case 'tasks':
|
|
3516
|
-
// 📖 `kandown tasks` — cheatsheet for the commands above (`list help`
|
|
3517
|
-
// etc. would collide with real usage, so the index lives on its own verb).
|
|
3518
|
-
printTaskCommandsHelp();
|
|
3519
|
-
break;
|
|
3520
|
-
|
|
3521
|
-
case 'work':
|
|
3522
|
-
// 📖 `kandown work` — the agent entrypoint (rules + live board digest).
|
|
3523
|
-
// See cmdWork's doc comment for the full rationale.
|
|
3524
|
-
await cmdWork(rest);
|
|
3525
|
-
break;
|
|
3526
|
-
|
|
3527
|
-
case 'help':
|
|
3528
|
-
case '--help':
|
|
3529
|
-
case '-h':
|
|
3530
|
-
help();
|
|
3531
|
-
break;
|
|
3532
|
-
|
|
3533
|
-
case undefined:
|
|
3534
|
-
// 📖 kandown (no args) — serve the web UI over localhost and open the board TUI.
|
|
3535
|
-
await cmdServe(rest);
|
|
3536
|
-
break;
|
|
3537
|
-
|
|
3538
|
-
default:
|
|
3539
|
-
if (cmd.startsWith('-')) {
|
|
3540
|
-
await cmdServe([cmd, ...rest]);
|
|
3541
|
-
break;
|
|
3542
|
-
}
|
|
3543
|
-
err(`Unknown command: ${cmd}`);
|
|
3544
|
-
help();
|
|
3545
|
-
process.exit(1);
|
|
3546
|
-
}
|
|
3547
|
-
|
|
3548
|
-
// 📖 Some bundled/imported dependencies can leave passive handles alive even
|
|
3549
|
-
// after a one-shot CLI command has finished its synchronous work. Scripted
|
|
3550
|
-
// commands must be pipeline-safe, so terminate explicitly once their handler
|
|
3551
|
-
// returns. The daemon runner, MCP server, and interactive TUI paths are the
|
|
3552
|
-
// long-lived exceptions.
|
|
3553
|
-
if (SCRIPTED_COMMANDS.has(cmd) && !(cmd === 'daemon' && rest[0] === 'run')) {
|
|
3554
|
-
process.exit(0);
|
|
3555
|
-
}
|
|
1080
|
+
void main();
|