kaizen-agent 1.9.3 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/agents/kaizen-builder.md +4 -1
- package/cli/board.test.ts +48 -0
- package/cli/install.test.ts +85 -0
- package/cli/install.ts +46 -10
- package/cli/notes.test.ts +1 -1
- package/cli/settings.test.ts +50 -0
- package/cli/settings.ts +90 -35
- package/cli/sources.test.ts +44 -5
- package/cli/sources.ts +11 -6
- package/cli/state.test.ts +214 -2
- package/cli/state.ts +218 -16
- package/cli/web.ts +70 -14
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +1 -0
- package/skills/kaizen/config.default.yml +10 -4
- package/skills/kaizen/spec-build.md +17 -0
- package/skills/kaizen/spec-plan.md +6 -1
- package/web/board.html +214 -49
- package/web/notebook.js +2 -2
- package/web/notes-lib.ts +1 -1
- package/web/notes.html +1 -0
package/README.md
CHANGED
|
@@ -301,6 +301,7 @@ and every `kaizen settings` knob, same rows, same `config.yml`. `--port N` picks
|
|
|
301
301
|
serves, the board opens on every project, or on the one picked last time in that browser; `?all=0` in the URL narrows it to the one it was started in. `--daemon` starts the server
|
|
302
302
|
detached so it outlives the terminal (pid kept in `~/.kaizen/web.pid`); `--stop` ends it. `kaizen web --shortcut` adds a launcher
|
|
303
303
|
the OS can find — Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows. Each one starts the board in the background (`--daemon`) and opens it; `kaizen web --stop` ends it.
|
|
304
|
+
On Windows `kaizen web` starts in the background unless `--foreground` is given; `kaizen web --stop` ends it.
|
|
304
305
|
|
|
305
306
|
**A notebook beside the board.** The note icon in the board's header opens
|
|
306
307
|
`/notebook`: markdown notes for the project, kept as plain files in `.kaizen/notes/`
|
|
@@ -327,10 +328,14 @@ slower CPU path); a browser without a microphone API or WebAssembly shows no mic
|
|
|
327
328
|
|
|
328
329
|
Requests that already live somewhere else — a team's Google Sheet, a Doc, a shared
|
|
329
330
|
note — do not have to be retyped. The link icon beside the gear opens **Request
|
|
330
|
-
sources**: paste a link, and kaizen reads it and adds every request it has not
|
|
331
|
-
before to Ideas, each with a note saying where it came from
|
|
332
|
-
|
|
333
|
-
|
|
331
|
+
sources**: paste a link, and kaizen reads it at once and adds every request it has not
|
|
332
|
+
seen before to Ideas, each with a note saying where it came from and a *gathered* chip;
|
|
333
|
+
the panel says what came back, or why the link could not be read. In a sheet, the header
|
|
334
|
+
is looked for in the first five rows that hold anything, and a column headed `Request`,
|
|
335
|
+
`Idea`, `Title`, `Task`, `PBI`, `Summary`, `Name`, `Detail`, `Details`, `Description`,
|
|
336
|
+
`Deskripsi`, `Feedback`, `Issue`, `Permintaan` or `Judul` is the request; the other
|
|
337
|
+
cells become its notes. Without such
|
|
338
|
+
a header the request is one column for the whole sheet, the one holding the most text. A row whose `Status` column says `Done`,
|
|
334
339
|
`Finish` or `Finished` is left out, and gathered later if its status changes. A doc or
|
|
335
340
|
a plain-text link gives one idea per line.
|
|
336
341
|
|
|
@@ -657,14 +662,14 @@ with a comment on each key, if you would rather edit the file.
|
|
|
657
662
|
| `--yes` | Take every default, ask nothing |
|
|
658
663
|
| `--verbose` | List every link instead of a one-line summary |
|
|
659
664
|
| `upgrade` | Pull, relink, and clear the installer cache. No prompts. |
|
|
660
|
-
| `uninstall` | Remove the links, the clone, and the launcher. Asks whether to keep your runs and settings. |
|
|
665
|
+
| `uninstall` | Remove the links, the clone, and the launcher. Asks whether to keep your runs and settings. The clone goes only when it is `~/kaizen`, cloned from kaizen's repository, with no local changes; any other `KAIZEN_HOME` is kept and named. |
|
|
661
666
|
| `uninstall --purge` | The same, and delete `~/.kaizen` and this project's `.kaizen/` too. |
|
|
662
667
|
| `settings` | Open the settings browser for the nearest `.kaizen/`. Also `config`. |
|
|
663
668
|
|
|
664
669
|
Uninstalling works the same on Linux, macOS and Windows — `kaizen uninstall`, or
|
|
665
670
|
`bunx kaizen-agent uninstall` when the command itself is already gone. It offers three
|
|
666
671
|
answers — keep your runs and settings, remove everything, or cancel — and takes the
|
|
667
|
-
first without asking when the output is not a terminal. It lists what it removed; the `## Kaizen workflow` line in each project's `CLAUDE.md` is left for
|
|
672
|
+
first without asking when the output is not a terminal. It lists what it removed and what it kept; the `## Kaizen workflow` line in each project's `CLAUDE.md` is left for
|
|
668
673
|
you to delete, since you may have edited around it.
|
|
669
674
|
|
|
670
675
|
A global install also writes `~/.local/bin/kaizen` (`kaizen.cmd` in bun's bin
|
package/agents/kaizen-builder.md
CHANGED
|
@@ -68,7 +68,10 @@ change — do not refactor around it. Write what you changed to
|
|
|
68
68
|
- **Verification** — each check carried out, with its actual result. Quote failures.
|
|
69
69
|
- **Not done** — steps skipped, out-of-scope observations, known gaps.
|
|
70
70
|
- **Preview** — how to see the result: commands to run it locally and the URL for an
|
|
71
|
-
app; the path to open, or the result itself, for anything else.
|
|
71
|
+
app; the path to open, or the result itself, for anything else. Then `### How to test`:
|
|
72
|
+
numbered steps from the plan's gate, each an action and what the person should see,
|
|
73
|
+
plus any test command with its expected result. `###` subheads only — the board ends
|
|
74
|
+
the section at the next `##`. The board shows it.
|
|
72
75
|
- **Commit** — in a git repository, the commit message for the work: a subject
|
|
73
76
|
`<type>(<scope>): <what changed>` (type one of `feat`, `fix`, `refactor`, `perf`,
|
|
74
77
|
`docs`, `test`, `build`, `ci`, `chore`, `style`, `revert`; at most 72 characters)
|
package/cli/board.test.ts
CHANGED
|
@@ -53,3 +53,51 @@ test("undraft removes by prefix and leaves the rest", () => {
|
|
|
53
53
|
undraft("f:");
|
|
54
54
|
expect(Object.keys(ss).sort()).toEqual(["kz-d:n:x", "kz-form"]);
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
// A building run's progress list, lifted the same way.
|
|
58
|
+
const pat = html.indexOf("function progress(");
|
|
59
|
+
const progress = new Function(`${esc}\n${html.slice(pat, html.indexOf("\n\n", pat))}\nreturn progress`)() as (src: string, prev?: string[]) => string;
|
|
60
|
+
|
|
61
|
+
test("progress: each item marked done, working on or to do, with the count", () => {
|
|
62
|
+
const out = progress("# Progress\n- done: one\n- doing: two <b>x</b>\n- todo: `three`\n- open: not an item\n");
|
|
63
|
+
expect(out).toContain("1 of 3 done");
|
|
64
|
+
expect(out).toContain("width:33%");
|
|
65
|
+
expect(out.match(/<li class="p-(\w+)/g)).toEqual(['<li class="p-done', '<li class="p-doing', '<li class="p-todo']);
|
|
66
|
+
expect(out).toContain("two <b>x</b>");
|
|
67
|
+
expect(out).toContain("<code>three</code>");
|
|
68
|
+
expect(out).not.toContain("chg");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("progress: only an item whose status moved animates", () => {
|
|
72
|
+
const out = progress("- done: one\n- doing: two\n- todo: three\n", ["done", "todo", "todo"]);
|
|
73
|
+
expect(out.match(/<li class="[^"]*"/g)).toEqual(['<li class="p-done"', '<li class="p-doing chg"', '<li class="p-todo"']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("progress: a file with no items draws nothing", () => {
|
|
77
|
+
expect(progress("# Progress\n\nnothing yet\n")).toBe("");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// A plan's open questions, lifted the same way: the function ends at the first line not indented.
|
|
81
|
+
const pq = html.indexOf("function planQuestions(");
|
|
82
|
+
type Q = { q: string; multi: boolean; options: { text: string; label: string; rec: boolean }[] };
|
|
83
|
+
const planQuestions = new Function(`${html.slice(pq).split(/\n(?! )/)[0]}\nreturn planQuestions`)() as (src: string) => Q[] | null;
|
|
84
|
+
|
|
85
|
+
test("planQuestions: Q lines with options, recommended and multi-select read", () => {
|
|
86
|
+
const qs = planQuestions(`# Plan\n## 9. Open questions\n\nQ: Which improvements to build? (multi-select)\n - Own project in row (recommended) — fixes Move; XS\n - Gather on add — instant result\nQ: Where the progress shows\n - Top of the Backlog tab (recommended): what you asked for.\n - Its own tab: adds an eighth tab\n\n- Note: not an option\n## 10. Out of scope suggestions\n- not a question\n`);
|
|
87
|
+
expect(qs!.map((q) => [q.q, q.multi])).toEqual([["Which improvements to build? (multi-select)", true], ["Where the progress shows", false]]);
|
|
88
|
+
expect(qs[0]!.options.map((o) => [o.label, o.rec])).toEqual([["Own project in row", true], ["Gather on add", false]]);
|
|
89
|
+
expect(qs[1]!.options.map((o) => o.label)).toEqual(["Top of the Backlog tab", "Its own tab"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("planQuestions: fenced and numbered questions, sub-headings kept, none and missing read as nothing", () => {
|
|
93
|
+
const qs = planQuestions("## Open questions\n### Board\n```\nQ1: How should it run?\n - Detach (recommended): prompt comes back\n - Foreground: as is\n```\n");
|
|
94
|
+
expect(qs!.map((q) => [q.q, q.options.length])).toEqual([["How should it run?", 2]]);
|
|
95
|
+
expect(planQuestions("## 9. Open questions\n\nNone.\n")).toEqual([]);
|
|
96
|
+
expect(planQuestions("## 9. Open questions\n- Building → Backlog taken literally.\n")).toBeNull();
|
|
97
|
+
expect(planQuestions("# Plan\nno section\n")).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("planQuestions: a bullet after a blank line is not one more option", () => {
|
|
101
|
+
const qs = planQuestions("## Open questions\nQ: One?\n - A (recommended)\n - B\n\n- Note: aside\n")!;
|
|
102
|
+
expect(qs[0]!.options.map((o) => o.label)).toEqual(["A", "B"]);
|
|
103
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// `kaizen uninstall` against throwaway folders only. Every path it may delete comes
|
|
2
|
+
// from the env built here (HOME, KAIZEN_HOME, TMPDIR all scratch), never from the
|
|
3
|
+
// real environment: on 2026-10-01 an uninstall pointed at a checkout deleted it.
|
|
4
|
+
// Skipped on Windows: there uninstall also walks every drive (searchRoots) and
|
|
5
|
+
// strips kaizen links from real projects, whatever HOME says.
|
|
6
|
+
import { test, expect } from "bun:test";
|
|
7
|
+
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, symlinkSync } from "node:fs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
const script = join(dirname(import.meta.path), "install.ts");
|
|
12
|
+
const it = test.skipIf(process.platform === "win32");
|
|
13
|
+
const REPO = "https://github.com/hfadhlullah/kaizen.git";
|
|
14
|
+
|
|
15
|
+
// `custom` sets KAIZEN_HOME to a folder outside HOME; otherwise it is unset and the
|
|
16
|
+
// install is the scratch home's own ~/kaizen.
|
|
17
|
+
const uninstall = (make: (k: string) => void, custom = false) => {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), "kz-uninstall-"));
|
|
19
|
+
const home = join(root, "home"), k = custom ? join(root, "kaizen") : join(home, "kaizen");
|
|
20
|
+
mkdirSync(home);
|
|
21
|
+
make(k);
|
|
22
|
+
const env: Record<string, string> = { PATH: process.env.PATH ?? "", HOME: home, USERPROFILE: home, TMPDIR: root };
|
|
23
|
+
if (custom) env.KAIZEN_HOME = k;
|
|
24
|
+
const p = Bun.spawnSync([process.execPath, script, "uninstall", "--yes"], { cwd: home, env });
|
|
25
|
+
const left = existsSync(k), out = p.stdout.toString();
|
|
26
|
+
rmSync(root, { recursive: true, force: true });
|
|
27
|
+
if (p.exitCode !== 0) throw new Error(p.stderr.toString());
|
|
28
|
+
return { left, out };
|
|
29
|
+
};
|
|
30
|
+
const kaizenFiles = (k: string) => { mkdirSync(join(k, "cli"), { recursive: true }); writeFileSync(join(k, "cli", "install.ts"), ""); };
|
|
31
|
+
// A clone without the network: an empty repository whose origin is `url`.
|
|
32
|
+
const clone = (url: string, dirty = false, more?: (k: string) => void) => (k: string) => {
|
|
33
|
+
mkdirSync(k, { recursive: true });
|
|
34
|
+
Bun.spawnSync(["git", "init", "-q", k]);
|
|
35
|
+
Bun.spawnSync(["git", "-C", k, "remote", "add", "origin", url]);
|
|
36
|
+
if (dirty) writeFileSync(join(k, "mine.txt"), "uncommitted");
|
|
37
|
+
more?.(k);
|
|
38
|
+
};
|
|
39
|
+
// The clone used as a project: its .kaizen/ is ignored, so `status --porcelain` alone reads clean.
|
|
40
|
+
const runs = (k: string) => {
|
|
41
|
+
writeFileSync(join(k, ".git", "info", "exclude"), ".kaizen/\n");
|
|
42
|
+
mkdirSync(join(k, ".kaizen", "runs", "r1"), { recursive: true });
|
|
43
|
+
writeFileSync(join(k, ".kaizen", "runs", "r1", "state.json"), "{}");
|
|
44
|
+
};
|
|
45
|
+
const commit = (k: string) => {
|
|
46
|
+
Bun.spawnSync(["git", "-C", k, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "mine"]);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
it("uninstall leaves a KAIZEN_HOME other than ~/kaizen, git checkout or not", () => {
|
|
50
|
+
const r = uninstall((k) => { kaizenFiles(k); mkdirSync(join(k, ".git")); }, true);
|
|
51
|
+
expect(r.left).toBe(true);
|
|
52
|
+
expect(r.out).toContain("KAIZEN_HOME is not ~/kaizen");
|
|
53
|
+
expect(uninstall(kaizenFiles, true).left).toBe(true);
|
|
54
|
+
// ~/kaizen is a link to the checkout KAIZEN_HOME names: same realpath, still refused.
|
|
55
|
+
const linked = uninstall((k) => { kaizenFiles(k); mkdirSync(join(k, ".git")); symlinkSync(k, join(dirname(k), "home", "kaizen")); }, true);
|
|
56
|
+
expect(linked.left).toBe(true);
|
|
57
|
+
expect(linked.out).toContain("KAIZEN_HOME is not ~/kaizen");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("uninstall leaves a ~/kaizen that is not a kaizen install", () => {
|
|
61
|
+
const r = uninstall((k) => { mkdirSync(k); writeFileSync(join(k, "notes.txt"), "mine"); });
|
|
62
|
+
expect(r.left).toBe(true);
|
|
63
|
+
expect(r.out).toContain("not a kaizen install");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("uninstall still removes the copy kaizen made without git", () => {
|
|
67
|
+
expect(uninstall(kaizenFiles).left).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it.skipIf(!Bun.which("git"))("uninstall removes a clean clone of kaizen and keeps any other", () => {
|
|
71
|
+
expect(uninstall(clone(REPO)).left).toBe(false);
|
|
72
|
+
expect(uninstall(clone(REPO.replace(/\.git$/, ""))).left).toBe(false);
|
|
73
|
+
const dirty = uninstall(clone(REPO, true));
|
|
74
|
+
expect(dirty.left).toBe(true);
|
|
75
|
+
expect(dirty.out).toContain("local changes");
|
|
76
|
+
const other = uninstall(clone("https://example.com/someone/else.git"));
|
|
77
|
+
expect(other.left).toBe(true);
|
|
78
|
+
expect(other.out).toContain("another repository");
|
|
79
|
+
const used = uninstall(clone(REPO, false, runs));
|
|
80
|
+
expect(used.left).toBe(true);
|
|
81
|
+
expect(used.out).toContain("ignored files");
|
|
82
|
+
const unpushed = uninstall(clone(REPO, false, commit));
|
|
83
|
+
expect(unpushed.left).toBe(true);
|
|
84
|
+
expect(unpushed.out).toContain("commits not pushed");
|
|
85
|
+
});
|
package/cli/install.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import {
|
|
3
|
-
symlinkSync, mkdirSync, readdirSync, lstatSync,
|
|
3
|
+
symlinkSync, mkdirSync, readdirSync, lstatSync, unlinkSync,
|
|
4
4
|
existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync, renameSync,
|
|
5
|
-
statSync, cpSync, realpathSync,
|
|
5
|
+
statSync, cpSync, realpathSync, rmdirSync,
|
|
6
6
|
} from "node:fs";
|
|
7
|
-
import { join, dirname, basename } from "node:path";
|
|
7
|
+
import { join, dirname, basename, resolve } from "node:path";
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
9
|
import { applyPreset, detectPreset, PRESETS, type PresetName } from "./settings.ts";
|
|
10
10
|
|
|
@@ -141,11 +141,14 @@ async function removeEverything() {
|
|
|
141
141
|
}
|
|
142
142
|
const gone: string[] = [];
|
|
143
143
|
const stuck: string[] = [];
|
|
144
|
+
const kept: string[] = [];
|
|
144
145
|
const drop = (p: string) => {
|
|
145
146
|
if (!existsSync(p) && !isLink(p)) return;
|
|
146
147
|
// Windows refuses to delete a file another process holds open, and a silent
|
|
147
148
|
// failure here would read as a clean uninstall that removed nothing.
|
|
148
|
-
|
|
149
|
+
// A link goes as a link: a recursive delete aimed at a junction can walk into
|
|
150
|
+
// the clone it points at.
|
|
151
|
+
try { if (isLink(p)) removeLink(p); else rmSync(p, { recursive: true, force: true }); gone.push(tilde(p)); }
|
|
149
152
|
catch { stuck.push(tilde(p)); }
|
|
150
153
|
};
|
|
151
154
|
|
|
@@ -172,7 +175,26 @@ async function removeEverything() {
|
|
|
172
175
|
}
|
|
173
176
|
}
|
|
174
177
|
|
|
175
|
-
|
|
178
|
+
// KAIZEN_HOME can point anywhere, a development checkout included, and deleting
|
|
179
|
+
// that takes .git and every uncommitted edit with it. Only ~/kaizen is deleted,
|
|
180
|
+
// and only as kaizen left it: its own clone with a clean tree, or the copy made
|
|
181
|
+
// without git. Anything else is named and left for its owner. The path is compared
|
|
182
|
+
// as written, so a KAIZEN_HOME that reaches ~/kaizen through a link is refused too.
|
|
183
|
+
// A clone holds no ignored files unless someone used it (.kaizen/ is ignored), and
|
|
184
|
+
// no commits that are not on a remote.
|
|
185
|
+
const install = process.env.KAIZEN_HOME ?? join(home, "kaizen");
|
|
186
|
+
const git = async (...a: string[]) => {
|
|
187
|
+
try { return (await Bun.$`git -C ${install} ${a}`.quiet().text()).trim(); } catch { return null; }
|
|
188
|
+
};
|
|
189
|
+
const repo = (u: string | null) => u?.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
190
|
+
const why = !existsSync(install) ? ""
|
|
191
|
+
: resolve(install) !== join(home, "kaizen") ? "KAIZEN_HOME is not ~/kaizen"
|
|
192
|
+
: !existsSync(join(install, ".git")) ? (existsSync(join(install, "cli", "install.ts")) ? "" : "not a kaizen install")
|
|
193
|
+
: repo(await git("remote", "get-url", "origin")) !== repo(REPO_URL) ? "a git checkout of another repository"
|
|
194
|
+
: (await git("status", "--porcelain", "--ignored")) !== "" ? "a git checkout with local changes or ignored files"
|
|
195
|
+
: (await git("rev-list", "--branches", "--not", "--remotes")) !== "" ? "a git checkout with commits not pushed" : "";
|
|
196
|
+
if (why) kept.push(`${tilde(install)} ${c.dim(`(${why}; delete it yourself if it is only kaizen)`)}`);
|
|
197
|
+
else drop(install);
|
|
176
198
|
drop(join(process.env.BUN_INSTALL ?? join(home, ".bun"), "bin", "kaizen.cmd"));
|
|
177
199
|
drop(join(home, ".local", "bin", "kaizen"));
|
|
178
200
|
await clearBunxCache();
|
|
@@ -184,7 +206,8 @@ async function removeEverything() {
|
|
|
184
206
|
|
|
185
207
|
console.log(`\n ${c.bold("Uninstalled")}`);
|
|
186
208
|
for (const p of gone) console.log(` ${c.dim("removed " + p)}`);
|
|
187
|
-
|
|
209
|
+
for (const p of kept) console.log(` ${c.dim("kept ")}${p}`);
|
|
210
|
+
if (!gone.length && !stuck.length && !kept.length) console.log(` ${c.dim("nothing left to remove")}`);
|
|
188
211
|
if (stuck.length) {
|
|
189
212
|
console.log(`\n ${c.bold("could not remove")} ${c.dim("— close any dashboard or agent still using these, then run it again")}`);
|
|
190
213
|
for (const p of stuck) console.log(` ${c.dim(p)}`);
|
|
@@ -201,6 +224,12 @@ function isLink(p: string) {
|
|
|
201
224
|
try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
|
|
202
225
|
}
|
|
203
226
|
|
|
227
|
+
// Removes the link itself, never what it points at. A Windows junction is a
|
|
228
|
+
// directory to unlink(), so rmdir() is the fallback; neither one recurses.
|
|
229
|
+
function removeLink(p: string) {
|
|
230
|
+
try { unlinkSync(p); } catch { rmdirSync(p); }
|
|
231
|
+
}
|
|
232
|
+
|
|
204
233
|
// Nothing to install into. kaizen is a workflow an agent runs; on its own it does
|
|
205
234
|
// nothing, so send them to the agent first rather than laying out files no reader
|
|
206
235
|
// will ever load.
|
|
@@ -296,7 +325,10 @@ if (wantWeb) {
|
|
|
296
325
|
process.exit(0);
|
|
297
326
|
}
|
|
298
327
|
|
|
299
|
-
|
|
328
|
+
// On Windows a board in the foreground shares its console with every child it
|
|
329
|
+
// starts and Ctrl+C on kaizen.cmd asks "Terminate batch job (Y/N)?", so there a
|
|
330
|
+
// bare `kaizen web` detaches like the shortcut; --foreground keeps it here.
|
|
331
|
+
if (args.has("--daemon") || (process.platform === "win32" && !args.has("--foreground"))) {
|
|
300
332
|
// The daemon is this same command, detached, with the browser left to us:
|
|
301
333
|
// ignored stdio is what lets it outlive the terminal.
|
|
302
334
|
// A board running an older kaizen is not "already running": the child finds
|
|
@@ -306,7 +338,7 @@ if (wantWeb) {
|
|
|
306
338
|
console.log(` kaizen web already running at ${url}`);
|
|
307
339
|
} else {
|
|
308
340
|
const argv = Bun.argv.slice(2).filter((a) => a !== "--daemon" && a !== "--no-open");
|
|
309
|
-
const child = Bun.spawn([process.execPath, Bun.main, ...argv, "--no-open"], {
|
|
341
|
+
const child = Bun.spawn([process.execPath, Bun.main, ...argv, "--no-open", "--foreground"], {
|
|
310
342
|
detached: true, stdio: ["ignore", "ignore", "ignore"], windowsHide: true,
|
|
311
343
|
});
|
|
312
344
|
child.unref();
|
|
@@ -661,7 +693,10 @@ function linkState(src: string, dest: string) {
|
|
|
661
693
|
if (isWindows && st.isFile()) return sameFile(src, dest) ? "linked" : "stale";
|
|
662
694
|
return "occupied";
|
|
663
695
|
}
|
|
664
|
-
|
|
696
|
+
// Compared by where they lead, not by spelling: a junction reads back with a
|
|
697
|
+
// trailing \ or a \\?\ prefix, which made every upgrade relink every skill.
|
|
698
|
+
try { return realpathSync(dest) === realpathSync(src) ? "linked" : "stale"; }
|
|
699
|
+
catch { return "stale"; } // dangling: still a link to replace, not a free slot
|
|
665
700
|
} catch {
|
|
666
701
|
return "missing";
|
|
667
702
|
}
|
|
@@ -686,7 +721,8 @@ for (const [src, sub] of links) {
|
|
|
686
721
|
continue;
|
|
687
722
|
}
|
|
688
723
|
mkdirSync(dir, { recursive: true });
|
|
689
|
-
if (state
|
|
724
|
+
if (state === "occupied") rmSync(dest, { recursive: true, force: true });
|
|
725
|
+
else if (state === "stale") removeLink(dest);
|
|
690
726
|
makeLink(src, dest);
|
|
691
727
|
wrote++;
|
|
692
728
|
if (verbose) console.log(c.dim(` link ${name}`));
|
package/cli/notes.test.ts
CHANGED
|
@@ -210,7 +210,7 @@ test("the notebook's design tokens are the board's, line for line", () => {
|
|
|
210
210
|
expect(block("board.html")).toContain("data-theme=dark]{--shade");
|
|
211
211
|
const syms = (f: string) => readFileSync(join(root, "web", f), "utf8").split("\n").filter((l) => l.startsWith('<symbol id="s-')).join("\n");
|
|
212
212
|
expect(syms("notes.html")).toBe(syms("board.html"));
|
|
213
|
-
expect(syms("board.html").split("\n").length).toBe(
|
|
213
|
+
expect(syms("board.html").split("\n").length).toBe(7);
|
|
214
214
|
const mascot = (f: string) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(".mascot{"));
|
|
215
215
|
expect(mascot("notes.html")).toBe(mascot("board.html")!);
|
|
216
216
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Settings against a throwaway home and project: a project with its own config.yml
|
|
2
|
+
// must not hide the board's machine-wide settings. Bun reads HOME once at startup,
|
|
3
|
+
// so the settings code runs in a child process with a fake HOME; setting HOME here
|
|
4
|
+
// would write to the real ~/.kaizen/config.yml.
|
|
5
|
+
import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
|
|
10
|
+
const repo = join(dirname(import.meta.path), "..");
|
|
11
|
+
const defaults = join(repo, "skills/kaizen/config.default.yml");
|
|
12
|
+
let root: string, home: string, project: string;
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
root = mkdtempSync(join(tmpdir(), "kaizen-settings-"));
|
|
16
|
+
home = join(root, "home");
|
|
17
|
+
mkdirSync(join(home, ".kaizen"), { recursive: true });
|
|
18
|
+
writeFileSync(join(home, ".kaizen", "config.yml"), readFileSync(defaults, "utf8").replace('projects_dir: ""', "projects_dir: ~/Projects"));
|
|
19
|
+
// A project that carries its own config, as a cloned repo with .kaizen/config.yml does.
|
|
20
|
+
project = join(home, "Projects", "app");
|
|
21
|
+
mkdirSync(join(project, ".git"), { recursive: true });
|
|
22
|
+
mkdirSync(join(project, ".kaizen"), { recursive: true });
|
|
23
|
+
copyFileSync(defaults, join(project, ".kaizen", "config.yml"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
27
|
+
|
|
28
|
+
// Runs `code` with listSettings/setSetting in scope, from inside the project, and returns what it prints.
|
|
29
|
+
const inProject = (code: string) => {
|
|
30
|
+
const script = `import { listSettings, setSetting } from ${JSON.stringify(join(repo, "cli/settings.ts"))};
|
|
31
|
+
const repo = ${JSON.stringify(repo)};
|
|
32
|
+
const value = (k) => listSettings(repo).rows.find((r) => r.key === k)?.value;
|
|
33
|
+
console.log(JSON.stringify(await (async () => { ${code} })()));`;
|
|
34
|
+
const p = Bun.spawnSync(["bun", "-e", script], { cwd: project, env: { ...process.env, HOME: home, USERPROFILE: home } });
|
|
35
|
+
if (p.exitCode !== 0) throw new Error(p.stderr.toString());
|
|
36
|
+
return JSON.parse(p.stdout.toString());
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
test("the projects folder survives moving into a project with its own config", () => {
|
|
40
|
+
expect(inProject(`return value("board.projects_dir")`)).toBe("~/Projects");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("board settings save to the global config, project settings to the project", () => {
|
|
44
|
+
expect(inProject(`return [setSetting(repo, "board.projects_dir", "~/Work"), value("board.projects_dir")]`)).toEqual([{ ok: true }, "~/Work"]);
|
|
45
|
+
expect(readFileSync(join(home, ".kaizen", "config.yml"), "utf8")).toContain("projects_dir: ~/Work");
|
|
46
|
+
expect(readFileSync(join(project, ".kaizen", "config.yml"), "utf8")).toContain('projects_dir: ""');
|
|
47
|
+
|
|
48
|
+
expect(inProject(`return setSetting(repo, "git.auto_commit", "true")`)).toEqual({ ok: true });
|
|
49
|
+
expect(readFileSync(join(project, ".kaizen", "config.yml"), "utf8")).toMatch(/auto_commit: true/);
|
|
50
|
+
});
|