@williamthorsen/nmr 2.1.0 → 2.2.1
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 +39 -0
- package/.readyup/kits/default.js +11 -11
- package/.readyup/manifest.json +14 -14
- package/CHANGELOG.md +13 -0
- package/README.md +3 -7
- package/dist/esm/delegation/delegate-host.js +8 -1
- package/dist/esm/delegation/host-filesystem.d.ts +24 -0
- package/dist/esm/delegation/host-filesystem.js +45 -0
- package/dist/esm/delegation/remote-host-session.d.ts +4 -0
- package/dist/esm/delegation/remote-host-session.js +5 -3
- package/dist/esm/delegation/remote-run.d.ts +12 -6
- package/dist/esm/delegation/remote-run.js +30 -8
- package/dist/esm/delegation/remote-session.d.ts +2 -2
- package/dist/esm/delegation/remote-session.js +1 -1
- package/dist/esm/delegation/removeStaleCheckouts.d.ts +33 -0
- package/dist/esm/delegation/removeStaleCheckouts.js +98 -0
- package/dist/esm/delegation/user-config.d.ts +4 -0
- package/dist/esm/delegation/user-config.js +13 -3
- package/docs/delegate.md +23 -6
- package/package.json +8 -8
package/.meta/changelog.json
CHANGED
|
@@ -1,4 +1,43 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "2.2.1",
|
|
4
|
+
"date": "2026-11-13",
|
|
5
|
+
"sections": [
|
|
6
|
+
{
|
|
7
|
+
"title": "🐛 Bug fixes",
|
|
8
|
+
"audience": "all",
|
|
9
|
+
"items": [
|
|
10
|
+
{
|
|
11
|
+
"description": "Fixes the issue that a delegated `nmr` run on a host whose Node comes from a version manager's shims ran on the Node chosen before the checkout, rather than the one pinned by the snapshot, on the first delegation from a clone and the first after a repo changed its pin. (#1337)",
|
|
12
|
+
"hash": "eb9f33c1b5cc919e00f7d33927899c48dbe7c3f7",
|
|
13
|
+
"entry": 1
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "2.2.0",
|
|
21
|
+
"date": "2026-11-12",
|
|
22
|
+
"sections": [
|
|
23
|
+
{
|
|
24
|
+
"title": "🎉 Features",
|
|
25
|
+
"audience": "all",
|
|
26
|
+
"items": [
|
|
27
|
+
{
|
|
28
|
+
"description": "Makes each `nmr` delegation remove, before it pushes, the host's other checkouts that have gone unused for longer than the new user-config key `delegate.removeCheckoutsAfterDays` allows (7 days by default, `false` to keep them), while keeping any checkout whose lock has a live holder. (#1317)",
|
|
29
|
+
"hash": "529687fecf5111851cb369c5ee9554d9711dad62",
|
|
30
|
+
"entry": 1
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"description": "Adds to the failure message of a delegation step that fails with `No space left on device` whether the host's filesystem ran out of inodes or of space. (#1317)",
|
|
34
|
+
"hash": "529687fecf5111851cb369c5ee9554d9711dad62",
|
|
35
|
+
"entry": 2
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
2
41
|
{
|
|
3
42
|
"version": "2.1.0",
|
|
4
43
|
"date": "2026-11-12",
|
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.46.
|
|
3
|
+
export const __readyupVersion = "0.46.2";
|
|
4
4
|
import { createRequire as __rdyCreateRequire } from 'node:module';
|
|
5
5
|
const require = __rdyCreateRequire(import.meta.url);
|
|
6
6
|
|
|
@@ -7382,12 +7382,12 @@ import { isRecord, listTrackedFiles } from "readyup/check-utils";
|
|
|
7382
7382
|
import { globSync } from "node:fs";
|
|
7383
7383
|
import { basename, join, sep } from "node:path";
|
|
7384
7384
|
|
|
7385
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.10.
|
|
7385
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.10.1/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
|
|
7386
7386
|
function isError(error) {
|
|
7387
7387
|
return error instanceof Error || Object.prototype.toString.call(error) === "[object Error]";
|
|
7388
7388
|
}
|
|
7389
7389
|
|
|
7390
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.10.
|
|
7390
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.10.1/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
|
|
7391
7391
|
function describeError(error) {
|
|
7392
7392
|
try {
|
|
7393
7393
|
if (isError(error) && typeof error.message === "string" && error.message !== "") {
|
|
@@ -8161,11 +8161,11 @@ function formatMalformedScript(dir, key, value) {
|
|
|
8161
8161
|
return `${rejection} A step list belongs in \`${CONFIG_RELATIVE_PATH}\` under \`${field}\`.`;
|
|
8162
8162
|
}
|
|
8163
8163
|
|
|
8164
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.
|
|
8164
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.9.0/node_modules/@williamthorsen/toolbelt.cli/dist/esm/3-candidate/parseArgs.js
|
|
8165
8165
|
import path6 from "node:path";
|
|
8166
8166
|
import process2 from "node:process";
|
|
8167
8167
|
|
|
8168
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.
|
|
8168
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.9.0/node_modules/@williamthorsen/toolbelt.cli/dist/esm/3-candidate/UsageError.js
|
|
8169
8169
|
var UsageError = class extends Error {
|
|
8170
8170
|
constructor(message, options) {
|
|
8171
8171
|
super(message, options);
|
|
@@ -8173,7 +8173,7 @@ var UsageError = class extends Error {
|
|
|
8173
8173
|
}
|
|
8174
8174
|
};
|
|
8175
8175
|
|
|
8176
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.
|
|
8176
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.9.0/node_modules/@williamthorsen/toolbelt.cli/dist/esm/3-candidate/ParseError.js
|
|
8177
8177
|
var ParseError = class extends UsageError {
|
|
8178
8178
|
kind;
|
|
8179
8179
|
token;
|
|
@@ -8185,12 +8185,12 @@ var ParseError = class extends UsageError {
|
|
|
8185
8185
|
}
|
|
8186
8186
|
};
|
|
8187
8187
|
|
|
8188
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.
|
|
8188
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.9.0/node_modules/@williamthorsen/toolbelt.cli/dist/esm/3-candidate/resolveLongName.js
|
|
8189
8189
|
function resolveLongName(key, definition) {
|
|
8190
8190
|
return definition.long ?? key.replaceAll(/[A-Z]/g, (capital) => `-${capital.toLowerCase()}`);
|
|
8191
8191
|
}
|
|
8192
8192
|
|
|
8193
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.
|
|
8193
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.9.0/node_modules/@williamthorsen/toolbelt.cli/dist/esm/3-candidate/tokenizeArgs.js
|
|
8194
8194
|
import { parseArgs as nodeParseArgs } from "node:util";
|
|
8195
8195
|
function tokenizeArgs(argv, schema, extraBooleans = []) {
|
|
8196
8196
|
const nodeOptions = {};
|
|
@@ -8220,7 +8220,7 @@ function findFlagEntry(flagsByName, token) {
|
|
|
8220
8220
|
return entry.definition.short === token.rawName.slice(1) ? entry : void 0;
|
|
8221
8221
|
}
|
|
8222
8222
|
|
|
8223
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.
|
|
8223
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.9.0/node_modules/@williamthorsen/toolbelt.cli/dist/esm/3-candidate/validateSpec.js
|
|
8224
8224
|
var CAMEL_CASE_KEY = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]+)*$/;
|
|
8225
8225
|
function validateSpec(spec) {
|
|
8226
8226
|
validateFlags(spec.flags ?? {});
|
|
@@ -8301,7 +8301,7 @@ function validateOperands(operands) {
|
|
|
8301
8301
|
}
|
|
8302
8302
|
}
|
|
8303
8303
|
|
|
8304
|
-
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.
|
|
8304
|
+
// ../../node_modules/.pnpm/@williamthorsen+toolbelt.cli@0.9.0/node_modules/@williamthorsen/toolbelt.cli/dist/esm/3-candidate/parseArgs.js
|
|
8305
8305
|
function parseArgs(argv, spec, options = {}) {
|
|
8306
8306
|
validateSpec(spec);
|
|
8307
8307
|
return parseValidatedArgs(argv, spec, options);
|
|
@@ -9662,7 +9662,7 @@ function hasSupportedStrictLintVersion() {
|
|
|
9662
9662
|
});
|
|
9663
9663
|
}
|
|
9664
9664
|
function getMinVersion() {
|
|
9665
|
-
const pickedFields = { "version": "2.1
|
|
9665
|
+
const pickedFields = { "version": "2.2.1" };
|
|
9666
9666
|
if (typeof pickedFields["version"] !== "string") {
|
|
9667
9667
|
throw new TypeError("nmr/package.json: 'version' must be a string");
|
|
9668
9668
|
}
|
package/.readyup/manifest.json
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
"kits": [
|
|
4
4
|
{
|
|
5
5
|
"bundledDependencies": {
|
|
6
|
-
"@williamthorsen/toolbelt.arrays": "6.5.
|
|
7
|
-
"@williamthorsen/toolbelt.cli": "0.
|
|
8
|
-
"@williamthorsen/toolbelt.datetime": "4.1.
|
|
9
|
-
"@williamthorsen/toolbelt.errors": "0.10.
|
|
10
|
-
"@williamthorsen/toolbelt.filesystem": "0.10.
|
|
11
|
-
"@williamthorsen/toolbelt.guards": "4.2.
|
|
12
|
-
"@williamthorsen/toolbelt.numbers": "8.0.
|
|
13
|
-
"@williamthorsen/toolbelt.objects": "4.4.
|
|
14
|
-
"@williamthorsen/toolbelt.packaging": "0.7.
|
|
6
|
+
"@williamthorsen/toolbelt.arrays": "6.5.1",
|
|
7
|
+
"@williamthorsen/toolbelt.cli": "0.9.0",
|
|
8
|
+
"@williamthorsen/toolbelt.datetime": "4.1.4",
|
|
9
|
+
"@williamthorsen/toolbelt.errors": "0.10.1",
|
|
10
|
+
"@williamthorsen/toolbelt.filesystem": "0.10.3",
|
|
11
|
+
"@williamthorsen/toolbelt.guards": "4.2.3",
|
|
12
|
+
"@williamthorsen/toolbelt.numbers": "8.0.1",
|
|
13
|
+
"@williamthorsen/toolbelt.objects": "4.4.3",
|
|
14
|
+
"@williamthorsen/toolbelt.packaging": "0.7.3",
|
|
15
15
|
"@williamthorsen/toolbelt.sets": "3.1.1",
|
|
16
|
-
"@williamthorsen/toolbelt.strings": "8.2.
|
|
16
|
+
"@williamthorsen/toolbelt.strings": "8.2.4",
|
|
17
17
|
"@williamthorsen/toolbelt.terminal": "0.3.1",
|
|
18
18
|
"ansi-regex": "6.6.0",
|
|
19
19
|
"ansi-styles": "6.2.3, 7.0.0",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"checklist": "nmr",
|
|
32
32
|
"checks": [
|
|
33
33
|
"@williamthorsen/nmr in devDependencies",
|
|
34
|
-
"@williamthorsen/nmr >= 2.1
|
|
34
|
+
"@williamthorsen/nmr >= 2.2.1",
|
|
35
35
|
"pnpm-workspace.yaml exists",
|
|
36
36
|
"catalogued dependencies use catalog:",
|
|
37
37
|
"package.json has packageManager field",
|
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
"path": "lib/workspace-build.ts"
|
|
236
236
|
},
|
|
237
237
|
{
|
|
238
|
-
"hash": "
|
|
238
|
+
"hash": "a00b2ec5",
|
|
239
239
|
"kind": "inline",
|
|
240
240
|
"path": "../package.json",
|
|
241
241
|
"paths": [
|
|
@@ -360,10 +360,10 @@
|
|
|
360
360
|
],
|
|
361
361
|
"name": "default",
|
|
362
362
|
"path": "kits/default.js",
|
|
363
|
-
"readyupVersion": "0.46.
|
|
363
|
+
"readyupVersion": "0.46.2",
|
|
364
364
|
"source": "kits/default.ts",
|
|
365
365
|
"sourceHash": "9fd61bf4",
|
|
366
|
-
"targetHash": "
|
|
366
|
+
"targetHash": "db59c5e7"
|
|
367
367
|
}
|
|
368
368
|
]
|
|
369
369
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 2.2.1 — 2026-11-13
|
|
6
|
+
|
|
7
|
+
### 🐛 Bug fixes
|
|
8
|
+
|
|
9
|
+
- Fixes the issue that a delegated `nmr` run on a host whose Node comes from a version manager's shims ran on the Node chosen before the checkout, rather than the one pinned by the snapshot, on the first delegation from a clone and the first after a repo changed its pin. (#1337)
|
|
10
|
+
|
|
11
|
+
## 2.2.0 — 2026-11-12
|
|
12
|
+
|
|
13
|
+
### 🎉 Features
|
|
14
|
+
|
|
15
|
+
- Makes each `nmr` delegation remove, before it pushes, the host's other checkouts that have gone unused for longer than the new user-config key `delegate.removeCheckoutsAfterDays` allows (7 days by default, `false` to keep them), while keeping any checkout whose lock has a live holder. (#1317)
|
|
16
|
+
- Adds to the failure message of a delegation step that fails with `No space left on device` whether the host's filesystem ran out of inodes or of space. (#1317)
|
|
17
|
+
|
|
5
18
|
## 2.1.0 — 2026-11-12
|
|
6
19
|
|
|
7
20
|
### 🎉 Features
|
package/README.md
CHANGED
|
@@ -5,15 +5,11 @@
|
|
|
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 — v2.1
|
|
8
|
+
## Release notes — v2.2.1 (2026-11-13)
|
|
9
9
|
|
|
10
|
-
###
|
|
10
|
+
### 🐛 Bug fixes
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
### ⚡ Performance
|
|
15
|
-
|
|
16
|
-
- Drops pnpm's topological sort, which runs each package only after its workspace dependencies finish, from every `-R` and `-F` fan-out except one of `build`, so that `nmr test`, the `test:*` commands, and `nmr typecheck` start each package without waiting for its workspace dependencies. (#1306)
|
|
12
|
+
- Fixes the issue that a delegated `nmr` run on a host whose Node comes from a version manager's shims ran on the Node chosen before the checkout, rather than the one pinned by the snapshot, on the first delegation from a clone and the first after a repo changed its pin. (#1337)
|
|
17
13
|
<!-- /section:release-notes -->
|
|
18
14
|
|
|
19
15
|
## Installation
|
|
@@ -13,7 +13,7 @@ import { createSnapshot } from "./createSnapshot.js";
|
|
|
13
13
|
import { chooseHost, probeHost } from "./remote-probe.js";
|
|
14
14
|
import { DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATE_TO_FLAG, DELEGATION_FAILURE_EXIT_CODE, deriveCheckoutPath, formatMisplacedDelegationWarning, readLastLine, runRemotely, } from "./remote-run.js";
|
|
15
15
|
import { resolveDelegationTarget } from "./resolveDelegationTarget.js";
|
|
16
|
-
import { formatDisplayPath, loadUserConfig } from "./user-config.js";
|
|
16
|
+
import { DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS, formatDisplayPath, loadUserConfig } from "./user-config.js";
|
|
17
17
|
export async function resolveExecutionTarget(invocation) {
|
|
18
18
|
const { command, context, env, parsedArgs, stderr, styles } = invocation;
|
|
19
19
|
if (parsedArgs.passthrough.some(isDelegationFlag)) {
|
|
@@ -69,6 +69,7 @@ export async function runPlanOnHost(plan, target) {
|
|
|
69
69
|
target,
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
72
73
|
const PROBE_RESULT_PHRASES = {
|
|
73
74
|
busy: 'is busy',
|
|
74
75
|
free: 'is free',
|
|
@@ -133,6 +134,7 @@ async function delegate(options) {
|
|
|
133
134
|
}
|
|
134
135
|
const { host } = choice;
|
|
135
136
|
const retained = { stderr: createBoundedBuffer(), stdout: createBoundedBuffer() };
|
|
137
|
+
const removeCheckoutsAfterMs = snapshot.ok ? await readRemoveCheckoutsAfterMs(env) : null;
|
|
136
138
|
const result = snapshot.ok
|
|
137
139
|
? await runRemotely({
|
|
138
140
|
command,
|
|
@@ -142,6 +144,7 @@ async function delegate(options) {
|
|
|
142
144
|
monorepoRoot: context.monorepoRoot,
|
|
143
145
|
nmrArgs: composeForwardedArgs(command, parsedArgs),
|
|
144
146
|
remoteEnv,
|
|
147
|
+
removeCheckoutsAfterMs,
|
|
145
148
|
snapshot,
|
|
146
149
|
stderr: createTee(stderr, retained.stderr),
|
|
147
150
|
stdout: createTee(stdout, retained.stdout),
|
|
@@ -178,6 +181,10 @@ async function delegate(options) {
|
|
|
178
181
|
function formatRunHereLine(command, listedHosts, reason) {
|
|
179
182
|
return `nmr: running ${command} here rather than on ${listedHosts}: ${reason}\n`;
|
|
180
183
|
}
|
|
184
|
+
async function readRemoveCheckoutsAfterMs(env) {
|
|
185
|
+
const days = (await loadUserConfig(env))?.config.delegate?.removeCheckoutsAfterDays ?? DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS;
|
|
186
|
+
return days === false ? null : days * MS_PER_DAY;
|
|
187
|
+
}
|
|
181
188
|
async function resolveHost(hosts, toplevel, env, stderr) {
|
|
182
189
|
const [first, ...rest] = hosts;
|
|
183
190
|
if (rest.length === 0) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appends to a failed step's message the resource that the host's filesystem has run out of, when the step's output
|
|
3
|
+
* reports a full filesystem and the counts that `runOnHost` reads say which. A write fails alike for lack of space and
|
|
4
|
+
* of inodes.
|
|
5
|
+
*/
|
|
6
|
+
export declare function appendFilesystemReason(message: string, output: string, runOnHost: (script: string) => Promise<{
|
|
7
|
+
exitCode: number;
|
|
8
|
+
output: string;
|
|
9
|
+
}>): Promise<string>;
|
|
10
|
+
/** Composes the shell command that prints, on the host, the free counts of the filesystem that holds its home. */
|
|
11
|
+
export declare function composeFilesystemCheckScript(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Names the resource that the host's filesystem has run out of, from what `composeFilesystemCheckScript` printed, or
|
|
14
|
+
* returns `undefined` when neither is exhausted or the output does not include the counts. A filesystem that reports
|
|
15
|
+
* a total of zero inodes, as btrfs does, allocates them as it goes, so it cannot run out of them.
|
|
16
|
+
*/
|
|
17
|
+
export declare function describeExhaustedResource(output: string): string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Runs on the host and prints, as one JSON line, the total and free inodes and the blocks available to this user on
|
|
20
|
+
* the filesystem that holds the home directory. It is sent as source, so it refers to nothing outside its own body.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export declare function readHostFilesystem(): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { quoteForShell } from "../helpers/quoteForShell.js";
|
|
2
|
+
export async function appendFilesystemReason(message, output, runOnHost) {
|
|
3
|
+
if (!FULL_FILESYSTEM_PATTERN.test(output))
|
|
4
|
+
return message;
|
|
5
|
+
const check = await runOnHost(composeFilesystemCheckScript());
|
|
6
|
+
const reason = check.exitCode === 0 ? describeExhaustedResource(check.output) : undefined;
|
|
7
|
+
return reason === undefined ? message : `${message}: ${reason}`;
|
|
8
|
+
}
|
|
9
|
+
export function composeFilesystemCheckScript() {
|
|
10
|
+
return `node -e ${quoteForShell(`(${readHostFilesystem.toString()})();`)}`;
|
|
11
|
+
}
|
|
12
|
+
export function describeExhaustedResource(output) {
|
|
13
|
+
const counts = output
|
|
14
|
+
.split('\n')
|
|
15
|
+
.map(parseCounts)
|
|
16
|
+
.findLast((value) => value !== undefined);
|
|
17
|
+
if (counts !== undefined && counts.files > 0 && counts.ffree === 0)
|
|
18
|
+
return "the host's filesystem has run out of inodes";
|
|
19
|
+
if (counts?.bavail === 0)
|
|
20
|
+
return "the host's filesystem has run out of space";
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
export function readHostFilesystem() {
|
|
24
|
+
const fs = process.getBuiltinModule('node:fs');
|
|
25
|
+
const os = process.getBuiltinModule('node:os');
|
|
26
|
+
const { bavail, ffree, files } = fs.statfsSync(os.homedir());
|
|
27
|
+
process.stdout.write(`${JSON.stringify({ bavail, ffree, files })}\n`);
|
|
28
|
+
}
|
|
29
|
+
const FULL_FILESYSTEM_PATTERN = /no space left on device|ENOSPC/i;
|
|
30
|
+
function parseCounts(line) {
|
|
31
|
+
try {
|
|
32
|
+
const value = JSON.parse(line);
|
|
33
|
+
if (typeof value !== 'object' || value === null)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (!('bavail' in value) || !('ffree' in value) || !('files' in value))
|
|
36
|
+
return undefined;
|
|
37
|
+
const { bavail, ffree, files } = value;
|
|
38
|
+
return typeof bavail === 'number' && typeof ffree === 'number' && typeof files === 'number'
|
|
39
|
+
? { bavail, ffree, files }
|
|
40
|
+
: undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -55,6 +55,10 @@ export type SessionRecord = {
|
|
|
55
55
|
* out the snapshot, installs, runs nmr, and reports the passes that nmr wrote to the pass manifest. It is sent as
|
|
56
56
|
* source, so it refers to nothing outside its own body.
|
|
57
57
|
*
|
|
58
|
+
* Every process that the session starts runs on the login shell's `PATH`, which the session script passes as the
|
|
59
|
+
* argument after `params`: A version manager's `node` shim puts the `bin` directory of the Node that it chose before
|
|
60
|
+
* the checkout in front of the session's own `PATH`, and the snapshot may pin another.
|
|
61
|
+
*
|
|
58
62
|
* The lock follows the machine lock's rules: A waiter reclaims it at once when the holder's pid is gone, and once the
|
|
59
63
|
* holder has stopped refreshing it for a minute otherwise. When the session's stdin ends, which the local `ssh` causes
|
|
60
64
|
* by exiting, the session stops its current phase's process group, releases the lock, and exits.
|
|
@@ -16,6 +16,8 @@ export function runHostSession(params) {
|
|
|
16
16
|
const STOP_GRACE_MS = 5000;
|
|
17
17
|
const NODE_MODULES_DIR = path.join(params.rootDir, 'node_modules');
|
|
18
18
|
const BINS_RECORD_PATH = path.join(NODE_MODULES_DIR, '.nmr-delegate-bins');
|
|
19
|
+
const loginPath = process.argv[2] || process.env['PATH'];
|
|
20
|
+
const childEnv = { ...process.env, PATH: loginPath };
|
|
19
21
|
const token = crypto.randomUUID();
|
|
20
22
|
let isHolding = false;
|
|
21
23
|
let activeChild;
|
|
@@ -30,7 +32,7 @@ export function runHostSession(params) {
|
|
|
30
32
|
['checkout', '--quiet', '--force', '--detach', params.sha],
|
|
31
33
|
['clean', '--force', '-d', '--quiet'],
|
|
32
34
|
]) {
|
|
33
|
-
const result = await runPhase('git', args, '.',
|
|
35
|
+
const result = await runPhase('git', args, '.', childEnv);
|
|
34
36
|
if (isStopping)
|
|
35
37
|
return;
|
|
36
38
|
if (result.exitCode !== 0) {
|
|
@@ -43,7 +45,7 @@ export function runHostSession(params) {
|
|
|
43
45
|
removeWorkspaceState();
|
|
44
46
|
}
|
|
45
47
|
const install = await runPhase('pnpm', ['install', '--frozen-lockfile'], params.rootDir, {
|
|
46
|
-
...
|
|
48
|
+
...childEnv,
|
|
47
49
|
CI: 'true',
|
|
48
50
|
});
|
|
49
51
|
if (isStopping)
|
|
@@ -62,7 +64,7 @@ export function runHostSession(params) {
|
|
|
62
64
|
const child = childProcess.spawn('pnpm', ['exec', 'nmr', '--no-delegate', ...params.nmrArgs], {
|
|
63
65
|
cwd: params.runDir,
|
|
64
66
|
detached: true,
|
|
65
|
-
env: { ...
|
|
67
|
+
env: { ...childEnv, ...params.remoteEnv, ...manifestEnv },
|
|
66
68
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
67
69
|
});
|
|
68
70
|
activeChild = child;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Writable } from 'node:stream';
|
|
2
2
|
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
3
|
+
/** The directory on the host, relative to its home directory, that contains one directory per checkout key. */
|
|
4
|
+
export declare const CHECKOUTS_DIR = ".cache/nmr/delegate";
|
|
3
5
|
/** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
|
|
4
6
|
export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
|
|
5
7
|
/** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
|
|
@@ -56,9 +58,11 @@ export declare function composeGitSshCommand(env: NodeJS.ProcessEnv): string;
|
|
|
56
58
|
* @internal
|
|
57
59
|
*/
|
|
58
60
|
export declare function composeSshArgv(env: NodeJS.ProcessEnv, host: string, remoteCommand: string): string[];
|
|
61
|
+
/** Returns the key of the checkout on the host that serves the worktree at `toplevel` on the machine `hostname`. */
|
|
62
|
+
export declare function deriveCheckoutKey(hostname: string, toplevel: string): string;
|
|
59
63
|
/**
|
|
60
|
-
* Returns the checkout's path on the host, relative to its home directory:
|
|
61
|
-
* repository
|
|
64
|
+
* Returns the checkout's path on the host, relative to its home directory: the checkout's key, followed by the
|
|
65
|
+
* repository's own directory name, which nmr on the host reports as the root's scope.
|
|
62
66
|
*
|
|
63
67
|
* @internal
|
|
64
68
|
*/
|
|
@@ -74,11 +78,12 @@ export declare function readLastLine(output: string): string;
|
|
|
74
78
|
* Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
|
|
75
79
|
* `stdout` and `stderr` and returning its exit status.
|
|
76
80
|
*
|
|
77
|
-
* The host keeps one checkout per
|
|
81
|
+
* The host keeps one checkout per worktree on each delegating machine, under `~/.cache/nmr/delegate/`, so concurrent
|
|
78
82
|
* delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
|
|
79
|
-
* between runs.
|
|
80
|
-
*
|
|
81
|
-
*
|
|
83
|
+
* between runs. Before the push, the delegation removes the host's other checkouts that have not been used for
|
|
84
|
+
* longer than `removeCheckoutsAfterMs`, unless it is `null`. Check out, install, and run take one connection, whose
|
|
85
|
+
* session holds the checkout's lock, so that two delegations from one worktree run one after the other; what a phase
|
|
86
|
+
* other than `run` prints is returned rather than streamed, because it matters only when the phase fails.
|
|
82
87
|
*/
|
|
83
88
|
export declare function runRemotely(options: {
|
|
84
89
|
command: string;
|
|
@@ -88,6 +93,7 @@ export declare function runRemotely(options: {
|
|
|
88
93
|
monorepoRoot: string;
|
|
89
94
|
nmrArgs: readonly string[];
|
|
90
95
|
remoteEnv: Readonly<Record<string, string>>;
|
|
96
|
+
removeCheckoutsAfterMs: number | null;
|
|
91
97
|
snapshot: {
|
|
92
98
|
sha: string;
|
|
93
99
|
toplevel: string;
|
|
@@ -9,8 +9,11 @@ import { createBoundedBuffer } from "../helpers/createBoundedBuffer.js";
|
|
|
9
9
|
import { quoteForShell } from "../helpers/quoteForShell.js";
|
|
10
10
|
import { STALE_AFTER_MS } from "../machine-lock.js";
|
|
11
11
|
import { formatFlagSpelling } from "../nmr-flags.js";
|
|
12
|
+
import { appendFilesystemReason } from "./host-filesystem.js";
|
|
12
13
|
import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
|
|
13
14
|
import { composeSessionScript, createSessionDemultiplexer, formatDelegationWaitingLine } from "./remote-session.js";
|
|
15
|
+
import { CHECKOUT_STAMP_PATH, composeCheckoutRemovalScript } from "./removeStaleCheckouts.js";
|
|
16
|
+
export const CHECKOUTS_DIR = '.cache/nmr/delegate';
|
|
14
17
|
export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
|
|
15
18
|
export const DELEGATE_FLAG = formatFlagSpelling('delegate');
|
|
16
19
|
export const DELEGATE_TO_FLAG = formatFlagSpelling('delegateTo');
|
|
@@ -28,9 +31,11 @@ export function composeSshArgv(env, host, remoteCommand) {
|
|
|
28
31
|
? ['ssh', ...sshArgs]
|
|
29
32
|
: ['/bin/sh', '-c', `${sshCommand} "$@"`, sshCommand, ...sshArgs];
|
|
30
33
|
}
|
|
34
|
+
export function deriveCheckoutKey(hostname, toplevel) {
|
|
35
|
+
return createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
|
|
36
|
+
}
|
|
31
37
|
export function deriveCheckoutPath(hostname, toplevel) {
|
|
32
|
-
|
|
33
|
-
return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
|
|
38
|
+
return path.posix.join(CHECKOUTS_DIR, deriveCheckoutKey(hostname, toplevel), path.basename(toplevel));
|
|
34
39
|
}
|
|
35
40
|
export function formatMisplacedDelegationWarning(command, style) {
|
|
36
41
|
return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG}, ${DELEGATE_TO_FLAG}, and ${NO_DELEGATE_FLAG} after the ` +
|
|
@@ -44,15 +49,30 @@ export function readLastLine(output) {
|
|
|
44
49
|
.findLast((line) => line !== '') ?? '');
|
|
45
50
|
}
|
|
46
51
|
export async function runRemotely(options) {
|
|
47
|
-
const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
|
|
52
|
+
const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, removeCheckoutsAfterMs, snapshot, stderr, stdout, } = options;
|
|
48
53
|
const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
|
|
49
54
|
const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
|
|
50
|
-
|
|
55
|
+
function runOnHost(script) {
|
|
56
|
+
return runSsh(host, script, env);
|
|
57
|
+
}
|
|
58
|
+
const removal = composeCheckoutRemovalScript({
|
|
59
|
+
checkoutsDir: CHECKOUTS_DIR,
|
|
60
|
+
lockPath: CHECKOUT_LOCK_PATH,
|
|
61
|
+
ownKey: deriveCheckoutKey(os.hostname(), snapshot.toplevel),
|
|
62
|
+
removeAfterMs: removeCheckoutsAfterMs,
|
|
63
|
+
staleAfterMs: STALE_AFTER_MS,
|
|
64
|
+
stampPath: CHECKOUT_STAMP_PATH,
|
|
65
|
+
});
|
|
66
|
+
const prepare = await runSsh(host, `{ ${removal} || :; }; mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; } && ` +
|
|
67
|
+
`{ touch ${quoteForShell(CHECKOUT_STAMP_PATH)} || :; }`, env);
|
|
51
68
|
if (prepare.exitCode !== 0) {
|
|
69
|
+
const isUnreachable = prepare.exitCode === DELEGATION_FAILURE_EXIT_CODE;
|
|
52
70
|
return {
|
|
53
71
|
ok: false,
|
|
54
|
-
isUnreachable
|
|
55
|
-
message:
|
|
72
|
+
isUnreachable,
|
|
73
|
+
message: isUnreachable
|
|
74
|
+
? `could not prepare ${host}`
|
|
75
|
+
: await appendFilesystemReason(`could not prepare ${host}`, prepare.output, runOnHost),
|
|
56
76
|
output: prepare.output,
|
|
57
77
|
phase: 'prepare',
|
|
58
78
|
waitedMs: 0,
|
|
@@ -69,7 +89,8 @@ export async function runRemotely(options) {
|
|
|
69
89
|
`${snapshot.sha}:${SNAPSHOT_REF}`,
|
|
70
90
|
], { cwd: snapshot.toplevel, env: { ...env, [SSH_COMMAND_ENV_VAR]: composeGitSshCommand(env) } });
|
|
71
91
|
if (push.exitCode !== 0) {
|
|
72
|
-
|
|
92
|
+
const message = await appendFilesystemReason(`could not push to ${host}`, push.output, runOnHost);
|
|
93
|
+
return { ok: false, message, output: push.output, phase: 'push', waitedMs: 0 };
|
|
73
94
|
}
|
|
74
95
|
const nonce = randomUUID();
|
|
75
96
|
const marker = `__NMR_DELEGATE_${nonce}__`;
|
|
@@ -127,7 +148,8 @@ export async function runRemotely(options) {
|
|
|
127
148
|
demultiplexer.flush();
|
|
128
149
|
if (failure !== undefined) {
|
|
129
150
|
const output = [demultiplexer.readOutput(), failure.output].filter((text) => text !== '').join('\n');
|
|
130
|
-
|
|
151
|
+
const message = await appendFilesystemReason(describeFailedPhase(failure, host), output, runOnHost);
|
|
152
|
+
return { ok: false, message, output, phase: failure.phase, waitedMs };
|
|
131
153
|
}
|
|
132
154
|
if (!demultiplexer.hasStarted()) {
|
|
133
155
|
return {
|
|
@@ -14,8 +14,8 @@ export interface SessionDemultiplexer {
|
|
|
14
14
|
stream: Writable;
|
|
15
15
|
}
|
|
16
16
|
/**
|
|
17
|
-
* Composes the script that runs the host session from the checkout
|
|
18
|
-
* can name the host's home directory.
|
|
17
|
+
* Composes the script that runs the host session from the checkout, passing it the shell's `PATH` for the processes
|
|
18
|
+
* that it starts. `checkoutDir` is a shell expression, so that it can name the host's home directory.
|
|
19
19
|
*/
|
|
20
20
|
export declare function composeSessionScript(options: {
|
|
21
21
|
checkoutDir: string;
|
|
@@ -5,7 +5,7 @@ import { runHostSession, } from "./remote-host-session.js";
|
|
|
5
5
|
export function composeSessionScript(options) {
|
|
6
6
|
const { checkoutDir, params } = options;
|
|
7
7
|
const source = `(${runHostSession.toString()})(JSON.parse(process.argv[1]));`;
|
|
8
|
-
return `cd ${checkoutDir} && exec node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`;
|
|
8
|
+
return `cd ${checkoutDir} && exec node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))} "$PATH"`;
|
|
9
9
|
}
|
|
10
10
|
export function createSessionDemultiplexer(options) {
|
|
11
11
|
const { marker, onMalformedRecord, onRecord, stderr } = options;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The file whose mtime records a checkout's last use, relative to the checkout. */
|
|
2
|
+
export declare const CHECKOUT_STAMP_PATH = ".git/nmr-delegate-used";
|
|
3
|
+
/** What the removal needs, sent to it as JSON. */
|
|
4
|
+
export interface CheckoutRemovalParams {
|
|
5
|
+
/** The directory that contains one directory per checkout key, relative to the host's home directory. */
|
|
6
|
+
checkoutsDir: string;
|
|
7
|
+
/** The checkout's lock file, relative to the checkout. */
|
|
8
|
+
lockPath: string;
|
|
9
|
+
/** The key of the delegation's own checkout, which is never removed. */
|
|
10
|
+
ownKey: string;
|
|
11
|
+
/** Age past which an unused checkout is removed, or `null` to keep every checkout. */
|
|
12
|
+
removeAfterMs: number | null;
|
|
13
|
+
/** Age at which an unrefreshed lock file is abandoned. */
|
|
14
|
+
staleAfterMs: number;
|
|
15
|
+
/** The file whose mtime records the checkout's last use, relative to the checkout. */
|
|
16
|
+
stampPath: string;
|
|
17
|
+
}
|
|
18
|
+
/** Composes the shell command that runs `removeStaleCheckouts` on the host. */
|
|
19
|
+
export declare function composeCheckoutRemovalScript(params: CheckoutRemovalParams): string;
|
|
20
|
+
/**
|
|
21
|
+
* Runs on the host and removes every checkout other than the delegation's own that has not been used for longer than
|
|
22
|
+
* `removeAfterMs` and whose lock does not have a live holder. A checkout's last use is the newest mtime of its stamp,
|
|
23
|
+
* `.git/index`, and `.git/HEAD`, so that a checkout made by an nmr that does not write the stamp still has one.
|
|
24
|
+
*
|
|
25
|
+
* Each checkout is renamed at once to a `.removing-` sibling, which needs no new inode, so that removal works on a
|
|
26
|
+
* filesystem without any free inodes and a delegation that starts later creates a fresh checkout. A detached `rm`
|
|
27
|
+
* then deletes every `.removing-` sibling, including those that an interrupted removal left, so that the delegation
|
|
28
|
+
* does not wait for it. An entry that cannot be read or moved is skipped. It is sent as source, so it refers to
|
|
29
|
+
* nothing outside its own body.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export declare function removeStaleCheckouts(params: CheckoutRemovalParams): void;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { quoteForShell } from "../helpers/quoteForShell.js";
|
|
2
|
+
export const CHECKOUT_STAMP_PATH = '.git/nmr-delegate-used';
|
|
3
|
+
export function composeCheckoutRemovalScript(params) {
|
|
4
|
+
const source = `(${removeStaleCheckouts.toString()})(JSON.parse(process.argv[1]));`;
|
|
5
|
+
return `node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`;
|
|
6
|
+
}
|
|
7
|
+
export function removeStaleCheckouts(params) {
|
|
8
|
+
const fs = process.getBuiltinModule('node:fs');
|
|
9
|
+
const childProcess = process.getBuiltinModule('node:child_process');
|
|
10
|
+
const crypto = process.getBuiltinModule('node:crypto');
|
|
11
|
+
const os = process.getBuiltinModule('node:os');
|
|
12
|
+
const path = process.getBuiltinModule('node:path');
|
|
13
|
+
const removeAfterMs = params.removeAfterMs;
|
|
14
|
+
if (removeAfterMs === null)
|
|
15
|
+
return;
|
|
16
|
+
const rootDir = path.join(os.homedir(), params.checkoutsDir);
|
|
17
|
+
const REMOVING_PREFIX = '.removing-';
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
for (const key of readNames(rootDir)) {
|
|
20
|
+
if (key.startsWith('.') || key === params.ownKey)
|
|
21
|
+
continue;
|
|
22
|
+
const keyDir = path.join(rootDir, key);
|
|
23
|
+
const checkouts = readNames(keyDir);
|
|
24
|
+
if (checkouts.length !== 1)
|
|
25
|
+
continue;
|
|
26
|
+
const checkoutDir = path.join(keyDir, checkouts[0] ?? '');
|
|
27
|
+
const lastUsedMs = readLastUsedMs(checkoutDir);
|
|
28
|
+
if (lastUsedMs === undefined || now - lastUsedMs <= removeAfterMs)
|
|
29
|
+
continue;
|
|
30
|
+
if (isLockHeld(path.join(checkoutDir, params.lockPath)))
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
fs.renameSync(keyDir, path.join(rootDir, `${REMOVING_PREFIX}${crypto.randomUUID()}`));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const moved = readNames(rootDir)
|
|
39
|
+
.filter((name) => name.startsWith(REMOVING_PREFIX))
|
|
40
|
+
.map((name) => path.join(rootDir, name));
|
|
41
|
+
if (moved.length > 0) {
|
|
42
|
+
childProcess.spawn('rm', ['-rf', ...moved], { detached: true, stdio: 'ignore' }).unref();
|
|
43
|
+
}
|
|
44
|
+
function isLockHeld(lockPath) {
|
|
45
|
+
let content;
|
|
46
|
+
let mtimeMs;
|
|
47
|
+
try {
|
|
48
|
+
content = fs.readFileSync(lockPath, 'utf8');
|
|
49
|
+
({ mtimeMs } = fs.statSync(lockPath));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (Date.now() - mtimeMs > params.staleAfterMs)
|
|
55
|
+
return false;
|
|
56
|
+
const pid = readHolderPid(content);
|
|
57
|
+
return pid === undefined || !isProcessGone(pid);
|
|
58
|
+
}
|
|
59
|
+
function isProcessGone(pid) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function readHolderPid(content) {
|
|
69
|
+
try {
|
|
70
|
+
const value = JSON.parse(content);
|
|
71
|
+
if (typeof value !== 'object' || value === null || !('pid' in value))
|
|
72
|
+
return undefined;
|
|
73
|
+
return typeof value.pid === 'number' ? value.pid : undefined;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function readLastUsedMs(checkoutDir) {
|
|
80
|
+
let newestMs;
|
|
81
|
+
for (const file of [params.stampPath, '.git/index', '.git/HEAD']) {
|
|
82
|
+
try {
|
|
83
|
+
newestMs = Math.max(newestMs ?? 0, fs.statSync(path.join(checkoutDir, file)).mtimeMs);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return newestMs;
|
|
89
|
+
}
|
|
90
|
+
function readNames(dir) {
|
|
91
|
+
try {
|
|
92
|
+
return fs.readdirSync(dir);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -2,6 +2,8 @@ import type { HostList } from './remote-run.js';
|
|
|
2
2
|
/** The values that `delegate.when` accepts, each naming the runs that delegate without being asked. */
|
|
3
3
|
declare const DELEGATE_WHEN_VALUES: readonly ["agent", "always"];
|
|
4
4
|
export type DelegateWhen = (typeof DELEGATE_WHEN_VALUES)[number];
|
|
5
|
+
/** The age, in days, past which a delegation removes a host's unused checkouts when the user config does not set one. */
|
|
6
|
+
export declare const DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS = 7;
|
|
5
7
|
/**
|
|
6
8
|
* Renders a path for a message, with `~` standing for the home directory that `env` names when the path lies under
|
|
7
9
|
* it.
|
|
@@ -30,6 +32,8 @@ export interface UserConfig {
|
|
|
30
32
|
/** This machine's delegation hosts, and which runs delegate to them without being asked. */
|
|
31
33
|
export interface UserDelegateConfig {
|
|
32
34
|
hosts: HostList;
|
|
35
|
+
/** The age, in days, past which a delegation removes the host's unused checkouts; `false` keeps them. */
|
|
36
|
+
removeCheckoutsAfterDays?: number | false;
|
|
33
37
|
when?: DelegateWhen;
|
|
34
38
|
}
|
|
35
39
|
export {};
|
|
@@ -5,6 +5,7 @@ import { assertRecognizedKeys } from "../config-validation.js";
|
|
|
5
5
|
import { importConfigDefault } from "../helpers/importConfigDefault.js";
|
|
6
6
|
import { resolveXdgBaseDir } from "../helpers/resolveXdgBaseDir.js";
|
|
7
7
|
const DELEGATE_WHEN_VALUES = ['agent', 'always'];
|
|
8
|
+
export const DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS = 7;
|
|
8
9
|
export function formatDisplayPath(filePath, env) {
|
|
9
10
|
const home = env['HOME'];
|
|
10
11
|
if (home === undefined || !path.isAbsolute(home)) {
|
|
@@ -30,7 +31,7 @@ export function resolveUserConfigPath(env) {
|
|
|
30
31
|
}
|
|
31
32
|
const USER_CONFIG_RELATIVE_PATH = path.join('nmr', 'config.ts');
|
|
32
33
|
const RECOGNIZED_KEYS = ['delegate'];
|
|
33
|
-
const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'when'];
|
|
34
|
+
const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'removeCheckoutsAfterDays', 'when'];
|
|
34
35
|
function isDelegateWhen(value) {
|
|
35
36
|
const values = DELEGATE_WHEN_VALUES;
|
|
36
37
|
return values.includes(value);
|
|
@@ -45,16 +46,18 @@ function validateDelegateField(value, configPath) {
|
|
|
45
46
|
}
|
|
46
47
|
assertRecognizedKeys(delegate, RECOGNIZED_DELEGATE_KEYS, configPath, 'delegate.');
|
|
47
48
|
const hosts = validateHosts(delegate['hosts'], configPath);
|
|
49
|
+
const removeCheckoutsAfterDays = validateRemoveCheckoutsAfterDays(delegate['removeCheckoutsAfterDays'], configPath);
|
|
50
|
+
const removal = removeCheckoutsAfterDays === undefined ? {} : { removeCheckoutsAfterDays };
|
|
48
51
|
const when = delegate['when'];
|
|
49
52
|
if (when === undefined) {
|
|
50
|
-
return { hosts };
|
|
53
|
+
return { hosts, ...removal };
|
|
51
54
|
}
|
|
52
55
|
if (!isDelegateWhen(when)) {
|
|
53
56
|
const renderedValue = typeof when === 'string' ? when : JSON.stringify(when);
|
|
54
57
|
throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.when\` is \`${renderedValue}\`, which is not one of: ` +
|
|
55
58
|
DELEGATE_WHEN_VALUES.join(', '));
|
|
56
59
|
}
|
|
57
|
-
return { hosts, when };
|
|
60
|
+
return { hosts, ...removal, when };
|
|
58
61
|
}
|
|
59
62
|
function validateHosts(value, configPath) {
|
|
60
63
|
const names = Array.isArray(value) && value.every((host) => typeof host === 'string')
|
|
@@ -67,6 +70,13 @@ function validateHosts(value, configPath) {
|
|
|
67
70
|
}
|
|
68
71
|
return [first, ...rest];
|
|
69
72
|
}
|
|
73
|
+
function validateRemoveCheckoutsAfterDays(value, configPath) {
|
|
74
|
+
if (value === undefined || value === false || (typeof value === 'number' && Number.isFinite(value) && value > 0)) {
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.removeCheckoutsAfterDays\` must be a positive number of days, ` +
|
|
78
|
+
'or `false` to keep every checkout');
|
|
79
|
+
}
|
|
70
80
|
function validateUserConfig(value, configPath) {
|
|
71
81
|
if (!isRecord(value)) {
|
|
72
82
|
throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
|
package/docs/delegate.md
CHANGED
|
@@ -12,18 +12,19 @@ A machine that runs several worktrees' checks at once runs out of memory, and th
|
|
|
12
12
|
|
|
13
13
|
## The user config
|
|
14
14
|
|
|
15
|
-
`~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts
|
|
15
|
+
`~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts, when to delegate to them without being asked, and how long a host keeps an unused checkout. It honors one key, `delegate`:
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
export default {
|
|
19
|
-
delegate: { hosts: ['imac2', 'imac3'], when: 'agent' },
|
|
19
|
+
delegate: { hosts: ['imac2', 'imac3'], removeCheckoutsAfterDays: 14, when: 'agent' },
|
|
20
20
|
};
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
- **`hosts`** lists the hosts in order of preference. It is required whenever `delegate` is present.
|
|
24
|
+
- **`removeCheckoutsAfterDays`** sets how long a checkout on a host may go unused before a delegation from this machine removes it, in days; `false` keeps every checkout. It defaults to 7. See [unused checkouts](#unused-checkouts).
|
|
24
25
|
- **`when`** turns on delegation by default: `'agent'` for every run whose environment contains an agent-harness marker, the variables that also select [quiet output](reporting.md#configuring), extended by the repo's `output.extraAgentEnvVars`; `'always'` for every run. Without `when`, nmr delegates only on request, and `hosts` serves `--delegate`.
|
|
25
26
|
|
|
26
|
-
nmr reads the file
|
|
27
|
+
nmr reads the file when a run reaches rule 7 of [choosing where a command runs](#choosing-where-a-command-runs), and for every delegation, however it was requested, to learn `removeCheckoutsAfterDays`; a run that an earlier rule keeps here does not read it. An unrecognized key, a malformed `hosts`, or an unknown `when` fails that run with an error naming the file and the key. The file cannot import anything from a repo, so it is a plain object rather than a `defineConfig` call. When a directory above it contains a `package.json` without `"type"`, such as a stray `~/package.json`, Node warns on every load that the file's module type is not specified; a `~/.config/nmr/package.json` containing `{ "type": "module" }` silences it.
|
|
27
28
|
|
|
28
29
|
A pass that a host earns satisfies a later delegation to that host, but not a run here: Under `when`, a run that `--no-delegate` keeps here re-runs a check that the host already passed. See [consulting the cache first](#consulting-the-cache-first).
|
|
29
30
|
|
|
@@ -100,11 +101,21 @@ $ nmr --delegate-to imac2 ci
|
|
|
100
101
|
|
|
101
102
|
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.
|
|
102
103
|
|
|
103
|
-
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
|
|
104
|
+
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 worktree 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. When the workspace packages' `bin` entries differ from those of the checkout's last successful install, nmr first removes pnpm's workspace state, so that the install links the new bins, which an unchanged lockfile would otherwise let pnpm skip; that install reruns the workspace's `prepare` scripts.
|
|
104
105
|
|
|
105
106
|
- **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
|
|
106
107
|
- **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. A delegation flag refuses such a tree; under `NMR_DELEGATE` or the user config, the command runs here, and nmr prints one line saying why.
|
|
107
108
|
|
|
109
|
+
### Unused checkouts
|
|
110
|
+
|
|
111
|
+
A worktree adds a checkout on each host to which it delegates, with its own `node_modules`, so the checkouts accumulate as worktrees come and go. Before it pushes, each delegation removes the host's other checkouts that have not been used for longer than the delegating machine's `removeCheckoutsAfterDays`, whichever machine created them:
|
|
112
|
+
|
|
113
|
+
- **A checkout's last use** is the last delegation to it. A checkout made by an nmr that does not record its use is dated by its last check out.
|
|
114
|
+
- **A checkout in use is kept**: one whose lock, described below, has a live holder, whatever its age. The delegation's own checkout is never removed.
|
|
115
|
+
- **The delegation does not wait for the deletion.** It moves each checkout aside at once and deletes it in the background, so a delegation that starts later creates a fresh checkout and installs it from scratch.
|
|
116
|
+
- **The removal runs before the push**, so a host whose filesystem the checkouts have filled recovers without anyone deleting files by hand: The delegation that removes the checkouts may still fail, and the next one finds the room. Removal never fails a delegation.
|
|
117
|
+
- **Each machine applies its own age.** A machine whose user config says `false` removes nothing, while another machine delegating to the same host still removes the checkouts that are older than its own age.
|
|
118
|
+
|
|
108
119
|
Delegations from the same worktree share its checkout on the host, so they run one at a time. A delegation that starts while another holds the checkout waits for it, saying so on stderr, then runs:
|
|
109
120
|
|
|
110
121
|
```console
|
|
@@ -128,7 +139,13 @@ A failure of the delegation itself, rather than of the command, exits 255, the s
|
|
|
128
139
|
❌ my-repo: ci: failed on 📡 imac2 in 10.0s (exit 255) — could not prepare imac2
|
|
129
140
|
```
|
|
130
141
|
|
|
131
|
-
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>`.
|
|
142
|
+
The steps are `prepare`, which reaches the host, removes its [unused checkouts](#unused-checkouts), and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
|
|
143
|
+
|
|
144
|
+
A step that fails with `No space left on device` reports which resource the host's filesystem has run out of, because a write fails the same way for both:
|
|
145
|
+
|
|
146
|
+
```console
|
|
147
|
+
❌ my-repo: ci: failed on 📡 imac2 in 3.1s (exit 255) — could not push to imac2: the host's filesystem has run out of inodes
|
|
148
|
+
```
|
|
132
149
|
|
|
133
150
|
### When no host answers
|
|
134
151
|
|
|
@@ -145,5 +162,5 @@ Every other failure still exits 255: a delegation that `--delegate`, `--delegate
|
|
|
145
162
|
## Setting up the host
|
|
146
163
|
|
|
147
164
|
- **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.
|
|
148
|
-
- **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.
|
|
165
|
+
- **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. After it checks out the snapshot, nmr starts the install and the command on that `PATH`, so a version manager's shims select the Node and pnpm that the snapshot pins. 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.
|
|
149
166
|
- **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": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -81,19 +81,19 @@
|
|
|
81
81
|
],
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@williamthorsen/nmr-core": "0.16.2",
|
|
84
|
-
"@williamthorsen/toolbelt.errors": "0.10.
|
|
85
|
-
"@williamthorsen/toolbelt.filesystem": "0.10.
|
|
86
|
-
"glob": "14.0.
|
|
87
|
-
"prettier": "3.
|
|
84
|
+
"@williamthorsen/toolbelt.errors": "0.10.1",
|
|
85
|
+
"@williamthorsen/toolbelt.filesystem": "0.10.3",
|
|
86
|
+
"glob": "14.0.5",
|
|
87
|
+
"prettier": "3.10.0",
|
|
88
88
|
"prettier-plugin-sh": "0.20.2",
|
|
89
89
|
"taze": "21.3.0",
|
|
90
90
|
"unconfig": "7.5.0"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
|
-
"@williamthorsen/toolbelt.testing": "0.8.
|
|
94
|
-
"@williamthorsen/toolbelt.vitest": "0.13.
|
|
93
|
+
"@williamthorsen/toolbelt.testing": "0.8.3",
|
|
94
|
+
"@williamthorsen/toolbelt.vitest": "0.13.5",
|
|
95
95
|
"esbuild": "0.28.2",
|
|
96
|
-
"readyup": "0.46.
|
|
96
|
+
"readyup": "0.46.2",
|
|
97
97
|
"tinyglobby": "0.2.17",
|
|
98
98
|
"typescript": "6.0.3",
|
|
99
99
|
"vite": "8.3.8",
|