@williamthorsen/nmr 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.readyup/kits/default.js +4 -3
- package/.readyup/manifest.json +2 -2
- package/README.md +13 -17
- package/agents/guidance/rulebooks/nmr.md +1 -1
- package/dist/esm/check-cache.d.ts +1 -1
- package/dist/esm/check-cache.js +3 -3
- package/dist/esm/commands/clean.js +1 -1
- package/dist/esm/default-scripts.js +1 -1
- package/dist/esm/helpers/createBoundedBuffer.d.ts +9 -0
- package/dist/esm/helpers/createBoundedBuffer.js +60 -0
- package/dist/esm/runCli.js +21 -14
- package/dist/esm/runner.d.ts +18 -1
- package/dist/esm/runner.js +97 -24
- package/package.json +2 -2
package/.readyup/kits/default.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @noformat — @generated. Do not edit. Compiled by rdy. */
|
|
2
2
|
/* eslint-disable */
|
|
3
|
-
export const __readyupVersion = "0.
|
|
3
|
+
export const __readyupVersion = "0.26.0";
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
// .readyup/kits/default.ts
|
|
@@ -61,7 +61,8 @@ var rootScripts = {
|
|
|
61
61
|
lint: "eslint --fix .",
|
|
62
62
|
"lint:check": "eslint .",
|
|
63
63
|
"lint:strict": "strict-lint",
|
|
64
|
-
|
|
64
|
+
// The audit costs seconds and `ci` costs minutes, so the cheap gate fails first.
|
|
65
|
+
prepush: ["audit", "ci"],
|
|
65
66
|
"report-overrides": "nmr-report-overrides",
|
|
66
67
|
"root:check": ["root:typecheck", "fmt:check", "root:lint:check", "root:test"],
|
|
67
68
|
"root:lint": "eslint --fix --ignore-pattern 'packages/**' .",
|
|
@@ -375,7 +376,7 @@ function formatPaths(paths) {
|
|
|
375
376
|
${paths.map((path2) => ` ${path2}`).join("\n")}`;
|
|
376
377
|
}
|
|
377
378
|
function getMinVersion() {
|
|
378
|
-
const picked = { "version": "0.
|
|
379
|
+
const picked = { "version": "0.29.0" };
|
|
379
380
|
if (typeof picked["version"] !== "string") {
|
|
380
381
|
throw new TypeError("nmr/package.json: 'version' must be a string");
|
|
381
382
|
}
|
package/.readyup/manifest.json
CHANGED
package/README.md
CHANGED
|
@@ -3,29 +3,23 @@
|
|
|
3
3
|
Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
|
|
4
4
|
|
|
5
5
|
<!-- section:release-notes -->
|
|
6
|
-
## Release notes — v0.
|
|
6
|
+
## Release notes — v0.29.0 (2026-08-09)
|
|
7
7
|
|
|
8
8
|
### 🎉 Features
|
|
9
9
|
|
|
10
|
-
-
|
|
10
|
+
- Run the dependency audit before ci in prepush (#634)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- Stop reporting non-issues in the readyup kits (#613)
|
|
15
|
-
|
|
16
|
-
Modifies the `release-kit` and `nmr` ReadyUp kits to avoid verbose reporting when an initial check determines that further checks aren't applicable. In particular, a repo with no `release-kit` config file gets a single line in the `release-kit` report, and a repo that publishes nothing now bypasses the `npm-auto-publish` kit instead of failing it.
|
|
12
|
+
`nmr prepush` now runs the dependency audit before the code-quality gate, so a vulnerability surfaces within seconds instead of after a full build-and-check pass. A failing audit now stops the run before the code-quality gate.
|
|
17
13
|
|
|
18
14
|
### 🐛 Bug fixes
|
|
19
15
|
|
|
20
|
-
-
|
|
16
|
+
- Record no pass when build output changes while a check runs (#641)
|
|
21
17
|
|
|
22
|
-
Fixes an issue where
|
|
18
|
+
Fixes an issue where rebuilding a package in one terminal while `nmr prepush` ran in another could mark a check as passed against build output it never saw, causing a later run on the same tree to skip it. Because `nmr ci` builds during its own run, it now needs one further run before it can skip, and that run repeats only the build phase.
|
|
23
19
|
|
|
24
|
-
|
|
20
|
+
- Stream command output so a large writer is no longer killed (#642)
|
|
25
21
|
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
Fixes an issue where a failed or still-running `nmr build` could leave a package with no build output at all, breaking the tooling that a subsequent build depends on. Separately, the error reported when a package's build output is missing now names the correct recovery command.
|
|
22
|
+
Fixes an issue where a command that wrote more than a mebibyte of output under `nmr -q` was cut off and reported as failed while the tool itself kept running. When `nmr`'s output is piped or redirected to a file, a command's output now appears as it is produced instead of arriving all at once when the command exits. Piping `nmr` into a reader that stops early, such as `head`, now ends the run cleanly. A command killed by a signal now reports 128 plus the signal number rather than a bare 1.
|
|
29
23
|
<!-- /section:release-notes -->
|
|
30
24
|
|
|
31
25
|
## Installation
|
|
@@ -277,7 +271,7 @@ A hit requires all of these to match the run that recorded the pass:
|
|
|
277
271
|
|
|
278
272
|
A hit additionally requires the build output of every package nmr's own build covers to be present, and to have been built from this tree. Output is git-ignored, so neither its removal nor its replacement moves the hash: restoring a tree with `git stash` or `git checkout` restores none of the output that tree was built with. nmr compares each covered package's recorded build digest against the one on disk, so a `dist` compiled from another tree is a miss the following run repairs. A package that overrides `build` or `compile` emits on terms nmr does not know and is exempt.
|
|
279
273
|
|
|
280
|
-
A pass is recorded only when the command exits 0, only when the tree still matches the one the run started against, only when every covered package's output is present, and never for a run that executed nothing (a `""`/`":"` skip override, or an `NMR_RUN_IF_PRESENT` miss).
|
|
274
|
+
A pass is recorded only when the command exits 0, only when the tree still matches the one the run started against, only when every covered package's output is present and unchanged since the run started, and never for a run that executed nothing (a `""`/`":"` skip override, or an `NMR_RUN_IF_PRESENT` miss). Output that moves mid-run leaves no answer to which output the pass was earned over, whether another process rebuilt it or the chain built it itself: `nmr ci` is the one default command whose chain does, and it records nothing on a run whose build did work. The `nmr check:strict` inside that chain still records, because its own window opens after the build.
|
|
281
275
|
|
|
282
276
|
### Out of contract
|
|
283
277
|
|
|
@@ -438,9 +432,11 @@ A run that collects no test files passes. That is what lets `nmr test:tool` fan
|
|
|
438
432
|
| `build` | `pnpm --recursive exec nmr build` |
|
|
439
433
|
| `ci` | `build`, `check:strict` |
|
|
440
434
|
| `clean` | `nmr-clean` |
|
|
441
|
-
| `prepush` | `
|
|
435
|
+
| `prepush` | `audit`, `ci` |
|
|
436
|
+
|
|
437
|
+
`ci` is what a code-quality workflow runs; it leaves out the audit, which belongs in a workflow of its own. `prepush` is what a developer runs before pushing: both gates, audit first. The audit takes seconds and `ci` takes minutes, so a vulnerability surfaces before the long gate runs. It composes `ci` rather than restating its stages, so a stage added to `ci` joins the pre-push run too.
|
|
442
438
|
|
|
443
|
-
|
|
439
|
+
The audit reaches the network. With no network, `ci` is the gate that still runs.
|
|
444
440
|
|
|
445
441
|
Neither is bound to a git hook. `prepush` is named for when you run it, not for a hook nmr installs.
|
|
446
442
|
|
|
@@ -576,7 +572,7 @@ nmr build # Compile to .js and .d.ts in one pass
|
|
|
576
572
|
# From the monorepo root
|
|
577
573
|
nmr test # Root tests + recursive workspace tests
|
|
578
574
|
nmr ci # build + check:strict
|
|
579
|
-
nmr prepush #
|
|
575
|
+
nmr prepush # audit + ci
|
|
580
576
|
|
|
581
577
|
# Target specific packages
|
|
582
578
|
nmr --filter core test # Test only the core package
|
|
@@ -9,6 +9,6 @@ delivery: ambient
|
|
|
9
9
|
- Use `nmr <command>`, not `pnpm run <command>`, for anything nmr provides. Run bare `nmr` to list every command and the shell command it resolves to; a wrong guess reports only `Unknown command`, with no list.
|
|
10
10
|
- Scope follows cwd, and bare `nmr` lists both registries: from the repo root a command covers root files and every workspace; from inside a package, that package alone. `nmr -F <pkg> <command>` targets one package from anywhere; `nmr root:<command>` targets root files alone, which isolates a failure to root code.
|
|
11
11
|
- Never `npx nmr`: inside a git worktree it can resolve a different nmr from outside the tree. Fall back to `pnpm exec nmr`.
|
|
12
|
-
- `nmr ci` runs what a code-quality workflow runs (build, then strict checks) and not the dependency audit, which belongs to a workflow of its own; `nmr prepush` runs
|
|
12
|
+
- `nmr ci` runs what a code-quality workflow runs (build, then strict checks) and not the dependency audit, which belongs to a workflow of its own; `nmr prepush` runs the audit first, then `ci`, so a vulnerability stops the run before the long gate. Neither installs or binds a git hook.
|
|
13
13
|
- A check that already passed on the current working tree skips, printing `⏭️ <command>: passed …`. Any edit, addition, or deletion re-runs it, so a skip is a real pass and needs no confirming: never pass `--no-cache` to get a "real" run, which costs the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`.
|
|
14
14
|
- The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), the `nmr-compile` build (there is no repo-local build script), and the check-result cache's key, its `checkCache` config, and every condition that disables it.
|
|
@@ -46,7 +46,7 @@ export declare function computeCacheKey(options: {
|
|
|
46
46
|
reason: string;
|
|
47
47
|
};
|
|
48
48
|
export declare function encodeTreeSnapshot(snapshot: TreeSnapshot): string;
|
|
49
|
-
export declare function findStaleBuildOutput(
|
|
49
|
+
export declare function findStaleBuildOutput(earlier: Record<string, string>, later: Record<string, string>): string | undefined;
|
|
50
50
|
export declare function formatMisplacedNoCacheWarning(command: string): string;
|
|
51
51
|
export declare function formatSkipLine(command: string, entry: CheckCacheEntry, now: number): string;
|
|
52
52
|
export declare function readBuildOutputState(monorepoRoot: string, config: NmrConfig): Promise<BuildOutputState>;
|
package/dist/esm/check-cache.js
CHANGED
|
@@ -81,9 +81,9 @@ export function computeCacheKey(options) {
|
|
|
81
81
|
export function encodeTreeSnapshot(snapshot) {
|
|
82
82
|
return `${snapshot.hash} ${snapshot.headSha}`;
|
|
83
83
|
}
|
|
84
|
-
export function findStaleBuildOutput(
|
|
85
|
-
const names = [...new Set([...Object.keys(
|
|
86
|
-
return names.find((name) =>
|
|
84
|
+
export function findStaleBuildOutput(earlier, later) {
|
|
85
|
+
const names = [...new Set([...Object.keys(earlier), ...Object.keys(later)])].toSorted();
|
|
86
|
+
return names.find((name) => earlier[name] !== later[name]);
|
|
87
87
|
}
|
|
88
88
|
export function formatMisplacedNoCacheWarning(command) {
|
|
89
89
|
return (`⚠️ --no-cache after the command name is passed to \`${command}\`, not read by nmr. ` +
|
|
@@ -52,7 +52,7 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
|
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
54
|
const command = applyDevBin(resolved.command, config.devBin, monorepoRoot);
|
|
55
|
-
const exitCode = runCommand(command, packageDir);
|
|
55
|
+
const { exitCode } = await runCommand(command, packageDir);
|
|
56
56
|
if (exitCode !== 0) {
|
|
57
57
|
throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
|
|
58
58
|
}
|
|
@@ -38,7 +38,7 @@ export const rootScripts = {
|
|
|
38
38
|
lint: 'eslint --fix .',
|
|
39
39
|
'lint:check': 'eslint .',
|
|
40
40
|
'lint:strict': 'strict-lint',
|
|
41
|
-
prepush: ['
|
|
41
|
+
prepush: ['audit', 'ci'],
|
|
42
42
|
'report-overrides': 'nmr-report-overrides',
|
|
43
43
|
'root:check': ['root:typecheck', 'fmt:check', 'root:lint:check', 'root:test'],
|
|
44
44
|
'root:lint': "eslint --fix --ignore-pattern 'packages/**' .",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface BoundedBufferOptions {
|
|
2
|
+
headLimitBytes?: number;
|
|
3
|
+
tailLimitBytes?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface BoundedBuffer {
|
|
6
|
+
append: (chunk: Buffer) => void;
|
|
7
|
+
toBuffer: () => Buffer;
|
|
8
|
+
}
|
|
9
|
+
export declare function createBoundedBuffer(options?: BoundedBufferOptions): BoundedBuffer;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const DEFAULT_HEAD_LIMIT_BYTES = 1_048_576;
|
|
2
|
+
const DEFAULT_TAIL_LIMIT_BYTES = 1_048_576;
|
|
3
|
+
export function createBoundedBuffer(options = {}) {
|
|
4
|
+
const headLimitBytes = options.headLimitBytes ?? DEFAULT_HEAD_LIMIT_BYTES;
|
|
5
|
+
const tailLimitBytes = options.tailLimitBytes ?? DEFAULT_TAIL_LIMIT_BYTES;
|
|
6
|
+
const headChunks = [];
|
|
7
|
+
let headBytes = 0;
|
|
8
|
+
let tailRing;
|
|
9
|
+
let tailOffset = 0;
|
|
10
|
+
let tailBytes = 0;
|
|
11
|
+
let elidedBytes = 0;
|
|
12
|
+
function append(chunk) {
|
|
13
|
+
let remainder = chunk;
|
|
14
|
+
if (headBytes < headLimitBytes) {
|
|
15
|
+
const takenBytes = Math.min(headLimitBytes - headBytes, remainder.length);
|
|
16
|
+
headChunks.push(remainder.subarray(0, takenBytes));
|
|
17
|
+
headBytes += takenBytes;
|
|
18
|
+
remainder = remainder.subarray(takenBytes);
|
|
19
|
+
}
|
|
20
|
+
if (remainder.length > 0) {
|
|
21
|
+
appendToTail(remainder);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function appendToTail(chunk) {
|
|
25
|
+
const ring = (tailRing ??= Buffer.alloc(tailLimitBytes));
|
|
26
|
+
if (chunk.length >= tailLimitBytes) {
|
|
27
|
+
elidedBytes += tailBytes + chunk.length - tailLimitBytes;
|
|
28
|
+
chunk.copy(ring, 0, chunk.length - tailLimitBytes);
|
|
29
|
+
tailOffset = 0;
|
|
30
|
+
tailBytes = tailLimitBytes;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
elidedBytes += Math.max(0, tailBytes + chunk.length - tailLimitBytes);
|
|
34
|
+
const untilWrapBytes = Math.min(chunk.length, tailLimitBytes - tailOffset);
|
|
35
|
+
chunk.copy(ring, tailOffset, 0, untilWrapBytes);
|
|
36
|
+
if (untilWrapBytes < chunk.length) {
|
|
37
|
+
chunk.copy(ring, 0, untilWrapBytes);
|
|
38
|
+
}
|
|
39
|
+
tailOffset = (tailOffset + chunk.length) % tailLimitBytes;
|
|
40
|
+
tailBytes = Math.min(tailLimitBytes, tailBytes + chunk.length);
|
|
41
|
+
}
|
|
42
|
+
function toBuffer() {
|
|
43
|
+
const headBuffer = Buffer.concat(headChunks);
|
|
44
|
+
if (tailRing === undefined) {
|
|
45
|
+
return headBuffer;
|
|
46
|
+
}
|
|
47
|
+
const tailBuffer = tailBytes < tailLimitBytes
|
|
48
|
+
? tailRing.subarray(0, tailBytes)
|
|
49
|
+
: Buffer.concat([tailRing.subarray(tailOffset), tailRing.subarray(0, tailOffset)]);
|
|
50
|
+
if (elidedBytes === 0) {
|
|
51
|
+
return Buffer.concat([headBuffer, tailBuffer]);
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat([headBuffer, Buffer.from(formatElisionMarker(elidedBytes)), tailBuffer]);
|
|
54
|
+
}
|
|
55
|
+
return { append, toBuffer };
|
|
56
|
+
}
|
|
57
|
+
function formatElisionMarker(byteCount) {
|
|
58
|
+
const unit = byteCount === 1 ? 'byte' : 'bytes';
|
|
59
|
+
return `\n… nmr elided ${byteCount.toLocaleString('en-US')} ${unit} …\n`;
|
|
60
|
+
}
|
package/dist/esm/runCli.js
CHANGED
|
@@ -41,13 +41,13 @@ export async function runCli(options) {
|
|
|
41
41
|
const runOptions = { quiet: parsed.quiet, stdout, stderr, env: childEnv };
|
|
42
42
|
if (parsed.filter) {
|
|
43
43
|
const delegateCmd = `pnpm --filter ${shellQuote(parsed.filter)} exec nmr ${command}${passthrough}`;
|
|
44
|
-
const exitCode = runCommand(delegateCmd, context.monorepoRoot, runOptions);
|
|
44
|
+
const { exitCode } = await runCommand(delegateCmd, context.monorepoRoot, runOptions);
|
|
45
45
|
return { exitCode };
|
|
46
46
|
}
|
|
47
47
|
if (parsed.recursive) {
|
|
48
48
|
const delegateEnv = { ...childEnv, NMR_RUN_IF_PRESENT: '1' };
|
|
49
49
|
const delegateCmd = `pnpm --recursive exec nmr ${command}${passthrough}`;
|
|
50
|
-
const exitCode = runCommand(delegateCmd, context.monorepoRoot, { ...runOptions, env: delegateEnv });
|
|
50
|
+
const { exitCode } = await runCommand(delegateCmd, context.monorepoRoot, { ...runOptions, env: delegateEnv });
|
|
51
51
|
return { exitCode };
|
|
52
52
|
}
|
|
53
53
|
const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
|
|
@@ -133,7 +133,7 @@ function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
|
|
|
133
133
|
return resolved.command !== '' && resolved.command !== ':';
|
|
134
134
|
}
|
|
135
135
|
async function lookUpRecordedPass(options) {
|
|
136
|
-
const { anchorDir, command, env, key, monorepoRoot, stderr } = options;
|
|
136
|
+
const { anchorDir, buildOutput, command, env, key, monorepoRoot, stderr } = options;
|
|
137
137
|
const entry = await readCheckCacheEntry({ anchorDir, command, monorepoRoot });
|
|
138
138
|
if (entry === undefined) {
|
|
139
139
|
writeDebugNote(`running ${command}: no pass recorded for this scope`, env, stderr);
|
|
@@ -143,13 +143,12 @@ async function lookUpRecordedPass(options) {
|
|
|
143
143
|
writeDebugNote(`running ${command}: the tree or its inputs changed since the last pass`, env, stderr);
|
|
144
144
|
return undefined;
|
|
145
145
|
}
|
|
146
|
-
const
|
|
147
|
-
const [missing] = output.missing;
|
|
146
|
+
const [missing] = buildOutput.missing;
|
|
148
147
|
if (missing !== undefined) {
|
|
149
148
|
writeDebugNote(`running ${command}: ${missing} has no build output`, env, stderr);
|
|
150
149
|
return undefined;
|
|
151
150
|
}
|
|
152
|
-
const stale = findStaleBuildOutput(entry.buildDigests,
|
|
151
|
+
const stale = findStaleBuildOutput(entry.buildDigests, buildOutput.digests);
|
|
153
152
|
if (stale !== undefined) {
|
|
154
153
|
writeDebugNote(`running ${command}: ${stale}'s build output came from a different tree`, env, stderr);
|
|
155
154
|
return undefined;
|
|
@@ -255,6 +254,11 @@ async function recordPass(options) {
|
|
|
255
254
|
writeDebugNote(`not recording ${command}: ${missing} has no build output`, env, stderr);
|
|
256
255
|
return;
|
|
257
256
|
}
|
|
257
|
+
const changed = findStaleBuildOutput(options.buildOutputBefore.digests, output.digests);
|
|
258
|
+
if (changed !== undefined) {
|
|
259
|
+
writeDebugNote(`not recording ${command}: ${changed}'s build output changed while it ran`, env, stderr);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
258
262
|
try {
|
|
259
263
|
await writeCheckCacheEntry({
|
|
260
264
|
anchorDir,
|
|
@@ -304,14 +308,16 @@ function resolveCacheKey(options) {
|
|
|
304
308
|
}
|
|
305
309
|
async function runGated(options) {
|
|
306
310
|
const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
|
|
307
|
-
const
|
|
308
|
-
|
|
311
|
+
const gate = key !== undefined && snapshot !== undefined
|
|
312
|
+
? { buildOutputBefore: await readBuildOutputState(monorepoRoot, options.config), key, snapshot }
|
|
313
|
+
: undefined;
|
|
314
|
+
if (gate !== undefined && !options.noCache) {
|
|
309
315
|
const skipLine = await lookUpRecordedPass({
|
|
310
316
|
anchorDir,
|
|
317
|
+
buildOutput: gate.buildOutputBefore,
|
|
311
318
|
command,
|
|
312
|
-
config: options.config,
|
|
313
319
|
env,
|
|
314
|
-
key,
|
|
320
|
+
key: gate.key,
|
|
315
321
|
monorepoRoot,
|
|
316
322
|
stderr,
|
|
317
323
|
});
|
|
@@ -326,18 +332,19 @@ async function runGated(options) {
|
|
|
326
332
|
stdout.write(options.overrideNotice);
|
|
327
333
|
}
|
|
328
334
|
const startedAt = Date.now();
|
|
329
|
-
const exitCode = runCommand(commandString, anchorDir, options.runOptions);
|
|
330
|
-
if (exitCode === 0 &&
|
|
335
|
+
const { exitCode } = await runCommand(commandString, anchorDir, options.runOptions);
|
|
336
|
+
if (exitCode === 0 && gate !== undefined) {
|
|
331
337
|
await recordPass({
|
|
332
338
|
anchorDir,
|
|
339
|
+
buildOutputBefore: gate.buildOutputBefore,
|
|
333
340
|
command,
|
|
334
341
|
commandString,
|
|
335
342
|
config: options.config,
|
|
336
343
|
durationMs: Date.now() - startedAt,
|
|
337
344
|
env,
|
|
338
|
-
key,
|
|
345
|
+
key: gate.key,
|
|
339
346
|
monorepoRoot,
|
|
340
|
-
snapshot,
|
|
347
|
+
snapshot: gate.snapshot,
|
|
341
348
|
stderr,
|
|
342
349
|
});
|
|
343
350
|
}
|
package/dist/esm/runner.d.ts
CHANGED
|
@@ -5,4 +5,21 @@ export interface RunCommandOptions {
|
|
|
5
5
|
stderr?: Writable;
|
|
6
6
|
env?: NodeJS.ProcessEnv;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
type CommandCompletion = {
|
|
9
|
+
outcome: 'exited';
|
|
10
|
+
exitCode: number;
|
|
11
|
+
} | {
|
|
12
|
+
outcome: 'signaled';
|
|
13
|
+
exitCode: number;
|
|
14
|
+
signal: NodeJS.Signals;
|
|
15
|
+
} | {
|
|
16
|
+
outcome: 'spawn-failed';
|
|
17
|
+
exitCode: number;
|
|
18
|
+
error: Error;
|
|
19
|
+
};
|
|
20
|
+
export type RunCommandResult = CommandCompletion & {
|
|
21
|
+
stdout: Buffer | undefined;
|
|
22
|
+
stderr: Buffer | undefined;
|
|
23
|
+
};
|
|
24
|
+
export declare function runCommand(command: string, cwd?: string, options?: RunCommandOptions): Promise<RunCommandResult>;
|
|
25
|
+
export {};
|
package/dist/esm/runner.js
CHANGED
|
@@ -1,41 +1,114 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import process from 'node:process';
|
|
3
4
|
import { reportError } from '@williamthorsen/nmr-core';
|
|
4
|
-
|
|
5
|
+
import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
|
|
6
|
+
const SIGNAL_EXIT_BASE = 128;
|
|
7
|
+
const PIPE_DRAIN_GRACE_MS = 2_000;
|
|
8
|
+
export async function runCommand(command, cwd, options) {
|
|
5
9
|
const quiet = options?.quiet === true;
|
|
6
10
|
const stdout = options?.stdout ?? process.stdout;
|
|
7
11
|
const stderr = options?.stderr ?? process.stderr;
|
|
8
12
|
const env = options?.env ?? process.env;
|
|
9
|
-
const
|
|
10
|
-
const stderrChannel = quiet ? 'pipe' : streamFdOrPipe(stderr);
|
|
11
|
-
const result = spawnSync(command, [], {
|
|
13
|
+
const child = spawn(command, [], {
|
|
12
14
|
shell: true,
|
|
13
|
-
stdio: ['inherit',
|
|
15
|
+
stdio: ['inherit', resolveChannel(stdout, quiet), resolveChannel(stderr, quiet)],
|
|
14
16
|
cwd,
|
|
15
17
|
env,
|
|
16
18
|
});
|
|
17
|
-
|
|
19
|
+
const stdoutCapture = child.stdout === null ? undefined : captureStream(child.stdout, quiet ? undefined : stdout);
|
|
20
|
+
const stderrCapture = child.stderr === null ? undefined : captureStream(child.stderr, quiet ? undefined : stderr);
|
|
21
|
+
const completion = await awaitCompletion(child, () => {
|
|
22
|
+
stdoutCapture?.stopReading();
|
|
23
|
+
stderrCapture?.stopReading();
|
|
24
|
+
});
|
|
25
|
+
stdoutCapture?.detach();
|
|
26
|
+
stderrCapture?.detach();
|
|
27
|
+
const result = {
|
|
28
|
+
...completion,
|
|
29
|
+
stdout: stdoutCapture?.toBuffer(),
|
|
30
|
+
stderr: stderrCapture?.toBuffer(),
|
|
31
|
+
};
|
|
32
|
+
if (result.outcome === 'spawn-failed') {
|
|
18
33
|
reportError(result.error.message, stderr);
|
|
19
|
-
return
|
|
34
|
+
return result;
|
|
20
35
|
}
|
|
21
|
-
if (quiet) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
if (quiet && result.exitCode !== 0) {
|
|
37
|
+
writeBuffer(result.stdout, stderr);
|
|
38
|
+
writeBuffer(result.stderr, stderr);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function captureStream(source, destination) {
|
|
43
|
+
const retained = createBoundedBuffer();
|
|
44
|
+
let isComplete = true;
|
|
45
|
+
function stopReading() {
|
|
46
|
+
if (destination !== undefined)
|
|
47
|
+
source.unpipe(destination);
|
|
48
|
+
source.destroy();
|
|
49
|
+
}
|
|
50
|
+
function abandon() {
|
|
51
|
+
isComplete = false;
|
|
52
|
+
stopReading();
|
|
26
53
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
writeBuffer(result.stderr, stderr);
|
|
54
|
+
source.on('error', abandon);
|
|
55
|
+
if (destination !== undefined) {
|
|
56
|
+
destination.on('error', abandon);
|
|
57
|
+
source.pipe(destination, { end: false });
|
|
32
58
|
}
|
|
33
|
-
|
|
59
|
+
source.on('data', (chunk) => retained.append(chunk));
|
|
60
|
+
return {
|
|
61
|
+
toBuffer: () => (isComplete ? retained.toBuffer() : undefined),
|
|
62
|
+
detach: () => {
|
|
63
|
+
destination?.off('error', abandon);
|
|
64
|
+
},
|
|
65
|
+
stopReading,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function awaitCompletion(child, stopReading) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
let exitCompletion;
|
|
71
|
+
let graceTimer;
|
|
72
|
+
let isSettled = false;
|
|
73
|
+
function settle(completion) {
|
|
74
|
+
if (isSettled)
|
|
75
|
+
return;
|
|
76
|
+
isSettled = true;
|
|
77
|
+
if (graceTimer !== undefined)
|
|
78
|
+
clearTimeout(graceTimer);
|
|
79
|
+
resolve(completion);
|
|
80
|
+
}
|
|
81
|
+
child.on('error', (error) => {
|
|
82
|
+
settle({ outcome: 'spawn-failed', exitCode: 1, error });
|
|
83
|
+
});
|
|
84
|
+
child.on('exit', (code, signal) => {
|
|
85
|
+
const completion = describeExit(code, signal);
|
|
86
|
+
exitCompletion = completion;
|
|
87
|
+
graceTimer = setTimeout(() => {
|
|
88
|
+
stopReading();
|
|
89
|
+
settle(completion);
|
|
90
|
+
}, PIPE_DRAIN_GRACE_MS);
|
|
91
|
+
});
|
|
92
|
+
child.on('close', () => {
|
|
93
|
+
settle(exitCompletion ?? { outcome: 'exited', exitCode: 1 });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function describeExit(code, signal) {
|
|
98
|
+
if (signal !== null) {
|
|
99
|
+
return { outcome: 'signaled', exitCode: SIGNAL_EXIT_BASE + os.constants.signals[signal], signal };
|
|
100
|
+
}
|
|
101
|
+
return { outcome: 'exited', exitCode: code ?? 1 };
|
|
102
|
+
}
|
|
103
|
+
function resolveChannel(stream, quiet) {
|
|
104
|
+
if (quiet)
|
|
105
|
+
return 'pipe';
|
|
106
|
+
return isTerminal(stream) ? stream.fd : 'pipe';
|
|
34
107
|
}
|
|
35
|
-
function
|
|
36
|
-
return '
|
|
108
|
+
function isTerminal(stream) {
|
|
109
|
+
return 'isTTY' in stream && stream.isTTY === true && 'fd' in stream && typeof stream.fd === 'number';
|
|
37
110
|
}
|
|
38
|
-
function writeBuffer(buffer,
|
|
39
|
-
if (buffer.length > 0)
|
|
40
|
-
|
|
111
|
+
function writeBuffer(buffer, destination) {
|
|
112
|
+
if (buffer !== undefined && buffer.length > 0)
|
|
113
|
+
destination.write(buffer);
|
|
41
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"prettier": "3.9.6",
|
|
75
|
-
"readyup": "0.
|
|
75
|
+
"readyup": "0.26.0",
|
|
76
76
|
"tinyglobby": "0.2.17",
|
|
77
77
|
"vitest": "4.1.10"
|
|
78
78
|
},
|