kaizen-agent 1.9.4 → 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 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 seen
331
- before to Ideas, each with a note saying where it came from. In a sheet, a column headed
332
- `Request`, `Idea`, `Title`, `Task`, `PBI`, `Summary` or `Name` is the request and the
333
- other cells become its notes; without such a header the request is one column for the whole sheet, the one holding the most text. A row whose `Status` column says `Done`,
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
@@ -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. The board shows it.
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 &lt;b&gt;x&lt;/b&gt;");
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, readlinkSync, unlinkSync,
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
- try { rmSync(p, { recursive: true, force: true }); gone.push(tilde(p)); }
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
- drop(process.env.KAIZEN_HOME ?? join(home, "kaizen"));
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
- if (!gone.length && !stuck.length) console.log(` ${c.dim("nothing left to remove")}`);
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
- if (args.has("--daemon")) {
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
- return readlinkSync(dest) === src ? "linked" : "stale";
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 !== "missing") rmSync(dest, { recursive: true, force: true });
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(6);
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
+ });