@williamthorsen/nmr 0.47.0 → 0.48.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,4 +1,52 @@
1
1
  [
2
+ {
3
+ "version": "0.48.0",
4
+ "date": "2026-10-17",
5
+ "sections": [
6
+ {
7
+ "title": "🎉 Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Adds the `--delegate <host>` flag to `nmr`, which runs a command whose result is its exit status, such as `ci`, `check`, or a `test` command, on that host over SSH against the local working tree with its uncommitted and untracked files, and exits 255 when the delegation fails outside the command. (#1058)",
12
+ "hash": "dbd1714d65a833a258ebcc859d6aed130a3d9910",
13
+ "entry": 1
14
+ },
15
+ {
16
+ "description": "Adds the `NMR_DELEGATE` environment variable, which names the host on which `nmr` runs every command that can be delegated, and the `--no-delegate` flag, which runs a command on the local machine whatever `NMR_DELEGATE` names. (#1058)",
17
+ "hash": "dbd1714d65a833a258ebcc859d6aed130a3d9910",
18
+ "entry": 2
19
+ },
20
+ {
21
+ "description": "Adds the host to the verdict for a delegated command, as `passed on <host>` in the line and as a `host` field in the `--json` object. (#1058)",
22
+ "hash": "dbd1714d65a833a258ebcc859d6aed130a3d9910",
23
+ "entry": 3
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "title": "📦 Dependencies",
29
+ "audience": "dev",
30
+ "items": [
31
+ {
32
+ "description": "Upgrades `@williamthorsen/eslint-config-typescript` from 18.0.0 to 19.0.0 and rewrites the code in `build-info`, `nmr-core`, `nmr`, and `release-kit` that its new and widened rules report, without changing behavior. (#1047)",
33
+ "hash": "610ee159fdc429681b1078b500a064c5df296724",
34
+ "entry": 1
35
+ },
36
+ {
37
+ "description": "Upgrades `readyup` from 0.40.0 to 0.41.0 and recompiles the kits in `nmr`, `release-kit`, and `v11y-check`, whose manifests now record each checklist's check names under `checklistChecks`. (#1047)",
38
+ "hash": "610ee159fdc429681b1078b500a064c5df296724",
39
+ "entry": 2
40
+ },
41
+ {
42
+ "description": "Upgrades `pnpm` to 12.11.2, `@types/node` to 24.19.2, `lefthook` to 2.2.1, `@williamthorsen/strict-lint` to 14.2.0, `@williamthorsen/tsconfig` to 0.11.0, `smol-toml` to 1.9.1, and `vite` to 8.3.4. (#1047)",
43
+ "hash": "610ee159fdc429681b1078b500a064c5df296724",
44
+ "entry": 3
45
+ }
46
+ ]
47
+ }
48
+ ]
49
+ },
2
50
  {
3
51
  "version": "0.47.0",
4
52
  "date": "2026-10-15",
@@ -1,6 +1,6 @@
1
1
  /** @noformat -- @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.40.0";
3
+ export const __readyupVersion = "0.41.0";
4
4
  import { createRequire as __rdyCreateRequire } from 'node:module';
5
5
  const require = __rdyCreateRequire(import.meta.url);
6
6
 
@@ -7442,10 +7442,11 @@ function findClosestName(name, candidates) {
7442
7442
  let shortestDistance = Infinity;
7443
7443
  for (const candidate of candidates) {
7444
7444
  const distance = measureDistance(name, candidate);
7445
- if (distance < shortestDistance) {
7446
- shortestDistance = distance;
7447
- closestName = candidate;
7445
+ if (distance >= shortestDistance) {
7446
+ continue;
7448
7447
  }
7448
+ shortestDistance = distance;
7449
+ closestName = candidate;
7449
7450
  }
7450
7451
  return shortestDistance <= ceiling ? closestName : void 0;
7451
7452
  }
@@ -8510,7 +8511,7 @@ var default_default = defineRdyKit({
8510
8511
  {
8511
8512
  name: ".config/nmr.config.ts uses defineConfig",
8512
8513
  severity: "recommend",
8513
- skip: () => !fileExists(".config/nmr.config.ts") ? "no nmr config file" : false,
8514
+ skip: () => !fileExists(".config/nmr.config.ts") && "no nmr config file",
8514
8515
  check: () => fileContains(".config/nmr.config.ts", /defineConfig/),
8515
8516
  fix: "Wrap the config export with defineConfig() from @williamthorsen/nmr/config for type safety"
8516
8517
  },
@@ -8521,7 +8522,7 @@ var default_default = defineRdyKit({
8521
8522
  return `eslint >= ${MIN_ESLINT_VERSION}`;
8522
8523
  },
8523
8524
  severity: "error",
8524
- skip: () => !hasDevDependency("eslint") ? "eslint not installed" : false,
8525
+ skip: () => !hasDevDependency("eslint") && "eslint not installed",
8525
8526
  check: hasSupportedEslintVersion,
8526
8527
  get fix() {
8527
8528
  return `pnpm add --save-dev eslint@^${MIN_ESLINT_VERSION}. Earlier releases resolve config from the working directory, so nmr's root lint and lint:check would apply the root config to every package`;
@@ -8532,7 +8533,7 @@ var default_default = defineRdyKit({
8532
8533
  return `@williamthorsen/strict-lint >= ${MIN_STRICT_LINT_VERSION}`;
8533
8534
  },
8534
8535
  severity: "error",
8535
- skip: () => !hasDevDependency("@williamthorsen/strict-lint") ? "strict-lint not installed" : false,
8536
+ skip: () => !hasDevDependency("@williamthorsen/strict-lint") && "strict-lint not installed",
8536
8537
  check: hasSupportedStrictLintVersion,
8537
8538
  get fix() {
8538
8539
  return `pnpm add --save-dev @williamthorsen/strict-lint@^${MIN_STRICT_LINT_VERSION}. Earlier releases pin ESLint to one config and resolve ceilings from the working directory, so nmr's root lint:strict would report the wrong rules for every package`;
@@ -8939,7 +8940,7 @@ function formatPaths(paths) {
8939
8940
  ${paths.map((path7) => ` ${path7}`).join("\n")}`;
8940
8941
  }
8941
8942
  function getMinVersion() {
8942
- const pickedFields = { "version": "0.47.0" };
8943
+ const pickedFields = { "version": "0.48.0" };
8943
8944
  if (typeof pickedFields["version"] !== "string") {
8944
8945
  throw new TypeError("nmr/package.json: 'version' must be a string");
8945
8946
  }
@@ -9133,7 +9134,7 @@ function testSuiteGatesTestFileConventions(cwd = process.cwd()) {
9133
9134
  }
9134
9135
  function describeConventionsGuardSkip() {
9135
9136
  const guard = findConventionsGuard(process.cwd());
9136
- return guard === void 0 ? false : `reported by checkTestFileConventions in ${guard}`;
9137
+ return guard !== void 0 && `reported by checkTestFileConventions in ${guard}`;
9137
9138
  }
9138
9139
  function findConventionsGuard(cwd) {
9139
9140
  return findTestFiles(cwd).find(
@@ -9170,7 +9171,7 @@ var NEVER_INSTALLED_PROGRAMS = /* @__PURE__ */ new Set(["corepack", "gh"]);
9170
9171
  var statusByCwd = /* @__PURE__ */ new Map();
9171
9172
  async function describeSelfHostedRunnerSkip(cwd = process.cwd(), readFacts) {
9172
9173
  const status = await getSelfHostedRunnerStatus(cwd, readFacts);
9173
- return "skip" in status ? status.skip : false;
9174
+ return "skip" in status && status.skip;
9174
9175
  }
9175
9176
  function findSelfHostedRunnerFailures(facts, cwd) {
9176
9177
  const failures = [];
@@ -19,6 +19,51 @@
19
19
  "wrap-ansi": "10.0.2",
20
20
  "yaml": "2.9.1"
21
21
  },
22
+ "checklistChecks": [
23
+ {
24
+ "checklist": "nmr",
25
+ "checks": [
26
+ "@williamthorsen/nmr in devDependencies",
27
+ "@williamthorsen/nmr >= 0.48.0",
28
+ "pnpm-workspace.yaml exists",
29
+ "catalogued dependencies use catalog:",
30
+ "package.json has packageManager field",
31
+ ".tool-versions does not list pnpm",
32
+ "no package.json declares a pnpm field",
33
+ "no package.json declares a workspaces field",
34
+ ".config/nmr.config.ts uses defineConfig",
35
+ "eslint >= 10.0.0",
36
+ "@williamthorsen/strict-lint >= 9.3.0",
37
+ "root package.json has no nmr-provided scripts",
38
+ "no root install script runs lefthook install unguarded",
39
+ "all workspace packages can build",
40
+ "every bin target is a committed wrapper",
41
+ "every bin wrapper's build-output target is covered by files",
42
+ "no retired Vitest config variants",
43
+ "every vitest.config builds on @williamthorsen/nmr/vitest",
44
+ "vitest.root.config.ts builds on @williamthorsen/nmr/vitest",
45
+ "every workspace with a Vite config has a Vitest config",
46
+ "the test suite gates the test-file conventions",
47
+ "every test file names its isolation tier",
48
+ "every test file is under a __tests__ directory",
49
+ "no package re-exports the ancestor Vitest config",
50
+ "Prettier config builds on @williamthorsen/nmr/prettier",
51
+ "taze.config.ts builds on @williamthorsen/nmr/taze",
52
+ "taze config declares no option taze discards",
53
+ "v11y-check in devDependencies",
54
+ "scripts/run-workspace-script.ts does not exist",
55
+ "no workspace packages reference run-workspace-script or \"pnpm run ws\""
56
+ ]
57
+ },
58
+ {
59
+ "checklist": "self-hosted runner",
60
+ "checks": [
61
+ "GitHub reports the repo's runner settings",
62
+ "CI_RUNS_ON selects the self-hosted runner",
63
+ "the self-hosted runner can run CI"
64
+ ]
65
+ }
66
+ ],
22
67
  "checklists": [
23
68
  "nmr",
24
69
  "self-hosted runner"
@@ -41,7 +86,7 @@
41
86
  "path": "../../nmr-core/dist/esm/hasErrnoCode.js"
42
87
  },
43
88
  {
44
- "hash": "211453c6",
89
+ "hash": "98509862",
45
90
  "kind": "module",
46
91
  "path": "../../nmr-core/dist/esm/hashWorkingTree.js"
47
92
  },
@@ -56,7 +101,7 @@
56
101
  "path": "../../nmr-core/dist/esm/index.js"
57
102
  },
58
103
  {
59
- "hash": "2507870d",
104
+ "hash": "89d3e3b4",
60
105
  "kind": "module",
61
106
  "path": "../../nmr-core/dist/esm/parseArgs.js"
62
107
  },
@@ -81,12 +126,12 @@
81
126
  "path": "../../nmr-core/dist/esm/writeFileWithCheck.js"
82
127
  },
83
128
  {
84
- "hash": "aaeb1a84",
129
+ "hash": "ffa13ccc",
85
130
  "kind": "module",
86
131
  "path": "kits/default.ts"
87
132
  },
88
133
  {
89
- "hash": "a06b470a",
134
+ "hash": "47f25de7",
90
135
  "kind": "inline",
91
136
  "path": "../package.json",
92
137
  "paths": [
@@ -119,7 +164,7 @@
119
164
  "path": "../src/helpers/escapeControlCharacters.ts"
120
165
  },
121
166
  {
122
- "hash": "47249ca4",
167
+ "hash": "2d1cf397",
123
168
  "kind": "module",
124
169
  "path": "../src/helpers/findClosestName.ts"
125
170
  },
@@ -186,10 +231,10 @@
186
231
  ],
187
232
  "name": "default",
188
233
  "path": "kits/default.js",
189
- "readyupVersion": "0.40.0",
234
+ "readyupVersion": "0.41.0",
190
235
  "source": "kits/default.ts",
191
- "sourceHash": "aaeb1a84",
192
- "targetHash": "125a39cc"
236
+ "sourceHash": "ffa13ccc",
237
+ "targetHash": "6baed250"
193
238
  }
194
239
  ]
195
240
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.48.0 — 2026-10-17
6
+
7
+ ### 🎉 Features
8
+
9
+ - Adds the `--delegate <host>` flag to `nmr`, which runs a command whose result is its exit status, such as `ci`, `check`, or a `test` command, on that host over SSH against the local working tree with its uncommitted and untracked files, and exits 255 when the delegation fails outside the command. (#1058)
10
+ - Adds the `NMR_DELEGATE` environment variable, which names the host on which `nmr` runs every command that can be delegated, and the `--no-delegate` flag, which runs a command on the local machine whatever `NMR_DELEGATE` names. (#1058)
11
+ - Adds the host to the verdict for a delegated command, as `passed on <host>` in the line and as a `host` field in the `--json` object. (#1058)
12
+
13
+ ### 📦 Dependencies
14
+
15
+ - Upgrades `@williamthorsen/eslint-config-typescript` from 18.0.0 to 19.0.0 and rewrites the code in `build-info`, `nmr-core`, `nmr`, and `release-kit` that its new and widened rules report, without changing behavior. (#1047)
16
+ - Upgrades `readyup` from 0.40.0 to 0.41.0 and recompiles the kits in `nmr`, `release-kit`, and `v11y-check`, whose manifests now record each checklist's check names under `checklistChecks`. (#1047)
17
+ - Upgrades `pnpm` to 12.11.2, `@types/node` to 24.19.2, `lefthook` to 2.2.1, `@williamthorsen/strict-lint` to 14.2.0, `@williamthorsen/tsconfig` to 0.11.0, `smol-toml` to 1.9.1, and `vite` to 8.3.4. (#1047)
18
+
5
19
  ## 0.47.0 — 2026-10-15
6
20
 
7
21
  ### 🎉 Features
package/README.md CHANGED
@@ -5,15 +5,13 @@
5
5
  Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v0.47.0 (2026-10-15)
8
+ ## Release notes — v0.48.0 (2026-10-17)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Sets `maxWorkers` on every project built by `defineVitestConfig` and `defineRootVitestConfig` to one worker per 4 GiB of total memory, bounded by one fewer than the core count, in place of Vitest's default of one fewer than the core count, which does not take memory into account; machines with 2 or fewer cores keep 3 workers. (#1044)
13
- - Applies the `--maxWorkers` (or `--max-workers`) flag to every project, which Vitest does not do itself, making the flag override both the derived count and a `maxWorkers` set through the `project` or `tiers` seam, while the `VITEST_MAX_WORKERS` environment variable still overrides the flag. (#1044)
14
- - Adds a machine lock that makes a heavy command (`check`, `ci`, and every `test` command except `test:watch`) wait, printing `nmr: waiting for …`, until the heavy command already running on the machine finishes; a nested command and a `-R` or `-F` fan-out take the lock once, and a cached pass does not wait. (#1045)
15
- - Adds the `--no-lock` flag and the `NMR_NO_LOCK=1` environment variable, which run a command and the commands nested in it without waiting for the machine lock, for example on a self-hosted CI machine that runs several runners in parallel. (#1045)
16
- - Extends the `nmr` rulebook to state that a run printing `nmr: waiting for …` is waiting for the machine lock rather than stuck, and that an agent must neither kill it nor rerun it with `--no-lock`. (#1045)
12
+ - Adds the `--delegate <host>` flag to `nmr`, which runs a command whose result is its exit status, such as `ci`, `check`, or a `test` command, on that host over SSH against the local working tree with its uncommitted and untracked files, and exits 255 when the delegation fails outside the command. (#1058)
13
+ - Adds the `NMR_DELEGATE` environment variable, which names the host on which `nmr` runs every command that can be delegated, and the `--no-delegate` flag, which runs a command on the local machine whatever `NMR_DELEGATE` names. (#1058)
14
+ - Adds the host to the verdict for a delegated command, as `passed on <host>` in the line and as a `host` field in the `--json` object. (#1058)
17
15
  <!-- /section:release-notes -->
18
16
 
19
17
  ## Installation
@@ -195,6 +193,17 @@ nmr: waiting for test in /Users/me/repos/other-repo (pid 41872) to finish
195
193
 
196
194
  The lock lives in `/tmp/nmr-<uid>/`, or in `NMR_LOCK_DIR`; a sandboxed session needs write access to it. `nmr --no-lock <command>` or `NMR_NO_LOCK=1` runs without waiting. [Machine lock](docs/machine-lock.md) covers which commands take it, nesting and fan-outs, reclaiming a lock that its holder left behind, and CI.
197
195
 
196
+ ## Delegating to another machine
197
+
198
+ `nmr --delegate <host> <command>` runs the command on another machine over SSH, against this working tree, and exits with its status. `NMR_DELEGATE=<host>` makes that the default:
199
+
200
+ ```console
201
+ $ nmr --delegate imac2 ci
202
+ ✅ my-repo: ci: passed on imac2 in 1m 48s
203
+ ```
204
+
205
+ [Delegating to another machine](docs/delegate.md) covers which commands can be delegated, what the host runs, how failures are reported, and setting up the host.
206
+
198
207
  ## Shared configs and helpers
199
208
 
200
209
  ### Shared Vitest config
@@ -299,6 +308,7 @@ rdy list --from npm:@williamthorsen/nmr # what nmr publishes
299
308
  - [What nmr reports](docs/reporting.md): Verdicts, `--json`, output style, and verbosity
300
309
  - [Check-result cache](docs/check-cache.md): What is skipped, what a hit requires, and replaying, reading, and bypassing recorded runs
301
310
  - [Machine lock](docs/machine-lock.md): Which commands wait for one another, where the lock lives, and bypassing it
311
+ - [Delegating to another machine](docs/delegate.md): `--delegate`, `NMR_DELEGATE`, what the host runs, and setting it up
302
312
  - [Dependency upgrades](docs/upgrades.md): `nmr upgrade`, the upgrade policy, `report-catalog`, `report-overrides`, and `nmr-taze`
303
313
  - [Standalone utilities](docs/utilities.md): `nmr-clean`, `nmr-compile`, `nmr-fmt`, and `ensure-prepublish-hooks`
304
314
  - [Shared Vitest config](docs/vitest.md): Test tiers, test selections, the conventions check, and customization
@@ -15,4 +15,5 @@ delivery: ambient
15
15
  - Every command that nmr runs reports one line -- `PASS <scope>: <command>: passed in 12.4s`, `FAIL … failed in 1.2s (exit 1)`, `SKIP … passed 4m ago on this tree`, or `NOOP … skipped, the override is empty` -- and a composite reports alongside every command into which it expands. Those are the words that a pipe and a CI log get; a terminal gets ✅, ❌, ⏩, and ⚪ in their place, and `--output-style rich` or `NMR_OUTPUT_STYLE=rich` forces the emoji on any stream. The lines print in every verbosity, so `nmr -q <command>` is the low-noise way to run rather than a silent one: It withholds the output of the commands that nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. nmr reads its own flags only before the command name and passes everything after the name to the commands that run: `nmr ci -q` hands `-q` to `nmr-fmt`, which rejects it. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
16
16
  - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing that the test reaches -- `tool` (a process that the test starts, or a program other than the package's own code that the test makes do its work, including an embedded engine, which the package installs and the test runs in-process, such as PGlite), `localhost` (a service already running on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --sources` reports both and warns that the check is missing.
17
17
  - A heavy command (`check`, `ci`, and the `test` commands but `test:watch`) may print `nmr: waiting for <command> in <dir> (pid <pid>) to finish` and wait: nmr runs one at a time on the machine, across every repository. The run is not stuck, and it continues once the holder ends. Do not kill it, and do not rerun it with `--no-lock` to get past the wait, which defeats the memory protection that the lock exists for. A warning that nmr is `running without the machine lock` names a directory that the sandbox cannot write.
18
- - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (the repo does not define a build script of its own); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; `machine-lock.md` for which commands take the machine lock, where it lives, and its bypass; and `check-cache.md` for the check-result cache's key, the retention key to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
18
+ - A verdict reading `passed on <host>` or `failed on <host>` reports a command that ran on another machine, because `--delegate` or `NMR_DELEGATE` sent it there; the output above it is the host's. Exit 255 with `could not prepare <host>` inside a sandbox means that the sandbox refused `ssh`: Ask for network access to the host rather than treating the host as broken, or run the command here with `nmr --no-delegate <command>`.
19
+ - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (the repo does not define a build script of its own); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; `machine-lock.md` for which commands take the machine lock, where it lives, and its bypass; `delegate.md` for which commands `--delegate` runs on another machine, what the host runs, and its setup; and `check-cache.md` for the check-result cache's key, the retention key to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
@@ -9,10 +9,11 @@ export function prependBinDirs(options) {
9
9
  const additions = [];
10
10
  for (const dir of [anchorDir, monorepoRoot]) {
11
11
  const binDir = path.resolve(dir, 'node_modules', '.bin');
12
- if (!seen.has(binDir)) {
13
- seen.add(binDir);
14
- additions.push(binDir);
12
+ if (seen.has(binDir)) {
13
+ continue;
15
14
  }
15
+ seen.add(binDir);
16
+ additions.push(binDir);
16
17
  }
17
18
  if (additions.length === 0) {
18
19
  return env;
@@ -126,7 +126,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
126
126
  function classifyListedFile(cwd, file) {
127
127
  try {
128
128
  const stats = lstatSync(path.resolve(cwd, file), { throwIfNoEntry: false });
129
- return stats?.isFile() === true ? { kind: 'formattable' } : { kind: 'excluded' };
129
+ return { kind: stats?.isFile() === true ? 'formattable' : 'excluded' };
130
130
  }
131
131
  catch (error) {
132
132
  const code = UNREADABLE_CODES.find((candidate) => hasErrnoCode(error, candidate));
package/dist/esm/help.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { isHookName } from "./helpers/hook-name.js";
2
2
  import { OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG } from "./output-style.js";
3
+ import { DELEGATE_ENV_VAR } from "./remote-run.js";
3
4
  import { buildRootRegistry, buildWorkspaceRegistry, describeScript, isSelfReferential, readPackageJsonScripts, } from "./resolver.js";
4
5
  const LIFECYCLE_SCRIPT_NAMES = new Set([
5
6
  'dependencies',
@@ -32,15 +33,18 @@ export function generateHelp(config, monorepoRoot, packageDir, shouldUseRoot) {
32
33
  ' -R, --recursive Run command in all packages',
33
34
  ' -w, --workspace-root Use root scripts, running at the monorepo root',
34
35
  " -q, --quiet Suppress command output, keeping nmr's verdicts",
36
+ ' --delegate <host> Run on <host> over SSH, against this working tree',
35
37
  ' --json Report one JSON object per command, withholding command output',
36
38
  ' --log Print the recorded run instead of running (up to 256 KiB)',
37
39
  ' --no-cache Run even if this tree already passed; record the result',
40
+ ' --no-delegate Run here, even when NMR_DELEGATE names a host',
38
41
  ' --no-lock Run without waiting for another heavy command on this machine',
39
42
  ` ${OUTPUT_STYLE_FLAG} <style> Output style: auto (default), plain, or rich`,
40
43
  ' -?, --help Show this help',
41
44
  ' -V, --version Show version number',
42
45
  '',
43
46
  'Environment:',
47
+ ` ${DELEGATE_ENV_VAR} Host on which to run each command that can be delegated`,
44
48
  ` ${OUTPUT_STYLE_ENV_VAR} Output style: auto (default), plain, or rich. auto prints`,
45
49
  ' plain, without emoji, when CI is set or the stream is not a terminal.',
46
50
  '',
@@ -4,10 +4,11 @@ export function findClosestName(name, candidates) {
4
4
  let shortestDistance = Infinity;
5
5
  for (const candidate of candidates) {
6
6
  const distance = measureDistance(name, candidate);
7
- if (distance < shortestDistance) {
8
- shortestDistance = distance;
9
- closestName = candidate;
7
+ if (distance >= shortestDistance) {
8
+ continue;
10
9
  }
10
+ shortestDistance = distance;
11
+ closestName = candidate;
11
12
  }
12
13
  return shortestDistance <= ceiling ? closestName : undefined;
13
14
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shell-escapes a single argument by wrapping in single quotes
3
+ * and escaping any embedded single quotes.
4
+ */
5
+ export declare function quoteForShell(arg: string): string;
@@ -0,0 +1,3 @@
1
+ export function quoteForShell(arg) {
2
+ return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
3
+ }
@@ -0,0 +1,136 @@
1
+ import type { Writable } from 'node:stream';
2
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
3
+ /** Names the host to which nmr delegates a command when the invocation does not say otherwise. */
4
+ export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
5
+ /** The flag that names the host on which to run the command. */
6
+ export declare const DELEGATE_FLAG = "--delegate";
7
+ /** The exit status of a delegation that failed outside its command, which matches `ssh`'s own. */
8
+ export declare const DELEGATION_FAILURE_EXIT_CODE = 255;
9
+ /** The flag that runs the command here, whatever `NMR_DELEGATE` names. */
10
+ export declare const NO_DELEGATE_FLAG = "--no-delegate";
11
+ /** Keeps `ssh` from prompting or waiting on a terminal, and bounds the wait for a host that does not answer. */
12
+ export declare const SSH_OPTIONS: readonly string[];
13
+ /** Where a delegation runs, or why the invocation cannot run where it asked to. */
14
+ export type DelegationTarget = {
15
+ kind: 'local';
16
+ } | {
17
+ kind: 'remote';
18
+ host: string;
19
+ source: 'env' | 'flag';
20
+ } | {
21
+ kind: 'refused';
22
+ error: string;
23
+ };
24
+ /** The phases of a delegation that run on the host, each reported by name when it fails. */
25
+ export type RemotePhase = 'check out' | 'install' | 'prepare' | 'push' | 'run';
26
+ /** The status of a delegated command, or the phase that kept it from finishing and what that phase printed. */
27
+ export type RemoteRunResult = {
28
+ ok: true;
29
+ exitCode: number;
30
+ } | {
31
+ ok: false;
32
+ message: string;
33
+ output: string;
34
+ phase: RemotePhase;
35
+ };
36
+ /** A commit that records the working tree, or why one could not be made. */
37
+ export type SnapshotResult = {
38
+ ok: true;
39
+ sha: string;
40
+ toplevel: string;
41
+ } | {
42
+ ok: false;
43
+ isUnsupported: boolean;
44
+ reason: string;
45
+ };
46
+ /**
47
+ * Composes the script that runs nmr on the host, from the directory that corresponds to the local one.
48
+ *
49
+ * A Node launcher starts nmr in its own process group, with stdin closed, and reads the connection's stdin, which the
50
+ * local `ssh` holds open and closes when it exits, as on Ctrl-C; on that end of input, the launcher stops nmr's whole
51
+ * group, so that an interrupted run does not continue on the host. The launcher rather than the shell's job control
52
+ * makes the group, because a shell such as dash refuses job control without a terminal. `--no-delegate` keeps a host
53
+ * whose environment sets `NMR_DELEGATE` from passing the command on again.
54
+ *
55
+ * The script writes nmr's status to `statusPath` before it exits, so that a 255 from `ssh` can be told apart: It is
56
+ * nmr's own when the file records it, and a dropped connection otherwise.
57
+ *
58
+ * @internal
59
+ */
60
+ export declare function composeRunScript(options: {
61
+ nmrArgs: readonly string[];
62
+ remoteEnv: Readonly<Record<string, string>>;
63
+ runDir: string;
64
+ statusPath: string;
65
+ }): string;
66
+ /**
67
+ * Builds a commit whose tree is the working tree as it is on disk: tracked changes, staged or not, and untracked
68
+ * files that git does not ignore. The commit is built in a temporary index, so the repository's index, refs, and
69
+ * stash are untouched; the only write is to the object database.
70
+ *
71
+ * The identity and date are fixed, so that the same tree on the same `HEAD` always yields the same commit and a
72
+ * repeated push transfers nothing. A submodule, or a nested repository that `git add` would record as one, is
73
+ * refused: Its content is in a repository that the snapshot does not include.
74
+ */
75
+ export declare function createSnapshot(cwd: string, env: NodeJS.ProcessEnv): SnapshotResult;
76
+ /**
77
+ * Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
78
+ * repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
79
+ *
80
+ * @internal
81
+ */
82
+ export declare function deriveCheckoutPath(hostname: string, toplevel: string): string;
83
+ /**
84
+ * Renders the warning for a `--delegate` or `--no-delegate` that appears after the command name, which makes it an
85
+ * argument to the command rather than a flag to nmr.
86
+ */
87
+ export declare function formatMisplacedDelegationWarning(command: string, style: OutputStyle): string;
88
+ /**
89
+ * Decides where an invocation runs. `--no-delegate` outranks `--delegate`, which outranks `NMR_DELEGATE`.
90
+ *
91
+ * A command that rewrites the tree, or that runs until it is stopped, cannot be delegated, and neither can `--log`,
92
+ * which reads this machine's recordings. Asked for by the flag, such an invocation is refused; asked for by the
93
+ * variable, which sets a default for every invocation, it runs here.
94
+ */
95
+ export declare function resolveDelegationTarget(options: {
96
+ command: string;
97
+ env: NodeJS.ProcessEnv;
98
+ flagHost: string | undefined;
99
+ isOptedOut: boolean;
100
+ isReading: boolean;
101
+ }): DelegationTarget;
102
+ /**
103
+ * Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
104
+ * `stdout` and `stderr` and returning its exit status.
105
+ *
106
+ * The host keeps one checkout per local repository and host name, under `~/.cache/nmr/delegate/`, so concurrent
107
+ * delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
108
+ * between runs. Each phase is its own connection, so that a failure names its phase; what a phase other than `run`
109
+ * prints is returned rather than streamed, because it matters only when the phase fails.
110
+ */
111
+ export declare function runRemotely(options: {
112
+ cwd: string;
113
+ env: NodeJS.ProcessEnv;
114
+ host: string;
115
+ monorepoRoot: string;
116
+ nmrArgs: readonly string[];
117
+ remoteEnv: Readonly<Record<string, string>>;
118
+ snapshot: {
119
+ sha: string;
120
+ toplevel: string;
121
+ };
122
+ stderr: Writable;
123
+ stdout: Writable;
124
+ }): Promise<RemoteRunResult>;
125
+ /**
126
+ * Wraps a script to run under `/bin/sh` with the `PATH` that the user's interactive login shell sets. pnpm's own
127
+ * installer and the common version managers set it up in the interactive startup file, such as `~/.zshrc`, which a
128
+ * non-interactive shell does not read. The script runs under `sh` rather than that shell itself, because the login
129
+ * shell, such as fish, may not accept the POSIX syntax in which the scripts are written.
130
+ *
131
+ * A startup file may print to stdout, so the shell prints `PATH` after a marker, and only what follows the last
132
+ * marker is read. When no marker arrives, the script keeps the `PATH` with which `ssh` started it.
133
+ *
134
+ * @internal
135
+ */
136
+ export declare function wrapRemoteScript(script: string): string;
@@ -0,0 +1,251 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { GIT_OUTPUT_LIMIT, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
7
+ import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
8
+ import { quoteForShell } from "./helpers/quoteForShell.js";
9
+ export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
10
+ export const DELEGATE_FLAG = '--delegate';
11
+ export const DELEGATION_FAILURE_EXIT_CODE = 255;
12
+ export const NO_DELEGATE_FLAG = '--no-delegate';
13
+ export const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
14
+ export function composeRunScript(options) {
15
+ const { nmrArgs, remoteEnv, runDir, statusPath } = options;
16
+ const assignments = Object.entries(remoteEnv).map(([name, value]) => `${name}=${quoteForShell(value)}`);
17
+ const invocation = [
18
+ ...assignments,
19
+ 'node',
20
+ '-e',
21
+ quoteForShell(RUN_LAUNCHER_SOURCE),
22
+ 'pnpm',
23
+ 'exec',
24
+ 'nmr',
25
+ '--no-delegate',
26
+ ...nmrArgs.map(quoteForShell),
27
+ ].join(' ');
28
+ return [
29
+ `rm -f ${statusPath}`,
30
+ `mkdir -p ${runDir} && cd ${runDir} || exit 1`,
31
+ invocation,
32
+ 'nmr_status=$?',
33
+ String.raw `printf '%s\n' "$nmr_status" > ${statusPath}`,
34
+ 'exit "$nmr_status"',
35
+ ].join('\n');
36
+ }
37
+ export function createSnapshot(cwd, env) {
38
+ const toplevelResult = runGit(['rev-parse', '--show-toplevel'], cwd, env);
39
+ if (!toplevelResult.ok) {
40
+ return { ok: false, isUnsupported: true, reason: `not a git repository: ${toplevelResult.error}` };
41
+ }
42
+ const toplevel = toplevelResult.stdout.trim();
43
+ if (fs.existsSync(path.join(toplevel, '.gitmodules'))) {
44
+ return { ok: false, isUnsupported: true, reason: 'the repository declares submodules' };
45
+ }
46
+ const indexPathResult = runGit(['rev-parse', '--git-path', 'index'], toplevel, env);
47
+ if (!indexPathResult.ok) {
48
+ return { ok: false, isUnsupported: false, reason: indexPathResult.error };
49
+ }
50
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nmr-snapshot-'));
51
+ try {
52
+ const tempIndex = path.join(tempDir, 'index');
53
+ const indexPath = path.resolve(toplevel, indexPathResult.stdout.trim());
54
+ if (fs.existsSync(indexPath)) {
55
+ fs.copyFileSync(indexPath, tempIndex);
56
+ }
57
+ const indexEnv = { ...env, GIT_INDEX_FILE: tempIndex };
58
+ const addResult = runGit(['add', '--all'], toplevel, indexEnv);
59
+ if (!addResult.ok) {
60
+ return { ok: false, isUnsupported: false, reason: addResult.error };
61
+ }
62
+ const stageResult = runGit(['ls-files', '--stage', '-z'], toplevel, indexEnv);
63
+ if (!stageResult.ok) {
64
+ return { ok: false, isUnsupported: false, reason: stageResult.error };
65
+ }
66
+ if (stageResult.stdout.split('\0').some((entry) => entry.startsWith('160000 '))) {
67
+ return { ok: false, isUnsupported: true, reason: 'the working tree contains a submodule or a nested repository' };
68
+ }
69
+ const treeResult = runGit(['write-tree'], toplevel, indexEnv);
70
+ if (!treeResult.ok) {
71
+ return { ok: false, isUnsupported: false, reason: treeResult.error };
72
+ }
73
+ const headResult = runGit(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], toplevel, env);
74
+ const parentArgs = headResult.ok ? ['-p', headResult.stdout.trim()] : [];
75
+ const commitResult = runGit(['commit-tree', treeResult.stdout.trim(), ...parentArgs, '-m', 'nmr delegate snapshot'], toplevel, { ...env, ...SNAPSHOT_IDENTITY_ENV });
76
+ if (!commitResult.ok) {
77
+ return { ok: false, isUnsupported: false, reason: commitResult.error };
78
+ }
79
+ return { ok: true, sha: commitResult.stdout.trim(), toplevel };
80
+ }
81
+ finally {
82
+ fs.rmSync(tempDir, { force: true, recursive: true });
83
+ }
84
+ }
85
+ export function deriveCheckoutPath(hostname, toplevel) {
86
+ const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
87
+ return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
88
+ }
89
+ export function formatMisplacedDelegationWarning(command, style) {
90
+ return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG} and ${NO_DELEGATE_FLAG} after the command name are passed ` +
91
+ `to \`${command}\`, not read by nmr. Did you mean \`nmr ${DELEGATE_FLAG} <host> ${command}\`?`);
92
+ }
93
+ export function resolveDelegationTarget(options) {
94
+ const { command, env, flagHost, isOptedOut, isReading } = options;
95
+ if (isOptedOut) {
96
+ return { kind: 'local' };
97
+ }
98
+ const isDelegable = !isReading && !NON_DELEGABLE_COMMANDS.has(command);
99
+ if (flagHost !== undefined) {
100
+ if (isDelegable) {
101
+ return { kind: 'remote', host: flagHost, source: 'flag' };
102
+ }
103
+ const subject = isReading ? '--log' : `\`${command}\``;
104
+ return {
105
+ kind: 'refused',
106
+ error: `${subject} cannot be delegated: it ${isReading ? "reads this machine's recordings" : 'rewrites the working tree or runs until stopped'}. Run it without ${DELEGATE_FLAG}.`,
107
+ };
108
+ }
109
+ const envHost = env[DELEGATE_ENV_VAR];
110
+ if (envHost !== undefined && envHost !== '' && isDelegable) {
111
+ return { kind: 'remote', host: envHost, source: 'env' };
112
+ }
113
+ return { kind: 'local' };
114
+ }
115
+ export async function runRemotely(options) {
116
+ const { cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
117
+ const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
118
+ const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
119
+ const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
120
+ if (prepare.exitCode !== 0) {
121
+ return { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare' };
122
+ }
123
+ const push = await runProcess([
124
+ 'git',
125
+ 'push',
126
+ '--force',
127
+ '--no-verify',
128
+ '--quiet',
129
+ `--receive-pack=${RECEIVE_PACK_COMMAND}`,
130
+ `${host}:${checkoutPath}`,
131
+ `${snapshot.sha}:${SNAPSHOT_REF}`,
132
+ ], { cwd: snapshot.toplevel, env: { ...env, GIT_SSH_COMMAND: ['ssh', ...SSH_OPTIONS].join(' ') } });
133
+ if (push.exitCode !== 0) {
134
+ return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push' };
135
+ }
136
+ const checkout = await runSsh(host, `cd ${checkoutDir} && git checkout --quiet --force --detach ${snapshot.sha} && git clean --force -d --quiet`, env);
137
+ if (checkout.exitCode !== 0) {
138
+ return { ok: false, message: `could not check out on ${host}`, output: checkout.output, phase: 'check out' };
139
+ }
140
+ const rootDir = joinRemotePath(checkoutDir, path.relative(snapshot.toplevel, monorepoRoot));
141
+ const install = await runSsh(host, `cd ${rootDir} && CI=true pnpm install --frozen-lockfile`, env);
142
+ if (install.exitCode !== 0) {
143
+ const message = install.exitCode === COMMAND_NOT_FOUND_EXIT_CODE
144
+ ? `could not install on ${host}: pnpm is not on the PATH that the interactive login shell sets there`
145
+ : `could not install on ${host}`;
146
+ return { ok: false, message, output: install.output, phase: 'install' };
147
+ }
148
+ const runDir = joinRemotePath(checkoutDir, path.relative(snapshot.toplevel, cwd));
149
+ const statusPath = `${checkoutDir}/.git/${quoteForShell(RUN_STATUS_FILE)}`;
150
+ const run = await runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(composeRunScript({ nmrArgs, remoteEnv, runDir, statusPath }))], {
151
+ env,
152
+ isStreaming: true,
153
+ stderr,
154
+ stdout,
155
+ });
156
+ if (run.exitCode === DELEGATION_FAILURE_EXIT_CODE) {
157
+ const recorded = await runSsh(host, `cat ${statusPath}`, env);
158
+ if (recorded.exitCode !== 0 || recorded.output.trim() !== String(DELEGATION_FAILURE_EXIT_CODE)) {
159
+ return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run' };
160
+ }
161
+ }
162
+ return { ok: true, exitCode: run.exitCode };
163
+ }
164
+ export function wrapRemoteScript(script) {
165
+ return `exec /bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}\n${script}`)}`;
166
+ }
167
+ const PATH_MARKER = '__NMR_LOGIN_PATH__=';
168
+ const LOGIN_PATH_PREAMBLE = [
169
+ `nmr_login_path=$("\${SHELL:-/bin/sh}" -ilc 'printf "\\n${PATH_MARKER}%s\\n" "$PATH"' </dev/null 2>/dev/null | sed -n 's/^${PATH_MARKER}//p' | tail -n 1)`,
170
+ '[ -n "$nmr_login_path" ] && PATH=$nmr_login_path',
171
+ 'export PATH',
172
+ ].join('; ');
173
+ const RECEIVE_PACK_COMMAND = `/bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}; exec git receive-pack "$1"`)} nmr-receive-pack`;
174
+ const RUN_LAUNCHER_SOURCE = [
175
+ "const { spawn } = require('node:child_process');",
176
+ "const { constants } = require('node:os');",
177
+ 'const [file, ...args] = process.argv.slice(1);',
178
+ "const child = spawn(file, args, { detached: true, stdio: ['ignore', 'inherit', 'inherit'] });",
179
+ "const stop = () => { try { process.kill(-child.pid, 'SIGTERM'); } catch {} };",
180
+ "process.stdin.on('end', stop).on('error', stop).resume();",
181
+ "child.on('error', (error) => { console.error(file + ': ' + error.message); process.exit(127); });",
182
+ "child.on('close', (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] ?? 0)));",
183
+ ].join('\n');
184
+ const RUN_STATUS_FILE = 'nmr-run-status';
185
+ const COMMAND_NOT_FOUND_EXIT_CODE = 127;
186
+ const PHASE_OUTPUT_LIMIT_BYTES = 65_536;
187
+ const NON_DELEGABLE_BASE_COMMANDS = ['build', 'clean', 'compile', 'fix', 'fmt', 'lint', 'test:watch', 'upgrade'];
188
+ const NON_DELEGABLE_COMMANDS = new Set([
189
+ ...NON_DELEGABLE_BASE_COMMANDS,
190
+ ...NON_DELEGABLE_BASE_COMMANDS.map((command) => `root:${command}`),
191
+ ]);
192
+ const SNAPSHOT_IDENTITY_ENV = {
193
+ GIT_AUTHOR_DATE: '@0 +0000',
194
+ GIT_AUTHOR_EMAIL: 'nmr@localhost',
195
+ GIT_AUTHOR_NAME: 'nmr delegate',
196
+ GIT_COMMITTER_DATE: '@0 +0000',
197
+ GIT_COMMITTER_EMAIL: 'nmr@localhost',
198
+ GIT_COMMITTER_NAME: 'nmr delegate',
199
+ };
200
+ const SNAPSHOT_REF = 'refs/nmr/delegate';
201
+ function forwardChunk(chunk, stream, buffer) {
202
+ if (stream === undefined) {
203
+ buffer.append(chunk);
204
+ return;
205
+ }
206
+ stream.write(chunk);
207
+ }
208
+ function joinRemotePath(checkoutDir, relativeDir) {
209
+ const posixDir = relativeDir.split(path.sep).join('/');
210
+ return posixDir === '' ? checkoutDir : `${checkoutDir}/${quoteForShell(posixDir)}`;
211
+ }
212
+ function runGit(args, cwd, env) {
213
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT });
214
+ if (result.error !== undefined) {
215
+ return { ok: false, error: result.error.message };
216
+ }
217
+ if (result.status !== 0) {
218
+ return { ok: false, error: result.stderr.trim() || `git ${args[0] ?? ''} exited ${result.status ?? 'abnormally'}` };
219
+ }
220
+ return { ok: true, stdout: result.stdout };
221
+ }
222
+ function runProcess(argv, options) {
223
+ const { cwd, env, isStreaming = false, stderr, stdout } = options;
224
+ const [file = '', ...args] = argv;
225
+ const buffer = createBoundedBuffer({
226
+ headLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
227
+ tailLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
228
+ });
229
+ return new Promise((resolve) => {
230
+ const child = spawn(file, args, { cwd, env, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
231
+ child.stdout?.on('data', (chunk) => {
232
+ forwardChunk(chunk, isStreaming ? stdout : undefined, buffer);
233
+ });
234
+ child.stderr?.on('data', (chunk) => {
235
+ forwardChunk(chunk, isStreaming ? stderr : undefined, buffer);
236
+ });
237
+ child.on('error', (error) => {
238
+ resolve({ exitCode: COMMAND_NOT_FOUND_EXIT_CODE, output: `${file}: ${error.message}` });
239
+ });
240
+ child.on('close', (code, signal) => {
241
+ child.stdin?.destroy();
242
+ resolve({
243
+ exitCode: code ?? 128 + (signal === null ? 0 : os.constants.signals[signal]),
244
+ output: buffer.readBuffer().toString('utf8'),
245
+ });
246
+ });
247
+ });
248
+ }
249
+ function runSsh(host, script, env) {
250
+ return runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], { env });
251
+ }
@@ -14,10 +14,12 @@ import { readFilterSelection } from "./helpers/filter-selection.js";
14
14
  import { findClosestName } from "./helpers/findClosestName.js";
15
15
  import { isHookName } from "./helpers/hook-name.js";
16
16
  import { resolvePackageJsonPath } from "./helpers/package-json.js";
17
+ import { quoteForShell } from "./helpers/quoteForShell.js";
17
18
  import { composeTranscript } from "./helpers/transcript.js";
18
19
  import { acquireMachineLock, formatMisplacedNoLockWarning, HEAVY_COMMANDS, LOCK_TOKEN_ENV_VAR, NO_LOCK_ENV_VAR, } from "./machine-lock.js";
19
20
  import { isTerminalStream, OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG, resolveOutputStyles } from "./output-style.js";
20
21
  import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
22
+ import { createSnapshot, DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATION_FAILURE_EXIT_CODE, formatMisplacedDelegationWarning, NO_DELEGATE_FLAG, resolveDelegationTarget, runRemotely, } from "./remote-run.js";
21
23
  import { assembleReplay } from "./replay-assembly.js";
22
24
  import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
23
25
  import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, findChainedSelfReference, readPackageJsonScripts, resolveScript, } from "./resolver.js";
@@ -38,6 +40,10 @@ export async function runCli(options) {
38
40
  return { exitCode: established.result.exitCode };
39
41
  }
40
42
  const invocation = established.value;
43
+ const delegated = await runOnDelegateHost(invocation);
44
+ if (delegated !== undefined) {
45
+ return delegated;
46
+ }
41
47
  const planned = await planRun(invocation);
42
48
  const { exitCode, isUnknownCommand = false } = planned.settled ? planned.result : await executePlan(planned.value);
43
49
  if (exitCode !== 0 && !isUnknownCommand && isTopLevelInvocation(options.env)) {
@@ -92,6 +98,7 @@ async function establishInvocation(options) {
92
98
  anchorDir,
93
99
  command: parsedArgs.command,
94
100
  context,
101
+ cwd,
95
102
  env,
96
103
  format,
97
104
  parsedArgs,
@@ -253,6 +260,77 @@ async function executePlan(plan) {
253
260
  reportVerdict({ command, scope, ...outcome }, stdout, format, styles.stdout);
254
261
  return { exitCode };
255
262
  }
263
+ async function runOnDelegateHost(invocation) {
264
+ const { command, context, cwd, env, format, parsedArgs, scope, stderr, stdout, styles } = invocation;
265
+ if (parsedArgs.passthrough.some(isDelegationFlag)) {
266
+ stderr.write(`${formatMisplacedDelegationWarning(command, styles.stderr)}
267
+ `);
268
+ }
269
+ const target = resolveDelegationTarget({
270
+ command,
271
+ env,
272
+ flagHost: parsedArgs.delegateHost,
273
+ isOptedOut: parsedArgs.shouldRunLocally,
274
+ isReading: parsedArgs.shouldPrintLog,
275
+ });
276
+ if (target.kind === 'local') {
277
+ return undefined;
278
+ }
279
+ if (target.kind === 'refused') {
280
+ reportError(target.error, stderr);
281
+ return { exitCode: 1 };
282
+ }
283
+ const { host } = target;
284
+ const startedAtMs = Date.now();
285
+ const snapshot = createSnapshot(cwd, env);
286
+ if (!snapshot.ok && snapshot.isUnsupported) {
287
+ if (target.source === 'flag') {
288
+ reportError(`Cannot delegate to ${host}: ${snapshot.reason}`, stderr);
289
+ return { exitCode: 1 };
290
+ }
291
+ stderr.write(`nmr: running ${command} here rather than on ${host}: ${snapshot.reason}\n`);
292
+ return undefined;
293
+ }
294
+ const result = snapshot.ok
295
+ ? await runRemotely({
296
+ cwd,
297
+ env,
298
+ host,
299
+ monorepoRoot: context.monorepoRoot,
300
+ nmrArgs: composeForwardedArgs(command, parsedArgs),
301
+ remoteEnv: {
302
+ [COMMAND_VERBOSITY_ENV_VAR]: invocation.verbosity,
303
+ [OUTPUT_STYLE_ENV_VAR]: styles.stdout,
304
+ [REPORT_FORMAT_ENV_VAR]: format,
305
+ },
306
+ snapshot,
307
+ stderr,
308
+ stdout,
309
+ })
310
+ : { ok: false, message: 'could not snapshot the working tree', output: snapshot.reason };
311
+ const durationMs = Date.now() - startedAtMs;
312
+ if (!result.ok) {
313
+ if (result.output.trim() !== '') {
314
+ stderr.write(result.output.endsWith('\n') ? result.output : `${result.output}\n`);
315
+ }
316
+ const verdict = {
317
+ command,
318
+ detail: result.message,
319
+ durationMs,
320
+ exitCode: DELEGATION_FAILURE_EXIT_CODE,
321
+ host,
322
+ outcome: 'failed',
323
+ scope,
324
+ };
325
+ reportVerdict(verdict, stdout, format, styles.stdout);
326
+ return { exitCode: DELEGATION_FAILURE_EXIT_CODE };
327
+ }
328
+ const outcome = result.exitCode === 0
329
+ ? { outcome: 'passed', durationMs }
330
+ : { outcome: 'failed', durationMs, exitCode: result.exitCode };
331
+ reportVerdict({ command, host, scope, ...outcome }, stdout, format, styles.stdout);
332
+ return { exitCode: result.exitCode };
333
+ }
256
334
  const FILTER_FLAGS = new Set(['-F', '--filter']);
257
335
  const BOOLEAN_FLAGS = new Map([
258
336
  ['-?', 'shouldShowHelp'],
@@ -260,6 +338,7 @@ const BOOLEAN_FLAGS = new Map([
260
338
  ['--json', 'shouldEmitJson'],
261
339
  ['--log', 'shouldPrintLog'],
262
340
  ['--no-cache', 'shouldBypassCache'],
341
+ [NO_DELEGATE_FLAG, 'shouldRunLocally'],
263
342
  ['--no-lock', 'shouldBypassLock'],
264
343
  ['-q', 'quiet'],
265
344
  ['--quiet', 'quiet'],
@@ -273,8 +352,9 @@ const BOOLEAN_FLAGS = new Map([
273
352
  function buildChildEnv(options) {
274
353
  const { env, format, passthrough, runId, shouldBypassCache, shouldBypassLock, snapshot, styles, verbosity } = options;
275
354
  const bypassesCache = shouldBypassCache || passthrough.length > 0;
355
+ const { [DELEGATE_ENV_VAR]: _delegateHost, ...inheritedEnv } = env;
276
356
  return {
277
- ...env,
357
+ ...inheritedEnv,
278
358
  ...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
279
359
  ...(bypassesCache && { [NO_CACHE_ENV_VAR]: '1' }),
280
360
  ...(shouldBypassLock && { [NO_LOCK_ENV_VAR]: '1' }),
@@ -340,6 +420,19 @@ function composeDelegation(options) {
340
420
  },
341
421
  };
342
422
  }
423
+ function composeForwardedArgs(command, parsedArgs) {
424
+ return [
425
+ ...(parsedArgs.filter === undefined ? [] : ['--filter', parsedArgs.filter]),
426
+ ...(parsedArgs.recursive ? ['--recursive'] : []),
427
+ ...(parsedArgs.isWorkspaceRoot ? ['--workspace-root'] : []),
428
+ ...(parsedArgs.quiet ? ['--quiet'] : []),
429
+ ...(parsedArgs.shouldEmitJson ? ['--json'] : []),
430
+ ...(parsedArgs.shouldBypassCache ? ['--no-cache'] : []),
431
+ ...(parsedArgs.shouldBypassLock ? ['--no-lock'] : []),
432
+ command,
433
+ ...parsedArgs.passthrough,
434
+ ];
435
+ }
343
436
  async function composeRetention(options) {
344
437
  const { anchorDir, command, key, retainedOutput, runId } = options;
345
438
  if (retainedOutput !== undefined) {
@@ -662,6 +755,7 @@ function parseArgs(args) {
662
755
  shouldBypassLock: false,
663
756
  shouldEmitJson: false,
664
757
  shouldPrintLog: false,
758
+ shouldRunLocally: false,
665
759
  shouldShowHelp: false,
666
760
  shouldShowVersion: false,
667
761
  passthrough: [],
@@ -696,6 +790,15 @@ function parseArgs(args) {
696
790
  index += styleArgument.consumedCount;
697
791
  continue;
698
792
  }
793
+ const delegateArgument = readDelegateArgument(args, index);
794
+ if (delegateArgument !== undefined) {
795
+ if (!delegateArgument.ok) {
796
+ return delegateArgument;
797
+ }
798
+ parsedArgs.delegateHost = delegateArgument.value;
799
+ index += delegateArgument.consumedCount;
800
+ continue;
801
+ }
699
802
  parsedArgs.command = arg;
700
803
  parsedArgs.passthrough = args.slice(index + 1);
701
804
  break;
@@ -742,18 +845,24 @@ function readPresentation(options) {
742
845
  }
743
846
  return { ok: true, format: format.format, styles, verbosity: verbosity.verbosity };
744
847
  }
745
- function readOutputStyleArgument(args, index) {
848
+ function readDelegateArgument(args, index) {
849
+ return readFlagValue(args, index, DELEGATE_FLAG, 'a host argument');
850
+ }
851
+ function readFlagValue(args, index, flag, requirement) {
746
852
  const arg = args[index] ?? '';
747
- const assignment = arg.startsWith(`${OUTPUT_STYLE_FLAG}=`) ? arg.slice(OUTPUT_STYLE_FLAG.length + 1) : undefined;
748
- if (arg !== OUTPUT_STYLE_FLAG && assignment === undefined) {
853
+ const assignment = arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : undefined;
854
+ if (arg !== flag && assignment === undefined) {
749
855
  return undefined;
750
856
  }
751
857
  const value = assignment ?? args[index + 1];
752
858
  if (!value) {
753
- return { ok: false, error: `${OUTPUT_STYLE_FLAG} requires a value argument: auto, plain, or rich` };
859
+ return { ok: false, error: `${flag} requires ${requirement}` };
754
860
  }
755
861
  return { ok: true, value, consumedCount: assignment === undefined ? 2 : 1 };
756
862
  }
863
+ function readOutputStyleArgument(args, index) {
864
+ return readFlagValue(args, index, OUTPUT_STYLE_FLAG, 'a value argument: auto, plain, or rich');
865
+ }
757
866
  function readRunnableSteps(resolvedScript, workspacePackageDirs) {
758
867
  if (workspacePackageDirs.length > 0) {
759
868
  return { isEmptiedByWorkspace: false, steps: resolvedScript.steps };
@@ -1004,6 +1113,9 @@ function isDefinedInAnyScope(command, context) {
1004
1113
  }
1005
1114
  return [context.monorepoRoot, ...context.workspacePackageDirs].some((dir) => Object.hasOwn(readPackageJsonScripts(dir) ?? {}, command));
1006
1115
  }
1116
+ function isDelegationFlag(arg) {
1117
+ return arg === DELEGATE_FLAG || arg === NO_DELEGATE_FLAG || arg.startsWith(`${DELEGATE_FLAG}=`);
1118
+ }
1007
1119
  function isTopLevelInvocation(env) {
1008
1120
  const runId = env[RUN_ID_ENV_VAR];
1009
1121
  return runId === undefined || runId === '';
@@ -1032,8 +1144,8 @@ function readMisplacedFlagLength(args, index) {
1032
1144
  if (booleanFlag !== undefined) {
1033
1145
  return booleanFlag === 'shouldShowHelp' || booleanFlag === 'shouldShowVersion' ? undefined : 1;
1034
1146
  }
1035
- const styleArgument = readOutputStyleArgument(args, index);
1036
- return styleArgument?.ok === true ? styleArgument.consumedCount : undefined;
1147
+ const valueArgument = readOutputStyleArgument(args, index) ?? readDelegateArgument(args, index);
1148
+ return valueArgument?.ok === true ? valueArgument.consumedCount : undefined;
1037
1149
  }
1038
1150
  function splitMisplacedFlags(passthrough) {
1039
1151
  const flagNames = new Set();
@@ -1052,15 +1164,12 @@ function splitMisplacedFlags(passthrough) {
1052
1164
  index++;
1053
1165
  continue;
1054
1166
  }
1055
- flagNames.add(arg.startsWith(`${OUTPUT_STYLE_FLAG}=`) ? OUTPUT_STYLE_FLAG : arg);
1167
+ flagNames.add(arg.split('=', 1)[0] ?? arg);
1056
1168
  movedTokens.push(...passthrough.slice(index, index + flagLength));
1057
1169
  index += flagLength;
1058
1170
  }
1059
1171
  return { flagNames: [...flagNames], movedTokens, remainingTokens };
1060
1172
  }
1061
- function quoteForShell(arg) {
1062
- return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
1063
- }
1064
1173
  function wrapWithHooks(command, mainSteps, registry, anchorDir, isWorkspaceRoot) {
1065
1174
  const steps = [];
1066
1175
  if (hasRunnableHook(`${command}:pre`, registry, anchorDir, isWorkspaceRoot)) {
@@ -29,6 +29,8 @@ export type NoOpReason = 'empty-override' | 'empty-workspace' | 'noop-override';
29
29
  */
30
30
  export type Verdict = {
31
31
  command: string;
32
+ /** The host on which a delegated command ran, which a command that ran here does not have. */
33
+ host?: string;
32
34
  scope: string;
33
35
  } & VerdictOutcome;
34
36
  /**
@@ -23,13 +23,14 @@ const MIN_CUT_BYTES = Buffer.byteLength(TRUNCATION_MARK);
23
23
  const REPLAY_MARKER = 'replayed:';
24
24
  function describeOutcome(verdict, style) {
25
25
  const statuses = STATUS_GLYPHS[style];
26
+ const onHost = verdict.host === undefined ? '' : ` on ${verdict.host}`;
26
27
  switch (verdict.outcome) {
27
28
  case 'passed':
28
- return { marker: statuses.passed.text, phrase: `passed in ${formatDuration(verdict.durationMs)}` };
29
+ return { marker: statuses.passed.text, phrase: `passed${onHost} in ${formatDuration(verdict.durationMs)}` };
29
30
  case 'failed':
30
31
  return {
31
32
  marker: statuses.failed.text,
32
- phrase: `failed in ${formatDuration(verdict.durationMs)} (exit ${verdict.exitCode})`,
33
+ phrase: `failed${onHost} in ${formatDuration(verdict.durationMs)} (exit ${verdict.exitCode})`,
33
34
  };
34
35
  case 'recalled': {
35
36
  const saving = formatSaving(verdict.savedMs);
@@ -135,10 +135,9 @@ function matchSubpath(exportsValue, subpath) {
135
135
  continue;
136
136
  const base = key.slice(0, star);
137
137
  const suffix = key.slice(star + 1);
138
- if (!subpath.startsWith(base) || !subpath.endsWith(suffix))
139
- continue;
140
- if (subpath.length < base.length + suffix.length)
138
+ if (!subpath.startsWith(base) || !subpath.endsWith(suffix) || subpath.length < base.length + suffix.length) {
141
139
  continue;
140
+ }
142
141
  if (bestMatch !== undefined && bestMatch.base.length >= base.length)
143
142
  continue;
144
143
  bestMatch = { base, value, wildcard: subpath.slice(base.length, subpath.length - suffix.length) };
@@ -93,7 +93,7 @@ $ nmr check
93
93
  ⏩ nmr-core: check: passed 3m ago on this tree, saved ~48s — replayed: nmr-core: fmt:check: All matched files use Prettier code style!; nmr-core: test: Test Files 6 passed (6) Tests 41 passed (41)
94
94
  ```
95
95
 
96
- The assembly is flat: A constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test` rather than one opaque `check:strict` line. A delegate expands into the scopes to which it fans out, and a constituent that did not record an excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all. `ci` replays what `check:strict` earned and nothing for `build`.
96
+ The assembly is flat: A constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test` rather than one opaque `check:strict` line. A fan-out expands into the scopes that it selects, and a constituent that did not record an excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all. `ci` replays what `check:strict` earned and nothing for `build`.
97
97
 
98
98
  Every excerpt in the assembly was certified during a single run, on a single tree, because nmr assembles it when the composite records its pass rather than when it skips. `NMR_RUN_ID` makes that provable; see [reserved environment variables](#reserved-environment-variables).
99
99
 
@@ -133,7 +133,7 @@ $ nmr --log test
133
133
  🟠 nmr: test: no recording; the last pass was 3m ago, on a tree that this is not
134
134
  ```
135
135
 
136
- **`--log` applies to the scopes that `-F` and `-R` select.** nmr passes the flag to the delegate. Each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: Partial coverage is normal for a survey, and failing on the first gap would hide every scope that had something to show. A selection that does not include any scope is the other case and fails, as it does for a run: An empty selection is not a partial survey.
136
+ **`--log` applies to the scopes that `-F` and `-R` select.** nmr passes the flag to each scope that it selects. Each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: Partial coverage is normal for a survey, and failing on the first gap would hide every scope that had something to show. A selection that does not include any scope is the other case and fails, as it does for a run: An empty selection is not a partial survey.
137
137
 
138
138
  **Retention has a ceiling of 256 KiB per recorded pass**, distinct from the 512 bytes to which a verdict line is held. nmr keeps 128 KiB from each end of a transcript that overruns it, because a run's opening and its closing statement each contain what the other does not, and it marks the drop with a line that names the dropped bytes, rather than cutting silently.
139
139
 
@@ -0,0 +1,61 @@
1
+ # Delegating to another machine
2
+
3
+ nmr runs a command on another machine over SSH, against the working tree on this one, and reports the command's output and exit status here:
4
+
5
+ ```console
6
+ $ nmr --delegate imac2 ci
7
+ # ... the host's own output and verdicts ...
8
+ ✅ my-repo: ci: passed on imac2 in 1m 48s
9
+ ```
10
+
11
+ A machine that runs several worktrees' checks at once runs out of memory, and the [machine lock](machine-lock.md) protects it by making the checks wait for one another. Delegation moves a check to a machine that has the room for it instead.
12
+
13
+ ## Choosing where a command runs
14
+
15
+ | Step | Effect |
16
+ | ----------------------------- | ----------------------------------------------------------------------- |
17
+ | `nmr --delegate <host> <cmd>` | Runs the command on `<host>`. `--delegate=<host>` is the same. |
18
+ | `NMR_DELEGATE=<host>` | Runs every command that can be delegated on `<host>`. |
19
+ | `nmr --no-delegate <cmd>` | Runs the command here, whatever `NMR_DELEGATE` names. It outranks both. |
20
+
21
+ `<host>` is anything that `ssh` accepts: a name from `~/.ssh/config`, `user@host`, or an address. Like every nmr flag, these belong before the command name; nmr warns about one written after it, because the command then runs here.
22
+
23
+ `NMR_DELEGATE` is how an agent session's checks reach the host without any change to the commands that the agent runs: Set it in the session's environment.
24
+
25
+ ## What can be delegated
26
+
27
+ Every command whose contribution is its exit status: `ci`, `prepush`, `check`, the `test` commands, `typecheck`, and a custom script such as `test:e2e`. nmr decides on the name that the invocation gives, so `ci` runs on the host as one unit, `build` step included.
28
+
29
+ These run here, and `--delegate` refuses them: `build`, `clean`, `compile`, `fix`, `fmt`, `lint`, `test:watch`, and `upgrade`, with their `root:` forms, which rewrite the working tree or run until stopped; and `--log`, which reads this machine's recordings. Under `NMR_DELEGATE`, they run here without comment.
30
+
31
+ ## What the host runs
32
+
33
+ nmr records the working tree as it is on disk as a commit: tracked changes, staged or not, and untracked files that git does not ignore. This is the tree that a local run checks. The commit is built in a temporary index, so the repository's index, refs, and stash do not change.
34
+
35
+ nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each repository on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. nmr does not remove these checkouts.
36
+
37
+ - **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
38
+ - **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. `--delegate` refuses such a tree; under `NMR_DELEGATE`, the command runs here, and nmr prints one line saying why.
39
+ - **One worktree delegates one command at a time.** Two delegations from the same worktree at once share a checkout on the host.
40
+
41
+ On the host, the command runs under the host's own [machine lock](machine-lock.md) and [check-result cache](check-cache.md), so a host that serves several worktrees runs their heavy commands one at a time, and a tree that already passed there skips. A delegated run does not take the lock here, and it neither consults nor records this machine's cache, whose key is specific to the machine.
42
+
43
+ Ctrl-C ends the run on the host as well as here.
44
+
45
+ ## Reporting
46
+
47
+ The host's nmr prints its output and verdicts, and nmr here adds one verdict for the whole delegation, its transfer and install included, which names the host. `--json` adds a `host` field to that object; see [reporting for a machine](reporting.md#reporting-for-a-machine).
48
+
49
+ A failure of the delegation itself, rather than of the command, exits 255, the status with which `ssh` reports its own failures, and names the step that failed, after what that step printed:
50
+
51
+ ```console
52
+ ❌ my-repo: ci: failed on imac2 in 10.0s (exit 255) — could not prepare imac2
53
+ ```
54
+
55
+ The steps are `prepare`, which reaches the host and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
56
+
57
+ ## Setting up the host
58
+
59
+ - **SSH that does not prompt.** nmr runs `ssh` with `BatchMode=yes`, so a key that needs a passphrase must be in the agent, and the host key must already be known. An unreachable host fails within 10 seconds.
60
+ - **git, Node, and pnpm on the interactive login shell's `PATH`.** nmr reads `PATH` from the host's login shell started as an interactive one, which loads `~/.zshrc` or `~/.bashrc` as well as the login profile: That is where pnpm's installer and the common version managers put Node and pnpm. What the startup files print does not reach the output, but whatever else they do runs before each step. When pnpm is not on that `PATH`, the install step fails, naming `PATH`. The login shell must accept `-ilc`: bash and zsh do.
61
+ - **A sandboxed agent session needs network access to the host.** A sandbox that allows only HTTP egress refuses `ssh`, and the delegation fails at `prepare`.
@@ -17,9 +17,10 @@ The waiting line goes to stderr in every verbosity and report format. The time s
17
17
  - **`test:watch` does not take the lock.** It runs until it is stopped, and would block every other run for as long.
18
18
  - **A composite takes the lock when it reaches a heavy step.** `prepush` runs its audit unlocked, then waits, if it has to, before `ci`.
19
19
  - **A command nested in a heavy one runs under its hold.** The holder passes its token to the processes that it starts, so `ci` running `check:strict` running `test` takes the lock once.
20
- - **A fan-out takes the lock once.** `nmr -R test` and `nmr -F <pattern> test` hold it in the delegating process, so the packages that pnpm runs in parallel do not wait on one another.
20
+ - **A fan-out takes the lock once.** `nmr -R test` and `nmr -F <pattern> test` hold it in the process that fans out, so the packages that pnpm runs in parallel do not wait on one another.
21
21
  - **A cached pass does not wait.** nmr consults the [check-result cache](check-cache.md) before it takes the lock, and a command that the cache serves never waits. A run that did wait consults the cache again, because the run that it waited on may have recorded a pass for the same tree.
22
22
  - **`--log` does not take the lock**, because it runs nothing.
23
+ - **A [delegated](delegate.md) command takes the host's lock**, not this machine's.
23
24
 
24
25
  ## Where the lock lives
25
26
 
package/docs/reporting.md CHANGED
@@ -29,7 +29,7 @@ $ nmr --json check
29
29
  {"command":"check","scope":"nmr-core","outcome":"passed","durationMs":31204}
30
30
  ```
31
31
 
32
- Every object contains `command`, `scope`, and `outcome`, and then the fields that only that outcome includes:
32
+ Every object contains `command`, `scope`, and `outcome`, and then the fields that only that outcome includes. A [delegated](delegate.md) command's own verdict also contains `host`, the host on which it ran:
33
33
 
34
34
  | `outcome` | Also contains |
35
35
  | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -77,7 +77,7 @@
77
77
  "!docs/*.v*.md"
78
78
  ],
79
79
  "dependencies": {
80
- "@williamthorsen/nmr-core": "0.13.3",
80
+ "@williamthorsen/nmr-core": "0.13.4",
81
81
  "@williamthorsen/toolbelt.errors": "0.8.3",
82
82
  "@williamthorsen/toolbelt.filesystem": "0.9.3",
83
83
  "glob": "13.0.6",
@@ -90,10 +90,10 @@
90
90
  "@williamthorsen/toolbelt.testing": "0.6.3",
91
91
  "@williamthorsen/toolbelt.vitest": "0.10.3",
92
92
  "esbuild": "0.28.2",
93
- "readyup": "0.40.0",
93
+ "readyup": "0.41.0",
94
94
  "tinyglobby": "0.2.17",
95
95
  "typescript": "6.0.3",
96
- "vite": "8.3.3",
96
+ "vite": "8.3.4",
97
97
  "vitest": "4.1.11",
98
98
  "yaml": "2.9.1"
99
99
  },