@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.
- package/.meta/changelog.json +60 -0
- package/.readyup/kits/default.js +1 -1
- package/.readyup/manifest.json +3 -3
- package/CHANGELOG.md +24 -0
- package/README.md +4 -21
- package/agents/guidance/rulebooks/nmr.md +1 -1
- package/dist/esm/delegate-host.js +22 -9
- package/dist/esm/help.js +1 -0
- package/dist/esm/remote-probe.d.ts +41 -9
- package/dist/esm/remote-probe.js +24 -14
- package/dist/esm/remote-run.d.ts +27 -2
- package/dist/esm/remote-run.js +32 -4
- package/docs/delegate.md +12 -6
- package/package.json +2 -2
package/.meta/changelog.json
CHANGED
|
@@ -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",
|
package/.readyup/kits/default.js
CHANGED
|
@@ -9184,7 +9184,7 @@ function hasSupportedStrictLintVersion() {
|
|
|
9184
9184
|
});
|
|
9185
9185
|
}
|
|
9186
9186
|
function getMinVersion() {
|
|
9187
|
-
const pickedFields = { "version": "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
|
}
|
package/.readyup/manifest.json
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"checklist": "nmr",
|
|
25
25
|
"checks": [
|
|
26
26
|
"@williamthorsen/nmr in devDependencies",
|
|
27
|
-
"@williamthorsen/nmr >= 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": "
|
|
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": "
|
|
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.
|
|
8
|
+
## Release notes — v0.50.0 (2026-10-21)
|
|
9
9
|
|
|
10
10
|
### 🎉 Features
|
|
11
11
|
|
|
12
|
-
- Makes a
|
|
13
|
-
-
|
|
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
|
|
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
|
|
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,
|
|
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(
|
|
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 =
|
|
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
|
-
/**
|
|
3
|
-
|
|
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<
|
|
13
|
-
}): Promise<
|
|
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
|
|
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<
|
|
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
|
|
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>):
|
|
63
|
+
export declare function selectHost(hosts: HostList, results: ReadonlyMap<string, ProbeResult>): HostSelection | undefined;
|
package/dist/esm/remote-probe.js
CHANGED
|
@@ -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
|
|
19
|
+
let report;
|
|
19
20
|
try {
|
|
20
|
-
|
|
21
|
+
report = await probe(host, controller.signal);
|
|
21
22
|
}
|
|
22
23
|
catch {
|
|
23
|
-
|
|
24
|
+
report = { result: 'no-answer', sshMessage: '' };
|
|
24
25
|
}
|
|
25
26
|
if (controller.signal.aborted)
|
|
26
27
|
return;
|
|
27
|
-
results.set(host, result);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/esm/remote-run.d.ts
CHANGED
|
@@ -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:
|
|
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.
|
package/dist/esm/remote-run.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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`
|
|
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
|
|
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.
|
|
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.
|
|
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",
|