@forwardimpact/libwiki 0.2.9 → 0.2.10

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/bin/fit-wiki.js CHANGED
@@ -15,6 +15,7 @@ import { runClaimCommand, runReleaseCommand } from "../src/commands/claim.js";
15
15
  import { runInboxCommand } from "../src/commands/inbox.js";
16
16
  import { runRotateCommand } from "../src/commands/rotate.js";
17
17
  import { runAuditCommand } from "../src/commands/audit.js";
18
+ import { runFixCommand } from "../src/commands/fix.js";
18
19
 
19
20
  const { version: VERSION } = JSON.parse(
20
21
  readFileSync(new URL("../package.json", import.meta.url), "utf8"),
@@ -156,6 +157,15 @@ const definition = {
156
157
  },
157
158
  },
158
159
  },
160
+ {
161
+ name: "fix",
162
+ description:
163
+ "Auto-fix wiki audit findings using an AI agent (technical-writer, Haiku)",
164
+ options: {
165
+ ...wikiRootOpt,
166
+ ...todayOpt,
167
+ },
168
+ },
159
169
  {
160
170
  name: "memo",
161
171
  description: "Send a cross-team memo into a teammate's Message Inbox",
@@ -228,6 +238,7 @@ const definition = {
228
238
  "fit-wiki inbox list --agent staff-engineer",
229
239
  "fit-wiki rotate --agent staff-engineer",
230
240
  "fit-wiki audit",
241
+ "fit-wiki fix",
231
242
  'fit-wiki memo --from staff-engineer --to security-engineer --message "audit d642ff0c"',
232
243
  "fit-wiki refresh",
233
244
  "fit-wiki init",
@@ -260,6 +271,7 @@ const COMMANDS = {
260
271
  inbox: runInboxCommand,
261
272
  rotate: runRotateCommand,
262
273
  audit: runAuditCommand,
274
+ fix: runFixCommand,
263
275
  memo: runMemoCommand,
264
276
  refresh: runRefreshCommand,
265
277
  init: runInitCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libwiki",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -47,12 +47,13 @@
47
47
  "dependencies": {
48
48
  "@forwardimpact/libcli": "^0.1.0",
49
49
  "@forwardimpact/libconfig": "^0.1.77",
50
+ "@forwardimpact/libeval": "^0.1.49",
50
51
  "@forwardimpact/libpreflight": "^0.1.0",
51
52
  "@forwardimpact/libutil": "^0.1.0",
52
53
  "@forwardimpact/libxmr": "^1.1.0"
53
54
  },
54
55
  "devDependencies": {
55
- "@forwardimpact/libharness": "^0.1.5"
56
+ "@forwardimpact/libmock": "^0.1.0"
56
57
  },
57
58
  "engines": {
58
59
  "bun": ">=1.2.0",
@@ -8,15 +8,16 @@ import {
8
8
  parseClaims,
9
9
  filterExpired,
10
10
  } from "../active-claims.js";
11
+ import { createDefaultIo } from "../io.js";
11
12
 
12
- function projectRoot() {
13
+ function projectRoot(io) {
13
14
  const logger = { debug() {} };
14
- const finder = new Finder(fsAsync, logger, process);
15
- return finder.findProjectRoot(process.cwd());
15
+ const finder = new Finder(fsAsync, logger, { cwd: io.cwd });
16
+ return finder.findProjectRoot(io.cwd());
16
17
  }
17
18
 
18
- function memoryPath(values) {
19
- const root = projectRoot();
19
+ function memoryPath(values, io) {
20
+ const root = projectRoot(io);
20
21
  const wikiRoot = values["wiki-root"] || path.join(root, "wiki");
21
22
  return path.join(wikiRoot, "MEMORY.md");
22
23
  }
@@ -33,19 +34,19 @@ function addDays(today, n) {
33
34
  }
34
35
 
35
36
  /** Insert a row into MEMORY.md `## Active Claims`. Refuses if (agent, target) already present. */
36
- export function runClaimCommand(values, _args, cli) {
37
- const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
37
+ export function runClaimCommand(values, _args, cli, io = createDefaultIo()) {
38
+ const agent = values.agent || io.env.LIBEVAL_AGENT_PROFILE;
38
39
  if (!agent) {
39
40
  cli.usageError("claim requires --agent or LIBEVAL_AGENT_PROFILE");
40
- process.exit(2);
41
+ return io.exit(2);
41
42
  }
42
43
  if (!values.target || !values.branch) {
43
44
  cli.usageError("claim requires --target and --branch");
44
- process.exit(2);
45
+ return io.exit(2);
45
46
  }
46
- const today = values.today || new Date().toISOString().slice(0, 10);
47
+ const today = values.today || io.today();
47
48
  const expires = values["expires-at"] || addDays(today, 7);
48
- const memPath = memoryPath(values);
49
+ const memPath = memoryPath(values, io);
49
50
  const text = readMemory(memPath);
50
51
  const result = appendClaim(text, {
51
52
  agent,
@@ -56,22 +57,20 @@ export function runClaimCommand(values, _args, cli) {
56
57
  expires_at: expires,
57
58
  });
58
59
  if (!result.inserted) {
59
- process.stderr.write(
60
- `claim already exists for ${agent}/${values.target}\n`,
61
- );
62
- process.exit(2);
60
+ io.stderr(`claim already exists for ${agent}/${values.target}\n`);
61
+ return io.exit(2);
63
62
  }
64
63
  writeFileSync(memPath, result.text);
65
- process.stdout.write(`claimed ${values.target} (expires ${expires})\n`);
64
+ io.stdout(`claimed ${values.target} (expires ${expires})\n`);
66
65
  }
67
66
 
68
67
  /** Remove a claim row. `--expired` cleans every row past expires_at. */
69
- export function runReleaseCommand(values, _args, cli) {
70
- const memPath = memoryPath(values);
68
+ export function runReleaseCommand(values, _args, cli, io = createDefaultIo()) {
69
+ const memPath = memoryPath(values, io);
71
70
  const text = readMemory(memPath);
72
71
 
73
72
  if (values.expired) {
74
- const today = values.today || new Date().toISOString().slice(0, 10);
73
+ const today = values.today || io.today();
75
74
  const claims = parseClaims(text);
76
75
  const { expired } = filterExpired(claims, today);
77
76
  let current = text;
@@ -84,24 +83,24 @@ export function runReleaseCommand(values, _args, cli) {
84
83
  }
85
84
  }
86
85
  writeFileSync(memPath, current);
87
- process.stdout.write(`released ${count} expired claim(s)\n`);
86
+ io.stdout(`released ${count} expired claim(s)\n`);
88
87
  return;
89
88
  }
90
89
 
91
- const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
90
+ const agent = values.agent || io.env.LIBEVAL_AGENT_PROFILE;
92
91
  if (!agent) {
93
92
  cli.usageError("release requires --agent or --expired");
94
- process.exit(2);
93
+ return io.exit(2);
95
94
  }
96
95
  if (!values.target) {
97
96
  cli.usageError("release requires --target (or --expired)");
98
- process.exit(2);
97
+ return io.exit(2);
99
98
  }
100
99
  const result = removeClaim(text, { agent, target: values.target });
101
100
  writeFileSync(memPath, result.text);
102
101
  if (!result.removed) {
103
- process.stdout.write(`no matching claim for ${agent}/${values.target}\n`);
102
+ io.stdout(`no matching claim for ${agent}/${values.target}\n`);
104
103
  } else {
105
- process.stdout.write(`released ${values.target}\n`);
104
+ io.stdout(`released ${values.target}\n`);
106
105
  }
107
106
  }
@@ -0,0 +1,64 @@
1
+ import fsAsync from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Writable } from "node:stream";
4
+ import { Finder, emitFindingsText, runRules } from "@forwardimpact/libutil";
5
+ import {
6
+ createAgentRunner,
7
+ composeProfilePrompt,
8
+ createRedactor,
9
+ } from "@forwardimpact/libeval";
10
+ import { RULES } from "../audit/rules.js";
11
+ import { buildContext, resolveScope } from "../audit/scopes.js";
12
+
13
+ /** Run the wiki audit and auto-fix findings via a Haiku-powered AgentRunner. */
14
+ export async function runFixCommand(values, _args, _cli) {
15
+ const finder = new Finder(fsAsync, { debug() {} }, process);
16
+ const projectRoot = finder.findProjectRoot(process.cwd());
17
+ const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
18
+ const today = values.today || new Date().toISOString().slice(0, 10);
19
+
20
+ const ctx = buildContext({ wikiRoot, today });
21
+ const findings = runRules(RULES, ctx, { resolveScope });
22
+
23
+ if (findings.length === 0) {
24
+ process.stdout.write("nothing to fix\n");
25
+ return;
26
+ }
27
+
28
+ const auditText = emitFindingsText(findings, { cwd: projectRoot });
29
+ const redactor = createRedactor();
30
+ const devNull = new Writable({
31
+ write(_c, _e, cb) {
32
+ cb();
33
+ },
34
+ });
35
+
36
+ const systemPrompt = composeProfilePrompt("technical-writer", {
37
+ profilesDir: path.resolve(projectRoot, ".claude/agents"),
38
+ });
39
+
40
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
41
+
42
+ const runner = createAgentRunner({
43
+ cwd: projectRoot,
44
+ query,
45
+ output: devNull,
46
+ model: "claude-haiku-4-5-20251001",
47
+ maxTurns: 15,
48
+ allowedTools: ["Read", "Write", "Edit"],
49
+ settingSources: ["project"],
50
+ systemPrompt,
51
+ redactor,
52
+ });
53
+
54
+ const task = [
55
+ `Fix these wiki audit findings.`,
56
+ `The wiki root is ${wikiRoot}.`,
57
+ ``,
58
+ auditText,
59
+ ].join("\n");
60
+
61
+ const result = await runner.run(task);
62
+ if (result.text) process.stdout.write(result.text + "\n");
63
+ process.exit(result.success ? 0 : 1);
64
+ }
@@ -6,6 +6,7 @@ import { Finder } from "@forwardimpact/libutil";
6
6
  import { createScriptConfig } from "@forwardimpact/libconfig";
7
7
  import { WikiRepo } from "../wiki-repo.js";
8
8
  import { listSkills } from "../skill-roster.js";
9
+ import { createDefaultIo } from "../io.js";
9
10
  import {
10
11
  ACTIVE_CLAIMS_HEADING,
11
12
  ACTIVE_CLAIMS_TABLE_HEADER,
@@ -13,8 +14,8 @@ import {
13
14
  } from "../constants.js";
14
15
 
15
16
  /** Resolve the wiki clone URL. Honors the FIT_WIKI_URL env var as an explicit override (for sandboxed environments where `origin` is rewritten to a local proxy that does not serve wiki repos); otherwise derives the URL by appending `.wiki.git` to the parent repo's `origin` remote. */
16
- export function deriveWikiUrl(parentDir) {
17
- if (process.env.FIT_WIKI_URL) return process.env.FIT_WIKI_URL;
17
+ export function deriveWikiUrl(parentDir, env = process.env) {
18
+ if (env.FIT_WIKI_URL) return env.FIT_WIKI_URL;
18
19
 
19
20
  const r = spawnSync("git", ["-C", parentDir, "remote", "get-url", "origin"], {
20
21
  encoding: "utf-8",
@@ -55,12 +56,10 @@ function scaffoldActiveClaims(memoryPath) {
55
56
  return true;
56
57
  }
57
58
 
58
- async function maybeCloneWiki(projectRoot, wikiDir) {
59
- const wikiUrl = deriveWikiUrl(projectRoot);
59
+ async function maybeCloneWiki(projectRoot, wikiDir, io) {
60
+ const wikiUrl = deriveWikiUrl(projectRoot, io.env);
60
61
  if (!wikiUrl) {
61
- process.stderr.write(
62
- "init: could not determine wiki URL from origin remote\n",
63
- );
62
+ io.stderr("init: could not determine wiki URL from origin remote\n");
64
63
  return;
65
64
  }
66
65
  const config = await createScriptConfig("wiki");
@@ -73,17 +72,20 @@ async function maybeCloneWiki(projectRoot, wikiDir) {
73
72
  if (cloneResult.cloned) {
74
73
  repo.inheritIdentity();
75
74
  } else {
76
- process.stderr.write(
77
- "init: could not clone wiki, continuing with local-only steps\n",
78
- );
75
+ io.stderr("init: could not clone wiki, continuing with local-only steps\n");
79
76
  }
80
77
  }
81
78
 
82
79
  /** Clone the wiki if not already present, scaffold Active Claims in MEMORY.md, and create per-skill metric directories. */
83
- export async function runInitCommand(values, _args, _cli) {
80
+ export async function runInitCommand(
81
+ values,
82
+ _args,
83
+ _cli,
84
+ io = createDefaultIo(),
85
+ ) {
84
86
  const logger = { debug() {} };
85
- const finder = new Finder(fsAsync, logger, process);
86
- const projectRoot = finder.findProjectRoot(process.cwd());
87
+ const finder = new Finder(fsAsync, logger, { cwd: io.cwd });
88
+ const projectRoot = finder.findProjectRoot(io.cwd());
87
89
 
88
90
  const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
89
91
  const skillsDir = path.resolve(
@@ -91,7 +93,7 @@ export async function runInitCommand(values, _args, _cli) {
91
93
  values["skills-dir"] ?? path.join(".claude", "skills"),
92
94
  );
93
95
 
94
- await maybeCloneWiki(projectRoot, wikiDir);
96
+ await maybeCloneWiki(projectRoot, wikiDir, io);
95
97
 
96
98
  if (existsSync(skillsDir)) {
97
99
  for (const slug of listSkills({ skillsDir })) {
@@ -102,11 +104,9 @@ export async function runInitCommand(values, _args, _cli) {
102
104
  if (existsSync(wikiDir)) {
103
105
  const memoryPath = path.join(wikiDir, "MEMORY.md");
104
106
  if (scaffoldActiveClaims(memoryPath)) {
105
- process.stdout.write(
106
- `init: scaffolded ${ACTIVE_CLAIMS_HEADING} in ${memoryPath}\n`,
107
- );
107
+ io.stdout(`init: scaffolded ${ACTIVE_CLAIMS_HEADING} in ${memoryPath}\n`);
108
108
  }
109
109
  }
110
110
 
111
- process.stdout.write(`init: wiki ready at ${wikiDir}\n`);
111
+ io.stdout(`init: wiki ready at ${wikiDir}\n`);
112
112
  }
@@ -8,24 +8,24 @@ import {
8
8
  appendEntry,
9
9
  } from "../weekly-log.js";
10
10
  import { DECISION_HEADING } from "../constants.js";
11
+ import { createDefaultIo } from "../io.js";
11
12
 
12
- function projectRootForCommand() {
13
+ function projectRootForCommand(io) {
13
14
  const logger = { debug() {} };
14
- const finder = new Finder(fsAsync, logger, process);
15
- return finder.findProjectRoot(process.cwd());
15
+ const finder = new Finder(fsAsync, logger, { cwd: io.cwd });
16
+ return finder.findProjectRoot(io.cwd());
16
17
  }
17
18
 
18
- function commonContext(values) {
19
- const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
19
+ function commonContext(values, io) {
20
+ const agent = values.agent || io.env.LIBEVAL_AGENT_PROFILE;
20
21
  if (!agent) {
21
- process.stderr.write(
22
- "log requires --agent <name> or LIBEVAL_AGENT_PROFILE env var\n",
23
- );
24
- process.exit(2);
22
+ io.stderr("log requires --agent <name> or LIBEVAL_AGENT_PROFILE env var\n");
23
+ io.exit(2);
24
+ return null;
25
25
  }
26
- const projectRoot = projectRootForCommand();
26
+ const projectRoot = projectRootForCommand(io);
27
27
  const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
28
- const today = values.today || new Date().toISOString().slice(0, 10);
28
+ const today = values.today || io.today();
29
29
  return { agent, wikiRoot, today };
30
30
  }
31
31
 
@@ -39,8 +39,10 @@ function lastDateHeading(text) {
39
39
  return last;
40
40
  }
41
41
 
42
- function runDecision(values) {
43
- const { agent, wikiRoot, today } = commonContext(values);
42
+ function runDecision(values, io) {
43
+ const ctx = commonContext(values, io);
44
+ if (!ctx) return;
45
+ const { agent, wikiRoot, today } = ctx;
44
46
  const surveyed = values.surveyed || "—";
45
47
  const chosen = values.chosen || "—";
46
48
  const rationale = values.rationale || "—";
@@ -63,14 +65,17 @@ function runDecision(values) {
63
65
  rotateIfOverBudget(wikiRoot, agent, today, lineCount);
64
66
  const target = weeklyLogPath(wikiRoot, agent, today);
65
67
  appendEntry(target, body, agent, today);
66
- process.stdout.write(`logged decision to ${target}\n`);
68
+ io.stdout(`logged decision to ${target}\n`);
67
69
  }
68
70
 
69
- function runNote(values) {
70
- const { agent, wikiRoot, today } = commonContext(values);
71
+ function runNote(values, io) {
72
+ const ctx = commonContext(values, io);
73
+ if (!ctx) return;
74
+ const { agent, wikiRoot, today } = ctx;
71
75
  if (!values.field || !values.body) {
72
- process.stderr.write("log note requires --field and --body\n");
73
- process.exit(2);
76
+ io.stderr("log note requires --field and --body\n");
77
+ io.exit(2);
78
+ return;
74
79
  }
75
80
  const fieldBlock = `### ${values.field}\n\n${values.body}\n`;
76
81
  // Conservative line budget: assume we'll prepend a date heading.
@@ -82,28 +87,30 @@ function runNote(values) {
82
87
  const existing = existsSync(target) ? readFileSync(target, "utf-8") : "";
83
88
  const body = lastDateHeading(existing) === today ? fieldBlock : withHeading;
84
89
  appendEntry(target, body, agent, today);
85
- process.stdout.write(`logged note to ${target}\n`);
90
+ io.stdout(`logged note to ${target}\n`);
86
91
  }
87
92
 
88
- function runDone(values) {
89
- const { agent, wikiRoot, today } = commonContext(values);
93
+ function runDone(values, io) {
94
+ const ctx = commonContext(values, io);
95
+ if (!ctx) return;
96
+ const { agent, wikiRoot, today } = ctx;
90
97
  const body = `### Closed\n\nRun closed ${today}.\n`;
91
98
  const lineCount = body.split("\n").length;
92
99
  rotateIfOverBudget(wikiRoot, agent, today, lineCount);
93
100
  const target = weeklyLogPath(wikiRoot, agent, today);
94
101
  appendEntry(target, body, agent, today);
95
- process.stdout.write(`closed entry in ${target}\n`);
102
+ io.stdout(`closed entry in ${target}\n`);
96
103
  }
97
104
 
98
105
  const SUBS = { decision: runDecision, note: runNote, done: runDone };
99
106
 
100
107
  /** Dispatch `log {decision|note|done}` to the matching sub-handler. */
101
- export function runLogCommand(values, args, cli) {
108
+ export function runLogCommand(values, args, cli, io = createDefaultIo()) {
102
109
  const sub = args[0];
103
110
  const handler = SUBS[sub];
104
111
  if (!handler) {
105
112
  cli.usageError("log requires subcommand: decision | note | done");
106
- process.exit(2);
113
+ return io.exit(2);
107
114
  }
108
- handler(values);
115
+ handler(values, io);
109
116
  }
@@ -7,16 +7,16 @@ import { createScriptConfig } from "@forwardimpact/libconfig";
7
7
  import { scanMarkers } from "../marker-scanner.js";
8
8
  import { renderBlock, BlockRenderError } from "../block-renderer.js";
9
9
  import { renderIssueList, parseRepoSlug } from "../issue-list-renderer.js";
10
+ import { createDefaultIo } from "../io.js";
10
11
 
11
- function currentStoryboardPath() {
12
- const now = new Date();
12
+ function currentStoryboardPath(now = new Date()) {
13
13
  const yyyy = now.getFullYear();
14
14
  const mm = String(now.getMonth() + 1).padStart(2, "0");
15
15
  return `wiki/storyboard-${yyyy}-M${mm}.md`;
16
16
  }
17
17
 
18
- function deriveParentRepo(parentDir) {
19
- if (process.env.FIT_GH_REPO) return process.env.FIT_GH_REPO;
18
+ function deriveParentRepo(parentDir, env) {
19
+ if (env.FIT_GH_REPO) return env.FIT_GH_REPO;
20
20
  const r = spawnSync("git", ["-C", parentDir, "remote", "get-url", "origin"], {
21
21
  encoding: "utf-8",
22
22
  stdio: "pipe",
@@ -55,10 +55,15 @@ function spliceBlock(lines, block, rendered) {
55
55
  }
56
56
 
57
57
  /** Re-render XmR chart blocks and issue-list blocks in a storyboard file. */
58
- export async function runRefreshCommand(values, args, _cli) {
58
+ export async function runRefreshCommand(
59
+ values,
60
+ args,
61
+ _cli,
62
+ io = createDefaultIo(),
63
+ ) {
59
64
  const logger = { debug() {} };
60
- const finder = new Finder(fsAsync, logger, process);
61
- const projectRoot = finder.findProjectRoot(process.cwd());
65
+ const finder = new Finder(fsAsync, logger, { cwd: io.cwd });
66
+ const projectRoot = finder.findProjectRoot(io.cwd());
62
67
 
63
68
  const storyboardPath = path.resolve(
64
69
  projectRoot,
@@ -84,7 +89,7 @@ export async function runRefreshCommand(values, args, _cli) {
84
89
  // overrides the parsed origin.
85
90
  const ghContext = {
86
91
  cwd: projectRoot,
87
- repo: deriveParentRepo(projectRoot),
92
+ repo: deriveParentRepo(projectRoot, io.env),
88
93
  token,
89
94
  };
90
95
 
@@ -100,7 +105,7 @@ export async function runRefreshCommand(values, args, _cli) {
100
105
  spliced = true;
101
106
  } catch (err) {
102
107
  if (!(err instanceof BlockRenderError)) throw err;
103
- process.stderr.write(
108
+ io.stderr(
104
109
  `refresh-error ${storyboardPath}:${block.openLine + 1} ${err.message}\n`,
105
110
  );
106
111
  }
@@ -108,8 +113,6 @@ export async function runRefreshCommand(values, args, _cli) {
108
113
 
109
114
  if (spliced) writeFileSync(storyboardPath, lines.join("\n"));
110
115
  if (values && values.format === "json") {
111
- process.stdout.write(
112
- JSON.stringify({ blocks: blocks.length, spliced }) + "\n",
113
- );
116
+ io.stdout(JSON.stringify({ blocks: blocks.length, spliced }) + "\n");
114
117
  }
115
118
  }
package/src/io.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Process-bound I/O collaborators shared across libwiki commands.
3
+ *
4
+ * Each command accepts an optional `io` argument; when omitted, the
5
+ * bound `process.*` defaults run. Tests construct a synthetic `io`
6
+ * (e.g. capturing stdout into a string and recording exit codes
7
+ * instead of terminating the runner) and call command handlers
8
+ * directly, avoiding `execFileSync("node", [...])`.
9
+ */
10
+ export function createDefaultIo() {
11
+ return {
12
+ stdout: (s) => process.stdout.write(s),
13
+ stderr: (s) => process.stderr.write(s),
14
+ exit: (code) => process.exit(code),
15
+ cwd: () => process.cwd(),
16
+ env: process.env,
17
+ today: () => new Date().toISOString().slice(0, 10),
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Test helper: synthetic `io` that captures stdout/stderr into strings
23
+ * and records exit codes instead of terminating the process. After a
24
+ * handler returns, inspect `out`/`err`/`exitCode` to assert behavior.
25
+ *
26
+ * @param {object} overrides - Per-test overrides (cwd, env, today).
27
+ * @returns {object} `{ stdout, stderr, exit, cwd, env, today, out, err, exitCode }`
28
+ */
29
+ export function createTestIo(overrides = {}) {
30
+ const io = {
31
+ out: "",
32
+ err: "",
33
+ exitCode: null,
34
+ stdout(s) {
35
+ io.out += s;
36
+ },
37
+ stderr(s) {
38
+ io.err += s;
39
+ },
40
+ exit(code) {
41
+ io.exitCode = code;
42
+ throw new IoExit(code);
43
+ },
44
+ cwd: overrides.cwd ?? (() => process.cwd()),
45
+ env: overrides.env ?? process.env,
46
+ today: overrides.today ?? (() => new Date().toISOString().slice(0, 10)),
47
+ };
48
+ return io;
49
+ }
50
+
51
+ /**
52
+ * Thrown by `createTestIo`'s `exit` so handlers stop unwinding the way
53
+ * `process.exit` would. Tests can catch it or wrap calls in
54
+ * `runWithIo(() => handler(...), io)`.
55
+ */
56
+ export class IoExit extends Error {
57
+ /** @param {number} code - Exit code the handler asked the process to exit with. */
58
+ constructor(code) {
59
+ super(`IoExit(${code})`);
60
+ this.name = "IoExit";
61
+ this.code = code;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Run a handler that may call `io.exit()`; swallow the synthetic
67
+ * IoExit so callers can inspect `io.exitCode` linearly without
68
+ * try/catch boilerplate.
69
+ */
70
+ export async function runWithIo(fn) {
71
+ try {
72
+ return await fn();
73
+ } catch (err) {
74
+ if (err instanceof IoExit) return undefined;
75
+ throw err;
76
+ }
77
+ }