@forwardimpact/libwiki 0.2.11 → 0.2.13

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.
@@ -1,13 +1,14 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
1
  import { MEMO_INBOX_MARKER, INBOX_HEADING } from "./constants.js";
3
2
  import { listAgents } from "./agent-roster.js";
4
3
 
5
- /** Insert memo:inbox markers into agent summary files that have an inbox heading but no marker yet. */
6
- export function insertMarkers(
7
- { agentsDir, wikiRoot },
8
- fs = { readFileSync, writeFileSync },
9
- ) {
10
- const agents = listAgents({ agentsDir, wikiRoot });
4
+ /**
5
+ * Insert memo:inbox markers into agent summary files that have an inbox heading
6
+ * but no marker yet.
7
+ * @param {{agentsDir: string, wikiRoot: string}} dirs
8
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
9
+ */
10
+ export function insertMarkers({ agentsDir, wikiRoot }, fs) {
11
+ const agents = listAgents({ agentsDir, wikiRoot }, fs);
11
12
  const inserted = [];
12
13
  const skipped = [];
13
14
  const errors = [];
@@ -12,10 +12,8 @@ function openLabel(open) {
12
12
  return open.kind === "xmr" ? open.metric : open.topic;
13
13
  }
14
14
 
15
- function warnDangling(open) {
16
- process.stderr.write(
17
- `dangling-marker ${openLabel(open)} at line ${open.openLine + 1}\n`,
18
- );
15
+ function warnDangling(open, warn) {
16
+ warn(`dangling-marker ${openLabel(open)} at line ${open.openLine + 1}\n`);
19
17
  }
20
18
 
21
19
  function tryOpen(line, i) {
@@ -68,8 +66,15 @@ function matchClose(line, open) {
68
66
  return Boolean(m && open.kind === "issue-list" && open.topic === m[1]);
69
67
  }
70
68
 
71
- /** Scan text for paired marker blocks (xmr or issue-list). Returns positions and metadata. */
72
- export function scanMarkers(text) {
69
+ /**
70
+ * Scan text for paired marker blocks (xmr or issue-list). Returns positions and
71
+ * metadata. Dangling open markers are reported through the injected `warn`
72
+ * callback (default: discard) instead of writing to the process directly.
73
+ * @param {string} text - The storyboard text to scan.
74
+ * @param {{warn?: (message: string) => void}} [options]
75
+ * @returns {Array<object>} The paired marker blocks.
76
+ */
77
+ export function scanMarkers(text, { warn = () => {} } = {}) {
73
78
  const lines = text.split("\n");
74
79
  const pairs = [];
75
80
  let open = null;
@@ -78,7 +83,7 @@ export function scanMarkers(text) {
78
83
  const line = lines[i];
79
84
  const newOpen = tryOpen(line, i);
80
85
  if (newOpen) {
81
- if (open) warnDangling(open);
86
+ if (open) warnDangling(open, warn);
82
87
  open = newOpen;
83
88
  continue;
84
89
  }
@@ -88,7 +93,7 @@ export function scanMarkers(text) {
88
93
  }
89
94
  }
90
95
 
91
- if (open) warnDangling(open);
96
+ if (open) warnDangling(open, warn);
92
97
 
93
98
  return pairs;
94
99
  }
@@ -1,11 +1,12 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
1
  import { MEMO_INBOX_MARKER } from "./constants.js";
3
2
 
4
- /** Append a timestamped memo bullet below the inbox marker in an agent's summary file. */
5
- export function writeMemo(
6
- { summaryPath, sender, message, today },
7
- fs = { readFileSync, writeFileSync },
8
- ) {
3
+ /**
4
+ * Append a timestamped memo bullet below the inbox marker in an agent's summary
5
+ * file.
6
+ * @param {{summaryPath: string, sender: string, message: string, today: string}} memo
7
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
8
+ */
9
+ export function writeMemo({ summaryPath, sender, message, today }, fs) {
9
10
  const content = fs.readFileSync(summaryPath, "utf-8");
10
11
  const lines = content.split("\n");
11
12
 
@@ -1,8 +1,12 @@
1
- import { readdirSync, statSync } from "node:fs";
2
1
  import path from "node:path";
3
2
 
4
- /** List all kata-prefixed skill directory names under the skills directory, sorted alphabetically. */
5
- export function listSkills({ skillsDir }, fs = { readdirSync, statSync }) {
3
+ /**
4
+ * List all kata-prefixed skill directory names under the skills directory,
5
+ * sorted alphabetically.
6
+ * @param {{skillsDir: string}} dirs
7
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
8
+ */
9
+ export function listSkills({ skillsDir }, fs) {
6
10
  const entries = fs.readdirSync(skillsDir);
7
11
  const skills = [];
8
12
 
package/src/status.js ADDED
@@ -0,0 +1,19 @@
1
+ // STATUS.md row ids may carry a `/<unit>` suffix denoting a
2
+ // per-migration-unit sub-row of a master spec (`1370/libutil`, …). The master
3
+ // `NNNN` row advances only when every sub-row reads `plan implemented`.
4
+
5
+ /** Matches a status-row id: four digits, optionally a `/<unit>` suffix. */
6
+ export const STATUS_ID_REGEX = /^\d{4}(\/[a-z0-9-]+)?$/;
7
+
8
+ /**
9
+ * Parse a status-row id into its master spec id and optional unit suffix.
10
+ * @param {string} id - The id field of a STATUS.md row.
11
+ * @returns {{ specId: string, unit: string|null }|null} Parsed parts, or null
12
+ * when the id does not match {@link STATUS_ID_REGEX}.
13
+ */
14
+ export function parseStatusRowId(id) {
15
+ if (typeof id !== "string" || !STATUS_ID_REGEX.test(id)) return null;
16
+ const slash = id.indexOf("/");
17
+ if (slash === -1) return { specId: id, unit: null };
18
+ return { specId: id.slice(0, slash), unit: id.slice(slash + 1) };
19
+ }
@@ -0,0 +1,13 @@
1
+ import { isoDate } from "@forwardimpact/libutil";
2
+
3
+ /**
4
+ * Today's ISO calendar date (`YYYY-MM-DD`) read from the injected clock.
5
+ * Commands that previously inlined `new Date().toISOString().slice(0, 10)` (or
6
+ * libwiki's `io.today()`) call this instead so the wall-clock read flows
7
+ * through `runtime.clock`.
8
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
9
+ * @returns {string}
10
+ */
11
+ export function currentDayIso(runtime) {
12
+ return isoDate(runtime.clock.now());
13
+ }
@@ -0,0 +1,24 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Find the project root by upward `package.json` discovery from the current
5
+ * working directory, using the injected `runtime.finder` (the one canonical
6
+ * Finder — SC9 keeps `new Finder(...)` inside libutil).
7
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
8
+ * @returns {string}
9
+ */
10
+ export function resolveProjectRoot(runtime) {
11
+ return runtime.finder.findProjectRoot(runtime.proc.cwd());
12
+ }
13
+
14
+ /**
15
+ * Resolve the wiki root, preserving the pre-1370 order: the `--wiki-root`
16
+ * option when given, else `<projectRoot>/wiki`. The finder is consulted only
17
+ * when no explicit `--wiki-root` is supplied.
18
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
19
+ * @param {Record<string, unknown>} [options] - Parsed CLI options (`ctx.options`).
20
+ * @returns {string}
21
+ */
22
+ export function resolveWikiRoot(runtime, options = {}) {
23
+ return options["wiki-root"] || path.join(resolveProjectRoot(runtime), "wiki");
24
+ }
package/src/weekly-log.js CHANGED
@@ -1,29 +1,14 @@
1
- import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";
2
1
  import path from "node:path";
2
+ import { isoWeekString } from "@forwardimpact/libutil";
3
3
  import { WEEKLY_LOG_LINE_BUDGET } from "./constants.js";
4
4
 
5
- /** Compute ISO 8601 year-week for a Date. Returns { year, week } where year is the ISO week-year (not necessarily the calendar year for edge weeks). */
6
- export function isoWeek(date) {
7
- const d = new Date(
8
- Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
9
- );
10
- // Thursday of week: ISO weeks are anchored on Thursday.
11
- const day = d.getUTCDay() || 7;
12
- d.setUTCDate(d.getUTCDate() + 4 - day);
13
- const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
14
- const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
15
- return { year: d.getUTCFullYear(), week };
16
- }
17
-
18
- function formatIsoWeek(date) {
19
- const { year, week } = isoWeek(date);
20
- return `${year}-W${String(week).padStart(2, "0")}`;
21
- }
5
+ // ISO week computation lives in libutil's calendar util (the one place a
6
+ // `new Date` is allowed); re-exported here for the existing public surface.
7
+ export { isoWeek } from "@forwardimpact/libutil";
22
8
 
23
9
  /** Return the path of the current weekly log file for an agent. */
24
10
  export function weeklyLogPath(wikiRoot, agent, today) {
25
- const date = today instanceof Date ? today : new Date(today);
26
- return path.join(wikiRoot, `${agent}-${formatIsoWeek(date)}.md`);
11
+ return path.join(wikiRoot, `${agent}-${isoWeekString(today)}.md`);
27
12
  }
28
13
 
29
14
  function countLines(text) {
@@ -34,11 +19,11 @@ function countLines(text) {
34
19
  return n;
35
20
  }
36
21
 
37
- function nextPartPath(filePath) {
22
+ function nextPartPath(filePath, fs) {
38
23
  const dir = path.dirname(filePath);
39
24
  const base = path.basename(filePath, ".md");
40
25
  let n = 1;
41
- while (existsSync(path.join(dir, `${base}-part${n}.md`))) n++;
26
+ while (fs.existsSync(path.join(dir, `${base}-part${n}.md`))) n++;
42
27
  return path.join(dir, `${base}-part${n}.md`);
43
28
  }
44
29
 
@@ -49,42 +34,57 @@ function agentTitle(agent) {
49
34
  .join(" ");
50
35
  }
51
36
 
52
- function defaultH1(filePath, agent, isoWeekStr) {
37
+ function defaultH1(agent, isoWeekStr) {
53
38
  return `# ${agentTitle(agent)} — ${isoWeekStr}\n`;
54
39
  }
55
40
 
56
- /** Rotate the current weekly log if next append would exceed the budget. Returns { rotated, fromPath, toPath }. */
41
+ /**
42
+ * Rotate the current weekly log if next append would exceed the budget.
43
+ * @returns {{rotated: boolean, fromPath: string, toPath?: string}}
44
+ * @param {string} wikiRoot
45
+ * @param {string} agent
46
+ * @param {string} today - ISO date string.
47
+ * @param {number} [appendLines=0]
48
+ * @param {{force?: boolean}} [options]
49
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
50
+ */
57
51
  export function rotateIfOverBudget(
58
52
  wikiRoot,
59
53
  agent,
60
54
  today,
61
55
  appendLines = 0,
62
56
  options = {},
57
+ fs,
63
58
  ) {
64
59
  const filePath = weeklyLogPath(wikiRoot, agent, today);
65
60
  const { force = false } = options;
66
- if (!existsSync(filePath)) return { rotated: false, fromPath: filePath };
67
- const text = readFileSync(filePath, "utf-8");
61
+ if (!fs.existsSync(filePath)) return { rotated: false, fromPath: filePath };
62
+ const text = fs.readFileSync(filePath, "utf-8");
68
63
  const current = countLines(text);
69
64
  if (!force && current + appendLines <= WEEKLY_LOG_LINE_BUDGET) {
70
65
  return { rotated: false, fromPath: filePath };
71
66
  }
72
- const toPath = nextPartPath(filePath);
73
- renameSync(filePath, toPath);
74
- const date = today instanceof Date ? today : new Date(today);
75
- writeFileSync(filePath, defaultH1(filePath, agent, formatIsoWeek(date)));
67
+ const toPath = nextPartPath(filePath, fs);
68
+ fs.renameSync(filePath, toPath);
69
+ fs.writeFileSync(filePath, defaultH1(agent, isoWeekString(today)));
76
70
  return { rotated: true, fromPath: filePath, toPath };
77
71
  }
78
72
 
79
- /** Append a body to a weekly log file. Creates it with an H1 if missing. */
80
- export function appendEntry(filePath, body, agent, today) {
81
- const date = today instanceof Date ? today : new Date(today);
82
- if (!existsSync(filePath)) {
83
- writeFileSync(filePath, defaultH1(filePath, agent, formatIsoWeek(date)));
73
+ /**
74
+ * Append a body to a weekly log file. Creates it with an H1 if missing.
75
+ * @param {string} filePath
76
+ * @param {string} body
77
+ * @param {string} agent
78
+ * @param {string} today - ISO date string.
79
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
80
+ */
81
+ export function appendEntry(filePath, body, agent, today, fs) {
82
+ if (!fs.existsSync(filePath)) {
83
+ fs.writeFileSync(filePath, defaultH1(agent, isoWeekString(today)));
84
84
  }
85
- const text = readFileSync(filePath, "utf-8");
85
+ const text = fs.readFileSync(filePath, "utf-8");
86
86
  const separator = text.endsWith("\n") ? "\n" : "\n\n";
87
- writeFileSync(
87
+ fs.writeFileSync(
88
88
  filePath,
89
89
  text + separator + body + (body.endsWith("\n") ? "" : "\n"),
90
90
  );
@@ -0,0 +1,164 @@
1
+ import path from "node:path";
2
+
3
+ /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
4
+ export class WikiPullConflict extends Error {
5
+ /** Create a WikiPullConflict with the stderr output from the failed rebase. */
6
+ constructor(stderr) {
7
+ super("rebase conflict on pull");
8
+ this.name = "WikiPullConflict";
9
+ this.stderr = stderr;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Consolidates the wiki repository's pull / rebase / conflict-resolve / push
15
+ * flow over an injected {@link import('@forwardimpact/libutil').GitClient}.
16
+ * Replaces the pre-1370 `WikiRepo`: all shelling-out flows through
17
+ * `gitClient` (itself over `runtime.subprocess`), so libwiki never imports
18
+ * `node:child_process` and tests inject `createMockGitClient`.
19
+ *
20
+ * Network operations (fetch / clone / push) authenticate by resolving a token
21
+ * lazily through `resolveToken` and threading it via `gitClient.withAuth`;
22
+ * local operations never call `resolveToken`. The callback owns the entire
23
+ * resolution policy and its throws propagate to the caller.
24
+ */
25
+ export class WikiSync {
26
+ #runtime;
27
+ #git;
28
+ #wikiDir;
29
+ #parentDir;
30
+ #resolveToken;
31
+
32
+ /**
33
+ * @param {object} options
34
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} options.runtime
35
+ * @param {import('@forwardimpact/libutil').GitClient} options.gitClient
36
+ * @param {string} options.wikiDir - The wiki clone directory.
37
+ * @param {string} options.parentDir - The parent project directory (identity source).
38
+ * @param {() => (string|null)} [options.resolveToken] - Lazy token resolver
39
+ * for network operations; returns a token string or null for anonymous.
40
+ */
41
+ constructor({ runtime, gitClient, wikiDir, parentDir, resolveToken }) {
42
+ if (!runtime) throw new Error("WikiSync: runtime is required");
43
+ if (!gitClient) throw new Error("WikiSync: gitClient is required");
44
+ if (typeof wikiDir !== "string" || wikiDir === "") {
45
+ throw new TypeError("WikiSync: wikiDir must be a non-empty string");
46
+ }
47
+ this.#runtime = runtime;
48
+ this.#git = gitClient;
49
+ this.#wikiDir = wikiDir;
50
+ this.#parentDir = parentDir;
51
+ this.#resolveToken = resolveToken ?? (() => null);
52
+ }
53
+
54
+ /** A GitClient authenticated with the lazily-resolved token, or the bare client when none. */
55
+ #authed() {
56
+ const token = this.#resolveToken();
57
+ return token ? this.#git.withAuth(token) : this.#git;
58
+ }
59
+
60
+ /** Whether the wiki directory is an initialized git clone. */
61
+ isCloned() {
62
+ return this.#runtime.fsSync.existsSync(path.join(this.#wikiDir, ".git"));
63
+ }
64
+
65
+ /** Clone the wiki from `url` if it is not already cloned. */
66
+ async ensureCloned(url) {
67
+ if (this.isCloned()) return { cloned: true, reason: "already-cloned" };
68
+ try {
69
+ await this.#authed().clone(url, this.#wikiDir);
70
+ return { cloned: true, reason: "cloned" };
71
+ } catch (err) {
72
+ return { cloned: false, reason: err.stderr?.trim() || err.message };
73
+ }
74
+ }
75
+
76
+ /** Copy git user.name and user.email from the parent repository into the wiki repository. */
77
+ async inheritIdentity() {
78
+ const name = await this.#git.configGet("user.name", {
79
+ cwd: this.#parentDir,
80
+ });
81
+ const email = await this.#git.configGet("user.email", {
82
+ cwd: this.#parentDir,
83
+ });
84
+ if (name)
85
+ await this.#git.configSet("user.name", name, { cwd: this.#wikiDir });
86
+ if (email) {
87
+ await this.#git.configSet("user.email", email, { cwd: this.#wikiDir });
88
+ }
89
+ }
90
+
91
+ /** Fetch origin/master using token auth when available. */
92
+ async fetch() {
93
+ // Resolve auth first so a misconfigured `resolveToken` still surfaces.
94
+ const client = this.#authed();
95
+ try {
96
+ await client.fetch("origin", "master", { cwd: this.#wikiDir });
97
+ } catch {
98
+ // WikiRepo treated fetch as fire-and-forget (it ignored the git result);
99
+ // a failed fetch leaves the local origin/master ref in place and the
100
+ // rebase proceeds against it. Preserved so push/pull degrade gracefully
101
+ // rather than crash when the network or credentials are unavailable.
102
+ }
103
+ }
104
+
105
+ /** Whether the wiki working tree has no uncommitted changes. */
106
+ async isClean() {
107
+ const r = await this.#git.status({ cwd: this.#wikiDir });
108
+ return r.stdout.trim() === "";
109
+ }
110
+
111
+ /** Fetch and rebase on origin/master, throwing WikiPullConflict if the rebase fails. */
112
+ async pull() {
113
+ await this.fetch();
114
+ const r = await this.#git.rebase("origin/master", { cwd: this.#wikiDir });
115
+ if (r.exitCode !== 0) {
116
+ await this.#git.rebaseAbort({ cwd: this.#wikiDir });
117
+ throw new WikiPullConflict(r.stderr?.trim() || "");
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Stage and commit any working-tree changes, then fetch, rebase on
123
+ * origin/master (falling back to a merge with -X ours if the rebase fails),
124
+ * and push if HEAD is ahead of origin/master. The commit gate and the push
125
+ * gate are independent so a clean tree with local commits still pushes.
126
+ */
127
+ async commitAndPush(message) {
128
+ if (!(await this.isClean())) {
129
+ await this.#git.commitAll(message, { cwd: this.#wikiDir });
130
+ }
131
+ if (!(await this.#hasCommitsAhead())) {
132
+ return { pushed: false, reason: "clean" };
133
+ }
134
+ await this.fetch();
135
+ const rebase = await this.#git.rebase("origin/master", {
136
+ cwd: this.#wikiDir,
137
+ });
138
+ if (rebase.exitCode !== 0) {
139
+ await this.#git.rebaseAbort({ cwd: this.#wikiDir });
140
+ await this.#git.mergeOursStrategy({
141
+ cwd: this.#wikiDir,
142
+ ref: "origin/master",
143
+ });
144
+ }
145
+ // Resolve auth first so a misconfigured `resolveToken` still surfaces; the
146
+ // push itself is fire-and-forget like WikiRepo (which ignored the push
147
+ // result and reported pushed:true regardless), so a network/credential
148
+ // failure degrades to "saved locally" rather than crashing the command.
149
+ const client = this.#authed();
150
+ try {
151
+ await client.push("origin", "master", { cwd: this.#wikiDir });
152
+ } catch {
153
+ // Intentionally ignored — preserves WikiRepo's fire-and-forget push.
154
+ }
155
+ return { pushed: true, reason: "pushed" };
156
+ }
157
+
158
+ async #hasCommitsAhead() {
159
+ const count = await this.#git.revListCount("origin/master..HEAD", {
160
+ cwd: this.#wikiDir,
161
+ });
162
+ return count > 0;
163
+ }
164
+ }
package/src/build-repo.js DELETED
@@ -1,20 +0,0 @@
1
- import path from "node:path";
2
- import fsAsync from "node:fs/promises";
3
- import { Finder } from "@forwardimpact/libutil";
4
- import { createScriptConfig } from "@forwardimpact/libconfig";
5
- import { WikiRepo } from "./wiki-repo.js";
6
-
7
- /** Construct a WikiRepo from CLI values and the working directory. */
8
- export async function buildRepo(values, cwd = process.cwd()) {
9
- const logger = { debug() {} };
10
- const finder = new Finder(fsAsync, logger, { cwd: () => cwd });
11
- const projectRoot = finder.findProjectRoot(cwd);
12
- const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
13
-
14
- const config = await createScriptConfig("wiki");
15
- return new WikiRepo({
16
- wikiDir,
17
- parentDir: projectRoot,
18
- resolveToken: () => config.ghToken(),
19
- });
20
- }
package/src/io.js DELETED
@@ -1,77 +0,0 @@
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
- }