@williamthorsen/nmr 0.28.0 → 0.30.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.
@@ -1,6 +1,6 @@
1
1
  /** @noformat — @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.24.0";
3
+ export const __readyupVersion = "0.26.0";
4
4
 
5
5
 
6
6
  // .readyup/kits/default.ts
@@ -48,7 +48,7 @@ var rootScripts = {
48
48
  audit: ["audit:prod", "audit:dev"],
49
49
  "audit:dev": "pnpm exec v11y --dev",
50
50
  "audit:prod": "pnpm exec v11y --prod",
51
- build: "pnpm --recursive exec nmr build",
51
+ build: ["-R build"],
52
52
  check: ["typecheck", "fmt:check", "lint:check", "test"],
53
53
  "check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage"],
54
54
  // Excludes the audit, which in CI has a workflow of its own.
@@ -61,7 +61,8 @@ var rootScripts = {
61
61
  lint: "eslint --fix .",
62
62
  "lint:check": "eslint .",
63
63
  "lint:strict": "strict-lint",
64
- prepush: ["ci", "audit"],
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/**' .",
@@ -73,13 +74,13 @@ var rootScripts = {
73
74
  "root:test:unit": "vitest --config ./vitest.root.config.ts --project unit",
74
75
  "root:typecheck": "tsgo --noEmit",
75
76
  "root:upgrade": "nmr-taze --include-locked",
76
- test: "nmr root:test && pnpm --recursive exec nmr test",
77
- "test:all": "nmr root:test:all && pnpm --recursive exec nmr test:all",
78
- "test:coverage": "nmr root:test && pnpm --recursive exec nmr test:coverage",
79
- "test:tool": "nmr root:test:tool && pnpm --recursive exec nmr test:tool",
80
- "test:unit": "nmr root:test:unit && pnpm --recursive exec nmr test:unit",
77
+ test: ["root:test", "-R test"],
78
+ "test:all": ["root:test:all", "-R test:all"],
79
+ "test:coverage": ["root:test", "-R test:coverage"],
80
+ "test:tool": ["root:test:tool", "-R test:tool"],
81
+ "test:unit": ["root:test:unit", "-R test:unit"],
81
82
  "test:watch": `vitest ${GATE_PROJECTS} --watch`,
82
- typecheck: "nmr root:typecheck && pnpm --recursive exec nmr typecheck",
83
+ typecheck: ["root:typecheck", "-R typecheck"],
83
84
  // The command must be a string rather than a composite: Passthrough args attach to the chain's last command.
84
85
  upgrade: "nmr-report-overrides && nmr-taze --include-locked --recursive"
85
86
  };
@@ -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.28.0" };
379
+ const picked = { "version": "0.30.0" };
379
380
  if (typeof picked["version"] !== "string") {
380
381
  throw new TypeError("nmr/package.json: 'version' must be a string");
381
382
  }
@@ -7,10 +7,10 @@
7
7
  ],
8
8
  "name": "default",
9
9
  "path": "kits/default.js",
10
- "readyupVersion": "0.24.0",
10
+ "readyupVersion": "0.26.0",
11
11
  "source": "kits/default.ts",
12
12
  "sourceHash": "7b2a223f",
13
- "targetHash": "b7abeb12"
13
+ "targetHash": "bdca19db"
14
14
  }
15
15
  ]
16
16
  }
package/README.md CHANGED
@@ -3,29 +3,25 @@
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.28.0 (2026-08-08)
6
+ ## Release notes — v0.30.0 (2026-08-10)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - Stop reporting absence checks that pass (#610)
10
+ - 🚨 **Breaking:** Resolve a command to a step list and reject a script element carrying shell syntax (#648)
11
11
 
12
- A ReadyUp check that checks for a problem (such as an unwanted file or a stale config reference) now stays silent unless it finds one. The `nmr`, `release-kit`, and `v11y-check` ReadyUp kits previously printed a line for every such check on every run.
12
+ A script defined in `.config/nmr.config.ts` as a list of nmr commands now holds one command name per entry, optionally preceded by nmr's own flags. An entry that carries a quoted argument or any other shell syntax previously ran as written and is now rejected when the config loads. Before upgrading, a repo relying on such an entry should define that command as a script of its own and reference it by name from the list.
13
13
 
14
- - Stop reporting non-issues in the readyup kits (#613)
14
+ - 🚨 **Breaking:** Sequence a command's steps in nmr rather than in a shell (#649)
15
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.
16
+ When a run in quiet mode fails, it now shows only the output of the command that failed instead of everything the run had produced up to that point. Quiet mode can now be set for a whole shell session with the `NMR_COMMAND_VERBOSITY` environment variable, which accepts `full` or `quiet`; `-q` still takes precedence for a single run, and an unrecognized value is reported as an error. Interrupting a run now stops the commands that would have followed it.
17
17
 
18
- ### 🐛 Bug fixes
18
+ Breaking: a `devBin` entry that maps `nmr` itself no longer takes effect inside a command that runs other nmr commands. Mapping any other tool (the documented use) is unaffected.
19
19
 
20
- - Fix prototype-key hole in nmr command runner (#624)
20
+ - 🚨 **Breaking:** Express the seven root defaults as steps (#650)
21
21
 
22
- Fixes an issue where running `nmr` with a command named for property inherited by JS objects (such as `constructor` or `toString`) failed with a type error instead of reporting the command as unknown.
22
+ Running `nmr build`, `nmr test`, `nmr test:all`, `nmr test:coverage`, `nmr test:tool`, `nmr test:unit`, or `nmr typecheck` from a monorepo root now passes over a package that does not define the command, instead of failing there. The first run of `test`, `test:coverage`, `test:tool`, `test:unit`, or `typecheck` after the upgrade no longer skips as already passed; skipping resumes on the run after.
23
23
 
24
- Also fixes all lint issues in the `nmr` package.
25
-
26
- - Publish build output atomically so a failed build cannot destroy it (#631)
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.
24
+ `nmr` now also warns when a command it is about to run invokes `nmr` again through a shell, where a failure reports the whole inner run's output instead of the failing command's. The warning catches only a direct `nmr` call, so a command that reaches `nmr` through another program goes unreported.
29
25
  <!-- /section:release-notes -->
30
26
 
31
27
  ## Installation
@@ -73,9 +69,9 @@ nmr's key feature is that the same command runs different scripts depending on w
73
69
 
74
70
  | Where you run `nmr` | Registry used | Working directory | `nmr test` runs |
75
71
  | --------------------------------- | ----------------- | ----------------- | -------------------------------------------------------------------- |
76
- | Monorepo root | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
72
+ | Monorepo root | Root scripts | Monorepo root | `root:test`, then `-R test` |
77
73
  | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest --project unit --project tool` (that package only) |
78
- | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
74
+ | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | `root:test`, then `-R test` |
79
75
 
80
76
  Relative paths in a script resolve against that working directory, not the invocation directory.
81
77
 
@@ -122,7 +118,13 @@ Script values can be `string` or `string[]`. Arrays expand to chained `nmr` sub-
122
118
  // expands to: nmr lint && nmr fmt
123
119
  ```
124
120
 
125
- Passthrough arguments attach to the final step alone, because the expansion is a single shell chain: `nmr fix --dry-run` runs `nmr lint && nmr fmt --dry-run`.
121
+ Each array element is a command name, optionally preceded by nmr's own flags, split on whitespace — `-R test` runs the element in every package, the way `nmr -R test` does. An element carrying a quoted argument or shell syntax is rejected when the config loads: name that command as a script of its own and reference the name.
122
+
123
+ Passthrough arguments attach to the final step alone: `nmr fix --dry-run` runs `nmr lint`, then `nmr fmt --dry-run`.
124
+
125
+ A string value is spawned through a shell as one command, so an `nmr` invocation inside it is invisible to the process above: a quiet failure surrenders the whole nested subtree rather than the failing step, and a loud run relays the nested tree through a forwarding pipe. nmr writes one line to stderr, in every verbosity, when a command resolves to a step whose first token is `nmr`.
126
+
127
+ That line reports a boundary rather than enforcing a rule, and it is partial: a step reaching nmr through another program, such as `pnpm --recursive exec nmr build`, goes unreported. Only the built-in defaults and `.config/nmr.config.ts` carry arrays, so a `package.json` script embedding nmr has nowhere to move but up a tier.
126
128
 
127
129
  ## Configuration
128
130
 
@@ -192,7 +194,7 @@ For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites
192
194
 
193
195
  ## Pre and post hooks
194
196
 
195
- Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same three tiers as any other script (built-in defaults, then `.config/nmr.config.ts`, then per-package `package.json`). Wrapping is uniform: nested invocations from composite expansion get their own hook treatment. Hook failure short-circuits the chain via shell `&&` semantics, propagating the failing exit code.
197
+ Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same three tiers as any other script (built-in defaults, then `.config/nmr.config.ts`, then per-package `package.json`). Wrapping is uniform: nested invocations from composite expansion get their own hook treatment. A failing hook ends the sequence, and its exit code propagates.
196
198
 
197
199
  Behaviors worth knowing:
198
200
 
@@ -277,7 +279,7 @@ A hit requires all of these to match the run that recorded the pass:
277
279
 
278
280
  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
281
 
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).
282
+ 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
283
 
282
284
  ### Out of contract
283
285
 
@@ -321,6 +323,10 @@ Reach for these in order; the first is almost always the right one.
321
323
 
322
324
  `NMR_TREE_SNAPSHOT` is nmr's own: it carries one observation of the tree from a top-level invocation down to the processes it spawns, so a chain hashes the tree once rather than at every link. nmr trusts an inherited value only while `HEAD` still stands where it did when the observation was taken, which bounds a process that outlives the run that spawned it. That bound does not extend to a tree edited without committing, so **a process that survives its run and later invokes nmr should clear `NMR_TREE_SNAPSHOT`** — a test suite that shells out to `nmr` is the case worth checking.
323
325
 
326
+ `NMR_COMMAND_VERBOSITY` is nmr's own as well, and carries how loudly a run reports the output of the commands it runs. Its values are `full` and `quiet`; any other non-empty value is reported and exits 1, rather than falling back to a mode nobody chose, and an empty value reads as unset. Each process in a chain suppresses the output of the command it runs rather than of everything below it, so a failure still surrenders the failing command's output. nmr's own messages — the skip line, the override notice, and the `""`/`":"` skip notices — keep the suppression `-q` has always given them.
327
+
328
+ `-q` sets it for the run and outranks an inherited value. Both values are spelled out so either direction is expressible: export `NMR_COMMAND_VERBOSITY=quiet` for a quiet shell, and set `full` on one invocation to opt back out.
329
+
324
330
  ### Configuring
325
331
 
326
332
  ```ts
@@ -433,14 +439,16 @@ A run that collects no test files passes. That is what lets `nmr test:tool` fan
433
439
 
434
440
  #### Build and CI
435
441
 
436
- | Command | Runs |
437
- | --------- | --------------------------------- |
438
- | `build` | `pnpm --recursive exec nmr build` |
439
- | `ci` | `build`, `check:strict` |
440
- | `clean` | `nmr-clean` |
441
- | `prepush` | `ci`, `audit` |
442
+ | Command | Runs |
443
+ | --------- | ----------------------- |
444
+ | `build` | `-R build` |
445
+ | `ci` | `build`, `check:strict` |
446
+ | `clean` | `nmr-clean` |
447
+ | `prepush` | `audit`, `ci` |
448
+
449
+ `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
450
 
443
- `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. It composes `ci` rather than restating its stages, so a stage added to `ci` joins the pre-push run too.
451
+ The audit reaches the network. With no network, `ci` is the gate that still runs.
444
452
 
445
453
  Neither is bound to a git hook. `prepush` is named for when you run it, not for a hook nmr installs.
446
454
 
@@ -461,14 +469,14 @@ Neither is bound to a git hook. `prepush` is named for when you run it, not for
461
469
 
462
470
  The same six names the workspace registry carries, so a command means the same thing from the monorepo root as from inside a package. Each fans out to root-level files and every workspace package.
463
471
 
464
- | Command | Runs |
465
- | --------------- | ----------------------------------------------------------- |
466
- | `test` | `nmr root:test && pnpm --recursive exec nmr test` |
467
- | `test:all` | `nmr root:test:all && pnpm --recursive exec nmr test:all` |
468
- | `test:coverage` | `nmr root:test && pnpm --recursive exec nmr test:coverage` |
469
- | `test:tool` | `nmr root:test:tool && pnpm --recursive exec nmr test:tool` |
470
- | `test:unit` | `nmr root:test:unit && pnpm --recursive exec nmr test:unit` |
471
- | `test:watch` | `vitest --project unit --project tool --watch` |
472
+ | Command | Runs |
473
+ | --------------- | ---------------------------------------------- |
474
+ | `test` | `root:test`, `-R test` |
475
+ | `test:all` | `root:test:all`, `-R test:all` |
476
+ | `test:coverage` | `root:test`, `-R test:coverage` |
477
+ | `test:tool` | `root:test:tool`, `-R test:tool` |
478
+ | `test:unit` | `root:test:unit`, `-R test:unit` |
479
+ | `test:watch` | `vitest --project unit --project tool --watch` |
472
480
 
473
481
  `test:coverage` chains `root:test` rather than a `root:test:coverage`, because the root config reports no coverage of its own; packages cover their own sources.
474
482
 
@@ -476,9 +484,9 @@ The same six names the workspace registry carries, so a command means the same t
476
484
 
477
485
  #### Typecheck
478
486
 
479
- | Command | Runs |
480
- | ----------- | ----------------------------------------------------------- |
481
- | `typecheck` | `nmr root:typecheck && pnpm --recursive exec nmr typecheck` |
487
+ | Command | Runs |
488
+ | ----------- | -------------------------------- |
489
+ | `typecheck` | `root:typecheck`, `-R typecheck` |
482
490
 
483
491
  #### Lint
484
492
 
@@ -576,7 +584,7 @@ nmr build # Compile to .js and .d.ts in one pass
576
584
  # From the monorepo root
577
585
  nmr test # Root tests + recursive workspace tests
578
586
  nmr ci # build + check:strict
579
- nmr prepush # ci + audit
587
+ nmr prepush # audit + ci
580
588
 
581
589
  # Target specific packages
582
590
  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 both. Neither installs or binds a git hook.
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(recorded: Record<string, string>, current: Record<string, string>): string | undefined;
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>;
@@ -9,6 +9,7 @@ import { formatDuration, formatSaving } from "./helpers/duration.js";
9
9
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
10
10
  import { getDefaultWorkspaceScripts } from "./resolve-scripts.js";
11
11
  import { buildWorkspaceRegistry, resolveScript } from "./resolver.js";
12
+ import { renderChain } from "./steps.js";
12
13
  import { getWorkspacePackageDirs } from "./workspace.js";
13
14
  export const CURRENT_RUNTIME = {
14
15
  arch: process.arch,
@@ -81,9 +82,9 @@ export function computeCacheKey(options) {
81
82
  export function encodeTreeSnapshot(snapshot) {
82
83
  return `${snapshot.hash} ${snapshot.headSha}`;
83
84
  }
84
- export function findStaleBuildOutput(recorded, current) {
85
- const names = [...new Set([...Object.keys(recorded), ...Object.keys(current)])].toSorted();
86
- return names.find((name) => recorded[name] !== current[name]);
85
+ export function findStaleBuildOutput(earlier, later) {
86
+ const names = [...new Set([...Object.keys(earlier), ...Object.keys(later)])].toSorted();
87
+ return names.find((name) => earlier[name] !== later[name]);
87
88
  }
88
89
  export function formatMisplacedNoCacheWarning(command) {
89
90
  return (`⚠️ --no-cache after the command name is passed to \`${command}\`, not read by nmr. ` +
@@ -196,7 +197,7 @@ function isProbeSubject(packageDir, registry) {
196
197
  return false;
197
198
  }
198
199
  const compile = resolveScript('compile', registry, packageDir, false);
199
- return compile !== undefined && compile.command === BUILT_IN_COMPILE;
200
+ return compile !== undefined && renderChain(compile.steps) === BUILT_IN_COMPILE;
200
201
  }
201
202
  function resolveEntryPath(options) {
202
203
  const anchorDir = path.resolve(options.anchorDir);
@@ -4,7 +4,8 @@ import { removeCheckCache } from "../check-cache.js";
4
4
  import { loadRootConfig } from "../config.js";
5
5
  import { findContainingPackageDir } from "../context.js";
6
6
  import { applyDevBin, buildWorkspaceRegistry, resolveScript } from "../resolver.js";
7
- import { runCommand } from "../runner.js";
7
+ import { resolveChannel, runCommand } from "../runner.js";
8
+ import { renderChain } from "../steps.js";
8
9
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
9
10
  import { resolveBuildCachePath } from "./build-output.js";
10
11
  const CLEAN_ICON = '🧹';
@@ -44,15 +45,18 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
44
45
  const registry = buildWorkspaceRegistry(config);
45
46
  for (const packageDir of workspacePackageDirs) {
46
47
  const resolved = resolveScript(CLEAN_COMMAND, registry, packageDir, false);
47
- if (resolved === undefined || resolved.command === '') {
48
+ const resolvedCommand = resolved === undefined ? undefined : renderChain(resolved.steps);
49
+ if (resolvedCommand === undefined || resolvedCommand === '') {
48
50
  continue;
49
51
  }
50
- if (resolved.command === BUILT_IN_CLEAN) {
52
+ if (resolvedCommand === BUILT_IN_CLEAN) {
51
53
  await cleanPackage(packageDir);
52
54
  continue;
53
55
  }
54
- const command = applyDevBin(resolved.command, config.devBin, monorepoRoot);
55
- const exitCode = runCommand(command, packageDir);
56
+ const command = applyDevBin(resolvedCommand, config.devBin, monorepoRoot);
57
+ const { exitCode } = await runCommand(command, packageDir, {
58
+ channels: { stderr: resolveChannel(process.stderr, false), stdout: resolveChannel(process.stdout, false) },
59
+ });
56
60
  if (exitCode !== 0) {
57
61
  throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
58
62
  }
@@ -10,6 +10,7 @@ import { existsSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
13
+ import { findUnexpressibleToken } from "./steps.js";
13
14
  const CONFIG_FILENAME = 'nmr.config.ts';
14
15
  const CONFIG_DIR = '.config';
15
16
  const CONFIG_TIERS = {
@@ -45,10 +46,26 @@ function validateScriptField(value, fieldName, configPath) {
45
46
  if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
46
47
  return undefined;
47
48
  }
48
- if (!isScriptRecord(value[fieldName])) {
49
+ const scripts = value[fieldName];
50
+ if (!isScriptRecord(scripts)) {
49
51
  throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | string[]>`);
50
52
  }
51
- return value[fieldName];
53
+ assertExpressibleElements(scripts, fieldName, configPath);
54
+ return scripts;
55
+ }
56
+ function assertExpressibleElements(scripts, fieldName, configPath) {
57
+ for (const [command, script] of Object.entries(scripts)) {
58
+ if (typeof script === 'string')
59
+ continue;
60
+ for (const element of script) {
61
+ const token = findUnexpressibleToken(element);
62
+ if (token === undefined)
63
+ continue;
64
+ throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}.${command}\` element \`${element}\` carries ` +
65
+ `\`${token}\`, which is neither a command name nor an nmr flag. Name it as a script of its own and ` +
66
+ `reference that name here.`);
67
+ }
68
+ }
52
69
  }
53
70
  function isStringArray(value) {
54
71
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
@@ -26,7 +26,7 @@ export const rootScripts = {
26
26
  audit: ['audit:prod', 'audit:dev'],
27
27
  'audit:dev': 'pnpm exec v11y --dev',
28
28
  'audit:prod': 'pnpm exec v11y --prod',
29
- build: 'pnpm --recursive exec nmr build',
29
+ build: ['-R build'],
30
30
  check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
31
31
  'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
32
32
  ci: ['build', 'check:strict'],
@@ -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: ['ci', 'audit'],
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/**' .",
@@ -50,12 +50,12 @@ export const rootScripts = {
50
50
  'root:test:unit': 'vitest --config ./vitest.root.config.ts --project unit',
51
51
  'root:typecheck': 'tsgo --noEmit',
52
52
  'root:upgrade': 'nmr-taze --include-locked',
53
- test: 'nmr root:test && pnpm --recursive exec nmr test',
54
- 'test:all': 'nmr root:test:all && pnpm --recursive exec nmr test:all',
55
- 'test:coverage': 'nmr root:test && pnpm --recursive exec nmr test:coverage',
56
- 'test:tool': 'nmr root:test:tool && pnpm --recursive exec nmr test:tool',
57
- 'test:unit': 'nmr root:test:unit && pnpm --recursive exec nmr test:unit',
53
+ test: ['root:test', '-R test'],
54
+ 'test:all': ['root:test:all', '-R test:all'],
55
+ 'test:coverage': ['root:test', '-R test:coverage'],
56
+ 'test:tool': ['root:test:tool', '-R test:tool'],
57
+ 'test:unit': ['root:test:unit', '-R test:unit'],
58
58
  'test:watch': `vitest ${GATE_PROJECTS} --watch`,
59
- typecheck: 'nmr root:typecheck && pnpm --recursive exec nmr typecheck',
59
+ typecheck: ['root:typecheck', '-R typecheck'],
60
60
  upgrade: 'nmr-report-overrides && nmr-taze --include-locked --recursive',
61
61
  };
@@ -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
+ }
@@ -1,11 +1,13 @@
1
1
  import type { ScriptRegistry, ScriptValue } from './resolve-scripts.js';
2
+ import type { Step } from './steps.js';
2
3
  import type { NmrConfig } from './types.js';
3
4
  export declare function applyDevBin(command: string, devBin: Record<string, string> | undefined, monorepoRoot: string): string;
5
+ export declare function applyDevBinToSteps(steps: readonly Step[], devBin: Record<string, string> | undefined, monorepoRoot: string): readonly Step[];
4
6
  export interface ResolvedScript {
5
- command: string;
6
7
  source: 'default' | 'package';
8
+ steps: readonly Step[];
7
9
  }
8
- export declare function expandScript(script: ScriptValue, workspaceRoot: boolean): string;
10
+ export declare function expandScript(script: ScriptValue, workspaceRoot: boolean): readonly Step[];
9
11
  export declare function describeScript(script: ScriptValue): string;
10
12
  export declare function readPackageJsonScripts(packageDir: string): Record<string, string> | undefined;
11
13
  export declare function buildWorkspaceRegistry(config: NmrConfig): ScriptRegistry;
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isObject } from "./helpers/type-guards.js";
4
4
  import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
5
+ import { composeNmrStep } from "./steps.js";
5
6
  export function applyDevBin(command, devBin, monorepoRoot) {
6
7
  if (!devBin) {
7
8
  return command;
@@ -16,6 +17,12 @@ export function applyDevBin(command, devBin, monorepoRoot) {
16
17
  const resolvedReplacement = resolveReplacementPaths(replacement, monorepoRoot);
17
18
  return resolvedReplacement + rest;
18
19
  }
20
+ export function applyDevBinToSteps(steps, devBin, monorepoRoot) {
21
+ if (!devBin) {
22
+ return steps;
23
+ }
24
+ return steps.map((step) => step.kind === 'opaque' ? { kind: 'opaque', command: applyDevBin(step.command, devBin, monorepoRoot) } : step);
25
+ }
19
26
  function resolveReplacementPaths(replacement, monorepoRoot) {
20
27
  const tokens = replacement.split(' ');
21
28
  return tokens
@@ -32,10 +39,9 @@ function resolveReplacementPaths(replacement, monorepoRoot) {
32
39
  }
33
40
  export function expandScript(script, workspaceRoot) {
34
41
  if (typeof script === 'string') {
35
- return script;
42
+ return [{ kind: 'opaque', command: script }];
36
43
  }
37
- const flag = workspaceRoot ? '-w ' : '';
38
- return script.map((s) => `nmr ${flag}${s}`).join(' && ');
44
+ return script.map((element) => composeNmrStep(element, workspaceRoot));
39
45
  }
40
46
  export function describeScript(script) {
41
47
  return typeof script === 'string' ? script : `[${script.join(', ')}]`;
@@ -85,7 +91,7 @@ export function resolveScript(commandName, registry, packageDir, workspaceRoot)
85
91
  if (pkgScripts && Object.hasOwn(pkgScripts, commandName)) {
86
92
  const override = pkgScripts[commandName];
87
93
  if (override !== undefined && !isSelfReferential(override, commandName)) {
88
- return { command: override, source: 'package' };
94
+ return { source: 'package', steps: [{ kind: 'opaque', command: override }] };
89
95
  }
90
96
  }
91
97
  }
@@ -96,5 +102,5 @@ export function resolveScript(commandName, registry, packageDir, workspaceRoot)
96
102
  if (registryEntry === undefined) {
97
103
  return undefined;
98
104
  }
99
- return { command: expandScript(registryEntry, workspaceRoot), source: 'default' };
105
+ return { source: 'default', steps: expandScript(registryEntry, workspaceRoot) };
100
106
  }
@@ -1,4 +1,5 @@
1
1
  import type { Writable } from 'node:stream';
2
+ export declare const RUN_IF_PRESENT_ENV_VAR = "NMR_RUN_IF_PRESENT";
2
3
  export interface RunCliOptions {
3
4
  args: string[];
4
5
  cwd: string;
@@ -4,9 +4,12 @@ import { computeCacheKey, CURRENT_RUNTIME, encodeTreeSnapshot, findStaleBuildOut
4
4
  import { resolveContext } from "./context.js";
5
5
  import { generateHelp } from "./help.js";
6
6
  import { isHookName } from "./helpers/hook-name.js";
7
- import { applyDevBin, buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
8
- import { runCommand } from "./runner.js";
7
+ import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
8
+ import { runSteps } from "./runner.js";
9
+ import { composeNmrStep, findShelledNmrStep, renderChain } from "./steps.js";
10
+ import { COMMAND_VERBOSITY_ENV_VAR, resolveVerbosity } from "./verbosity.js";
9
11
  const VERSION = readPackageVersion(import.meta.url);
12
+ export const RUN_IF_PRESENT_ENV_VAR = 'NMR_RUN_IF_PRESENT';
10
13
  export async function runCli(options) {
11
14
  const { args, cwd, env, stdout, stderr } = options;
12
15
  const parseResult = parseArgs(args);
@@ -15,10 +18,16 @@ export async function runCli(options) {
15
18
  return { exitCode: 1 };
16
19
  }
17
20
  const { parsed } = parseResult;
21
+ const verbosity = resolveVerbosity(env, parsed.quiet);
22
+ if (!verbosity.ok) {
23
+ reportError(verbosity.error, stderr);
24
+ return { exitCode: 1 };
25
+ }
18
26
  if (parsed.version) {
19
27
  stdout.write(`${VERSION}\n`);
20
28
  return { exitCode: 0 };
21
29
  }
30
+ const quiet = verbosity.verbosity === 'quiet';
22
31
  const context = await resolveContext(cwd);
23
32
  const useRoot = parsed.workspaceRoot || context.isRoot;
24
33
  const anchorDir = useRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
@@ -27,7 +36,6 @@ export async function runCli(options) {
27
36
  return { exitCode: 0 };
28
37
  }
29
38
  const { command } = parsed;
30
- const passthrough = parsed.passthrough.length > 0 ? ' ' + parsed.passthrough.map(shellQuote).join(' ') : '';
31
39
  const snapshot = openGate({
32
40
  command,
33
41
  config: context.config,
@@ -37,38 +45,48 @@ export async function runCli(options) {
37
45
  stderr,
38
46
  });
39
47
  const noCache = parsed.noCache || env[NO_CACHE_ENV_VAR] === '1';
40
- const childEnv = buildChildEnv(env, snapshot, noCache);
41
- const runOptions = { quiet: parsed.quiet, stdout, stderr, env: childEnv };
48
+ const childEnv = buildChildEnv(env, snapshot, noCache, verbosity.verbosity);
49
+ const runOptions = {
50
+ quiet,
51
+ stdout,
52
+ stderr,
53
+ env: childEnv,
54
+ };
42
55
  if (parsed.filter) {
43
- const delegateCmd = `pnpm --filter ${shellQuote(parsed.filter)} exec nmr ${command}${passthrough}`;
44
- const exitCode = runCommand(delegateCmd, context.monorepoRoot, runOptions);
45
- return { exitCode };
56
+ const delegate = composeDelegate(['--filter', parsed.filter], command, parsed.passthrough);
57
+ return runSteps([delegate], context.monorepoRoot, runOptions);
46
58
  }
47
59
  if (parsed.recursive) {
48
- const delegateEnv = { ...childEnv, NMR_RUN_IF_PRESENT: '1' };
49
- const delegateCmd = `pnpm --recursive exec nmr ${command}${passthrough}`;
50
- const exitCode = runCommand(delegateCmd, context.monorepoRoot, { ...runOptions, env: delegateEnv });
51
- return { exitCode };
60
+ const delegateEnv = { ...childEnv, [RUN_IF_PRESENT_ENV_VAR]: '1' };
61
+ const delegate = composeDelegate(['--recursive'], command, parsed.passthrough);
62
+ return runSteps([delegate], context.monorepoRoot, { ...runOptions, env: delegateEnv });
52
63
  }
53
64
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
54
65
  const resolved = resolveScript(command, registry, anchorDir, parsed.workspaceRoot);
55
66
  if (!resolved) {
56
- if (env['NMR_RUN_IF_PRESENT'] === '1') {
67
+ if (env[RUN_IF_PRESENT_ENV_VAR] === '1') {
57
68
  return { exitCode: 0 };
58
69
  }
59
70
  reportError(`Unknown command: ${command}`, stderr);
60
71
  return { exitCode: 1 };
61
72
  }
62
- const skipExitCode = handleSkipMessage(resolved.command, anchorDir, parsed.quiet, stdout);
73
+ const resolvedCommand = renderChain(resolved.steps);
74
+ const skipExitCode = handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout);
63
75
  if (skipExitCode !== undefined) {
64
76
  return { exitCode: skipExitCode };
65
77
  }
66
- const substitutedCommand = applyDevBin(resolved.command, context.config.devBin, context.monorepoRoot);
67
- const mainCommand = substitutedCommand + passthrough;
78
+ const substitutedSteps = applyDevBinToSteps(resolved.steps, context.config.devBin, context.monorepoRoot);
79
+ const substitutedCommand = renderChain(substitutedSteps);
80
+ const mainSteps = appendPassthrough(substitutedSteps, parsed.passthrough);
68
81
  const isHookInvocation = isHookName(command);
69
- const fullCommand = isHookInvocation
70
- ? mainCommand
71
- : wrapWithHooks(command, mainCommand, registry, anchorDir, parsed.workspaceRoot);
82
+ const fullSteps = isHookInvocation
83
+ ? mainSteps
84
+ : wrapWithHooks(command, mainSteps, registry, anchorDir, parsed.workspaceRoot);
85
+ const fullCommand = renderChain(fullSteps);
86
+ const shelledStep = findShelledNmrStep(fullSteps);
87
+ if (shelledStep !== undefined) {
88
+ stderr.write(`${formatShelledNmrWarning(command, shelledStep)}\n`);
89
+ }
72
90
  const key = resolveCacheKey({
73
91
  anchorDir,
74
92
  command,
@@ -77,19 +95,20 @@ export async function runCli(options) {
77
95
  monorepoRoot: context.monorepoRoot,
78
96
  snapshot,
79
97
  stderr,
80
- substitution: substitutedCommand === resolved.command ? undefined : substitutedCommand,
98
+ substitution: substitutedCommand === resolvedCommand ? undefined : substitutedCommand,
81
99
  });
82
100
  const exitCode = await runGated({
83
101
  anchorDir,
84
102
  command,
85
103
  commandString: fullCommand,
86
104
  config: context.config,
105
+ steps: fullSteps,
87
106
  env,
88
107
  key,
89
108
  monorepoRoot: context.monorepoRoot,
90
109
  noCache,
91
- overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, parsed.quiet),
92
- quiet: parsed.quiet,
110
+ overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, quiet),
111
+ quiet,
93
112
  runOptions,
94
113
  snapshot,
95
114
  stderr,
@@ -97,19 +116,37 @@ export async function runCli(options) {
97
116
  });
98
117
  return { exitCode };
99
118
  }
100
- function buildChildEnv(env, snapshot, noCache) {
119
+ function buildChildEnv(env, snapshot, noCache, verbosity) {
101
120
  return {
102
121
  ...env,
103
122
  ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
104
123
  ...(noCache && { [NO_CACHE_ENV_VAR]: '1' }),
124
+ [COMMAND_VERBOSITY_ENV_VAR]: verbosity,
105
125
  };
106
126
  }
127
+ function appendPassthrough(steps, passthrough) {
128
+ const last = steps.at(-1);
129
+ if (passthrough.length === 0 || last === undefined) {
130
+ return steps;
131
+ }
132
+ const bound = last.kind === 'structural'
133
+ ? { kind: 'structural', argv: [...last.argv, ...passthrough] }
134
+ : { kind: 'opaque', command: `${last.command} ${passthrough.map(shellQuote).join(' ')}` };
135
+ return [...steps.slice(0, -1), bound];
136
+ }
137
+ function composeDelegate(scope, command, passthrough) {
138
+ return { kind: 'structural', argv: ['pnpm', ...scope, 'exec', 'nmr', command, ...passthrough] };
139
+ }
107
140
  function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
108
141
  const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
109
142
  if (quiet || resolved.source !== 'package' || registryEntry === undefined) {
110
143
  return undefined;
111
144
  }
112
- return `📦 ${path.basename(anchorDir)}: Using override script: ${resolved.command}\n`;
145
+ return `📦 ${path.basename(anchorDir)}: Using override script: ${renderChain(resolved.steps)}\n`;
146
+ }
147
+ function formatShelledNmrWarning(command, step) {
148
+ return (`⚠️ ${command}: \`${step}\` runs nmr behind a shell, so nmr handles its output as a tool's rather than a ` +
149
+ `nested run's. A step list in \`.config/nmr.config.ts\` avoids it.`);
113
150
  }
114
151
  function handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout) {
115
152
  if (resolvedCommand === '') {
@@ -130,10 +167,11 @@ function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
130
167
  const resolved = resolveScript(hookName, registry, anchorDir, workspaceRoot);
131
168
  if (!resolved)
132
169
  return false;
133
- return resolved.command !== '' && resolved.command !== ':';
170
+ const chain = renderChain(resolved.steps);
171
+ return chain !== '' && chain !== ':';
134
172
  }
135
173
  async function lookUpRecordedPass(options) {
136
- const { anchorDir, command, env, key, monorepoRoot, stderr } = options;
174
+ const { anchorDir, buildOutput, command, env, key, monorepoRoot, stderr } = options;
137
175
  const entry = await readCheckCacheEntry({ anchorDir, command, monorepoRoot });
138
176
  if (entry === undefined) {
139
177
  writeDebugNote(`running ${command}: no pass recorded for this scope`, env, stderr);
@@ -143,13 +181,12 @@ async function lookUpRecordedPass(options) {
143
181
  writeDebugNote(`running ${command}: the tree or its inputs changed since the last pass`, env, stderr);
144
182
  return undefined;
145
183
  }
146
- const output = await readBuildOutputState(monorepoRoot, options.config);
147
- const [missing] = output.missing;
184
+ const [missing] = buildOutput.missing;
148
185
  if (missing !== undefined) {
149
186
  writeDebugNote(`running ${command}: ${missing} has no build output`, env, stderr);
150
187
  return undefined;
151
188
  }
152
- const stale = findStaleBuildOutput(entry.buildDigests, output.digests);
189
+ const stale = findStaleBuildOutput(entry.buildDigests, buildOutput.digests);
153
190
  if (stale !== undefined) {
154
191
  writeDebugNote(`running ${command}: ${stale}'s build output came from a different tree`, env, stderr);
155
192
  return undefined;
@@ -255,6 +292,11 @@ async function recordPass(options) {
255
292
  writeDebugNote(`not recording ${command}: ${missing} has no build output`, env, stderr);
256
293
  return;
257
294
  }
295
+ const changed = findStaleBuildOutput(options.buildOutputBefore.digests, output.digests);
296
+ if (changed !== undefined) {
297
+ writeDebugNote(`not recording ${command}: ${changed}'s build output changed while it ran`, env, stderr);
298
+ return;
299
+ }
258
300
  try {
259
301
  await writeCheckCacheEntry({
260
302
  anchorDir,
@@ -304,14 +346,16 @@ function resolveCacheKey(options) {
304
346
  }
305
347
  async function runGated(options) {
306
348
  const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
307
- const gated = key !== undefined && snapshot !== undefined;
308
- if (gated && !options.noCache) {
349
+ const gate = key !== undefined && snapshot !== undefined
350
+ ? { buildOutputBefore: await readBuildOutputState(monorepoRoot, options.config), key, snapshot }
351
+ : undefined;
352
+ if (gate !== undefined && !options.noCache) {
309
353
  const skipLine = await lookUpRecordedPass({
310
354
  anchorDir,
355
+ buildOutput: gate.buildOutputBefore,
311
356
  command,
312
- config: options.config,
313
357
  env,
314
- key,
358
+ key: gate.key,
315
359
  monorepoRoot,
316
360
  stderr,
317
361
  });
@@ -326,18 +370,19 @@ async function runGated(options) {
326
370
  stdout.write(options.overrideNotice);
327
371
  }
328
372
  const startedAt = Date.now();
329
- const exitCode = runCommand(commandString, anchorDir, options.runOptions);
330
- if (exitCode === 0 && gated) {
373
+ const { exitCode } = await runSteps(options.steps, anchorDir, options.runOptions);
374
+ if (exitCode === 0 && gate !== undefined) {
331
375
  await recordPass({
332
376
  anchorDir,
377
+ buildOutputBefore: gate.buildOutputBefore,
333
378
  command,
334
379
  commandString,
335
380
  config: options.config,
336
381
  durationMs: Date.now() - startedAt,
337
382
  env,
338
- key,
383
+ key: gate.key,
339
384
  monorepoRoot,
340
- snapshot,
385
+ snapshot: gate.snapshot,
341
386
  stderr,
342
387
  });
343
388
  }
@@ -346,15 +391,14 @@ async function runGated(options) {
346
391
  function shellQuote(arg) {
347
392
  return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
348
393
  }
349
- function wrapWithHooks(command, mainCommand, registry, anchorDir, workspaceRoot) {
350
- const segments = [];
351
- const flag = workspaceRoot ? '-w ' : '';
394
+ function wrapWithHooks(command, mainSteps, registry, anchorDir, workspaceRoot) {
395
+ const steps = [];
352
396
  if (hasRunnableHook(`${command}:pre`, registry, anchorDir, workspaceRoot)) {
353
- segments.push(`nmr ${flag}${command}:pre`);
397
+ steps.push(composeNmrStep(`${command}:pre`, workspaceRoot));
354
398
  }
355
- segments.push(mainCommand);
399
+ steps.push(...mainSteps);
356
400
  if (hasRunnableHook(`${command}:post`, registry, anchorDir, workspaceRoot)) {
357
- segments.push(`nmr ${flag}${command}:post`);
401
+ steps.push(composeNmrStep(`${command}:post`, workspaceRoot));
358
402
  }
359
- return segments.join(' && ');
403
+ return steps;
360
404
  }
@@ -1,8 +1,38 @@
1
1
  import type { Writable } from 'node:stream';
2
+ import type { Step } from './steps.js';
3
+ export type OutputChannel = number | 'pipe';
4
+ export interface OutputChannels {
5
+ stderr: OutputChannel;
6
+ stdout: OutputChannel;
7
+ }
2
8
  export interface RunCommandOptions {
9
+ channels: OutputChannels;
3
10
  quiet?: boolean;
4
11
  stdout?: Writable;
5
12
  stderr?: Writable;
6
13
  env?: NodeJS.ProcessEnv;
7
14
  }
8
- export declare function runCommand(command: string, cwd?: string, options?: RunCommandOptions): number;
15
+ export type RunStepsOptions = Omit<RunCommandOptions, 'channels'>;
16
+ type CommandCompletion = {
17
+ outcome: 'exited';
18
+ exitCode: number;
19
+ } | {
20
+ outcome: 'signaled';
21
+ exitCode: number;
22
+ signal: NodeJS.Signals;
23
+ } | {
24
+ outcome: 'spawn-failed';
25
+ exitCode: number;
26
+ error: Error;
27
+ };
28
+ export type RunCommandResult = CommandCompletion & {
29
+ stdout: Buffer | undefined;
30
+ stderr: Buffer | undefined;
31
+ };
32
+ export declare function resolveChannel(stream: Writable, quiet: boolean): OutputChannel;
33
+ export declare function resolveInheritedChannel(stream: Writable): OutputChannel;
34
+ export declare function runSteps(steps: readonly Step[], cwd: string | undefined, options: RunStepsOptions): Promise<{
35
+ exitCode: number;
36
+ }>;
37
+ export declare function runCommand(command: string, cwd: string | undefined, options: RunCommandOptions): Promise<RunCommandResult>;
38
+ export {};
@@ -1,41 +1,153 @@
1
- import { spawnSync } from 'node:child_process';
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
- export function runCommand(command, cwd, options) {
5
- const quiet = options?.quiet === true;
6
- const stdout = options?.stdout ?? process.stdout;
7
- const stderr = options?.stderr ?? process.stderr;
8
- const env = options?.env ?? process.env;
9
- const stdoutChannel = quiet ? 'pipe' : streamFdOrPipe(stdout);
10
- const stderrChannel = quiet ? 'pipe' : streamFdOrPipe(stderr);
11
- const result = spawnSync(command, [], {
12
- shell: true,
13
- stdio: ['inherit', stdoutChannel, stderrChannel],
5
+ import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
6
+ const SIGNAL_EXIT_BASE = 128;
7
+ const PIPE_DRAIN_GRACE_MS = 2_000;
8
+ export function resolveChannel(stream, quiet) {
9
+ if (quiet)
10
+ return 'pipe';
11
+ return isTerminal(stream) ? stream.fd : 'pipe';
12
+ }
13
+ export function resolveInheritedChannel(stream) {
14
+ return hasDescriptor(stream) ? stream.fd : 'pipe';
15
+ }
16
+ export async function runSteps(steps, cwd, options) {
17
+ const resolved = {
18
+ ...options,
19
+ quiet: options.quiet === true,
20
+ stdout: options.stdout ?? process.stdout,
21
+ stderr: options.stderr ?? process.stderr,
22
+ };
23
+ for (const step of steps) {
24
+ const { exitCode } = await runStep(step, cwd, resolved);
25
+ if (exitCode !== 0) {
26
+ return { exitCode };
27
+ }
28
+ }
29
+ return { exitCode: 0 };
30
+ }
31
+ export async function runCommand(command, cwd, options) {
32
+ return runSpawned({ args: [], file: command, useShell: true }, cwd, options);
33
+ }
34
+ async function runSpawned({ args, file, useShell }, cwd, options) {
35
+ const quiet = options.quiet === true;
36
+ const stdout = options.stdout ?? process.stdout;
37
+ const stderr = options.stderr ?? process.stderr;
38
+ const env = options.env ?? process.env;
39
+ const child = spawn(file, args, {
40
+ shell: useShell,
41
+ stdio: ['inherit', options.channels.stdout, options.channels.stderr],
14
42
  cwd,
15
43
  env,
16
44
  });
17
- if (result.error) {
45
+ const stdoutCapture = child.stdout === null ? undefined : captureStream(child.stdout, quiet ? undefined : stdout);
46
+ const stderrCapture = child.stderr === null ? undefined : captureStream(child.stderr, quiet ? undefined : stderr);
47
+ const completion = await awaitCompletion(child, () => {
48
+ stdoutCapture?.stopReading();
49
+ stderrCapture?.stopReading();
50
+ });
51
+ stdoutCapture?.detach();
52
+ stderrCapture?.detach();
53
+ const result = {
54
+ ...completion,
55
+ stdout: stdoutCapture?.toBuffer(),
56
+ stderr: stderrCapture?.toBuffer(),
57
+ };
58
+ if (result.outcome === 'spawn-failed') {
18
59
  reportError(result.error.message, stderr);
19
- return 1;
60
+ return result;
20
61
  }
21
- if (quiet) {
22
- if (result.status !== 0) {
23
- writeBuffer(result.stdout, stderr);
24
- writeBuffer(result.stderr, stderr);
25
- }
62
+ if (quiet && result.exitCode !== 0) {
63
+ writeBuffer(result.stdout, stderr);
64
+ writeBuffer(result.stderr, stderr);
65
+ }
66
+ return result;
67
+ }
68
+ function runStep(step, cwd, options) {
69
+ const { quiet, stderr, stdout } = options;
70
+ if (step.kind === 'opaque') {
71
+ return runSpawned({ args: [], file: step.command, useShell: true }, cwd, {
72
+ ...options,
73
+ channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
74
+ });
26
75
  }
27
- else {
28
- if (stdoutChannel === 'pipe')
29
- writeBuffer(result.stdout, stdout);
30
- if (stderrChannel === 'pipe')
31
- writeBuffer(result.stderr, stderr);
76
+ const [file, ...args] = step.argv;
77
+ return runSpawned({ args, file, useShell: false }, cwd, {
78
+ ...options,
79
+ quiet: false,
80
+ channels: { stderr: resolveInheritedChannel(stderr), stdout: resolveInheritedChannel(stdout) },
81
+ });
82
+ }
83
+ function captureStream(source, destination) {
84
+ const retained = createBoundedBuffer();
85
+ let isComplete = true;
86
+ function stopReading() {
87
+ if (destination !== undefined)
88
+ source.unpipe(destination);
89
+ source.destroy();
90
+ }
91
+ function abandon() {
92
+ isComplete = false;
93
+ stopReading();
94
+ }
95
+ source.on('error', abandon);
96
+ if (destination !== undefined) {
97
+ destination.on('error', abandon);
98
+ source.pipe(destination, { end: false });
99
+ }
100
+ source.on('data', (chunk) => retained.append(chunk));
101
+ return {
102
+ toBuffer: () => (isComplete ? retained.toBuffer() : undefined),
103
+ detach: () => {
104
+ destination?.off('error', abandon);
105
+ },
106
+ stopReading,
107
+ };
108
+ }
109
+ function awaitCompletion(child, stopReading) {
110
+ return new Promise((resolve) => {
111
+ let exitCompletion;
112
+ let graceTimer;
113
+ let isSettled = false;
114
+ function settle(completion) {
115
+ if (isSettled)
116
+ return;
117
+ isSettled = true;
118
+ if (graceTimer !== undefined)
119
+ clearTimeout(graceTimer);
120
+ resolve(completion);
121
+ }
122
+ child.on('error', (error) => {
123
+ settle({ outcome: 'spawn-failed', exitCode: 1, error });
124
+ });
125
+ child.on('exit', (code, signal) => {
126
+ const completion = describeExit(code, signal);
127
+ exitCompletion = completion;
128
+ graceTimer = setTimeout(() => {
129
+ stopReading();
130
+ settle(completion);
131
+ }, PIPE_DRAIN_GRACE_MS);
132
+ });
133
+ child.on('close', () => {
134
+ settle(exitCompletion ?? { outcome: 'exited', exitCode: 1 });
135
+ });
136
+ });
137
+ }
138
+ function describeExit(code, signal) {
139
+ if (signal !== null) {
140
+ return { outcome: 'signaled', exitCode: SIGNAL_EXIT_BASE + os.constants.signals[signal], signal };
32
141
  }
33
- return result.status ?? 1;
142
+ return { outcome: 'exited', exitCode: code ?? 1 };
143
+ }
144
+ function hasDescriptor(stream) {
145
+ return 'fd' in stream && typeof stream.fd === 'number';
34
146
  }
35
- function streamFdOrPipe(stream) {
36
- return 'fd' in stream && typeof stream.fd === 'number' ? stream.fd : 'pipe';
147
+ function isTerminal(stream) {
148
+ return 'isTTY' in stream && stream.isTTY === true && hasDescriptor(stream);
37
149
  }
38
- function writeBuffer(buffer, dest) {
39
- if (buffer.length > 0)
40
- dest.write(buffer);
150
+ function writeBuffer(buffer, destination) {
151
+ if (buffer !== undefined && buffer.length > 0)
152
+ destination.write(buffer);
41
153
  }
@@ -0,0 +1,11 @@
1
+ export type Step = {
2
+ kind: 'opaque';
3
+ command: string;
4
+ } | {
5
+ kind: 'structural';
6
+ argv: readonly [string, ...(readonly string[])];
7
+ };
8
+ export declare function composeNmrStep(element: string, workspaceRoot: boolean): Step;
9
+ export declare function findShelledNmrStep(steps: readonly Step[]): string | undefined;
10
+ export declare function findUnexpressibleToken(element: string): string | undefined;
11
+ export declare function renderChain(steps: readonly Step[]): string;
@@ -0,0 +1,31 @@
1
+ const SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
2
+ export function composeNmrStep(element, workspaceRoot) {
3
+ const flags = workspaceRoot ? ['-w'] : [];
4
+ return { kind: 'structural', argv: ['nmr', ...flags, ...tokenize(element)] };
5
+ }
6
+ export function findShelledNmrStep(steps) {
7
+ for (const step of steps) {
8
+ if (step.kind === 'opaque' && tokenize(step.command)[0] === 'nmr') {
9
+ return step.command;
10
+ }
11
+ }
12
+ return undefined;
13
+ }
14
+ export function findUnexpressibleToken(element) {
15
+ return tokenize(element).find((token) => !SHELL_SAFE_TOKEN.test(token));
16
+ }
17
+ export function renderChain(steps) {
18
+ return steps.map(renderStep).join(' && ');
19
+ }
20
+ function quoteToken(token) {
21
+ if (SHELL_SAFE_TOKEN.test(token)) {
22
+ return token;
23
+ }
24
+ return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
25
+ }
26
+ function renderStep(step) {
27
+ return step.kind === 'opaque' ? step.command : step.argv.map(quoteToken).join(' ');
28
+ }
29
+ function tokenize(element) {
30
+ return element.split(/\s+/).filter((token) => token.length > 0);
31
+ }
@@ -0,0 +1,11 @@
1
+ export declare const COMMAND_VERBOSITY_ENV_VAR = "NMR_COMMAND_VERBOSITY";
2
+ export declare const COMMAND_VERBOSITIES: readonly ["full", "quiet"];
3
+ export type CommandVerbosity = (typeof COMMAND_VERBOSITIES)[number];
4
+ export type VerbosityResolution = {
5
+ ok: true;
6
+ verbosity: CommandVerbosity;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ };
11
+ export declare function resolveVerbosity(env: NodeJS.ProcessEnv, quietFlag: boolean): VerbosityResolution;
@@ -0,0 +1,17 @@
1
+ export const COMMAND_VERBOSITY_ENV_VAR = 'NMR_COMMAND_VERBOSITY';
2
+ export const COMMAND_VERBOSITIES = ['full', 'quiet'];
3
+ export function resolveVerbosity(env, quietFlag) {
4
+ const raw = env[COMMAND_VERBOSITY_ENV_VAR];
5
+ const inherited = raw === undefined || raw === '' ? 'full' : raw;
6
+ if (!isCommandVerbosity(inherited)) {
7
+ return {
8
+ ok: false,
9
+ error: `${COMMAND_VERBOSITY_ENV_VAR} is \`${inherited}\`, which is not one of: ${COMMAND_VERBOSITIES.join(', ')}`,
10
+ };
11
+ }
12
+ return { ok: true, verbosity: quietFlag ? 'quiet' : inherited };
13
+ }
14
+ function isCommandVerbosity(value) {
15
+ const names = COMMAND_VERBOSITIES;
16
+ return names.includes(value);
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -66,13 +66,13 @@
66
66
  "dependencies": {
67
67
  "glob": "13.0.6",
68
68
  "prettier-plugin-sh": "0.19.0",
69
- "taze": "19.16.0",
69
+ "taze": "19.17.1",
70
70
  "yaml": "2.9.0",
71
71
  "@williamthorsen/nmr-core": "0.9.3"
72
72
  },
73
73
  "devDependencies": {
74
74
  "prettier": "3.9.6",
75
- "readyup": "0.24.0",
75
+ "readyup": "0.26.0",
76
76
  "tinyglobby": "0.2.17",
77
77
  "vitest": "4.1.10"
78
78
  },