@forwardimpact/libutil 0.1.84 → 0.1.85

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libutil",
3
- "version": "0.1.84",
3
+ "version": "0.1.85",
4
4
  "description": "Cross-cutting utilities: retry, hashing, token counting, and project discovery.",
5
5
  "keywords": [
6
6
  "util",
@@ -21,6 +21,9 @@
21
21
  "main": "./src/index.js",
22
22
  "exports": {
23
23
  ".": "./src/index.js",
24
+ "./runtime": "./src/runtime.js",
25
+ "./git-client": "./src/git-client.js",
26
+ "./gh-client": "./src/gh-client.js",
24
27
  "./bin/fit-download-bundle.js": "./bin/fit-download-bundle.js",
25
28
  "./bin/fit-tiktoken.js": "./bin/fit-tiktoken.js"
26
29
  },
@@ -0,0 +1,84 @@
1
+ // Pure calendar arithmetic on explicit date inputs.
2
+ //
3
+ // Every `new Date(...)` here operates only on a value the caller passed (a ms
4
+ // timestamp, a `Date`, or an ISO string) — never the ambient wall clock, which
5
+ // lives behind `runtime.clock.now()`. That is why this module is allow-listed
6
+ // in `scripts/check-ambient-deps.allow.yml` for the same reason `runtime.js`
7
+ // is: it produces deterministic time values from its arguments rather than
8
+ // reaching for an ambient dependency. Consumers read "now" from
9
+ // `runtime.clock.now()` and pass the result here when they need a formatted or
10
+ // shifted calendar value.
11
+
12
+ /**
13
+ * Normalise an input into a `Date`. Accepts a `Date`, a ms timestamp, or any
14
+ * string `Date` understands (notably an ISO `YYYY-MM-DD` date).
15
+ * @param {Date|number|string} input
16
+ * @returns {Date}
17
+ */
18
+ function toDate(input) {
19
+ return input instanceof Date ? input : new Date(input);
20
+ }
21
+
22
+ /**
23
+ * Format an input as an ISO calendar date (`YYYY-MM-DD`, UTC).
24
+ * @param {Date|number|string} input
25
+ * @returns {string}
26
+ */
27
+ export function isoDate(input) {
28
+ return toDate(input).toISOString().slice(0, 10);
29
+ }
30
+
31
+ /**
32
+ * Compute the ISO 8601 year-week for an input. `year` is the ISO week-year
33
+ * (not necessarily the calendar year for edge weeks).
34
+ * @param {Date|number|string} input
35
+ * @returns {{year: number, week: number}}
36
+ */
37
+ export function isoWeek(input) {
38
+ const date = toDate(input);
39
+ // Anchor on Thursday of the week: ISO weeks belong to the year of their
40
+ // Thursday.
41
+ const d = new Date(
42
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
43
+ );
44
+ const day = d.getUTCDay() || 7;
45
+ d.setUTCDate(d.getUTCDate() + 4 - day);
46
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
47
+ const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
48
+ return { year: d.getUTCFullYear(), week };
49
+ }
50
+
51
+ /**
52
+ * Format an input as `YYYY-Www` (e.g. `2026-W22`).
53
+ * @param {Date|number|string} input
54
+ * @returns {string}
55
+ */
56
+ export function isoWeekString(input) {
57
+ const { year, week } = isoWeek(input);
58
+ return `${year}-W${String(week).padStart(2, "0")}`;
59
+ }
60
+
61
+ /**
62
+ * Format an input as `YYYY-Mmm` (e.g. `2026-M05`, UTC month).
63
+ * @param {Date|number|string} input
64
+ * @returns {string}
65
+ */
66
+ export function yearMonth(input) {
67
+ const d = toDate(input);
68
+ const yyyy = d.getUTCFullYear();
69
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
70
+ return `${yyyy}-M${mm}`;
71
+ }
72
+
73
+ /**
74
+ * Shift an input by `n` whole days (UTC) and return the ISO calendar date.
75
+ * Negative `n` moves backward. The input is never mutated.
76
+ * @param {Date|number|string} input
77
+ * @param {number} n - Days to add (may be negative).
78
+ * @returns {string}
79
+ */
80
+ export function addDays(input, n) {
81
+ const d = new Date(toDate(input).getTime());
82
+ d.setUTCDate(d.getUTCDate() + n);
83
+ return d.toISOString().slice(0, 10);
84
+ }
package/src/finder.js CHANGED
@@ -1,43 +1,95 @@
1
- import fs from "fs";
2
- import fsAsync from "fs/promises";
1
+ import nodeFsSync from "node:fs";
2
+ import nodeFsPromises from "node:fs/promises";
3
3
  import path from "path";
4
4
  import { createRequire } from "node:module";
5
5
 
6
+ const NOOP_LOGGER = { debug() {} };
7
+
6
8
  /**
7
- * Finder class for project path resolution and symlink management
8
- * Handles filesystem operations for linking generated code to packages
9
+ * Detect the new collaborator-config constructor form. The legacy positional
10
+ * form passes an fs module as the first argument (which carries `readFile`);
11
+ * the new form passes `{ fs, fsSync?, proc, logger? }`.
12
+ * @param {*} arg - The first constructor argument.
13
+ * @returns {boolean}
14
+ */
15
+ function isRuntimeConfig(arg) {
16
+ return (
17
+ arg != null &&
18
+ typeof arg === "object" &&
19
+ !Array.isArray(arg) &&
20
+ (arg.proc !== undefined ||
21
+ arg.fsSync !== undefined ||
22
+ (arg.fs !== undefined && typeof arg.readFile !== "function"))
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Finder class for project path resolution and symlink management.
28
+ * Handles filesystem operations for linking generated code to packages.
29
+ *
30
+ * Two constructor forms are supported during the ambient-to-injected migration:
31
+ *
32
+ * - Collaborator config (canonical): `new Finder({ fs, fsSync?, proc, logger? })`.
33
+ * The injected `fs` (async) and `fsSync` (sync, for existence checks) flow
34
+ * through to every internal call — the spec-flagged dead-`fs` bug is fixed.
35
+ * - Legacy positional (deprecated, one migration cycle):
36
+ * `new Finder(fs, logger, process)`. Preserved byte-for-byte so existing
37
+ * call sites stay green until their per-unit migration PRs convert them.
9
38
  */
10
39
  export class Finder {
40
+ #fs;
41
+ #existsSync;
11
42
  #logger;
12
- #process;
43
+ #proc;
13
44
 
14
45
  /**
15
- * Creates a new Finder instance
16
- * @param {object} fs - Filesystem module (fs/promises)
17
- * @param {object} logger - Logger instance for debug output
18
- * @param {object} process - Process environment access (for testing)
46
+ * @param {object} fsOrConfig - Either `{ fs, fsSync?, proc, logger? }` (new)
47
+ * or the async fs module (legacy positional first argument).
48
+ * @param {object} [logger] - Legacy positional logger.
49
+ * @param {object} [proc] - Legacy positional process (cwd provider).
19
50
  */
20
- constructor(fs, logger, process = global.process) {
21
- if (!fs) throw new Error("fs is required");
51
+ constructor(fsOrConfig, logger, proc = global.process) {
52
+ if (isRuntimeConfig(fsOrConfig)) {
53
+ // Finder is the one module that legitimately bridges the sync and async
54
+ // fs surfaces (existence checks vs. symlink ops), so it reads both fields
55
+ // by property access rather than a single `{ fs, fsSync }` destructure
56
+ // (which design Decision 7 reserves for consumer modules).
57
+ const fs = fsOrConfig.fs;
58
+ const fsSync = fsOrConfig.fsSync;
59
+ const procArg = fsOrConfig.proc;
60
+ if (!fs) throw new Error("fs is required");
61
+ if (!procArg) throw new Error("proc is required");
62
+ this.#fs = fs;
63
+ const existsTarget = fsSync ?? fs;
64
+ this.#existsSync = existsTarget.existsSync.bind(existsTarget);
65
+ this.#proc = procArg;
66
+ this.#logger = fsOrConfig.logger ?? NOOP_LOGGER;
67
+ return;
68
+ }
69
+ // Legacy positional form: behavior identical to the pre-1370 Finder —
70
+ // every fs operation routes through the module-level node:fs imports
71
+ // (the historical dead-`fs` behavior callers depend on).
72
+ if (!fsOrConfig) throw new Error("fs is required");
22
73
  if (!logger) throw new Error("logger is required");
23
- if (!process) throw new Error("process is required");
24
-
74
+ if (!proc) throw new Error("process is required");
75
+ this.#fs = nodeFsPromises;
76
+ this.#existsSync = (p) => nodeFsSync.existsSync(p);
25
77
  this.#logger = logger;
26
- this.#process = process;
78
+ this.#proc = proc;
27
79
  }
28
80
 
29
81
  /**
30
- * Searches upward from one or more roots for a target file or directory.
82
+ * Searches upward from a root for a target file or directory.
31
83
  * @param {string} root - Starting directory to search from
32
84
  * @param {string} relativePath - Relative path to append while traversing upward
33
- * @param {number} maxDepth - Maximum parent levels to check
85
+ * @param {number} [maxDepth=3] - Maximum parent levels to check
34
86
  * @returns {string|null} Found absolute path or null
35
87
  */
36
88
  findUpward(root, relativePath, maxDepth = 3) {
37
89
  let current = root;
38
90
  for (let depth = 0; depth < maxDepth; depth++) {
39
91
  const candidate = path.join(current, relativePath);
40
- if (fs.existsSync(candidate)) {
92
+ if (this.#existsSync(candidate)) {
41
93
  return candidate;
42
94
  }
43
95
  const parent = path.dirname(current);
@@ -54,12 +106,12 @@ export class Finder {
54
106
  * @returns {string} Absolute path to found directory
55
107
  */
56
108
  findData(baseName, homeDir) {
57
- const cwd = this.#process.cwd();
109
+ const cwd = this.#proc.cwd();
58
110
  const found = this.findUpward(cwd, baseName);
59
111
  if (found) return found;
60
112
 
61
113
  const homePath = path.join(homeDir, ".fit", baseName);
62
- if (fs.existsSync(homePath)) return homePath;
114
+ if (this.#existsSync(homePath)) return homePath;
63
115
 
64
116
  throw new Error(
65
117
  `No ${baseName} directory found from ${cwd} or ${homePath}.`,
@@ -67,7 +119,7 @@ export class Finder {
67
119
  }
68
120
 
69
121
  /**
70
- * Find the project root directory
122
+ * Find the project root directory.
71
123
  * @param {string} startPath - Starting directory path
72
124
  * @returns {string} Project root directory path
73
125
  */
@@ -81,8 +133,8 @@ export class Finder {
81
133
  }
82
134
 
83
135
  /**
84
- * Resolve the actual filesystem path to a package
85
- * Works both in monorepo (./packages) and when installed as dependency
136
+ * Resolve the actual filesystem path to a package.
137
+ * Works both in monorepo (./packages) and when installed as dependency.
86
138
  * @param {string} projectRoot - Project root directory path
87
139
  * @param {"libtype"|"librpc"} packageName - Package name without scope
88
140
  * @returns {string} Absolute path to package directory
@@ -93,7 +145,7 @@ export class Finder {
93
145
  // First try local monorepo structures
94
146
  for (const dir of ["libraries", "packages"]) {
95
147
  const localPath = path.join(projectRoot, dir, packageName);
96
- if (fs.existsSync(localPath)) {
148
+ if (this.#existsSync(localPath)) {
97
149
  return localPath;
98
150
  }
99
151
  }
@@ -107,7 +159,7 @@ export class Finder {
107
159
  }
108
160
 
109
161
  /**
110
- * Resolve the generated directory path for a package
162
+ * Resolve the generated directory path for a package.
111
163
  * @param {string} projectRoot - Project root directory path
112
164
  * @param {"libtype"|"librpc"} packageName - Package name without scope
113
165
  * @returns {string} Absolute path to package's generated directory
@@ -118,32 +170,32 @@ export class Finder {
118
170
  }
119
171
 
120
172
  /**
121
- * Create symlink from source to target directory
173
+ * Create symlink from source to target directory.
122
174
  * @param {string} sourcePath - Source directory path
123
175
  * @param {string} targetPath - Target directory path
124
176
  * @returns {Promise<void>}
125
177
  */
126
178
  async createSymlink(sourcePath, targetPath) {
127
179
  // Ensure the source directory exists
128
- await fsAsync.mkdir(sourcePath, { recursive: true });
180
+ await this.#fs.mkdir(sourcePath, { recursive: true });
129
181
 
130
182
  // Remove the existing target if it exists
131
183
  try {
132
- const stats = await fsAsync.lstat(targetPath);
184
+ const stats = await this.#fs.lstat(targetPath);
133
185
  if (stats.isSymbolicLink()) {
134
- await fsAsync.unlink(targetPath);
186
+ await this.#fs.unlink(targetPath);
135
187
  } else {
136
- await fsAsync.rm(targetPath, { recursive: true, force: true });
188
+ await this.#fs.rm(targetPath, { recursive: true, force: true });
137
189
  }
138
190
  } catch {
139
191
  // Target doesn't exist, which is fine
140
192
  }
141
193
 
142
194
  // Ensure the target's parent directory exists before symlinking
143
- await fsAsync.mkdir(path.dirname(targetPath), { recursive: true });
195
+ await this.#fs.mkdir(path.dirname(targetPath), { recursive: true });
144
196
 
145
197
  // Create the symlink
146
- await fsAsync.symlink(sourcePath, targetPath, "dir");
198
+ await this.#fs.symlink(sourcePath, targetPath, "dir");
147
199
  this.#logger.debug("Finder", "Created symlink", {
148
200
  source_path: sourcePath,
149
201
  target_path: targetPath,
@@ -151,13 +203,14 @@ export class Finder {
151
203
  }
152
204
 
153
205
  /**
154
- * Create symlinks to the generated directory for standard packages
155
- * Attempts to find project root and create symlinks, but won't fail in test environments
206
+ * Create symlinks to the generated directory for standard packages.
207
+ * Attempts to find project root and create symlinks, but won't fail in
208
+ * test environments.
156
209
  * @param {string} generatedPath - Path to generated code directory
157
210
  * @returns {Promise<void>}
158
211
  */
159
212
  async createPackageSymlinks(generatedPath) {
160
- const projectRoot = this.findProjectRoot(this.#process.cwd());
213
+ const projectRoot = this.findProjectRoot(this.#proc.cwd());
161
214
  const packageNames = ["libtype", "librpc"];
162
215
 
163
216
  const promises = packageNames.map(async (packageName) => {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Error thrown when a `gh` subcommand exits non-zero.
3
+ */
4
+ export class GhError extends Error {
5
+ /**
6
+ * @param {string} subcmd - The gh subcommand that failed.
7
+ * @param {{stdout: string, stderr: string, exitCode: number}} result
8
+ */
9
+ constructor(subcmd, result) {
10
+ super(
11
+ `gh ${subcmd} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`,
12
+ );
13
+ this.name = "GhError";
14
+ this.exitCode = result.exitCode;
15
+ this.stdout = result.stdout;
16
+ this.stderr = result.stderr;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Typed wrapper over the `gh` CLI. Shelling-out flows through the injected
22
+ * `runtime.subprocess` so callers never import `node:child_process` and tests
23
+ * inject `createMockSubprocess`.
24
+ */
25
+ export class GhClient {
26
+ #runtime;
27
+
28
+ /**
29
+ * @param {object} options
30
+ * @param {import('./runtime.js').Runtime} options.runtime - The runtime bag.
31
+ */
32
+ constructor({ runtime }) {
33
+ if (!runtime) throw new Error("runtime is required");
34
+ this.#runtime = runtime;
35
+ }
36
+
37
+ /** Create a pull request. Returns the new PR URL (stdout). */
38
+ async prCreate({ cwd, title, body, base, head } = {}) {
39
+ const args = ["pr", "create"];
40
+ if (title) args.push("--title", title);
41
+ if (body) args.push("--body", body);
42
+ if (base) args.push("--base", base);
43
+ if (head) args.push("--head", head);
44
+ const result = await this.#run(args, { cwd });
45
+ return result.stdout.trim();
46
+ }
47
+
48
+ /** Merge a pull request. */
49
+ async prMerge(number, { cwd, method = "squash" } = {}) {
50
+ return this.#run(["pr", "merge", String(number), `--${method}`], { cwd });
51
+ }
52
+
53
+ /** GET an API path; returns parsed JSON. */
54
+ async apiGet(path, { cwd } = {}) {
55
+ const result = await this.#run(["api", path], { cwd });
56
+ return result.stdout.trim() ? JSON.parse(result.stdout) : null;
57
+ }
58
+
59
+ /** POST to an API path with `fields`; returns parsed JSON. */
60
+ async apiPost(path, fields = {}, { cwd } = {}) {
61
+ const args = ["api", "--method", "POST", path];
62
+ for (const [k, v] of Object.entries(fields)) {
63
+ args.push("-f", `${k}=${v}`);
64
+ }
65
+ const result = await this.#run(args, { cwd });
66
+ return result.stdout.trim() ? JSON.parse(result.stdout) : null;
67
+ }
68
+
69
+ async #run(args, { cwd } = {}) {
70
+ const result = await this.#runtime.subprocess.run("gh", args, {
71
+ cwd,
72
+ env: this.#runtime.proc.env,
73
+ });
74
+ if (result.exitCode !== 0) {
75
+ throw new GhError(args.join(" "), result);
76
+ }
77
+ return result;
78
+ }
79
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Error thrown when a git subcommand exits non-zero.
3
+ */
4
+ export class GitError extends Error {
5
+ /**
6
+ * @param {string} subcmd - The git subcommand that failed.
7
+ * @param {{stdout: string, stderr: string, exitCode: number}} result
8
+ */
9
+ constructor(subcmd, result) {
10
+ super(
11
+ `git ${subcmd} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`,
12
+ );
13
+ this.name = "GitError";
14
+ this.exitCode = result.exitCode;
15
+ this.stdout = result.stdout;
16
+ this.stderr = result.stderr;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Typed wrapper over the `git` CLI. All shelling-out flows through the
22
+ * injected `runtime.subprocess`, so callers never import `node:child_process`
23
+ * and tests inject `createMockSubprocess`. Methods resolve to the
24
+ * raw `{ stdout, stderr, exitCode }` result; `#run` throws a {@link GitError}
25
+ * on a non-zero exit unless `allowFailure` is set.
26
+ */
27
+ export class GitClient {
28
+ #runtime;
29
+ #token;
30
+
31
+ /**
32
+ * @param {object} options
33
+ * @param {import('./runtime.js').Runtime} options.runtime - The runtime bag.
34
+ * @param {string} [options.token] - Optional auth token threaded into env.
35
+ */
36
+ constructor({ runtime, token }) {
37
+ if (!runtime) throw new Error("runtime is required");
38
+ this.#runtime = runtime;
39
+ this.#token = token;
40
+ }
41
+
42
+ /** Clone `url` into `dir`. */
43
+ async clone(url, dir, opts = {}) {
44
+ return this.#run("clone", [url, dir, ...this.#flagOpts(opts)]);
45
+ }
46
+
47
+ /** Initialise a repository at `dir`. */
48
+ async init(dir) {
49
+ return this.#run("init", [dir]);
50
+ }
51
+
52
+ /** Fetch `refspec` from `remote`. */
53
+ async fetch(remote = "origin", refspec, { cwd } = {}) {
54
+ const args = ["fetch", remote];
55
+ if (refspec) args.push(refspec);
56
+ return this.#runRaw(args, { cwd });
57
+ }
58
+
59
+ /** Return `git status --porcelain` output. */
60
+ async status({ cwd }) {
61
+ return this.#runRaw(["status", "--porcelain"], { cwd });
62
+ }
63
+
64
+ /** Rebase the current branch onto `upstream`, optionally with a merge strategy. */
65
+ async rebase(upstream, { cwd, strategy } = {}) {
66
+ const args = ["rebase"];
67
+ if (strategy) args.push("-X", strategy);
68
+ args.push(upstream);
69
+ return this.#runRaw(args, { cwd, allowFailure: true });
70
+ }
71
+
72
+ /** Abort an in-progress rebase, leaving the working tree at its pre-rebase state. */
73
+ async rebaseAbort({ cwd } = {}) {
74
+ return this.#runRaw(["rebase", "--abort"], { cwd, allowFailure: true });
75
+ }
76
+
77
+ /** Merge `ref` into the current branch resolving conflicts with `-X ours`. */
78
+ async mergeOursStrategy({ cwd, ref }) {
79
+ return this.#runRaw(["merge", "-X", "ours", "--no-edit", ref], { cwd });
80
+ }
81
+
82
+ /** Stage all changes and commit with `message`. */
83
+ async commitAll(message, { cwd, author } = {}) {
84
+ await this.#runRaw(["add", "-A"], { cwd });
85
+ const args = ["commit", "-m", message];
86
+ if (author) args.push("--author", author);
87
+ return this.#runRaw(args, { cwd });
88
+ }
89
+
90
+ /** Push `branch` to `remote`. */
91
+ async push(remote = "origin", branch, { cwd, force = false } = {}) {
92
+ const args = ["push", remote];
93
+ if (branch) args.push(branch);
94
+ if (force) args.push("--force-with-lease");
95
+ return this.#runRaw(args, { cwd });
96
+ }
97
+
98
+ /** Count commits in `range` (`git rev-list --count`). */
99
+ async revListCount(range, { cwd }) {
100
+ const result = await this.#runRaw(["rev-list", "--count", range], { cwd });
101
+ return Number.parseInt(result.stdout.trim(), 10);
102
+ }
103
+
104
+ /** Read a config `key`. */
105
+ async configGet(key, { cwd } = {}) {
106
+ const result = await this.#runRaw(["config", "--get", key], {
107
+ cwd,
108
+ allowFailure: true,
109
+ });
110
+ return result.stdout.trim();
111
+ }
112
+
113
+ /** Set a config `key` to `value`. */
114
+ async configSet(key, value, { cwd } = {}) {
115
+ return this.#runRaw(["config", key, value], { cwd });
116
+ }
117
+
118
+ /** Count commits the current branch is ahead of `upstream`. */
119
+ async aheadCount({ cwd, upstream = "@{upstream}" } = {}) {
120
+ return this.revListCount(`${upstream}..HEAD`, { cwd });
121
+ }
122
+
123
+ /** Read the URL configured for `remote`. */
124
+ async remoteGetUrl(remote = "origin", { cwd }) {
125
+ const result = await this.#runRaw(["remote", "get-url", remote], { cwd });
126
+ return result.stdout.trim();
127
+ }
128
+
129
+ /** Return a new client that threads `token` into the git env. */
130
+ withAuth(token) {
131
+ return new GitClient({ runtime: this.#runtime, token });
132
+ }
133
+
134
+ #flagOpts(opts) {
135
+ const flags = [];
136
+ if (opts.depth) flags.push("--depth", String(opts.depth));
137
+ if (opts.branch) flags.push("--branch", opts.branch);
138
+ if (opts.bare) flags.push("--bare");
139
+ return flags;
140
+ }
141
+
142
+ #run(subcmd, args, { cwd, allowFailure = false } = {}) {
143
+ return this.#runRaw([subcmd, ...args], { cwd, allowFailure });
144
+ }
145
+
146
+ async #runRaw(args, { cwd, allowFailure = false } = {}) {
147
+ // Authenticate over HTTPS by injecting a per-invocation Basic auth header
148
+ // via git's `-c` config (the `-c http.extraHeader` must precede the
149
+ // subcommand). GitHub's git-over-HTTPS expects the token as the password in
150
+ // HTTP Basic auth (username `x-access-token`); a `bearer` scheme is rejected
151
+ // for PAT/OAuth tokens and only works for App installation tokens, so Basic
152
+ // is the broadly-compatible choice. No-op when the client carries no token.
153
+ const fullArgs = this.#token
154
+ ? [
155
+ "-c",
156
+ `http.extraHeader=Authorization: Basic ${Buffer.from(`x-access-token:${this.#token}`).toString("base64")}`,
157
+ ...args,
158
+ ]
159
+ : args;
160
+ const result = await this.#runtime.subprocess.run("git", fullArgs, {
161
+ cwd,
162
+ env: this.#runtime.proc.env,
163
+ });
164
+ if (!allowFailure && result.exitCode !== 0) {
165
+ throw new GitError(args.join(" "), result);
166
+ }
167
+ return result;
168
+ }
169
+ }
package/src/index.js CHANGED
@@ -157,6 +157,21 @@ export function execLine(shift, deps) {
157
157
  }
158
158
 
159
159
  export { Finder } from "./finder.js";
160
+ export {
161
+ createDefaultRuntime,
162
+ createDefaultProc,
163
+ createDefaultSubprocess,
164
+ createDefaultClock,
165
+ } from "./runtime.js";
166
+ export {
167
+ isoDate,
168
+ isoWeek,
169
+ isoWeekString,
170
+ yearMonth,
171
+ addDays,
172
+ } from "./calendar.js";
173
+ export { GitClient } from "./git-client.js";
174
+ export { GhClient } from "./gh-client.js";
160
175
  export { BundleDownloader } from "./downloader.js";
161
176
  export { TarExtractor, ZipExtractor } from "./extractor.js";
162
177
  export { ProcessorBase } from "./processor.js";
package/src/runtime.js ADDED
@@ -0,0 +1,226 @@
1
+ import { spawn as nodeSpawn, execFile, spawnSync } from "node:child_process";
2
+ import nodeFsSync from "node:fs";
3
+ import nodeFs from "node:fs/promises";
4
+ import { Finder } from "./finder.js";
5
+
6
+ /**
7
+ * @typedef {Object} Runtime
8
+ *
9
+ * The single bag of ambient collaborators threaded from every binary's
10
+ * entry point through `ctx.deps` into every constructor and factory
11
+ * Production wires the bag from `createDefaultRuntime`; tests
12
+ * wire it from libmock's `createTestRuntime`. A module destructures the
13
+ * fields it actually uses and never imports `node:fs` / `node:child_process`
14
+ * or reads `Date.now` / `process.*` directly.
15
+ *
16
+ * @property {Object} fs
17
+ * Async filesystem surface (the `node:fs/promises` shape): `readFile`,
18
+ * `writeFile`, `readdir`, `stat`, `mkdir`, `access`, `copyFile`, `cp`, `rm`,
19
+ * `lstat`, `unlink`, `symlink`, `utimes`, `chmod`. A module destructures
20
+ * `fs` xor `fsSync`, never both (design Decision 7).
21
+ * @property {Object} fsSync
22
+ * Sync filesystem surface (the `node:fs` shape): `existsSync`,
23
+ * `readFileSync`, `writeFileSync`, `mkdirSync`, `readdirSync`, `statSync`,
24
+ * `openSync`, `closeSync`, `unlinkSync`.
25
+ * @property {Object} proc
26
+ * Process surface: `cwd()`, `env`, `argv`, `stdin`, `stdout.write`,
27
+ * `stderr.write`, `exit(code)`, `kill(pid, signal)` (a negative `pid`
28
+ * signals the process group, e.g. for daemon teardown), and an `exitCode`
29
+ * accessor.
30
+ * @property {Object} clock
31
+ * Time surface: `now()`, `sleep(ms)`, `setTimeout(fn, ms)`,
32
+ * `clearTimeout(handle)`.
33
+ * @property {Object} subprocess
34
+ * Subprocess surface: `run(cmd, args, opts) -> Promise<{stdout, stderr,
35
+ * exitCode}>` (async, buffered), `runSync(cmd, args, opts) -> {stdout,
36
+ * stderr, exitCode}` (synchronous, buffered — for the rare caller that
37
+ * cannot go async, e.g. a sync config accessor shelling to `gh auth
38
+ * token`), and `spawn(cmd, args, opts) -> {stdout, stderr, exitCode, kill}`
39
+ * where `stdout`/`stderr` are AsyncIterables and `exitCode` a Promise.
40
+ * @property {Object} finder
41
+ * A constructed `Finder` (project path resolution + symlink management).
42
+ */
43
+
44
+ /**
45
+ * Build the process surface over a `process`-like source. The returned
46
+ * object is intentionally not frozen — `exitCode` is defined as an accessor
47
+ * with a setter, which `Object.freeze` would strip.
48
+ *
49
+ * @param {object} [options]
50
+ * @param {object} [options.source=process] - The backing process handle.
51
+ * @param {Record<string,string>} [options.env=source.env] - Backing env map
52
+ * the `env` Proxy reads through to on every access.
53
+ * @returns {object} The `proc` collaborator.
54
+ */
55
+ export function createDefaultProc({ source = process, env = source.env } = {}) {
56
+ const proc = {
57
+ cwd: () => source.cwd(),
58
+ env: new Proxy(env, {
59
+ get: (t, k) => t[k],
60
+ has: (t, k) => k in t,
61
+ set: (t, k, v) => {
62
+ t[k] = v;
63
+ return true;
64
+ },
65
+ deleteProperty: (t, k) => {
66
+ delete t[k];
67
+ return true;
68
+ },
69
+ ownKeys: (t) => Reflect.ownKeys(t),
70
+ getOwnPropertyDescriptor: (t, k) =>
71
+ Reflect.getOwnPropertyDescriptor(t, k) ?? {
72
+ configurable: true,
73
+ enumerable: true,
74
+ value: t[k],
75
+ writable: true,
76
+ },
77
+ }),
78
+ argv: Object.freeze([...source.argv]),
79
+ stdin: lineIterator(source.stdin),
80
+ stdout: { write: (s) => source.stdout.write(s) },
81
+ stderr: { write: (s) => source.stderr.write(s) },
82
+ exit: (code) => source.exit(code),
83
+ kill: (pid, signal) => source.kill(pid, signal),
84
+ };
85
+ Object.defineProperty(proc, "exitCode", {
86
+ enumerable: true,
87
+ get: () => source.exitCode,
88
+ set: (v) => {
89
+ source.exitCode = v;
90
+ },
91
+ });
92
+ return proc;
93
+ }
94
+
95
+ /**
96
+ * Adapt a readable stream into an `AsyncIterable<string>` of UTF-8 lines.
97
+ * @param {object} stream - A Node readable stream (e.g. `process.stdin`).
98
+ * @returns {AsyncIterable<string>}
99
+ */
100
+ function lineIterator(stream) {
101
+ return {
102
+ async *[Symbol.asyncIterator]() {
103
+ let buffer = "";
104
+ for await (const chunk of stream) {
105
+ buffer += chunk.toString("utf8");
106
+ let nl;
107
+ while ((nl = buffer.indexOf("\n")) !== -1) {
108
+ yield buffer.slice(0, nl);
109
+ buffer = buffer.slice(nl + 1);
110
+ }
111
+ }
112
+ if (buffer.length > 0) yield buffer;
113
+ },
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Build the clock surface backed by real timers.
119
+ * @returns {{now: () => number, sleep: (ms: number) => Promise<void>, setTimeout: Function, clearTimeout: Function}}
120
+ */
121
+ export function createDefaultClock() {
122
+ return {
123
+ now: () => Date.now(),
124
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
125
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
126
+ clearTimeout: (handle) => clearTimeout(handle),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Build the subprocess surface over `node:child_process`. `run` buffers the
132
+ * full output; `runSync` is its synchronous sibling; `spawn` exposes streaming
133
+ * AsyncIterables plus an exit Promise.
134
+ * @returns {{run: Function, runSync: Function, spawn: Function}}
135
+ */
136
+ export function createDefaultSubprocess() {
137
+ const runSync = (cmd, args = [], opts = {}) => {
138
+ const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
139
+ // `error` is set on spawn failure (e.g. ENOENT); mirror run()'s mapping:
140
+ // numeric status, 128 for a signal-kill, 127 for a spawn failure.
141
+ let exitCode = 0;
142
+ if (r.error) exitCode = 127;
143
+ else if (typeof r.status === "number") exitCode = r.status;
144
+ else if (r.signal) exitCode = 128;
145
+ return {
146
+ stdout: r.stdout ?? "",
147
+ stderr: r.stderr ?? "",
148
+ exitCode,
149
+ signal: r.signal ?? null,
150
+ };
151
+ };
152
+
153
+ const run = (cmd, args = [], opts = {}) =>
154
+ new Promise((resolve) => {
155
+ execFile(
156
+ cmd,
157
+ args,
158
+ { encoding: "utf8", ...opts },
159
+ (err, stdout, stderr) => {
160
+ resolve({
161
+ stdout: stdout ?? "",
162
+ stderr: stderr ?? "",
163
+ // Always numeric: child code on normal exit, 128 for a signal-kill
164
+ // (err.code null, err.signal set), 127 for a spawn failure
165
+ // (err.code is a string like "ENOENT").
166
+ exitCode: normalizeExitCode(err),
167
+ signal: err?.signal ?? null,
168
+ });
169
+ },
170
+ );
171
+ });
172
+
173
+ const spawn = (cmd, args = [], opts = {}) => {
174
+ const child = nodeSpawn(cmd, args, opts);
175
+ return {
176
+ stdout: child.stdout ?? emptyAsyncIterable(),
177
+ stderr: child.stderr ?? emptyAsyncIterable(),
178
+ exitCode: new Promise((resolve) => {
179
+ child.on("close", (code) => resolve(code ?? 0));
180
+ }),
181
+ kill: (signal) => child.kill(signal),
182
+ };
183
+ };
184
+
185
+ return { run, runSync, spawn };
186
+ }
187
+
188
+ /**
189
+ * Map an `execFile` error to a numeric exit code.
190
+ * @param {Error & {code?: number|string, signal?: string}} [err]
191
+ * @returns {number}
192
+ */
193
+ function normalizeExitCode(err) {
194
+ if (!err) return 0;
195
+ if (typeof err.code === "number") return err.code;
196
+ if (err.signal) return 128;
197
+ return 127;
198
+ }
199
+
200
+ function emptyAsyncIterable() {
201
+ return {
202
+ [Symbol.asyncIterator]() {
203
+ return { next: async () => ({ done: true, value: undefined }) };
204
+ },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Build the production runtime bag. Two-phase: phase 1 assembles the leaf
210
+ * collaborators, phase 2 constructs the `Finder` from them, then the whole
211
+ * bag is frozen and returned.
212
+ *
213
+ * @param {object} [options]
214
+ * @param {Record<string,string>} [options.env=process.env] - Backing env.
215
+ * @returns {Readonly<Runtime>}
216
+ */
217
+ export function createDefaultRuntime({ env = process.env } = {}) {
218
+ const fs = nodeFs;
219
+ const fsSync = nodeFsSync;
220
+ const proc = createDefaultProc({ source: process, env });
221
+ const clock = createDefaultClock();
222
+ const subprocess = createDefaultSubprocess();
223
+ // Finder needs the sync existence surface plus the async fs ops; pass both.
224
+ const finder = new Finder({ fs, fsSync, proc });
225
+ return Object.freeze({ fs, fsSync, proc, clock, subprocess, finder });
226
+ }