@williamthorsen/nmr 0.49.0 → 0.50.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,48 @@
1
1
  [
2
+ {
3
+ "version": "0.50.0",
4
+ "date": "2026-10-21",
5
+ "sections": [
6
+ {
7
+ "title": "🎉 Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Makes `nmr` run a command on this machine, after printing `nmr: running <command> here rather than on <hosts>` and what `ssh` reported, when the user config's `when` sends the command to other hosts and `ssh` cannot connect to any of them; until now, the delegation failed with exit 255 and a line that named `~/.config/nmr/config.ts`. (#1090)",
12
+ "hash": "07d3b1775a7d1353c3fdf2249970c11214e6a5af",
13
+ "entry": 1
14
+ },
15
+ {
16
+ "description": "Makes `nmr` delegate to a host that `ssh` connected to, rather than to the first listed host, when several delegation hosts are configured and the lock probe finds every one of them neither free nor busy. (#1090)",
17
+ "hash": "07d3b1775a7d1353c3fdf2249970c11214e6a5af",
18
+ "entry": 2
19
+ }
20
+ ]
21
+ },
22
+ {
23
+ "title": "🐛 Bug fixes",
24
+ "audience": "all",
25
+ "items": [
26
+ {
27
+ "description": "Fixes the issue that delegation ignored the environment's `GIT_SSH_COMMAND`: `nmr` replaced it with plain `ssh` for the push and ran plain `ssh` for every other call to the host, which kept a machine that reaches its hosts through an ssh wrapper or proxy, such as the Claude Code sandbox, from delegating. (#1088)",
28
+ "hash": "76f6fc6f3501848422196d551393e29813527ae8",
29
+ "entry": 1
30
+ }
31
+ ]
32
+ },
33
+ {
34
+ "title": "📚 Documentation",
35
+ "audience": "dev",
36
+ "items": [
37
+ {
38
+ "description": "Documents in `docs/delegate.md` and the nmr rulebook that a sandboxed session must admit the host's `HostName` on port 22, as `ssh -G <host>` prints it, rather than its `~/.ssh/config` alias. (#1088)",
39
+ "hash": "76f6fc6f3501848422196d551393e29813527ae8",
40
+ "entry": 2
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ },
2
46
  {
3
47
  "version": "0.49.0",
4
48
  "date": "2026-10-18",
@@ -94,6 +138,11 @@
94
138
  "description": "Fixes the issue that `NMR_NO_CACHE`, `NMR_DEBUG`, and `NMR_RUN_ID` set on this machine did not reach the delegation host, which left the host's cache in use under `NMR_NO_CACHE=1`. (#1080)",
95
139
  "hash": "ab09b251d5f0d641264b17e458cab60245596e08",
96
140
  "entry": 3
141
+ },
142
+ {
143
+ "description": "Make release-kit's readyup manifest independent of the compile directory (#1081)",
144
+ "body": "Fixes the issue that the manifest of release-kit's `npm-auto-publish` kit listed whichever workspaces `rdy compile` found in its working directory, so a rebuild from another directory changed it and the published copy named this repository's packages. Moves the construction of the kit's per-package rows to `rdy run` and raises the kit's `minReadyupVersion` to 0.42.0, the first readyup release that can build checks at run time.",
145
+ "hash": "bea0d66f5b804bf51259fa094a890af80917abca"
97
146
  }
98
147
  ]
99
148
  },
@@ -359,6 +408,17 @@
359
408
  }
360
409
  ]
361
410
  },
411
+ {
412
+ "title": "🏗️ Internal features",
413
+ "audience": "dev",
414
+ "items": [
415
+ {
416
+ "description": "Resolve package roots past dual-format marker manifests (#1016)",
417
+ "body": "Replaces nmr-core's hand-written package-root and version lookups with `findPackageRoot` and `resolveSelfVersion` re-exported from `@williamthorsen/toolbelt.packaging`, which pass over the `{\"type\": \"commonjs\"}` marker manifest of a dual-format layout to reach the package that declares a `name`. `nmr`, `release-kit`, and `v11y-check` switch from `readPackageVersion` to `resolveSelfVersion`, and the boundary guard now holds `toolbelt.packaging`, like `toolbelt.terminal`, to a single importer and a single declarer in nmr-core.",
418
+ "hash": "ff6627180d9e86d083656afb2c09a3fb190fc79d"
419
+ }
420
+ ]
421
+ },
362
422
  {
363
423
  "title": "♻️ Refactoring",
364
424
  "audience": "dev",
@@ -9184,7 +9184,7 @@ function hasSupportedStrictLintVersion() {
9184
9184
  });
9185
9185
  }
9186
9186
  function getMinVersion() {
9187
- const pickedFields = { "version": "0.49.0" };
9187
+ const pickedFields = { "version": "0.50.0" };
9188
9188
  if (typeof pickedFields["version"] !== "string") {
9189
9189
  throw new TypeError("nmr/package.json: 'version' must be a string");
9190
9190
  }
@@ -24,7 +24,7 @@
24
24
  "checklist": "nmr",
25
25
  "checks": [
26
26
  "@williamthorsen/nmr in devDependencies",
27
- "@williamthorsen/nmr >= 0.49.0",
27
+ "@williamthorsen/nmr >= 0.50.0",
28
28
  "pnpm-workspace.yaml exists",
29
29
  "catalogued dependencies use catalog:",
30
30
  "package.json has packageManager field",
@@ -201,7 +201,7 @@
201
201
  "path": "lib/workspace-build.ts"
202
202
  },
203
203
  {
204
- "hash": "08c09ade",
204
+ "hash": "4f47ba73",
205
205
  "kind": "inline",
206
206
  "path": "../package.json",
207
207
  "paths": [
@@ -319,7 +319,7 @@
319
319
  "readyupVersion": "0.42.0",
320
320
  "source": "kits/default.ts",
321
321
  "sourceHash": "9fd61bf4",
322
- "targetHash": "b0939edd"
322
+ "targetHash": "81b3206d"
323
323
  }
324
324
  ]
325
325
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.50.0 — 2026-10-21
6
+
7
+ ### 🎉 Features
8
+
9
+ - Makes `nmr` run a command on this machine, after printing `nmr: running <command> here rather than on <hosts>` and what `ssh` reported, when the user config's `when` sends the command to other hosts and `ssh` cannot connect to any of them; until now, the delegation failed with exit 255 and a line that named `~/.config/nmr/config.ts`. (#1090)
10
+ - Makes `nmr` delegate to a host that `ssh` connected to, rather than to the first listed host, when several delegation hosts are configured and the lock probe finds every one of them neither free nor busy. (#1090)
11
+
12
+ ### 🐛 Bug fixes
13
+
14
+ - Fixes the issue that delegation ignored the environment's `GIT_SSH_COMMAND`: `nmr` replaced it with plain `ssh` for the push and ran plain `ssh` for every other call to the host, which kept a machine that reaches its hosts through an ssh wrapper or proxy, such as the Claude Code sandbox, from delegating. (#1088)
15
+
16
+ ### 📚 Documentation
17
+
18
+ - Documents in `docs/delegate.md` and the nmr rulebook that a sandboxed session must admit the host's `HostName` on port 22, as `ssh -G <host>` prints it, rather than its `~/.ssh/config` alias. (#1088)
19
+
5
20
  ## 0.49.0 — 2026-10-18
6
21
 
7
22
  ### 🎉 Features
@@ -25,6 +40,9 @@ All notable changes to this project will be documented in this file.
25
40
 
26
41
  - Fixes the issue that the snapshot sent to the host by `nmr --delegate` omitted a file edited in the same second as git's last write of the index. (#1078)
27
42
  - Fixes the issue that `NMR_NO_CACHE`, `NMR_DEBUG`, and `NMR_RUN_ID` set on this machine did not reach the delegation host, which left the host's cache in use under `NMR_NO_CACHE=1`. (#1080)
43
+ - Make release-kit's readyup manifest independent of the compile directory (#1081)
44
+
45
+ Fixes the issue that the manifest of release-kit's `npm-auto-publish` kit listed whichever workspaces `rdy compile` found in its working directory, so a rebuild from another directory changed it and the published copy named this repository's packages. Moves the construction of the kit's per-package rows to `rdy run` and raises the kit's `minReadyupVersion` to 0.42.0, the first readyup release that can build checks at run time.
28
46
 
29
47
  ### ⚡ Performance
30
48
 
@@ -103,6 +121,12 @@ All notable changes to this project will be documented in this file.
103
121
  - Fixes the issue that the test-tier definitions in `vitest.md` gave a test that starts its own loopback server three defensible tiers: `tool` is now a process that the test starts or a program that the environment supplies, `localhost` a service already running on this machine, and an in-process server from the package's own dependency closure `unit`. (#1023)
104
122
  - Fixes the issue that `nmr`'s warning about a `package.json` script that runs `nmr` through a shell sent an entry whose whole value is `nmr <name>` to `.config/nmr.config.ts`, when the accurate edit is to replace that value with the command of the `<name>` script in the same file. (#1030)
105
123
 
124
+ ### 🏗️ Internal features
125
+
126
+ - Resolve package roots past dual-format marker manifests (#1016)
127
+
128
+ Replaces nmr-core's hand-written package-root and version lookups with `findPackageRoot` and `resolveSelfVersion` re-exported from `@williamthorsen/toolbelt.packaging`, which pass over the `{"type": "commonjs"}` marker manifest of a dual-format layout to reach the package that declares a `name`. `nmr`, `release-kit`, and `v11y-check` switch from `readPackageVersion` to `resolveSelfVersion`, and the boundary guard now holds `toolbelt.packaging`, like `toolbelt.terminal`, to a single importer and a single declarer in nmr-core.
129
+
106
130
  ### ♻️ Refactoring
107
131
 
108
132
  - Moves the work of `runInvocation` in `nmr` into the phase functions `establishInvocation`, `planRun`, and `executePlan`, which `runCli` calls in order, and changes `runGated` and `reportRecording` to take a named `RunPlan` in place of an options object that repeated its fields. (#1014)
package/README.md CHANGED
@@ -5,33 +5,16 @@
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.49.0 (2026-10-18)
8
+ ## Release notes — v0.50.0 (2026-10-21)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Makes a delegation wait while another delegation from the same worktree holds the host checkout, printing `nmr: waiting for <command> in <dir> (pid <pid>) to finish on <host>` and leaving the wait out of the verdict's duration. (#1079)
13
- - Extends Ctrl-C, which previously ended only the run step on the host, to end the delegation during the wait for the checkout, the check-out step, and the install step as well. (#1079)
14
- - Makes `nmr --delegate` fail at the check-out step with `could not check out on <host>: node is not on the PATH that the interactive login shell sets there` when the host's interactive login shell does not put Node on `PATH`, and documents that Node on the host must satisfy nmr's `engines` range (`>=24.16.0`). (#1079)
15
- - Makes `nmr --delegate` and `NMR_DELEGATE` look up this machine's check-result cache before contacting the host, so that a tree with a pass recorded here is reported as `passed … ago on this tree` and an unknown command is reported here, without either one reaching the host. (#1080)
16
- - Adds a record in this machine's check-result cache for each pass that a delegation host reports, so that a later delegation to the same host recalls it as `passed … ago on <host>` and `nmr --log` prints the host's transcript under a header that names the host. (#1080)
17
- - Makes `nmr --delegate` record the passes that the host reports for a delegated composite's constituents, so that `nmr --delegate <host> check` after `nmr --delegate <host> ci` on the same tree recalls the pass without contacting the host. (#1082)
18
- - Makes `nmr --delegate` record the excerpts that the host reports with each delegated pass, so that a recall of a delegated command, such as `ci`, replays them instead of nothing. (#1082)
19
- - Lets `--delegate` and `NMR_DELEGATE` take a comma-separated list of hosts in order of preference, from which nmr chooses the first host on which the delegation would not wait for a lock, otherwise the first host that answered nmr's lock check, and never the delegating machine. (#1083)
20
- - Extends the check-result cache to recall a pass earned on any host in the list, without checking any host's locks. (#1083)
21
- - 🚨 **Breaking:** Makes `--delegate` a boolean flag, under which `nmr` runs the command on a host that `NMR_DELEGATE` or the user config names, and adds `--delegate-to <hosts>` to name the hosts for one run. (#1084)
22
- - Adds a machine-level user config, `~/.config/nmr/config.ts`, in which `delegate.hosts` names this machine's hosts in order of preference and `delegate.when` selects the runs that `nmr` delegates to them without a flag: runs from a coding agent (`'agent'`) or every run (`'always'`), except when `CI` is set and for a `-F` or `-R` invocation. (#1084)
23
- - Adds the repo config key `delegate`, whose only accepted value, `false`, makes `nmr` run every check on the machine that has the working tree, overriding `NMR_DELEGATE` and the user config but not a delegation flag. (#1084)
24
- - Adds a stderr line, printed by `nmr` when a delegation decided by the user config cannot reach a host, that names the config file and the remedies in order: network access to the hosts, then `nmr --no-delegate <command>`. (#1084)
25
- - Updates the `nmr` rulebook to direct agents not to pass `--delegate` or `--delegate-to`, since the user config decides delegation, and to ask for network access to a host before running `nmr --no-delegate`. (#1084)
12
+ - Makes `nmr` run a command on this machine, after printing `nmr: running <command> here rather than on <hosts>` and what `ssh` reported, when the user config's `when` sends the command to other hosts and `ssh` cannot connect to any of them; until now, the delegation failed with exit 255 and a line that named `~/.config/nmr/config.ts`. (#1090)
13
+ - Makes `nmr` delegate to a host that `ssh` connected to, rather than to the first listed host, when several delegation hosts are configured and the lock probe finds every one of them neither free nor busy. (#1090)
26
14
 
27
15
  ### 🐛 Bug fixes
28
16
 
29
- - Fixes the issue that the snapshot sent to the host by `nmr --delegate` omitted a file edited in the same second as git's last write of the index. (#1078)
30
- - Fixes the issue that `NMR_NO_CACHE`, `NMR_DEBUG`, and `NMR_RUN_ID` set on this machine did not reach the delegation host, which left the host's cache in use under `NMR_NO_CACHE=1`. (#1080)
31
-
32
- ### ⚡ Performance
33
-
34
- - Keys the check cache on the git tree id that `git add --all` would record for the working tree, which lets `nmr` skip a check after the checked tree is committed, and invalidates every pass recorded by earlier versions once. (#1078)
17
+ - Fixes the issue that delegation ignored the environment's `GIT_SSH_COMMAND`: `nmr` replaced it with plain `ssh` for the push and ran plain `ssh` for every other call to the host, which kept a machine that reaches its hosts through an ssh wrapper or proxy, such as the Claude Code sandbox, from delegating. (#1088)
35
18
  <!-- /section:release-notes -->
36
19
 
37
20
  ## Installation
@@ -15,5 +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
- - Delegation is configured per machine, in nmr's user config (`~/.config/nmr/config.ts`), so a command may run on another machine without being asked: Never pass `--delegate` or `--delegate-to`. A verdict reading `passed on <host>` or `failed on <host>` reports a command that ran on that machine; the output above it is the host's. A delegation consults this machine's check-result cache first: `passed … ago on this tree` recalls a pass earned here, and `passed … ago on <host>` one that the host earned, without contacting it. A failure that names the user config, or exit 255 with `could not prepare <host>` inside a sandbox, means that the sandbox refused `ssh`: Ask for network access to the host first, and run the command here with `nmr --no-delegate <command>` only when that is refused.
18
+ - Delegation is configured per machine, in nmr's user config (`~/.config/nmr/config.ts`), so a command may run on another machine without being asked: Never pass `--delegate` or `--delegate-to`. A verdict reading `passed on <host>` or `failed on <host>` reports a command that ran on that machine; the output above it is the host's. A delegation consults this machine's check-result cache first: `passed … ago on this tree` recalls a pass earned here, and `passed … ago on <host>` one that the host earned, without contacting it. A line reading `nmr: running <command> here rather than on <hosts>` reports that `ssh` reached none of the hosts and that the command ran here, so its verdict stands. The line ends with what `ssh` reported: A `sandbox proxy` message means that the sandbox refused `ssh`, and only then does network access to the host's `HostName` on port 22, the name that `ssh -G <host>` prints rather than the alias, let later runs delegate rather than wait out the connect timeout first: Ask for it when the session will run more checks.
19
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 the user config, the rules that decide where a command runs, which commands can run 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.
@@ -8,7 +8,7 @@ import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
8
8
  import { isDelegationFlag } from "./invocation-args.js";
9
9
  import { reportVerdict } from "./presentation.js";
10
10
  import { chooseHost, probeHost } from "./remote-probe.js";
11
- import { DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATE_TO_FLAG, DELEGATION_FAILURE_EXIT_CODE, deriveCheckoutPath, formatMisplacedDelegationWarning, NO_DELEGATE_FLAG, runRemotely, } from "./remote-run.js";
11
+ import { DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATE_TO_FLAG, DELEGATION_FAILURE_EXIT_CODE, deriveCheckoutPath, formatMisplacedDelegationWarning, readLastLine, runRemotely, } from "./remote-run.js";
12
12
  import { createSnapshot } from "./remote-snapshot.js";
13
13
  import { formatDisplayPath, loadUserConfig } from "./user-config.js";
14
14
  export async function resolveExecutionTarget(invocation) {
@@ -70,6 +70,7 @@ const PROBE_RESULT_PHRASES = {
70
70
  busy: 'is busy',
71
71
  free: 'is free',
72
72
  'no-answer': 'did not answer the lock probe',
73
+ unreachable: 'could not be reached',
73
74
  };
74
75
  function composeForwardedArgs(command, parsedArgs) {
75
76
  return [
@@ -103,6 +104,9 @@ function describeDelegationSource(target, parsedArgs, env) {
103
104
  return parsedArgs.delegateHosts === undefined ? DELEGATE_FLAG : DELEGATE_TO_FLAG;
104
105
  }
105
106
  }
107
+ function describeUnreachableHost(host, sshMessage) {
108
+ return sshMessage === '' ? `${host} did not answer` : `${host} did not answer: ${sshMessage}`;
109
+ }
106
110
  async function delegate(options) {
107
111
  const { invocation, remoteEnv, target } = options;
108
112
  const { command, context, cwd, env, parsedArgs, stderr, stdout } = invocation;
@@ -114,10 +118,17 @@ async function delegate(options) {
114
118
  reportError(`Cannot delegate to ${listedHosts}: ${snapshot.reason}`, stderr);
115
119
  return { kind: 'refused', exitCode: 1 };
116
120
  }
117
- stderr.write(`nmr: running ${command} here rather than on ${listedHosts}: ${snapshot.reason}\n`);
121
+ stderr.write(formatRunHereLine(command, listedHosts, snapshot.reason));
122
+ return { kind: 'here' };
123
+ }
124
+ const choice = snapshot.ok
125
+ ? await resolveHost(target.hosts, snapshot.toplevel, env, stderr)
126
+ : { kind: 'chosen', host: target.hosts[0] };
127
+ if (choice.kind === 'unreachable' && target.source === 'config') {
128
+ stderr.write(formatRunHereLine(command, listedHosts, describeUnreachableHost(choice.host, choice.sshMessage)));
118
129
  return { kind: 'here' };
119
130
  }
120
- const host = snapshot.ok ? await resolveHost(target.hosts, snapshot.toplevel, env, stderr) : target.hosts[0];
131
+ const { host } = choice;
121
132
  const retained = { stderr: createBoundedBuffer(), stdout: createBoundedBuffer() };
122
133
  const result = snapshot.ok
123
134
  ? await runRemotely({
@@ -142,14 +153,13 @@ async function delegate(options) {
142
153
  treeId: snapshot.ok ? snapshot.treeId : undefined,
143
154
  };
144
155
  if (!result.ok) {
156
+ if (target.source === 'config' && 'phase' in result && result.phase === 'prepare' && result.isUnreachable) {
157
+ stderr.write(formatRunHereLine(command, listedHosts, describeUnreachableHost(host, readLastLine(result.output))));
158
+ return { kind: 'here' };
159
+ }
145
160
  if (result.output.trim() !== '') {
146
161
  stderr.write(result.output.endsWith('\n') ? result.output : `${result.output}\n`);
147
162
  }
148
- if (target.source === 'config' && 'phase' in result && result.phase === 'prepare') {
149
- const configPath = formatDisplayPath(target.configPath, env);
150
- stderr.write(`nmr: ${configPath} sent ${command} to ${listedHosts}; ask for network access to ${listedHosts}, ` +
151
- `or run it here with nmr ${NO_DELEGATE_FLAG} ${command}\n`);
152
- }
153
163
  return {
154
164
  ...ran,
155
165
  exitCode: DELEGATION_FAILURE_EXIT_CODE,
@@ -162,10 +172,13 @@ async function delegate(options) {
162
172
  : { outcome: 'failed', durationMs, exitCode: result.exitCode };
163
173
  return { ...ran, exitCode: result.exitCode, outcome, passes: result.passes };
164
174
  }
175
+ function formatRunHereLine(command, listedHosts, reason) {
176
+ return `nmr: running ${command} here rather than on ${listedHosts}: ${reason}\n`;
177
+ }
165
178
  async function resolveHost(hosts, toplevel, env, stderr) {
166
179
  const [first, ...rest] = hosts;
167
180
  if (rest.length === 0) {
168
- return first;
181
+ return { kind: 'chosen', host: first };
169
182
  }
170
183
  const checkoutPath = deriveCheckoutPath(os.hostname(), toplevel);
171
184
  return chooseHost({
package/dist/esm/help.js CHANGED
@@ -53,6 +53,7 @@ export function generateHelp(config, monorepoRoot, packageDir, shouldUseRoot) {
53
53
  'Files:',
54
54
  " ~/.config/nmr/config.ts User config: this machine's delegation hosts, and when to use them",
55
55
  ' ($XDG_CONFIG_HOME/nmr/config.ts when that is set)',
56
+ ' A command that it sends runs here when no host answers',
56
57
  '',
57
58
  'Workspace commands:',
58
59
  ];
@@ -1,6 +1,37 @@
1
1
  import { type HostList } from './remote-run.js';
2
- /** What a probe found on a host: whether a delegation started now would wait there, or that the host did not answer. */
3
- export type ProbeResult = 'busy' | 'free' | 'no-answer';
2
+ /**
3
+ * The host on which to run, or that `ssh` could not connect to any host, with what it printed for the first one
4
+ * listed.
5
+ */
6
+ export type HostChoice = {
7
+ kind: 'chosen';
8
+ host: string;
9
+ } | {
10
+ kind: 'unreachable';
11
+ host: string;
12
+ sshMessage: string;
13
+ };
14
+ /**
15
+ * The host that the probe results settle on, or that every host is unreachable.
16
+ *
17
+ * @internal
18
+ */
19
+ export type HostSelection = {
20
+ kind: 'chosen';
21
+ host: string;
22
+ } | {
23
+ kind: 'unreachable';
24
+ };
25
+ /** What a probe found, with the last line that `ssh` printed when it could not connect. */
26
+ export interface ProbeReport {
27
+ result: ProbeResult;
28
+ sshMessage: string;
29
+ }
30
+ /**
31
+ * What a probe found on a host: whether a delegation started now would wait there, that the host connected but did
32
+ * not answer, or that `ssh` could not connect to it.
33
+ */
34
+ export type ProbeResult = 'busy' | 'free' | 'no-answer' | 'unreachable';
4
35
  /**
5
36
  * Probes every host at once and resolves to the one on which to run, per `selectHost`, as soon as the results that
6
37
  * have arrived settle it. Reports each result that arrives before then, and aborts the probes still running once it
@@ -9,23 +40,24 @@ export type ProbeResult = 'busy' | 'free' | 'no-answer';
9
40
  export declare function chooseHost(options: {
10
41
  hosts: HostList;
11
42
  onSettled: (host: string, result: ProbeResult) => void;
12
- probe: (host: string, signal: AbortSignal) => Promise<ProbeResult>;
13
- }): Promise<string>;
43
+ probe: (host: string, signal: AbortSignal) => Promise<ProbeReport>;
44
+ }): Promise<HostChoice>;
14
45
  /**
15
46
  * Asks `host` whether a delegation from this checkout would wait there: whether its machine lock, or the lock of the
16
- * checkout at `checkoutPath`, has a live holder. A host that fails to run the probe, or does not print its report,
17
- * did not answer.
47
+ * checkout at `checkoutPath`, has a live holder. A host to which `ssh` cannot connect is unreachable, and one that
48
+ * fails to run the probe, or does not print its report, did not answer.
18
49
  */
19
50
  export declare function probeHost(options: {
20
51
  checkoutPath: string;
21
52
  env: NodeJS.ProcessEnv;
22
53
  host: string;
23
54
  signal: AbortSignal;
24
- }): Promise<ProbeResult>;
55
+ }): Promise<ProbeReport>;
25
56
  /**
26
57
  * Returns the host on which to run, or `undefined` while a host whose result could change the choice has not
27
- * answered: the first free host in list order, otherwise the first busy one, otherwise the first host.
58
+ * answered: the first free host in list order, otherwise the first busy one, otherwise the first one that connected.
59
+ * When `ssh` could not connect to any host, returns that every host is unreachable.
28
60
  *
29
61
  * @internal
30
62
  */
31
- export declare function selectHost(hosts: HostList, results: ReadonlyMap<string, ProbeResult>): string | undefined;
63
+ export declare function selectHost(hosts: HostList, results: ReadonlyMap<string, ProbeResult>): HostSelection | undefined;
@@ -4,33 +4,38 @@ import { isObject } from "./helpers/type-guards.js";
4
4
  import { LOCK_DIR_ENV_VAR, LOCK_FILE, STALE_AFTER_MS } from "./machine-lock.js";
5
5
  import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
6
6
  import { probeHostLocks } from "./remote-probe-host.js";
7
- import { runSsh } from "./remote-run.js";
7
+ import { DELEGATION_FAILURE_EXIT_CODE, readLastLine, runSsh } from "./remote-run.js";
8
8
  export function chooseHost(options) {
9
9
  const { hosts, onSettled, probe } = options;
10
10
  const controller = new AbortController();
11
11
  const results = new Map();
12
+ const sshMessages = new Map();
12
13
  return new Promise((resolve) => {
13
14
  for (const host of hosts) {
14
15
  void settle(host, resolve);
15
16
  }
16
17
  });
17
18
  async function settle(host, resolve) {
18
- let result;
19
+ let report;
19
20
  try {
20
- result = await probe(host, controller.signal);
21
+ report = await probe(host, controller.signal);
21
22
  }
22
23
  catch {
23
- result = 'no-answer';
24
+ report = { result: 'no-answer', sshMessage: '' };
24
25
  }
25
26
  if (controller.signal.aborted)
26
27
  return;
27
- results.set(host, result);
28
- onSettled(host, result);
29
- const chosen = selectHost(hosts, results);
30
- if (chosen === undefined)
28
+ results.set(host, report.result);
29
+ sshMessages.set(host, report.sshMessage);
30
+ onSettled(host, report.result);
31
+ const selection = selectHost(hosts, results);
32
+ if (selection === undefined)
31
33
  return;
32
34
  controller.abort();
33
- resolve(chosen);
35
+ const [first] = hosts;
36
+ resolve(selection.kind === 'chosen'
37
+ ? selection
38
+ : { kind: 'unreachable', host: first, sshMessage: sshMessages.get(first) ?? '' });
34
39
  }
35
40
  }
36
41
  export async function probeHost(options) {
@@ -43,17 +48,20 @@ export async function probeHost(options) {
43
48
  };
44
49
  const source = `(${probeHostLocks.toString()})(JSON.parse(process.argv[1]));`;
45
50
  const { exitCode, output } = await runSsh(host, `exec node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`, env, signal);
51
+ if (exitCode === DELEGATION_FAILURE_EXIT_CODE) {
52
+ return { result: 'unreachable', sshMessage: readLastLine(output) };
53
+ }
46
54
  if (exitCode !== 0) {
47
- return 'no-answer';
55
+ return { result: 'no-answer', sshMessage: '' };
48
56
  }
49
57
  const busy = output
50
58
  .split('\n')
51
59
  .map(readBusyField)
52
60
  .findLast((value) => value !== undefined);
53
61
  if (busy === undefined) {
54
- return 'no-answer';
62
+ return { result: 'no-answer', sshMessage: '' };
55
63
  }
56
- return busy ? 'busy' : 'free';
64
+ return { result: busy ? 'busy' : 'free', sshMessage: '' };
57
65
  }
58
66
  export function selectHost(hosts, results) {
59
67
  for (const host of hosts) {
@@ -62,10 +70,12 @@ export function selectHost(hosts, results) {
62
70
  return undefined;
63
71
  }
64
72
  if (result === 'free') {
65
- return host;
73
+ return { kind: 'chosen', host };
66
74
  }
67
75
  }
68
- return hosts.find((host) => results.get(host) === 'busy') ?? hosts[0];
76
+ const host = hosts.find((candidate) => results.get(candidate) === 'busy') ??
77
+ hosts.find((candidate) => results.get(candidate) === 'no-answer');
78
+ return host === undefined ? { kind: 'unreachable' } : { kind: 'chosen', host };
69
79
  }
70
80
  function readBusyField(line) {
71
81
  try {
@@ -10,6 +10,8 @@ export declare const DELEGATE_TO_FLAG = "--delegate-to";
10
10
  export declare const DELEGATION_FAILURE_EXIT_CODE = 255;
11
11
  /** The flag that runs the command here, whatever `NMR_DELEGATE` or the user config says. */
12
12
  export declare const NO_DELEGATE_FLAG = "--no-delegate";
13
+ /** Names the ssh command that the environment supplies, which git and nmr run in place of plain `ssh`. */
14
+ export declare const SSH_COMMAND_ENV_VAR = "GIT_SSH_COMMAND";
13
15
  /** Keeps `ssh` from prompting or waiting on a terminal, and bounds the wait for a host that does not answer. */
14
16
  export declare const SSH_OPTIONS: readonly string[];
15
17
  /** Candidate hosts in order of preference. */
@@ -19,7 +21,7 @@ export type RemotePhase = 'check out' | 'install' | 'prepare' | 'push' | 'run';
19
21
  /**
20
22
  * The status of a delegated command and the pass-manifest entries that its run reported, unvalidated, or the phase
21
23
  * that kept it from finishing and what that phase printed, with how long the delegation waited for another
22
- * delegation's checkout.
24
+ * delegation's checkout. A failed `prepare` also reports whether `ssh` could not connect to the host.
23
25
  */
24
26
  export type RemoteRunResult = {
25
27
  ok: true;
@@ -28,11 +30,32 @@ export type RemoteRunResult = {
28
30
  waitedMs: number;
29
31
  } | {
30
32
  ok: false;
33
+ isUnreachable: boolean;
31
34
  message: string;
32
35
  output: string;
33
- phase: RemotePhase;
36
+ phase: 'prepare';
37
+ waitedMs: number;
38
+ } | {
39
+ ok: false;
40
+ message: string;
41
+ output: string;
42
+ phase: Exclude<RemotePhase, 'prepare'>;
34
43
  waitedMs: number;
35
44
  };
45
+ /**
46
+ * Returns the `GIT_SSH_COMMAND` with which `git push` reaches the host: the environment's ssh command, or plain
47
+ * `ssh`, followed by nmr's own options.
48
+ *
49
+ * @internal
50
+ */
51
+ export declare function composeGitSshCommand(env: NodeJS.ProcessEnv): string;
52
+ /**
53
+ * Returns the argv that runs `remoteCommand` on `host` with nmr's own options. An ssh command in the environment
54
+ * runs under `/bin/sh` with the arguments appended, as git runs it, because it may contain shell quoting.
55
+ *
56
+ * @internal
57
+ */
58
+ export declare function composeSshArgv(env: NodeJS.ProcessEnv, host: string, remoteCommand: string): string[];
36
59
  /**
37
60
  * Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
38
61
  * repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
@@ -45,6 +68,8 @@ export declare function deriveCheckoutPath(hostname: string, toplevel: string):
45
68
  * command rather than a flag to nmr.
46
69
  */
47
70
  export declare function formatMisplacedDelegationWarning(command: string, style: OutputStyle): string;
71
+ /** Returns the last line of `output` that is not blank, trimmed, or an empty string when there is none. */
72
+ export declare function readLastLine(output: string): string;
48
73
  /**
49
74
  * Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
50
75
  * `stdout` and `stderr` and returning its exit status.
@@ -15,7 +15,18 @@ export const DELEGATE_FLAG = '--delegate';
15
15
  export const DELEGATE_TO_FLAG = '--delegate-to';
16
16
  export const DELEGATION_FAILURE_EXIT_CODE = 255;
17
17
  export const NO_DELEGATE_FLAG = '--no-delegate';
18
+ export const SSH_COMMAND_ENV_VAR = 'GIT_SSH_COMMAND';
18
19
  export const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
20
+ export function composeGitSshCommand(env) {
21
+ return [resolveSshCommand(env) ?? 'ssh', ...SSH_OPTIONS].join(' ');
22
+ }
23
+ export function composeSshArgv(env, host, remoteCommand) {
24
+ const sshCommand = resolveSshCommand(env);
25
+ const sshArgs = [...SSH_OPTIONS, host, remoteCommand];
26
+ return sshCommand === undefined
27
+ ? ['ssh', ...sshArgs]
28
+ : ['/bin/sh', '-c', `${sshCommand} "$@"`, sshCommand, ...sshArgs];
29
+ }
19
30
  export function deriveCheckoutPath(hostname, toplevel) {
20
31
  const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
21
32
  return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
@@ -25,13 +36,26 @@ export function formatMisplacedDelegationWarning(command, style) {
25
36
  `command name are passed to \`${command}\`, not read by nmr. Write them before it: ` +
26
37
  `\`nmr ${DELEGATE_TO_FLAG} <host> ${command}\`.`);
27
38
  }
39
+ export function readLastLine(output) {
40
+ return (output
41
+ .split('\n')
42
+ .map((line) => line.trim())
43
+ .findLast((line) => line !== '') ?? '');
44
+ }
28
45
  export async function runRemotely(options) {
29
46
  const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
30
47
  const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
31
48
  const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
32
49
  const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
33
50
  if (prepare.exitCode !== 0) {
34
- return { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare', waitedMs: 0 };
51
+ return {
52
+ ok: false,
53
+ isUnreachable: prepare.exitCode === DELEGATION_FAILURE_EXIT_CODE,
54
+ message: `could not prepare ${host}`,
55
+ output: prepare.output,
56
+ phase: 'prepare',
57
+ waitedMs: 0,
58
+ };
35
59
  }
36
60
  const push = await runProcess([
37
61
  'git',
@@ -42,7 +66,7 @@ export async function runRemotely(options) {
42
66
  `--receive-pack=${RECEIVE_PACK_COMMAND}`,
43
67
  `${host}:${checkoutPath}`,
44
68
  `${snapshot.sha}:${SNAPSHOT_REF}`,
45
- ], { cwd: snapshot.toplevel, env: { ...env, GIT_SSH_COMMAND: ['ssh', ...SSH_OPTIONS].join(' ') } });
69
+ ], { cwd: snapshot.toplevel, env: { ...env, [SSH_COMMAND_ENV_VAR]: composeGitSshCommand(env) } });
46
70
  if (push.exitCode !== 0) {
47
71
  return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push', waitedMs: 0 };
48
72
  }
@@ -92,7 +116,7 @@ export async function runRemotely(options) {
92
116
  statusFile,
93
117
  },
94
118
  });
95
- const session = await runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], {
119
+ const session = await runProcess(composeSshArgv(env, host, wrapRemoteScript(script)), {
96
120
  env,
97
121
  isStreaming: true,
98
122
  stderr: demultiplexer.stream,
@@ -121,7 +145,7 @@ export async function runRemotely(options) {
121
145
  return { ok: true, exitCode: session.exitCode, passes, waitedMs };
122
146
  }
123
147
  export function runSsh(host, script, env, signal) {
124
- return runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], { env, signal });
148
+ return runProcess(composeSshArgv(env, host, wrapRemoteScript(script)), { env, signal });
125
149
  }
126
150
  export function wrapRemoteScript(script) {
127
151
  return `exec /bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}\n${script}`)}`;
@@ -157,6 +181,10 @@ function forwardChunk(chunk, stream, buffer) {
157
181
  }
158
182
  stream.write(chunk);
159
183
  }
184
+ function resolveSshCommand(env) {
185
+ const sshCommand = env[SSH_COMMAND_ENV_VAR];
186
+ return sshCommand === undefined || sshCommand === '' ? undefined : sshCommand;
187
+ }
160
188
  function runProcess(argv, options) {
161
189
  const { cwd, env, isStreaming = false, signal, stderr, stdout } = options;
162
190
  const [file = '', ...args] = argv;
package/docs/delegate.md CHANGED
@@ -48,7 +48,7 @@ The first rule that applies decides where a command runs:
48
48
  | 3 | The repo config declares `delegate: false` | Here |
49
49
  | 4 | `NMR_DELEGATE` names hosts | On a host; a command that cannot be delegated runs here, and a tree that cannot be snapshotted, here with one line |
50
50
  | 5 | `NMR_DELEGATE` is set but does not name a host | Here |
51
- | 6 | The user config's `when` matches, `CI` is unset, and the invocation is not `-F` or `-R` | On a host, as for rule 4 |
51
+ | 6 | The user config's `when` matches, `CI` is unset, and the invocation is not `-F` or `-R` | On a host, as for rule 4; here with one line when no host answers |
52
52
  | 7 | Otherwise | Here |
53
53
 
54
54
  - **A step and a `-F` or `-R` child run where their parent runs.** nmr gives every process that it starts an empty `NMR_DELEGATE`, which rule 5 reads as "here"; the run on a host already passes `--no-delegate`, so a host does not apply its own user config. A git hook that an agent's command starts is an agent run.
@@ -57,7 +57,7 @@ The first rule that applies decides where a command runs:
57
57
 
58
58
  ### Choosing among several hosts
59
59
 
60
- With several hosts, nmr asks every host at once, with one `ssh` call each, whether a delegation started now would wait there: whether the host's [machine lock](machine-lock.md), or the lock on this worktree's checkout there, has a live holder. A lock that its holder left behind does not count. nmr then runs on the first host in the list that is free; when none is, on the first host that answered; and when none answered, on the first host, where the delegation fails at `prepare`. It never runs the command here, because the machine that delegates is the one that delegation protects.
60
+ With several hosts, nmr asks every host at once, with one `ssh` call each, whether a delegation started now would wait there: whether the host's [machine lock](machine-lock.md), or the lock on this worktree's checkout there, has a live holder. A lock that its holder left behind does not count. nmr then runs on the first host in the list that is free; when none is, on the first busy host; and when no host reported either, on the first host that `ssh` connected to. When `ssh` connects to none of them, a delegation that the user config decided [runs here](#when-no-host-answers) without a further attempt, and a requested one fails at `prepare` on the first host. A requested delegation never runs the command here, because the machine that delegates is the one that delegation protects.
61
61
 
62
62
  ```console
63
63
  $ NMR_DELEGATE=imac2,imac3 nmr ci
@@ -128,14 +128,20 @@ A failure of the delegation itself, rather than of the command, exits 255, the s
128
128
 
129
129
  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>`.
130
130
 
131
- A delegation does not fall back to running here when it cannot reach a host. When the user config decided it and it fails at `prepare`, which is also where it fails when no host answered the probe, nmr adds a line naming the file and the remedies in order:
131
+ ### When no host answers
132
+
133
+ A host answers when `ssh` connects to it. When the user config's `when` sent the command and `ssh` cannot connect to any of its hosts, nmr runs the command here, as `--no-delegate` would, after one line that names the hosts and what `ssh` reported for the first one that it tried:
132
134
 
133
135
  ```console
134
- nmr: ~/.config/nmr/config.ts sent ci to imac2, imac3; ask for network access to imac2, imac3, or run it here with nmr --no-delegate ci
136
+ nmr: running ci here rather than on imac2, imac3: imac2 did not answer: ssh: connect to host imac2 port 22: Operation timed out
135
137
  ```
136
138
 
139
+ The run here is an ordinary local run: It takes the [machine lock](machine-lock.md), reports a verdict that does not name a host, and records a pass earned here. The fallback waits out the connect timeout on every invocation, so a machine that is away from its hosts for long is quicker with `--no-delegate` or a `when` that does not match.
140
+
141
+ Every other failure still exits 255: a delegation that `--delegate`, `--delegate-to`, or `NMR_DELEGATE` requested, which the developer asked to run elsewhere, and a failure on a host that answered, at `prepare` or later, which is a fault there to fix.
142
+
137
143
  ## Setting up the host
138
144
 
139
- - **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.
145
+ - **SSH that does not prompt.** nmr runs the ssh command that `GIT_SSH_COMMAND` names, evaluated by `/bin/sh` as git evaluates it, or plain `ssh` when the variable is unset or empty, and adds `BatchMode=yes` and a connect timeout after the command's own options, so an option that the command sets itself takes precedence. Under `BatchMode=yes`, 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.
140
146
  - **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. Node on the host must satisfy nmr's own `engines` range (`>=24.16.0`). When Node is not on that `PATH`, the check-out step fails, and when pnpm is not, the install step fails; each failure names `PATH`. The login shell must accept `-ilc`: bash and zsh do.
141
- - **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`. Under `when: 'agent'`, every check that the session runs fails this way until the session has that access.
147
+ - **A sandboxed agent session needs network access to the host.** A sandbox that allows only HTTP egress refuses `ssh`. Under `when: 'agent'`, every check that the session runs then [runs here](#when-no-host-answers) after the connect timeout, and a requested delegation fails at `prepare`, until the session has that access. The Claude Code sandbox reaches a host only through the proxy that its `GIT_SSH_COMMAND` names, and it admits the host by the name that ssh connects to, which is the `HostName` that `~/.ssh/config` gives the alias (`ssh -G <host>` prints it), on port 22: For `imac2` with `HostName skypilot-imac2.local`, the sandbox must admit `skypilot-imac2.local:22`, not `imac2`. Nobody has confirmed whether the proxy resolves an mDNS name such as `skypilot-imac2.local`: When a delegation to such a host does not answer, and `ssh` reports `sandbox proxy: <name>:22: 502 Bad Gateway`, give the alias a `HostName` that is an IP address or a unicast DNS name, and have the sandbox admit that name's port 22.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.49.0",
3
+ "version": "0.50.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.4",
80
+ "@williamthorsen/nmr-core": "0.13.5",
81
81
  "@williamthorsen/toolbelt.errors": "0.8.3",
82
82
  "@williamthorsen/toolbelt.filesystem": "0.9.3",
83
83
  "glob": "13.0.6",