@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 +4 -1
- package/src/calendar.js +84 -0
- package/src/finder.js +87 -34
- package/src/gh-client.js +79 -0
- package/src/git-client.js +169 -0
- package/src/index.js +15 -0
- package/src/runtime.js +226 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libutil",
|
|
3
|
-
"version": "0.1.
|
|
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
|
},
|
package/src/calendar.js
ADDED
|
@@ -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
|
|
2
|
-
import
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
#
|
|
43
|
+
#proc;
|
|
13
44
|
|
|
14
45
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* @param {object} logger -
|
|
18
|
-
* @param {object}
|
|
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(
|
|
21
|
-
if (
|
|
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 (!
|
|
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.#
|
|
78
|
+
this.#proc = proc;
|
|
27
79
|
}
|
|
28
80
|
|
|
29
81
|
/**
|
|
30
|
-
* Searches upward from
|
|
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 (
|
|
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.#
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
184
|
+
const stats = await this.#fs.lstat(targetPath);
|
|
133
185
|
if (stats.isSymbolicLink()) {
|
|
134
|
-
await
|
|
186
|
+
await this.#fs.unlink(targetPath);
|
|
135
187
|
} else {
|
|
136
|
-
await
|
|
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
|
|
195
|
+
await this.#fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
144
196
|
|
|
145
197
|
// Create the symlink
|
|
146
|
-
await
|
|
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
|
|
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.#
|
|
213
|
+
const projectRoot = this.findProjectRoot(this.#proc.cwd());
|
|
161
214
|
const packageNames = ["libtype", "librpc"];
|
|
162
215
|
|
|
163
216
|
const promises = packageNames.map(async (packageName) => {
|
package/src/gh-client.js
ADDED
|
@@ -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
|
+
}
|