@williamthorsen/nmr 0.29.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.
@@ -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.
@@ -74,13 +74,13 @@ var rootScripts = {
74
74
  "root:test:unit": "vitest --config ./vitest.root.config.ts --project unit",
75
75
  "root:typecheck": "tsgo --noEmit",
76
76
  "root:upgrade": "nmr-taze --include-locked",
77
- test: "nmr root:test && pnpm --recursive exec nmr test",
78
- "test:all": "nmr root:test:all && pnpm --recursive exec nmr test:all",
79
- "test:coverage": "nmr root:test && pnpm --recursive exec nmr test:coverage",
80
- "test:tool": "nmr root:test:tool && pnpm --recursive exec nmr test:tool",
81
- "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"],
82
82
  "test:watch": `vitest ${GATE_PROJECTS} --watch`,
83
- typecheck: "nmr root:typecheck && pnpm --recursive exec nmr typecheck",
83
+ typecheck: ["root:typecheck", "-R typecheck"],
84
84
  // The command must be a string rather than a composite: Passthrough args attach to the chain's last command.
85
85
  upgrade: "nmr-report-overrides && nmr-taze --include-locked --recursive"
86
86
  };
@@ -376,7 +376,7 @@ function formatPaths(paths) {
376
376
  ${paths.map((path2) => ` ${path2}`).join("\n")}`;
377
377
  }
378
378
  function getMinVersion() {
379
- const picked = { "version": "0.29.0" };
379
+ const picked = { "version": "0.30.0" };
380
380
  if (typeof picked["version"] !== "string") {
381
381
  throw new TypeError("nmr/package.json: 'version' must be a string");
382
382
  }
@@ -10,7 +10,7 @@
10
10
  "readyupVersion": "0.26.0",
11
11
  "source": "kits/default.ts",
12
12
  "sourceHash": "7b2a223f",
13
- "targetHash": "26c3d9eb"
13
+ "targetHash": "bdca19db"
14
14
  }
15
15
  ]
16
16
  }
package/README.md CHANGED
@@ -3,23 +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.29.0 (2026-08-09)
6
+ ## Release notes — v0.30.0 (2026-08-10)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - Run the dependency audit before ci in prepush (#634)
10
+ - 🚨 **Breaking:** Resolve a command to a step list and reject a script element carrying shell syntax (#648)
11
11
 
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.
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
- ### 🐛 Bug fixes
14
+ - 🚨 **Breaking:** Sequence a command's steps in nmr rather than in a shell (#649)
15
15
 
16
- - Record no pass when build output changes while a check runs (#641)
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
- 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.
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
- - Stream command output so a large writer is no longer killed (#642)
20
+ - 🚨 **Breaking:** Express the seven root defaults as steps (#650)
21
21
 
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.
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
+
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.
23
25
  <!-- /section:release-notes -->
24
26
 
25
27
  ## Installation
@@ -67,9 +69,9 @@ nmr's key feature is that the same command runs different scripts depending on w
67
69
 
68
70
  | Where you run `nmr` | Registry used | Working directory | `nmr test` runs |
69
71
  | --------------------------------- | ----------------- | ----------------- | -------------------------------------------------------------------- |
70
- | 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` |
71
73
  | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest --project unit --project tool` (that package only) |
72
- | 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` |
73
75
 
74
76
  Relative paths in a script resolve against that working directory, not the invocation directory.
75
77
 
@@ -116,7 +118,13 @@ Script values can be `string` or `string[]`. Arrays expand to chained `nmr` sub-
116
118
  // expands to: nmr lint && nmr fmt
117
119
  ```
118
120
 
119
- 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.
120
128
 
121
129
  ## Configuration
122
130
 
@@ -186,7 +194,7 @@ For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites
186
194
 
187
195
  ## Pre and post hooks
188
196
 
189
- 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.
190
198
 
191
199
  Behaviors worth knowing:
192
200
 
@@ -315,6 +323,10 @@ Reach for these in order; the first is almost always the right one.
315
323
 
316
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.
317
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
+
318
330
  ### Configuring
319
331
 
320
332
  ```ts
@@ -427,12 +439,12 @@ A run that collects no test files passes. That is what lets `nmr test:tool` fan
427
439
 
428
440
  #### Build and CI
429
441
 
430
- | Command | Runs |
431
- | --------- | --------------------------------- |
432
- | `build` | `pnpm --recursive exec nmr build` |
433
- | `ci` | `build`, `check:strict` |
434
- | `clean` | `nmr-clean` |
435
- | `prepush` | `audit`, `ci` |
442
+ | Command | Runs |
443
+ | --------- | ----------------------- |
444
+ | `build` | `-R build` |
445
+ | `ci` | `build`, `check:strict` |
446
+ | `clean` | `nmr-clean` |
447
+ | `prepush` | `audit`, `ci` |
436
448
 
437
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.
438
450
 
@@ -457,14 +469,14 @@ Neither is bound to a git hook. `prepush` is named for when you run it, not for
457
469
 
458
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.
459
471
 
460
- | Command | Runs |
461
- | --------------- | ----------------------------------------------------------- |
462
- | `test` | `nmr root:test && pnpm --recursive exec nmr test` |
463
- | `test:all` | `nmr root:test:all && pnpm --recursive exec nmr test:all` |
464
- | `test:coverage` | `nmr root:test && pnpm --recursive exec nmr test:coverage` |
465
- | `test:tool` | `nmr root:test:tool && pnpm --recursive exec nmr test:tool` |
466
- | `test:unit` | `nmr root:test:unit && pnpm --recursive exec nmr test:unit` |
467
- | `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` |
468
480
 
469
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.
470
482
 
@@ -472,9 +484,9 @@ The same six names the workspace registry carries, so a command means the same t
472
484
 
473
485
  #### Typecheck
474
486
 
475
- | Command | Runs |
476
- | ----------- | ----------------------------------------------------------- |
477
- | `typecheck` | `nmr root:typecheck && pnpm --recursive exec nmr typecheck` |
487
+ | Command | Runs |
488
+ | ----------- | -------------------------------- |
489
+ | `typecheck` | `root:typecheck`, `-R typecheck` |
478
490
 
479
491
  #### Lint
480
492
 
@@ -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,
@@ -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 } = await 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'],
@@ -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
  };
@@ -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 } = await 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 } = await 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,7 +167,8 @@ 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
174
  const { anchorDir, buildOutput, command, env, key, monorepoRoot, stderr } = options;
@@ -332,7 +370,7 @@ async function runGated(options) {
332
370
  stdout.write(options.overrideNotice);
333
371
  }
334
372
  const startedAt = Date.now();
335
- const { exitCode } = await runCommand(commandString, anchorDir, options.runOptions);
373
+ const { exitCode } = await runSteps(options.steps, anchorDir, options.runOptions);
336
374
  if (exitCode === 0 && gate !== undefined) {
337
375
  await recordPass({
338
376
  anchorDir,
@@ -353,15 +391,14 @@ async function runGated(options) {
353
391
  function shellQuote(arg) {
354
392
  return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
355
393
  }
356
- function wrapWithHooks(command, mainCommand, registry, anchorDir, workspaceRoot) {
357
- const segments = [];
358
- const flag = workspaceRoot ? '-w ' : '';
394
+ function wrapWithHooks(command, mainSteps, registry, anchorDir, workspaceRoot) {
395
+ const steps = [];
359
396
  if (hasRunnableHook(`${command}:pre`, registry, anchorDir, workspaceRoot)) {
360
- segments.push(`nmr ${flag}${command}:pre`);
397
+ steps.push(composeNmrStep(`${command}:pre`, workspaceRoot));
361
398
  }
362
- segments.push(mainCommand);
399
+ steps.push(...mainSteps);
363
400
  if (hasRunnableHook(`${command}:post`, registry, anchorDir, workspaceRoot)) {
364
- segments.push(`nmr ${flag}${command}:post`);
401
+ steps.push(composeNmrStep(`${command}:post`, workspaceRoot));
365
402
  }
366
- return segments.join(' && ');
403
+ return steps;
367
404
  }
@@ -1,10 +1,18 @@
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
  }
15
+ export type RunStepsOptions = Omit<RunCommandOptions, 'channels'>;
8
16
  type CommandCompletion = {
9
17
  outcome: 'exited';
10
18
  exitCode: number;
@@ -21,5 +29,10 @@ export type RunCommandResult = CommandCompletion & {
21
29
  stdout: Buffer | undefined;
22
30
  stderr: Buffer | undefined;
23
31
  };
24
- export declare function runCommand(command: string, cwd?: string, options?: RunCommandOptions): Promise<RunCommandResult>;
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>;
25
38
  export {};
@@ -5,14 +5,40 @@ import { reportError } from '@williamthorsen/nmr-core';
5
5
  import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
6
6
  const SIGNAL_EXIT_BASE = 128;
7
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
+ }
8
31
  export async function runCommand(command, cwd, options) {
9
- const quiet = options?.quiet === true;
10
- const stdout = options?.stdout ?? process.stdout;
11
- const stderr = options?.stderr ?? process.stderr;
12
- const env = options?.env ?? process.env;
13
- const child = spawn(command, [], {
14
- shell: true,
15
- stdio: ['inherit', resolveChannel(stdout, quiet), resolveChannel(stderr, quiet)],
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],
16
42
  cwd,
17
43
  env,
18
44
  });
@@ -39,6 +65,21 @@ export async function runCommand(command, cwd, options) {
39
65
  }
40
66
  return result;
41
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
+ });
75
+ }
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
+ }
42
83
  function captureStream(source, destination) {
43
84
  const retained = createBoundedBuffer();
44
85
  let isComplete = true;
@@ -100,13 +141,11 @@ function describeExit(code, signal) {
100
141
  }
101
142
  return { outcome: 'exited', exitCode: code ?? 1 };
102
143
  }
103
- function resolveChannel(stream, quiet) {
104
- if (quiet)
105
- return 'pipe';
106
- return isTerminal(stream) ? stream.fd : 'pipe';
144
+ function hasDescriptor(stream) {
145
+ return 'fd' in stream && typeof stream.fd === 'number';
107
146
  }
108
147
  function isTerminal(stream) {
109
- return 'isTTY' in stream && stream.isTTY === true && 'fd' in stream && typeof stream.fd === 'number';
148
+ return 'isTTY' in stream && stream.isTTY === true && hasDescriptor(stream);
110
149
  }
111
150
  function writeBuffer(buffer, destination) {
112
151
  if (buffer !== undefined && buffer.length > 0)
@@ -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.29.0",
3
+ "version": "0.30.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -66,7 +66,7 @@
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
  },