kaizen-agent 1.7.3 → 1.7.5
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/LICENSE +21 -21
- package/README.md +658 -657
- package/agents/kaizen-builder.md +77 -77
- package/agents/kaizen-planner.md +250 -250
- package/agents/kaizen-reviewer.md +127 -127
- package/cli/dashboard.ts +1187 -1187
- package/cli/install.ts +947 -947
- package/cli/publish-gh.sh +27 -27
- package/cli/release.sh +45 -45
- package/cli/settings.ts +416 -415
- package/cli/state.test.ts +197 -197
- package/cli/state.ts +704 -704
- package/cli/web.ts +423 -423
- package/commands/kaizen-abort.md +5 -5
- package/commands/kaizen-approve.md +5 -5
- package/commands/kaizen-auto.md +6 -6
- package/commands/kaizen-backlog.md +5 -5
- package/commands/kaizen-config.md +5 -5
- package/commands/kaizen-full.md +5 -5
- package/commands/kaizen-init.md +5 -5
- package/commands/kaizen-install.md +5 -5
- package/commands/kaizen-lite.md +5 -5
- package/commands/kaizen-plan.md +6 -6
- package/commands/kaizen-reject.md +6 -6
- package/commands/kaizen-review.md +6 -6
- package/commands/kaizen-run.md +5 -5
- package/commands/kaizen-status.md +5 -5
- package/package.json +1 -1
- package/skills/kaizen/README.md +281 -281
- package/skills/kaizen/SKILL.md +440 -440
- package/skills/kaizen/adapters.md +77 -77
- package/skills/kaizen/config.default.yml +178 -167
- package/skills/kaizen/spec-build.md +119 -119
- package/skills/kaizen/spec-main.md +104 -104
- package/skills/kaizen/spec-plan.md +201 -201
- package/skills/kaizen/spec-review.md +121 -121
- package/skills/kaizen/spec.md +153 -153
- package/skills/kaizen-help/SKILL.md +129 -129
- package/web/board.html +47 -23
package/cli/web.ts
CHANGED
|
@@ -1,423 +1,423 @@
|
|
|
1
|
-
// `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
|
|
2
|
-
// one HTML page; the page never sees the filesystem, only JSON.
|
|
3
|
-
import { existsSync, readFileSync, readdirSync, statSync, watch, appendFileSync, type FSWatcher } from "node:fs";
|
|
4
|
-
import { join, dirname } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
|
|
7
|
-
readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
|
|
8
|
-
appendNote, saveAttachment, pidOnPort,
|
|
9
|
-
} from "./state.ts";
|
|
10
|
-
|
|
11
|
-
const PAGE = join(dirname(import.meta.dir), "web", "board.html");
|
|
12
|
-
const DEFAULT_PORT = 7420;
|
|
13
|
-
|
|
14
|
-
type Opts = { port?: number; open?: boolean };
|
|
15
|
-
|
|
16
|
-
export async function web(repoDir: string, opts: Opts = {}) {
|
|
17
|
-
// A board started from the shortcut has no terminal to show a crash in, so any
|
|
18
|
-
// crash is written where it can be read afterwards.
|
|
19
|
-
const log = join(home, ".kaizen", "web.log");
|
|
20
|
-
for (const ev of ["uncaughtException", "unhandledRejection"] as const) {
|
|
21
|
-
process.on(ev, (err: unknown) => {
|
|
22
|
-
const line = `${new Date().toISOString()} ${ev}: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`;
|
|
23
|
-
try { appendFileSync(log, line); } catch { /* nowhere to write */ }
|
|
24
|
-
console.error(line);
|
|
25
|
-
if (ev === "uncaughtException") process.exit(1);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
const state = locate();
|
|
29
|
-
if (state && dirname(state) !== home) remember(dirname(state));
|
|
30
|
-
const project = state ? label(state) : null;
|
|
31
|
-
|
|
32
|
-
const states = (all: boolean) => (all || !state
|
|
33
|
-
? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
34
|
-
: [state]
|
|
35
|
-
).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
|
|
36
|
-
|
|
37
|
-
// Any tab on this machine can reach loopback. GET only reads the user's own
|
|
38
|
-
// files, but a POST from a page on the wider web must not write them.
|
|
39
|
-
const local = (req: Request) => {
|
|
40
|
-
const origin = req.headers.get("origin");
|
|
41
|
-
if (!origin) return true; // curl, same-origin fetch on old browsers
|
|
42
|
-
try {
|
|
43
|
-
const h = new URL(origin).hostname;
|
|
44
|
-
return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
|
|
45
|
-
} catch { return false; }
|
|
46
|
-
};
|
|
47
|
-
const json = (body: unknown, status = 200) =>
|
|
48
|
-
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
49
|
-
const bad = (why: string, status = 400) => json({ error: why }, status);
|
|
50
|
-
|
|
51
|
-
// Change notification: one SSE stream per open board, fed by fs.watch where it
|
|
52
|
-
// works and a slow poll where it does not.
|
|
53
|
-
const clients = new Set<ReadableStreamDefaultController>();
|
|
54
|
-
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
-
const changed = () => {
|
|
56
|
-
if (pending) return;
|
|
57
|
-
pending = setTimeout(() => {
|
|
58
|
-
pending = null;
|
|
59
|
-
for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
|
|
60
|
-
}, 200);
|
|
61
|
-
};
|
|
62
|
-
// Dev: the page reloads itself when board.html is saved. Server code needs
|
|
63
|
-
// `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
|
|
64
|
-
try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
|
|
65
|
-
const watchers: FSWatcher[] = [];
|
|
66
|
-
const watchAll = () => {
|
|
67
|
-
for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
|
|
68
|
-
watchers.length = 0;
|
|
69
|
-
for (const dir of states(true)) {
|
|
70
|
-
// An unhandled 'error' on a watcher (Windows: a watched dir renamed, EPERM on
|
|
71
|
-
// a locked file) would end the process; the poll covers what the watch misses.
|
|
72
|
-
try { watchers.push(watch(dir, { recursive: true }, changed).on("error", () => {})); } catch { /* poll covers it */ }
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
watchAll();
|
|
76
|
-
// The poll only speaks when something moved: a board reloading every two
|
|
77
|
-
// seconds for nothing is worse than no fallback at all.
|
|
78
|
-
const fingerprint = () => {
|
|
79
|
-
let sum = 0;
|
|
80
|
-
for (const dir of states(true)) {
|
|
81
|
-
for (const f of ["inbox.md", "backlog.md", "archive.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
|
|
82
|
-
const runs = join(dir, "runs");
|
|
83
|
-
if (!existsSync(runs)) continue;
|
|
84
|
-
for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
|
|
85
|
-
}
|
|
86
|
-
return sum;
|
|
87
|
-
};
|
|
88
|
-
let last = fingerprint();
|
|
89
|
-
let seen = states(true).join("\n");
|
|
90
|
-
const poll = setInterval(() => {
|
|
91
|
-
// A project registered since start (a run launched into a fresh dir) gets a watcher too.
|
|
92
|
-
const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
|
|
93
|
-
const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
|
|
94
|
-
}, 2000);
|
|
95
|
-
|
|
96
|
-
const BOOT = Date.now().toString(36);
|
|
97
|
-
const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
|
|
98
|
-
// A launcher clicked while a board is already up should show that board, not die.
|
|
99
|
-
// A bind can fail because a board is already there, or because the OS will not
|
|
100
|
-
// hand out the port at all (Windows reserves whole ranges for Hyper-V and WSL,
|
|
101
|
-
// and reports either as "in use"). Only a board that answers is "already
|
|
102
|
-
// running"; otherwise walk up to the next port that binds.
|
|
103
|
-
let port = opts.port ?? DEFAULT_PORT;
|
|
104
|
-
let server: ReturnType<typeof serve> | null = null;
|
|
105
|
-
for (let tries = 0; tries < 10 && !server; tries++, port++) {
|
|
106
|
-
try { server = serve(port); } catch (e) {
|
|
107
|
-
if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
|
|
108
|
-
const url = `http://127.0.0.1:${port}/`;
|
|
109
|
-
const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
|
|
110
|
-
if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
|
|
111
|
-
// A listener that does not answer is a board that hung; it is ours to stop.
|
|
112
|
-
const pid = await pidOnPort(port);
|
|
113
|
-
if (pid && pid !== process.pid) {
|
|
114
|
-
try { process.kill(pid, "SIGTERM"); console.log(` stopped a hung board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
|
|
115
|
-
await Bun.sleep(300);
|
|
116
|
-
try { server = serve(port); continue; } catch { /* still held */ }
|
|
117
|
-
}
|
|
118
|
-
console.log(` port ${port} refused by the OS and nothing answers there; trying ${port + 1}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (!server) { console.log(`\n kaizen web could not bind any port from ${opts.port ?? DEFAULT_PORT}\n`); return; }
|
|
122
|
-
|
|
123
|
-
function serve(port: number) { return Bun.serve({
|
|
124
|
-
hostname: "127.0.0.1",
|
|
125
|
-
port,
|
|
126
|
-
async fetch(req) {
|
|
127
|
-
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
128
|
-
// arrives with that hostname in Host, and gets nothing.
|
|
129
|
-
const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
|
|
130
|
-
if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
|
|
131
|
-
const url = new URL(req.url);
|
|
132
|
-
const path = url.pathname;
|
|
133
|
-
|
|
134
|
-
if (req.method === "GET" && path === "/") {
|
|
135
|
-
if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
|
|
136
|
-
return new Response(readFileSync(PAGE), {
|
|
137
|
-
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (req.method === "GET" && path === "/state") {
|
|
142
|
-
const all = url.searchParams.get("all") === "1";
|
|
143
|
-
const dirs = states(all);
|
|
144
|
-
return json({
|
|
145
|
-
project, all,
|
|
146
|
-
projects: states(true).map((d) => ({ dir: d, label: label(d) })),
|
|
147
|
-
cards: boardCards(dirs, Date.now()),
|
|
148
|
-
now: Date.now(),
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (req.method === "GET" && path.startsWith("/run/")) {
|
|
153
|
-
const id = decodeURIComponent(path.slice(5));
|
|
154
|
-
const dir = url.searchParams.get("dir") ?? state;
|
|
155
|
-
if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
|
|
156
|
-
const base = join(dir, "runs", id);
|
|
157
|
-
if (!existsSync(base)) return bad("no such run", 404);
|
|
158
|
-
const read = (f: string) => existsSync(join(base, f)) ? readFileSync(join(base, f), "utf8") : null;
|
|
159
|
-
return json({
|
|
160
|
-
id, short: short(id), state: read("state.json"),
|
|
161
|
-
request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
|
|
162
|
-
impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
|
|
163
|
-
notes: read("notes.md"),
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (req.method === "GET" && path === "/settings") {
|
|
168
|
-
const { listSettings } = await import("./settings.ts");
|
|
169
|
-
return json(listSettings(repoDir));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (req.method === "POST" && path === "/settings") {
|
|
173
|
-
if (!local(req)) return bad("cross-origin write refused", 403);
|
|
174
|
-
let body: any;
|
|
175
|
-
try { body = await req.json(); } catch { return bad("json body expected"); }
|
|
176
|
-
const { setSetting } = await import("./settings.ts");
|
|
177
|
-
const r = setSetting(repoDir, String(body.key ?? ""), String(body.value ?? ""));
|
|
178
|
-
return "error" in r ? bad(r.error) : json(r);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Attachments only, never a run's own files: the path must sit under an
|
|
182
|
-
// attachments/ directory of a known state dir, with no way back up.
|
|
183
|
-
if (req.method === "GET" && path === "/file") {
|
|
184
|
-
const dir = url.searchParams.get("dir") ?? state;
|
|
185
|
-
const p = url.searchParams.get("p") ?? "";
|
|
186
|
-
if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
187
|
-
if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
|
|
188
|
-
const f = join(dir, p);
|
|
189
|
-
if (!existsSync(f)) return bad("no such file", 404);
|
|
190
|
-
return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (req.method === "GET" && path === "/events") {
|
|
194
|
-
let ctrl: ReadableStreamDefaultController;
|
|
195
|
-
const stream = new ReadableStream({
|
|
196
|
-
// The boot id lets the page tell a server restart from a dropped
|
|
197
|
-
// connection; only the former means new code. The ping keeps Bun's
|
|
198
|
-
// idle timeout from closing the stream every ten seconds.
|
|
199
|
-
start(c) { ctrl = c; clients.add(c); c.enqueue(`data: hello ${BOOT}\n\n`); },
|
|
200
|
-
cancel() { clients.delete(ctrl); },
|
|
201
|
-
});
|
|
202
|
-
return new Response(stream, {
|
|
203
|
-
headers: { "content-type": "text/event-stream", "cache-control": "no-store", "connection": "keep-alive" },
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (req.method === "POST") {
|
|
208
|
-
if (!local(req)) return bad("cross-origin write refused", 403);
|
|
209
|
-
let body: any;
|
|
210
|
-
try { body = await req.json(); } catch { return bad("json body expected"); }
|
|
211
|
-
// No project here means the global inbox, which is where the TUI puts ideas too.
|
|
212
|
-
const dir: string = body.dir ?? state ?? join(home, ".kaizen");
|
|
213
|
-
if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
214
|
-
|
|
215
|
-
if (path === "/inbox") {
|
|
216
|
-
const text = String(body.text ?? "").trim();
|
|
217
|
-
// Absent means untouched; an empty string clears. replaceIdea keeps the
|
|
218
|
-
// old notes for any op that does not send them.
|
|
219
|
-
const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
|
|
220
|
-
if (body.op === "add") {
|
|
221
|
-
if (!text) return bad("empty idea");
|
|
222
|
-
writeInbox(dir, [...readInbox(dir), { status: "open", text, notes }]);
|
|
223
|
-
} else if (body.op === "edit") {
|
|
224
|
-
const next = String(body.next ?? "").trim();
|
|
225
|
-
if (!text || !next) return bad("empty idea");
|
|
226
|
-
replaceIdea(dir, text, notes === undefined ? { status: "open", text: next } : { status: "open", text: next, notes });
|
|
227
|
-
} else if (body.op === "reject") {
|
|
228
|
-
const why = String(body.reason ?? "").trim();
|
|
229
|
-
if (!why) return bad("a rejection needs a reason");
|
|
230
|
-
replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
|
|
231
|
-
} else if (body.op === "delete") {
|
|
232
|
-
replaceIdea(dir, text, null);
|
|
233
|
-
} else return bad("unknown op");
|
|
234
|
-
changed();
|
|
235
|
-
return json({ ok: true });
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (path === "/run") {
|
|
239
|
-
const text = String(body.text ?? "").trim();
|
|
240
|
-
const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
|
|
241
|
-
if (!text) return bad("empty idea");
|
|
242
|
-
const it: Item = parseItem(`${kind} ${text}`.trim());
|
|
243
|
-
it.notes = readInbox(dir).find((l) => l.status === "open" && l.text === text)?.notes;
|
|
244
|
-
const r = launchRun(it, dir);
|
|
245
|
-
if (r.ok) replaceIdea(dir, text, { status: "started", text });
|
|
246
|
-
changed();
|
|
247
|
-
return json(r.ok
|
|
248
|
-
? { ok: true, agent: r.agent, project: tilde(r.projectDir) }
|
|
249
|
-
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Archive is a list beside the runs, never a move: a run keeps its directory
|
|
253
|
-
// and its state, and the board simply stops showing it.
|
|
254
|
-
if (path === "/archive") {
|
|
255
|
-
const archived = body.archived !== false;
|
|
256
|
-
if (typeof body.id === "string") {
|
|
257
|
-
if (!/^[\w.-]+$/.test(body.id)) return bad("bad run id");
|
|
258
|
-
setArchived(dir, body.id, archived);
|
|
259
|
-
} else if (typeof body.text === "string") {
|
|
260
|
-
const text = body.text.trim();
|
|
261
|
-
const lines = readInbox(dir);
|
|
262
|
-
const at = lines.findIndex((l) => l.text === text && (archived ? l.status === "open" : l.status === "archived"));
|
|
263
|
-
if (at < 0) return bad("no such idea", 404);
|
|
264
|
-
lines[at] = { ...lines[at]!, status: archived ? "archived" : "open" };
|
|
265
|
-
writeInbox(dir, lines);
|
|
266
|
-
} else return bad("id or text required");
|
|
267
|
-
changed();
|
|
268
|
-
return json({ ok: true });
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// One chat message: text plus files (base64), each file saved and linked
|
|
272
|
-
// from the message so the note carries its own attachments.
|
|
273
|
-
if (path === "/note") {
|
|
274
|
-
const id = typeof body.id === "string" ? body.id : null;
|
|
275
|
-
if (id !== null && !/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
276
|
-
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
277
|
-
const files: { name: string; data: string }[] = Array.isArray(body.files) ? body.files : [];
|
|
278
|
-
let total = 0;
|
|
279
|
-
for (const f of files) total += (f.data?.length ?? 0);
|
|
280
|
-
if (total > 20_000_000) return bad("attachments over 15MB");
|
|
281
|
-
const links = files.map((f) => {
|
|
282
|
-
const bytes = Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64");
|
|
283
|
-
const rel = saveAttachment(dir, id, String(f.name ?? "file"), bytes).replace(/\\/g, "/");
|
|
284
|
-
return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel.replace(/^runs\/[^/]+\//, "")})`;
|
|
285
|
-
});
|
|
286
|
-
const note = [text, ...links].filter(Boolean).join("\n");
|
|
287
|
-
const failed = appendNote(dir, id !== null ? { id } : { text: String(body.for ?? "").trim() }, note);
|
|
288
|
-
changed();
|
|
289
|
-
return failed ? bad(failed, 404) : json({ ok: true });
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
if (path === "/notes") {
|
|
293
|
-
const id = String(body.id ?? "");
|
|
294
|
-
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
295
|
-
const failed = writeNotes(dir, id, String(body.text ?? ""));
|
|
296
|
-
changed();
|
|
297
|
-
return failed ? bad(failed, 404) : json({ ok: true });
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (path === "/abort") {
|
|
301
|
-
const id = String(body.id ?? "");
|
|
302
|
-
const why = String(body.why ?? "").trim() || "abandoned from the web board";
|
|
303
|
-
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
304
|
-
const failed = abandonRun(dir, id, why);
|
|
305
|
-
changed();
|
|
306
|
-
return failed ? bad(failed, 409) : json({ ok: true });
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return new Response("not found", { status: 404 });
|
|
311
|
-
},
|
|
312
|
-
}); }
|
|
313
|
-
|
|
314
|
-
const url = `http://127.0.0.1:${server.port}/`;
|
|
315
|
-
console.log(`\n kaizen web ${url}`);
|
|
316
|
-
console.log(` ${project ? `project ${project}` : "no .kaizen here; showing every known project"}`);
|
|
317
|
-
console.log(` ctrl-c to stop\n`);
|
|
318
|
-
if (opts.open !== false) await openApp(url);
|
|
319
|
-
|
|
320
|
-
await new Promise<void>((resolve) => {
|
|
321
|
-
const stop = () => { clearInterval(poll); clearInterval(ping); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
|
|
322
|
-
process.on("SIGINT", stop);
|
|
323
|
-
process.on("SIGTERM", stop);
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// A Chromium-family browser in --app mode is a window with no tabs or address bar,
|
|
328
|
-
// which is as close to a desktop app as a web page gets. Anything else gets a tab.
|
|
329
|
-
async function openApp(url: string) {
|
|
330
|
-
const chromes = process.platform === "darwin"
|
|
331
|
-
? ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"]
|
|
332
|
-
: process.platform === "win32"
|
|
333
|
-
? ["chrome.exe", "msedge.exe", "chromium.exe", "brave.exe"]
|
|
334
|
-
: ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "microsoft-edge", "brave", "brave-browser"];
|
|
335
|
-
try {
|
|
336
|
-
if (process.platform === "darwin") {
|
|
337
|
-
for (const app of chromes) {
|
|
338
|
-
const r = await Bun.$`open -a ${app} --args --app=${url}`.quiet().nothrow();
|
|
339
|
-
if (r.exitCode === 0) return true;
|
|
340
|
-
}
|
|
341
|
-
} else {
|
|
342
|
-
for (const bin of chromes) {
|
|
343
|
-
const found = Bun.which(bin) ?? (process.platform === "win32" ? winChrome(bin) : null);
|
|
344
|
-
if (!found) continue;
|
|
345
|
-
Bun.spawn([found, `--app=${url}`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }).unref();
|
|
346
|
-
return true;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
} catch { /* fall through to a plain tab */ }
|
|
350
|
-
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
351
|
-
if (!Bun.which(opener)) return false;
|
|
352
|
-
try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Windows keeps browsers out of PATH; the usual install roots are the next best guess.
|
|
356
|
-
function winChrome(exe: string) {
|
|
357
|
-
const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
|
|
358
|
-
const sub: Record<string, string> = {
|
|
359
|
-
"chrome.exe": "Google\\Chrome\\Application\\chrome.exe",
|
|
360
|
-
"msedge.exe": "Microsoft\\Edge\\Application\\msedge.exe",
|
|
361
|
-
"chromium.exe": "Chromium\\Application\\chrome.exe",
|
|
362
|
-
"brave.exe": "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
363
|
-
};
|
|
364
|
-
for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
|
|
365
|
-
return null;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// `kaizen web --shortcut`: a launcher the OS can find — Spotlight on macOS, the app
|
|
369
|
-
// menu / super-space on Linux, the Desktop on Windows. Each one runs `kaizen web` with
|
|
370
|
-
// the bun and script that ran this command, so it works for a git checkout and a
|
|
371
|
-
// bunx install alike.
|
|
372
|
-
export async function shortcut() {
|
|
373
|
-
const bun = process.execPath;
|
|
374
|
-
const script = Bun.argv[1]!;
|
|
375
|
-
const icon = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
|
|
376
|
-
const hasIcon = existsSync(icon);
|
|
377
|
-
const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
|
|
378
|
-
|
|
379
|
-
if (process.platform === "darwin") {
|
|
380
|
-
const app = join(home, "Applications", "Kaizen.app", "Contents");
|
|
381
|
-
mkdirSync(join(app, "MacOS"), { recursive: true });
|
|
382
|
-
writeFileSync(join(app, "MacOS", "kaizen"), `#!/bin/sh\nexec ${q(bun)} ${q(script)} web --daemon\n`);
|
|
383
|
-
chmodSync(join(app, "MacOS", "kaizen"), 0o755);
|
|
384
|
-
writeFileSync(join(app, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
|
|
385
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
386
|
-
<plist version="1.0"><dict>
|
|
387
|
-
<key>CFBundleName</key><string>Kaizen</string>
|
|
388
|
-
<key>CFBundleIdentifier</key><string>dev.kaizen.web</string>
|
|
389
|
-
<key>CFBundleExecutable</key><string>kaizen</string>
|
|
390
|
-
<key>CFBundlePackageType</key><string>APPL</string>
|
|
391
|
-
<key>LSUIElement</key><true/>
|
|
392
|
-
</dict></plist>
|
|
393
|
-
`);
|
|
394
|
-
return `${tilde(join(home, "Applications", "Kaizen.app"))} — Spotlight finds it once indexed`;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (process.platform === "win32") {
|
|
398
|
-
// A .lnk icon must be an .ico, and the board runs in its own minimised console
|
|
399
|
-
// rather than as a daemon: a detached child from a double-clicked shortcut has
|
|
400
|
-
// no console to report to, so a failure there looks like nothing happened.
|
|
401
|
-
// Closing that window stops the board.
|
|
402
|
-
const lnk = join(home, "Desktop", "Kaizen.lnk");
|
|
403
|
-
const ico = join(dirname(import.meta.dir), "assets", "kaizen.ico");
|
|
404
|
-
const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${bun}';$s.Arguments='"${script}" web';$s.WorkingDirectory='${home}';$s.WindowStyle=7;${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
|
|
405
|
-
await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
|
|
406
|
-
return `${lnk} — double-click opens the board in a minimised window; close that window to stop it`;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const dir = join(process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share"), "applications");
|
|
410
|
-
mkdirSync(dir, { recursive: true });
|
|
411
|
-
const file = join(dir, "kaizen.desktop");
|
|
412
|
-
writeFileSync(file, `[Desktop Entry]
|
|
413
|
-
Type=Application
|
|
414
|
-
Name=Kaizen
|
|
415
|
-
Comment=Kaizen board
|
|
416
|
-
Exec=${q(bun)} ${q(script)} web --daemon
|
|
417
|
-
${hasIcon ? `Icon=${icon}\n` : ""}Terminal=false
|
|
418
|
-
Categories=Development;
|
|
419
|
-
`);
|
|
420
|
-
return tilde(file);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const q = (s: string) => (/[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
|
|
1
|
+
// `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
|
|
2
|
+
// one HTML page; the page never sees the filesystem, only JSON.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync, watch, appendFileSync, type FSWatcher } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
|
|
7
|
+
readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
|
|
8
|
+
appendNote, saveAttachment, pidOnPort,
|
|
9
|
+
} from "./state.ts";
|
|
10
|
+
|
|
11
|
+
const PAGE = join(dirname(import.meta.dir), "web", "board.html");
|
|
12
|
+
const DEFAULT_PORT = 7420;
|
|
13
|
+
|
|
14
|
+
type Opts = { port?: number; open?: boolean };
|
|
15
|
+
|
|
16
|
+
export async function web(repoDir: string, opts: Opts = {}) {
|
|
17
|
+
// A board started from the shortcut has no terminal to show a crash in, so any
|
|
18
|
+
// crash is written where it can be read afterwards.
|
|
19
|
+
const log = join(home, ".kaizen", "web.log");
|
|
20
|
+
for (const ev of ["uncaughtException", "unhandledRejection"] as const) {
|
|
21
|
+
process.on(ev, (err: unknown) => {
|
|
22
|
+
const line = `${new Date().toISOString()} ${ev}: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`;
|
|
23
|
+
try { appendFileSync(log, line); } catch { /* nowhere to write */ }
|
|
24
|
+
console.error(line);
|
|
25
|
+
if (ev === "uncaughtException") process.exit(1);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const state = locate();
|
|
29
|
+
if (state && dirname(state) !== home) remember(dirname(state));
|
|
30
|
+
const project = state ? label(state) : null;
|
|
31
|
+
|
|
32
|
+
const states = (all: boolean) => (all || !state
|
|
33
|
+
? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
34
|
+
: [state]
|
|
35
|
+
).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
|
|
36
|
+
|
|
37
|
+
// Any tab on this machine can reach loopback. GET only reads the user's own
|
|
38
|
+
// files, but a POST from a page on the wider web must not write them.
|
|
39
|
+
const local = (req: Request) => {
|
|
40
|
+
const origin = req.headers.get("origin");
|
|
41
|
+
if (!origin) return true; // curl, same-origin fetch on old browsers
|
|
42
|
+
try {
|
|
43
|
+
const h = new URL(origin).hostname;
|
|
44
|
+
return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
|
|
45
|
+
} catch { return false; }
|
|
46
|
+
};
|
|
47
|
+
const json = (body: unknown, status = 200) =>
|
|
48
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
49
|
+
const bad = (why: string, status = 400) => json({ error: why }, status);
|
|
50
|
+
|
|
51
|
+
// Change notification: one SSE stream per open board, fed by fs.watch where it
|
|
52
|
+
// works and a slow poll where it does not.
|
|
53
|
+
const clients = new Set<ReadableStreamDefaultController>();
|
|
54
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
55
|
+
const changed = () => {
|
|
56
|
+
if (pending) return;
|
|
57
|
+
pending = setTimeout(() => {
|
|
58
|
+
pending = null;
|
|
59
|
+
for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
|
|
60
|
+
}, 200);
|
|
61
|
+
};
|
|
62
|
+
// Dev: the page reloads itself when board.html is saved. Server code needs
|
|
63
|
+
// `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
|
|
64
|
+
try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
|
|
65
|
+
const watchers: FSWatcher[] = [];
|
|
66
|
+
const watchAll = () => {
|
|
67
|
+
for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
|
|
68
|
+
watchers.length = 0;
|
|
69
|
+
for (const dir of states(true)) {
|
|
70
|
+
// An unhandled 'error' on a watcher (Windows: a watched dir renamed, EPERM on
|
|
71
|
+
// a locked file) would end the process; the poll covers what the watch misses.
|
|
72
|
+
try { watchers.push(watch(dir, { recursive: true }, changed).on("error", () => {})); } catch { /* poll covers it */ }
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
watchAll();
|
|
76
|
+
// The poll only speaks when something moved: a board reloading every two
|
|
77
|
+
// seconds for nothing is worse than no fallback at all.
|
|
78
|
+
const fingerprint = () => {
|
|
79
|
+
let sum = 0;
|
|
80
|
+
for (const dir of states(true)) {
|
|
81
|
+
for (const f of ["inbox.md", "backlog.md", "archive.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
|
|
82
|
+
const runs = join(dir, "runs");
|
|
83
|
+
if (!existsSync(runs)) continue;
|
|
84
|
+
for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
|
|
85
|
+
}
|
|
86
|
+
return sum;
|
|
87
|
+
};
|
|
88
|
+
let last = fingerprint();
|
|
89
|
+
let seen = states(true).join("\n");
|
|
90
|
+
const poll = setInterval(() => {
|
|
91
|
+
// A project registered since start (a run launched into a fresh dir) gets a watcher too.
|
|
92
|
+
const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
|
|
93
|
+
const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
|
|
94
|
+
}, 2000);
|
|
95
|
+
|
|
96
|
+
const BOOT = Date.now().toString(36);
|
|
97
|
+
const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
|
|
98
|
+
// A launcher clicked while a board is already up should show that board, not die.
|
|
99
|
+
// A bind can fail because a board is already there, or because the OS will not
|
|
100
|
+
// hand out the port at all (Windows reserves whole ranges for Hyper-V and WSL,
|
|
101
|
+
// and reports either as "in use"). Only a board that answers is "already
|
|
102
|
+
// running"; otherwise walk up to the next port that binds.
|
|
103
|
+
let port = opts.port ?? DEFAULT_PORT;
|
|
104
|
+
let server: ReturnType<typeof serve> | null = null;
|
|
105
|
+
for (let tries = 0; tries < 10 && !server; tries++, port++) {
|
|
106
|
+
try { server = serve(port); } catch (e) {
|
|
107
|
+
if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
|
|
108
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
109
|
+
const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
|
|
110
|
+
if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
|
|
111
|
+
// A listener that does not answer is a board that hung; it is ours to stop.
|
|
112
|
+
const pid = await pidOnPort(port);
|
|
113
|
+
if (pid && pid !== process.pid) {
|
|
114
|
+
try { process.kill(pid, "SIGTERM"); console.log(` stopped a hung board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
|
|
115
|
+
await Bun.sleep(300);
|
|
116
|
+
try { server = serve(port); continue; } catch { /* still held */ }
|
|
117
|
+
}
|
|
118
|
+
console.log(` port ${port} refused by the OS and nothing answers there; trying ${port + 1}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!server) { console.log(`\n kaizen web could not bind any port from ${opts.port ?? DEFAULT_PORT}\n`); return; }
|
|
122
|
+
|
|
123
|
+
function serve(port: number) { return Bun.serve({
|
|
124
|
+
hostname: "127.0.0.1",
|
|
125
|
+
port,
|
|
126
|
+
async fetch(req) {
|
|
127
|
+
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
128
|
+
// arrives with that hostname in Host, and gets nothing.
|
|
129
|
+
const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
|
|
130
|
+
if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
|
|
131
|
+
const url = new URL(req.url);
|
|
132
|
+
const path = url.pathname;
|
|
133
|
+
|
|
134
|
+
if (req.method === "GET" && path === "/") {
|
|
135
|
+
if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
|
|
136
|
+
return new Response(readFileSync(PAGE), {
|
|
137
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (req.method === "GET" && path === "/state") {
|
|
142
|
+
const all = url.searchParams.get("all") === "1";
|
|
143
|
+
const dirs = states(all);
|
|
144
|
+
return json({
|
|
145
|
+
project, all,
|
|
146
|
+
projects: states(true).map((d) => ({ dir: d, label: label(d) })),
|
|
147
|
+
cards: boardCards(dirs, Date.now()),
|
|
148
|
+
now: Date.now(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (req.method === "GET" && path.startsWith("/run/")) {
|
|
153
|
+
const id = decodeURIComponent(path.slice(5));
|
|
154
|
+
const dir = url.searchParams.get("dir") ?? state;
|
|
155
|
+
if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
|
|
156
|
+
const base = join(dir, "runs", id);
|
|
157
|
+
if (!existsSync(base)) return bad("no such run", 404);
|
|
158
|
+
const read = (f: string) => existsSync(join(base, f)) ? readFileSync(join(base, f), "utf8") : null;
|
|
159
|
+
return json({
|
|
160
|
+
id, short: short(id), state: read("state.json"),
|
|
161
|
+
request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
|
|
162
|
+
impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
|
|
163
|
+
notes: read("notes.md"),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (req.method === "GET" && path === "/settings") {
|
|
168
|
+
const { listSettings } = await import("./settings.ts");
|
|
169
|
+
return json(listSettings(repoDir));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (req.method === "POST" && path === "/settings") {
|
|
173
|
+
if (!local(req)) return bad("cross-origin write refused", 403);
|
|
174
|
+
let body: any;
|
|
175
|
+
try { body = await req.json(); } catch { return bad("json body expected"); }
|
|
176
|
+
const { setSetting } = await import("./settings.ts");
|
|
177
|
+
const r = setSetting(repoDir, String(body.key ?? ""), String(body.value ?? ""));
|
|
178
|
+
return "error" in r ? bad(r.error) : json(r);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Attachments only, never a run's own files: the path must sit under an
|
|
182
|
+
// attachments/ directory of a known state dir, with no way back up.
|
|
183
|
+
if (req.method === "GET" && path === "/file") {
|
|
184
|
+
const dir = url.searchParams.get("dir") ?? state;
|
|
185
|
+
const p = url.searchParams.get("p") ?? "";
|
|
186
|
+
if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
187
|
+
if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
|
|
188
|
+
const f = join(dir, p);
|
|
189
|
+
if (!existsSync(f)) return bad("no such file", 404);
|
|
190
|
+
return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (req.method === "GET" && path === "/events") {
|
|
194
|
+
let ctrl: ReadableStreamDefaultController;
|
|
195
|
+
const stream = new ReadableStream({
|
|
196
|
+
// The boot id lets the page tell a server restart from a dropped
|
|
197
|
+
// connection; only the former means new code. The ping keeps Bun's
|
|
198
|
+
// idle timeout from closing the stream every ten seconds.
|
|
199
|
+
start(c) { ctrl = c; clients.add(c); c.enqueue(`data: hello ${BOOT}\n\n`); },
|
|
200
|
+
cancel() { clients.delete(ctrl); },
|
|
201
|
+
});
|
|
202
|
+
return new Response(stream, {
|
|
203
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-store", "connection": "keep-alive" },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (req.method === "POST") {
|
|
208
|
+
if (!local(req)) return bad("cross-origin write refused", 403);
|
|
209
|
+
let body: any;
|
|
210
|
+
try { body = await req.json(); } catch { return bad("json body expected"); }
|
|
211
|
+
// No project here means the global inbox, which is where the TUI puts ideas too.
|
|
212
|
+
const dir: string = body.dir ?? state ?? join(home, ".kaizen");
|
|
213
|
+
if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
|
|
214
|
+
|
|
215
|
+
if (path === "/inbox") {
|
|
216
|
+
const text = String(body.text ?? "").trim();
|
|
217
|
+
// Absent means untouched; an empty string clears. replaceIdea keeps the
|
|
218
|
+
// old notes for any op that does not send them.
|
|
219
|
+
const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
|
|
220
|
+
if (body.op === "add") {
|
|
221
|
+
if (!text) return bad("empty idea");
|
|
222
|
+
writeInbox(dir, [...readInbox(dir), { status: "open", text, notes }]);
|
|
223
|
+
} else if (body.op === "edit") {
|
|
224
|
+
const next = String(body.next ?? "").trim();
|
|
225
|
+
if (!text || !next) return bad("empty idea");
|
|
226
|
+
replaceIdea(dir, text, notes === undefined ? { status: "open", text: next } : { status: "open", text: next, notes });
|
|
227
|
+
} else if (body.op === "reject") {
|
|
228
|
+
const why = String(body.reason ?? "").trim();
|
|
229
|
+
if (!why) return bad("a rejection needs a reason");
|
|
230
|
+
replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
|
|
231
|
+
} else if (body.op === "delete") {
|
|
232
|
+
replaceIdea(dir, text, null);
|
|
233
|
+
} else return bad("unknown op");
|
|
234
|
+
changed();
|
|
235
|
+
return json({ ok: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (path === "/run") {
|
|
239
|
+
const text = String(body.text ?? "").trim();
|
|
240
|
+
const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
|
|
241
|
+
if (!text) return bad("empty idea");
|
|
242
|
+
const it: Item = parseItem(`${kind} ${text}`.trim());
|
|
243
|
+
it.notes = readInbox(dir).find((l) => l.status === "open" && l.text === text)?.notes;
|
|
244
|
+
const r = launchRun(it, dir);
|
|
245
|
+
if (r.ok) replaceIdea(dir, text, { status: "started", text });
|
|
246
|
+
changed();
|
|
247
|
+
return json(r.ok
|
|
248
|
+
? { ok: true, agent: r.agent, project: tilde(r.projectDir) }
|
|
249
|
+
: { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Archive is a list beside the runs, never a move: a run keeps its directory
|
|
253
|
+
// and its state, and the board simply stops showing it.
|
|
254
|
+
if (path === "/archive") {
|
|
255
|
+
const archived = body.archived !== false;
|
|
256
|
+
if (typeof body.id === "string") {
|
|
257
|
+
if (!/^[\w.-]+$/.test(body.id)) return bad("bad run id");
|
|
258
|
+
setArchived(dir, body.id, archived);
|
|
259
|
+
} else if (typeof body.text === "string") {
|
|
260
|
+
const text = body.text.trim();
|
|
261
|
+
const lines = readInbox(dir);
|
|
262
|
+
const at = lines.findIndex((l) => l.text === text && (archived ? l.status === "open" : l.status === "archived"));
|
|
263
|
+
if (at < 0) return bad("no such idea", 404);
|
|
264
|
+
lines[at] = { ...lines[at]!, status: archived ? "archived" : "open" };
|
|
265
|
+
writeInbox(dir, lines);
|
|
266
|
+
} else return bad("id or text required");
|
|
267
|
+
changed();
|
|
268
|
+
return json({ ok: true });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// One chat message: text plus files (base64), each file saved and linked
|
|
272
|
+
// from the message so the note carries its own attachments.
|
|
273
|
+
if (path === "/note") {
|
|
274
|
+
const id = typeof body.id === "string" ? body.id : null;
|
|
275
|
+
if (id !== null && !/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
276
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
277
|
+
const files: { name: string; data: string }[] = Array.isArray(body.files) ? body.files : [];
|
|
278
|
+
let total = 0;
|
|
279
|
+
for (const f of files) total += (f.data?.length ?? 0);
|
|
280
|
+
if (total > 20_000_000) return bad("attachments over 15MB");
|
|
281
|
+
const links = files.map((f) => {
|
|
282
|
+
const bytes = Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64");
|
|
283
|
+
const rel = saveAttachment(dir, id, String(f.name ?? "file"), bytes).replace(/\\/g, "/");
|
|
284
|
+
return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel.replace(/^runs\/[^/]+\//, "")})`;
|
|
285
|
+
});
|
|
286
|
+
const note = [text, ...links].filter(Boolean).join("\n");
|
|
287
|
+
const failed = appendNote(dir, id !== null ? { id } : { text: String(body.for ?? "").trim() }, note);
|
|
288
|
+
changed();
|
|
289
|
+
return failed ? bad(failed, 404) : json({ ok: true });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (path === "/notes") {
|
|
293
|
+
const id = String(body.id ?? "");
|
|
294
|
+
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
295
|
+
const failed = writeNotes(dir, id, String(body.text ?? ""));
|
|
296
|
+
changed();
|
|
297
|
+
return failed ? bad(failed, 404) : json({ ok: true });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (path === "/abort") {
|
|
301
|
+
const id = String(body.id ?? "");
|
|
302
|
+
const why = String(body.why ?? "").trim() || "abandoned from the web board";
|
|
303
|
+
if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
|
|
304
|
+
const failed = abandonRun(dir, id, why);
|
|
305
|
+
changed();
|
|
306
|
+
return failed ? bad(failed, 409) : json({ ok: true });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return new Response("not found", { status: 404 });
|
|
311
|
+
},
|
|
312
|
+
}); }
|
|
313
|
+
|
|
314
|
+
const url = `http://127.0.0.1:${server.port}/`;
|
|
315
|
+
console.log(`\n kaizen web ${url}`);
|
|
316
|
+
console.log(` ${project ? `project ${project}` : "no .kaizen here; showing every known project"}`);
|
|
317
|
+
console.log(` ctrl-c to stop\n`);
|
|
318
|
+
if (opts.open !== false) await openApp(url);
|
|
319
|
+
|
|
320
|
+
await new Promise<void>((resolve) => {
|
|
321
|
+
const stop = () => { clearInterval(poll); clearInterval(ping); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
|
|
322
|
+
process.on("SIGINT", stop);
|
|
323
|
+
process.on("SIGTERM", stop);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A Chromium-family browser in --app mode is a window with no tabs or address bar,
|
|
328
|
+
// which is as close to a desktop app as a web page gets. Anything else gets a tab.
|
|
329
|
+
async function openApp(url: string) {
|
|
330
|
+
const chromes = process.platform === "darwin"
|
|
331
|
+
? ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"]
|
|
332
|
+
: process.platform === "win32"
|
|
333
|
+
? ["chrome.exe", "msedge.exe", "chromium.exe", "brave.exe"]
|
|
334
|
+
: ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "microsoft-edge", "brave", "brave-browser"];
|
|
335
|
+
try {
|
|
336
|
+
if (process.platform === "darwin") {
|
|
337
|
+
for (const app of chromes) {
|
|
338
|
+
const r = await Bun.$`open -a ${app} --args --app=${url}`.quiet().nothrow();
|
|
339
|
+
if (r.exitCode === 0) return true;
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
for (const bin of chromes) {
|
|
343
|
+
const found = Bun.which(bin) ?? (process.platform === "win32" ? winChrome(bin) : null);
|
|
344
|
+
if (!found) continue;
|
|
345
|
+
Bun.spawn([found, `--app=${url}`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }).unref();
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} catch { /* fall through to a plain tab */ }
|
|
350
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
351
|
+
if (!Bun.which(opener)) return false;
|
|
352
|
+
try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Windows keeps browsers out of PATH; the usual install roots are the next best guess.
|
|
356
|
+
function winChrome(exe: string) {
|
|
357
|
+
const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
|
|
358
|
+
const sub: Record<string, string> = {
|
|
359
|
+
"chrome.exe": "Google\\Chrome\\Application\\chrome.exe",
|
|
360
|
+
"msedge.exe": "Microsoft\\Edge\\Application\\msedge.exe",
|
|
361
|
+
"chromium.exe": "Chromium\\Application\\chrome.exe",
|
|
362
|
+
"brave.exe": "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
363
|
+
};
|
|
364
|
+
for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// `kaizen web --shortcut`: a launcher the OS can find — Spotlight on macOS, the app
|
|
369
|
+
// menu / super-space on Linux, the Desktop on Windows. Each one runs `kaizen web` with
|
|
370
|
+
// the bun and script that ran this command, so it works for a git checkout and a
|
|
371
|
+
// bunx install alike.
|
|
372
|
+
export async function shortcut() {
|
|
373
|
+
const bun = process.execPath;
|
|
374
|
+
const script = Bun.argv[1]!;
|
|
375
|
+
const icon = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
|
|
376
|
+
const hasIcon = existsSync(icon);
|
|
377
|
+
const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
|
|
378
|
+
|
|
379
|
+
if (process.platform === "darwin") {
|
|
380
|
+
const app = join(home, "Applications", "Kaizen.app", "Contents");
|
|
381
|
+
mkdirSync(join(app, "MacOS"), { recursive: true });
|
|
382
|
+
writeFileSync(join(app, "MacOS", "kaizen"), `#!/bin/sh\nexec ${q(bun)} ${q(script)} web --daemon\n`);
|
|
383
|
+
chmodSync(join(app, "MacOS", "kaizen"), 0o755);
|
|
384
|
+
writeFileSync(join(app, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
|
|
385
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
386
|
+
<plist version="1.0"><dict>
|
|
387
|
+
<key>CFBundleName</key><string>Kaizen</string>
|
|
388
|
+
<key>CFBundleIdentifier</key><string>dev.kaizen.web</string>
|
|
389
|
+
<key>CFBundleExecutable</key><string>kaizen</string>
|
|
390
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
391
|
+
<key>LSUIElement</key><true/>
|
|
392
|
+
</dict></plist>
|
|
393
|
+
`);
|
|
394
|
+
return `${tilde(join(home, "Applications", "Kaizen.app"))} — Spotlight finds it once indexed`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (process.platform === "win32") {
|
|
398
|
+
// A .lnk icon must be an .ico, and the board runs in its own minimised console
|
|
399
|
+
// rather than as a daemon: a detached child from a double-clicked shortcut has
|
|
400
|
+
// no console to report to, so a failure there looks like nothing happened.
|
|
401
|
+
// Closing that window stops the board.
|
|
402
|
+
const lnk = join(home, "Desktop", "Kaizen.lnk");
|
|
403
|
+
const ico = join(dirname(import.meta.dir), "assets", "kaizen.ico");
|
|
404
|
+
const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${bun}';$s.Arguments='"${script}" web';$s.WorkingDirectory='${home}';$s.WindowStyle=7;${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
|
|
405
|
+
await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
|
|
406
|
+
return `${lnk} — double-click opens the board in a minimised window; close that window to stop it`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const dir = join(process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share"), "applications");
|
|
410
|
+
mkdirSync(dir, { recursive: true });
|
|
411
|
+
const file = join(dir, "kaizen.desktop");
|
|
412
|
+
writeFileSync(file, `[Desktop Entry]
|
|
413
|
+
Type=Application
|
|
414
|
+
Name=Kaizen
|
|
415
|
+
Comment=Kaizen board
|
|
416
|
+
Exec=${q(bun)} ${q(script)} web --daemon
|
|
417
|
+
${hasIcon ? `Icon=${icon}\n` : ""}Terminal=false
|
|
418
|
+
Categories=Development;
|
|
419
|
+
`);
|
|
420
|
+
return tilde(file);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const q = (s: string) => (/[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
|