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