@williamthorsen/nmr 0.48.0 → 0.49.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 +158 -0
- package/.readyup/kits/default.js +1527 -1460
- package/.readyup/manifest.json +96 -11
- package/CHANGELOG.md +41 -0
- package/README.md +36 -8
- package/agents/guidance/rulebooks/nmr.md +2 -2
- package/dist/esm/check-cache-build-output.d.ts +24 -0
- package/dist/esm/check-cache-build-output.js +49 -0
- package/dist/esm/check-cache-delegation.d.ts +27 -0
- package/dist/esm/check-cache-delegation.js +92 -0
- package/dist/esm/check-cache-entries.d.ts +109 -0
- package/dist/esm/check-cache-entries.js +87 -0
- package/dist/esm/check-cache-gate.d.ts +64 -0
- package/dist/esm/check-cache-gate.js +226 -0
- package/dist/esm/check-cache-key.d.ts +47 -0
- package/dist/esm/check-cache-key.js +77 -0
- package/dist/esm/check-cache-record.d.ts +69 -0
- package/dist/esm/check-cache-record.js +167 -0
- package/dist/esm/check-cache.d.ts +8 -176
- package/dist/esm/check-cache.js +9 -214
- package/dist/esm/child-env.d.ts +57 -0
- package/dist/esm/child-env.js +59 -0
- package/dist/esm/child-process.d.ts +55 -0
- package/dist/esm/child-process.js +112 -0
- package/dist/esm/commands/build.d.ts +0 -16
- package/dist/esm/commands/build.js +9 -214
- package/dist/esm/commands/clean.js +3 -2
- package/dist/esm/commands/compiler.d.ts +18 -0
- package/dist/esm/commands/compiler.js +49 -0
- package/dist/esm/commands/fmt.d.ts +0 -39
- package/dist/esm/commands/fmt.js +4 -81
- package/dist/esm/commands/format-targets.d.ts +39 -0
- package/dist/esm/commands/format-targets.js +83 -0
- package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
- package/dist/esm/commands/rewrite-specifiers.js +122 -0
- package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
- package/dist/esm/commands/tsconfig-chain.js +63 -0
- package/dist/esm/config-check-cache.d.ts +17 -0
- package/dist/esm/config-check-cache.js +63 -0
- package/dist/esm/config-validation.d.ts +20 -0
- package/dist/esm/config-validation.js +236 -0
- package/dist/esm/config.js +4 -286
- package/dist/esm/crossing-diagnostics.d.ts +39 -0
- package/dist/esm/crossing-diagnostics.js +132 -0
- package/dist/esm/default-scripts.js +1 -1
- package/dist/esm/delegate-host.d.ts +43 -0
- package/dist/esm/delegate-host.js +178 -0
- package/dist/esm/delegation-target.d.ts +52 -0
- package/dist/esm/delegation-target.js +71 -0
- package/dist/esm/fan-out.d.ts +57 -0
- package/dist/esm/fan-out.js +146 -0
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/help.js +9 -3
- package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
- package/dist/esm/helpers/deriveExcerpt.js +1 -1
- package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
- package/dist/esm/helpers/renderQuotedList.js +9 -0
- package/dist/esm/hooks.d.ts +17 -0
- package/dist/esm/hooks.js +23 -0
- package/dist/esm/invocation-args.d.ts +40 -0
- package/dist/esm/invocation-args.js +178 -0
- package/dist/esm/invocation.d.ts +67 -0
- package/dist/esm/invocation.js +3 -0
- package/dist/esm/machine-lock.d.ts +2 -0
- package/dist/esm/machine-lock.js +1 -1
- package/dist/esm/output-channel.d.ts +23 -0
- package/dist/esm/output-channel.js +14 -0
- package/dist/esm/pass-manifest.d.ts +53 -0
- package/dist/esm/pass-manifest.js +89 -0
- package/dist/esm/presentation.d.ts +47 -0
- package/dist/esm/presentation.js +32 -0
- package/dist/esm/recording.d.ts +1 -1
- package/dist/esm/recording.js +2 -2
- package/dist/esm/remote-host-session.d.ts +62 -0
- package/dist/esm/remote-host-session.js +277 -0
- package/dist/esm/remote-probe-host.d.ts +22 -0
- package/dist/esm/remote-probe-host.js +50 -0
- package/dist/esm/remote-probe.d.ts +31 -0
- package/dist/esm/remote-probe.js +78 -0
- package/dist/esm/remote-run.d.ts +28 -73
- package/dist/esm/remote-run.js +103 -165
- package/dist/esm/remote-session.d.ts +40 -0
- package/dist/esm/remote-session.js +151 -0
- package/dist/esm/remote-snapshot.d.ts +21 -0
- package/dist/esm/remote-snapshot.js +80 -0
- package/dist/esm/replay-assembly.d.ts +2 -2
- package/dist/esm/replay-assembly.js +1 -1
- package/dist/esm/run-chain.d.ts +21 -0
- package/dist/esm/run-chain.js +108 -0
- package/dist/esm/runCli.d.ts +1 -9
- package/dist/esm/runCli.js +39 -1002
- package/dist/esm/runner.d.ts +1 -54
- package/dist/esm/runner.js +2 -125
- package/dist/esm/shell-syntax.d.ts +30 -0
- package/dist/esm/shell-syntax.js +120 -0
- package/dist/esm/steps.d.ts +0 -2
- package/dist/esm/steps.js +3 -119
- package/dist/esm/types.d.ts +5 -0
- package/dist/esm/user-config.d.ts +34 -0
- package/dist/esm/user-config.js +93 -0
- package/dist/esm/verbosity.d.ts +6 -0
- package/dist/esm/verbosity.js +7 -7
- package/dist/esm/verdict.d.ts +1 -1
- package/dist/esm/verdict.js +1 -1
- package/docs/check-cache.md +28 -12
- package/docs/delegate.md +96 -16
- package/docs/reporting.md +6 -1
- package/docs/scripts.md +2 -1
- package/package.json +3 -3
- /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
- /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type HostList } from './remote-run.js';
|
|
2
|
+
/** What a probe found on a host: whether a delegation started now would wait there, or that the host did not answer. */
|
|
3
|
+
export type ProbeResult = 'busy' | 'free' | 'no-answer';
|
|
4
|
+
/**
|
|
5
|
+
* Probes every host at once and resolves to the one on which to run, per `selectHost`, as soon as the results that
|
|
6
|
+
* have arrived settle it. Reports each result that arrives before then, and aborts the probes still running once it
|
|
7
|
+
* has chosen.
|
|
8
|
+
*/
|
|
9
|
+
export declare function chooseHost(options: {
|
|
10
|
+
hosts: HostList;
|
|
11
|
+
onSettled: (host: string, result: ProbeResult) => void;
|
|
12
|
+
probe: (host: string, signal: AbortSignal) => Promise<ProbeResult>;
|
|
13
|
+
}): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Asks `host` whether a delegation from this checkout would wait there: whether its machine lock, or the lock of the
|
|
16
|
+
* checkout at `checkoutPath`, has a live holder. A host that fails to run the probe, or does not print its report,
|
|
17
|
+
* did not answer.
|
|
18
|
+
*/
|
|
19
|
+
export declare function probeHost(options: {
|
|
20
|
+
checkoutPath: string;
|
|
21
|
+
env: NodeJS.ProcessEnv;
|
|
22
|
+
host: string;
|
|
23
|
+
signal: AbortSignal;
|
|
24
|
+
}): Promise<ProbeResult>;
|
|
25
|
+
/**
|
|
26
|
+
* Returns the host on which to run, or `undefined` while a host whose result could change the choice has not
|
|
27
|
+
* answered: the first free host in list order, otherwise the first busy one, otherwise the first host.
|
|
28
|
+
*
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export declare function selectHost(hosts: HostList, results: ReadonlyMap<string, ProbeResult>): string | undefined;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { quoteForShell } from "./helpers/quoteForShell.js";
|
|
3
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
4
|
+
import { LOCK_DIR_ENV_VAR, LOCK_FILE, STALE_AFTER_MS } from "./machine-lock.js";
|
|
5
|
+
import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
|
|
6
|
+
import { probeHostLocks } from "./remote-probe-host.js";
|
|
7
|
+
import { runSsh } from "./remote-run.js";
|
|
8
|
+
export function chooseHost(options) {
|
|
9
|
+
const { hosts, onSettled, probe } = options;
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const results = new Map();
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
for (const host of hosts) {
|
|
14
|
+
void settle(host, resolve);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
async function settle(host, resolve) {
|
|
18
|
+
let result;
|
|
19
|
+
try {
|
|
20
|
+
result = await probe(host, controller.signal);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
result = 'no-answer';
|
|
24
|
+
}
|
|
25
|
+
if (controller.signal.aborted)
|
|
26
|
+
return;
|
|
27
|
+
results.set(host, result);
|
|
28
|
+
onSettled(host, result);
|
|
29
|
+
const chosen = selectHost(hosts, results);
|
|
30
|
+
if (chosen === undefined)
|
|
31
|
+
return;
|
|
32
|
+
controller.abort();
|
|
33
|
+
resolve(chosen);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function probeHost(options) {
|
|
37
|
+
const { checkoutPath, env, host, signal } = options;
|
|
38
|
+
const params = {
|
|
39
|
+
checkoutLockPath: path.posix.join(checkoutPath, CHECKOUT_LOCK_PATH),
|
|
40
|
+
lockDirEnvVar: LOCK_DIR_ENV_VAR,
|
|
41
|
+
machineLockFile: LOCK_FILE,
|
|
42
|
+
staleAfterMs: STALE_AFTER_MS,
|
|
43
|
+
};
|
|
44
|
+
const source = `(${probeHostLocks.toString()})(JSON.parse(process.argv[1]));`;
|
|
45
|
+
const { exitCode, output } = await runSsh(host, `exec node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`, env, signal);
|
|
46
|
+
if (exitCode !== 0) {
|
|
47
|
+
return 'no-answer';
|
|
48
|
+
}
|
|
49
|
+
const busy = output
|
|
50
|
+
.split('\n')
|
|
51
|
+
.map(readBusyField)
|
|
52
|
+
.findLast((value) => value !== undefined);
|
|
53
|
+
if (busy === undefined) {
|
|
54
|
+
return 'no-answer';
|
|
55
|
+
}
|
|
56
|
+
return busy ? 'busy' : 'free';
|
|
57
|
+
}
|
|
58
|
+
export function selectHost(hosts, results) {
|
|
59
|
+
for (const host of hosts) {
|
|
60
|
+
const result = results.get(host);
|
|
61
|
+
if (result === undefined) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (result === 'free') {
|
|
65
|
+
return host;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return hosts.find((host) => results.get(host) === 'busy') ?? hosts[0];
|
|
69
|
+
}
|
|
70
|
+
function readBusyField(line) {
|
|
71
|
+
try {
|
|
72
|
+
const value = JSON.parse(line);
|
|
73
|
+
return isObject(value) && typeof value['busy'] === 'boolean' ? value['busy'] : undefined;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/esm/remote-run.d.ts
CHANGED
|
@@ -1,78 +1,38 @@
|
|
|
1
1
|
import type { Writable } from 'node:stream';
|
|
2
2
|
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
3
|
-
/** Names the
|
|
3
|
+
/** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
|
|
4
4
|
export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
|
|
5
|
-
/** The flag that
|
|
5
|
+
/** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
|
|
6
6
|
export declare const DELEGATE_FLAG = "--delegate";
|
|
7
|
+
/** The flag that names the hosts on which to run the command. */
|
|
8
|
+
export declare const DELEGATE_TO_FLAG = "--delegate-to";
|
|
7
9
|
/** The exit status of a delegation that failed outside its command, which matches `ssh`'s own. */
|
|
8
10
|
export declare const DELEGATION_FAILURE_EXIT_CODE = 255;
|
|
9
|
-
/** The flag that runs the command here, whatever `NMR_DELEGATE`
|
|
11
|
+
/** The flag that runs the command here, whatever `NMR_DELEGATE` or the user config says. */
|
|
10
12
|
export declare const NO_DELEGATE_FLAG = "--no-delegate";
|
|
11
13
|
/** Keeps `ssh` from prompting or waiting on a terminal, and bounds the wait for a host that does not answer. */
|
|
12
14
|
export declare const SSH_OPTIONS: readonly string[];
|
|
13
|
-
/**
|
|
14
|
-
export type
|
|
15
|
-
kind: 'local';
|
|
16
|
-
} | {
|
|
17
|
-
kind: 'remote';
|
|
18
|
-
host: string;
|
|
19
|
-
source: 'env' | 'flag';
|
|
20
|
-
} | {
|
|
21
|
-
kind: 'refused';
|
|
22
|
-
error: string;
|
|
23
|
-
};
|
|
15
|
+
/** Candidate hosts in order of preference. */
|
|
16
|
+
export type HostList = readonly [string, ...string[]];
|
|
24
17
|
/** The phases of a delegation that run on the host, each reported by name when it fails. */
|
|
25
18
|
export type RemotePhase = 'check out' | 'install' | 'prepare' | 'push' | 'run';
|
|
26
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* The status of a delegated command and the pass-manifest entries that its run reported, unvalidated, or the phase
|
|
21
|
+
* that kept it from finishing and what that phase printed, with how long the delegation waited for another
|
|
22
|
+
* delegation's checkout.
|
|
23
|
+
*/
|
|
27
24
|
export type RemoteRunResult = {
|
|
28
25
|
ok: true;
|
|
29
26
|
exitCode: number;
|
|
27
|
+
passes: unknown[];
|
|
28
|
+
waitedMs: number;
|
|
30
29
|
} | {
|
|
31
30
|
ok: false;
|
|
32
31
|
message: string;
|
|
33
32
|
output: string;
|
|
34
33
|
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;
|
|
34
|
+
waitedMs: number;
|
|
45
35
|
};
|
|
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
36
|
/**
|
|
77
37
|
* Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
|
|
78
38
|
* repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
|
|
@@ -81,34 +41,22 @@ export declare function createSnapshot(cwd: string, env: NodeJS.ProcessEnv): Sna
|
|
|
81
41
|
*/
|
|
82
42
|
export declare function deriveCheckoutPath(hostname: string, toplevel: string): string;
|
|
83
43
|
/**
|
|
84
|
-
* Renders the warning for a
|
|
85
|
-
*
|
|
44
|
+
* Renders the warning for a delegation flag that appears after the command name, which makes it an argument to the
|
|
45
|
+
* command rather than a flag to nmr.
|
|
86
46
|
*/
|
|
87
47
|
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
48
|
/**
|
|
103
49
|
* Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
|
|
104
50
|
* `stdout` and `stderr` and returning its exit status.
|
|
105
51
|
*
|
|
106
52
|
* The host keeps one checkout per local repository and host name, under `~/.cache/nmr/delegate/`, so concurrent
|
|
107
53
|
* delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
|
|
108
|
-
* between runs.
|
|
109
|
-
*
|
|
54
|
+
* between runs. Check out, install, and run take one connection, whose session holds the checkout's lock, so that two
|
|
55
|
+
* delegations from one worktree run one after the other; what a phase other than `run` prints is returned rather than
|
|
56
|
+
* streamed, because it matters only when the phase fails.
|
|
110
57
|
*/
|
|
111
58
|
export declare function runRemotely(options: {
|
|
59
|
+
command: string;
|
|
112
60
|
cwd: string;
|
|
113
61
|
env: NodeJS.ProcessEnv;
|
|
114
62
|
host: string;
|
|
@@ -122,6 +70,13 @@ export declare function runRemotely(options: {
|
|
|
122
70
|
stderr: Writable;
|
|
123
71
|
stdout: Writable;
|
|
124
72
|
}): Promise<RemoteRunResult>;
|
|
73
|
+
/**
|
|
74
|
+
* Runs a script on the host through `wrapRemoteScript`, capturing what it prints. An abort stops the `ssh` process.
|
|
75
|
+
*/
|
|
76
|
+
export declare function runSsh(host: string, script: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<{
|
|
77
|
+
exitCode: number;
|
|
78
|
+
output: string;
|
|
79
|
+
}>;
|
|
125
80
|
/**
|
|
126
81
|
* Wraps a script to run under `/bin/sh` with the `PATH` that the user's interactive login shell sets. pnpm's own
|
|
127
82
|
* installer and the common version managers set it up in the interactive startup file, such as `~/.zshrc`, which a
|
package/dist/esm/remote-run.js
CHANGED
|
@@ -1,124 +1,37 @@
|
|
|
1
|
-
import { spawn
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import fs from 'node:fs';
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
3
|
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
6
|
-
import
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
|
|
7
|
+
import { PASS_MANIFEST_DIR_ENV_VAR, writeDebugNote } from "./check-cache.js";
|
|
7
8
|
import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
|
|
8
9
|
import { quoteForShell } from "./helpers/quoteForShell.js";
|
|
10
|
+
import { STALE_AFTER_MS } from "./machine-lock.js";
|
|
11
|
+
import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
|
|
12
|
+
import { composeSessionScript, createSessionDemultiplexer, formatDelegationWaitingLine } from "./remote-session.js";
|
|
9
13
|
export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
|
|
10
14
|
export const DELEGATE_FLAG = '--delegate';
|
|
15
|
+
export const DELEGATE_TO_FLAG = '--delegate-to';
|
|
11
16
|
export const DELEGATION_FAILURE_EXIT_CODE = 255;
|
|
12
17
|
export const NO_DELEGATE_FLAG = '--no-delegate';
|
|
13
18
|
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
19
|
export function deriveCheckoutPath(hostname, toplevel) {
|
|
86
20
|
const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
|
|
87
21
|
return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
|
|
88
22
|
}
|
|
89
23
|
export function formatMisplacedDelegationWarning(command, style) {
|
|
90
|
-
return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG} and ${NO_DELEGATE_FLAG} after the
|
|
91
|
-
`to \`${command}\`, not read by nmr.
|
|
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' };
|
|
24
|
+
return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG}, ${DELEGATE_TO_FLAG}, and ${NO_DELEGATE_FLAG} after the ` +
|
|
25
|
+
`command name are passed to \`${command}\`, not read by nmr. Write them before it: ` +
|
|
26
|
+
`\`nmr ${DELEGATE_TO_FLAG} <host> ${command}\`.`);
|
|
114
27
|
}
|
|
115
28
|
export async function runRemotely(options) {
|
|
116
|
-
const { cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
|
|
29
|
+
const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
|
|
117
30
|
const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
|
|
118
31
|
const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
|
|
119
32
|
const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
|
|
120
33
|
if (prepare.exitCode !== 0) {
|
|
121
|
-
return { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare' };
|
|
34
|
+
return { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare', waitedMs: 0 };
|
|
122
35
|
}
|
|
123
36
|
const push = await runProcess([
|
|
124
37
|
'git',
|
|
@@ -131,35 +44,84 @@ export async function runRemotely(options) {
|
|
|
131
44
|
`${snapshot.sha}:${SNAPSHOT_REF}`,
|
|
132
45
|
], { cwd: snapshot.toplevel, env: { ...env, GIT_SSH_COMMAND: ['ssh', ...SSH_OPTIONS].join(' ') } });
|
|
133
46
|
if (push.exitCode !== 0) {
|
|
134
|
-
return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push' };
|
|
135
|
-
}
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
47
|
+
return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push', waitedMs: 0 };
|
|
48
|
+
}
|
|
49
|
+
const nonce = randomUUID();
|
|
50
|
+
const marker = `__NMR_DELEGATE_${nonce}__`;
|
|
51
|
+
const statusFile = `.git/nmr-run-status-${nonce}`;
|
|
52
|
+
const passes = [];
|
|
53
|
+
let waitStartedAtMs;
|
|
54
|
+
let waitedMs = 0;
|
|
55
|
+
let failure;
|
|
56
|
+
const demultiplexer = createSessionDemultiplexer({
|
|
57
|
+
marker,
|
|
58
|
+
onRecord(record) {
|
|
59
|
+
if (record.kind === 'waiting') {
|
|
60
|
+
waitStartedAtMs ??= Date.now();
|
|
61
|
+
stderr.write(`${formatDelegationWaitingLine(record.holder, host)}\n`);
|
|
62
|
+
}
|
|
63
|
+
else if (record.kind === 'locked' && waitStartedAtMs !== undefined) {
|
|
64
|
+
waitedMs = Date.now() - waitStartedAtMs;
|
|
65
|
+
}
|
|
66
|
+
else if (record.kind === 'failed') {
|
|
67
|
+
failure = record;
|
|
68
|
+
}
|
|
69
|
+
else if (record.kind === 'passes') {
|
|
70
|
+
passes.push(...record.entries);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
onMalformedRecord() {
|
|
74
|
+
writeDebugNote(`discarded a malformed pass manifest from ${host}`, env, stderr);
|
|
75
|
+
},
|
|
76
|
+
stderr,
|
|
77
|
+
});
|
|
78
|
+
const script = composeSessionScript({
|
|
79
|
+
checkoutDir,
|
|
80
|
+
params: {
|
|
81
|
+
holder: { command, dir: cwd, pid: process.pid },
|
|
82
|
+
lockPath: CHECKOUT_LOCK_PATH,
|
|
83
|
+
marker,
|
|
84
|
+
nmrArgs,
|
|
85
|
+
passManifestDir: `.git/nmr-passes-${nonce}`,
|
|
86
|
+
passManifestEnvVar: PASS_MANIFEST_DIR_ENV_VAR,
|
|
87
|
+
remoteEnv,
|
|
88
|
+
rootDir: toRemoteRelativeDir(path.relative(snapshot.toplevel, monorepoRoot)),
|
|
89
|
+
runDir: toRemoteRelativeDir(path.relative(snapshot.toplevel, cwd)),
|
|
90
|
+
sha: snapshot.sha,
|
|
91
|
+
staleAfterMs: STALE_AFTER_MS,
|
|
92
|
+
statusFile,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const session = await runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], {
|
|
151
96
|
env,
|
|
152
97
|
isStreaming: true,
|
|
153
|
-
stderr,
|
|
98
|
+
stderr: demultiplexer.stream,
|
|
154
99
|
stdout,
|
|
155
100
|
});
|
|
156
|
-
|
|
157
|
-
|
|
101
|
+
demultiplexer.flush();
|
|
102
|
+
if (failure !== undefined) {
|
|
103
|
+
const output = [demultiplexer.readOutput(), failure.output].filter((text) => text !== '').join('\n');
|
|
104
|
+
return { ok: false, message: describeFailedPhase(failure, host), output, phase: failure.phase, waitedMs };
|
|
105
|
+
}
|
|
106
|
+
if (!demultiplexer.hasStarted()) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
message: describeUnfinishedSession(session.exitCode, host),
|
|
110
|
+
output: demultiplexer.readOutput(),
|
|
111
|
+
phase: 'check out',
|
|
112
|
+
waitedMs,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (session.exitCode === DELEGATION_FAILURE_EXIT_CODE) {
|
|
116
|
+
const recorded = await runSsh(host, `cd ${checkoutDir} && cat ${quoteForShell(statusFile)} && rm -f ${quoteForShell(statusFile)}`, env);
|
|
158
117
|
if (recorded.exitCode !== 0 || recorded.output.trim() !== String(DELEGATION_FAILURE_EXIT_CODE)) {
|
|
159
|
-
return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run' };
|
|
118
|
+
return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run', waitedMs };
|
|
160
119
|
}
|
|
161
120
|
}
|
|
162
|
-
return { ok: true, exitCode:
|
|
121
|
+
return { ok: true, exitCode: session.exitCode, passes, waitedMs };
|
|
122
|
+
}
|
|
123
|
+
export function runSsh(host, script, env, signal) {
|
|
124
|
+
return runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], { env, signal });
|
|
163
125
|
}
|
|
164
126
|
export function wrapRemoteScript(script) {
|
|
165
127
|
return `exec /bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}\n${script}`)}`;
|
|
@@ -171,33 +133,23 @@ const LOGIN_PATH_PREAMBLE = [
|
|
|
171
133
|
'export PATH',
|
|
172
134
|
].join('; ');
|
|
173
135
|
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
136
|
const COMMAND_NOT_FOUND_EXIT_CODE = 127;
|
|
186
137
|
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
138
|
const SNAPSHOT_REF = 'refs/nmr/delegate';
|
|
139
|
+
function describeFailedPhase(failure, host) {
|
|
140
|
+
if (failure.phase === 'check out') {
|
|
141
|
+
return `could not check out on ${host}`;
|
|
142
|
+
}
|
|
143
|
+
return failure.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
|
+
}
|
|
147
|
+
function describeUnfinishedSession(exitCode, host) {
|
|
148
|
+
if (exitCode === COMMAND_NOT_FOUND_EXIT_CODE) {
|
|
149
|
+
return `could not check out on ${host}: node is not on the PATH that the interactive login shell sets there`;
|
|
150
|
+
}
|
|
151
|
+
return exitCode === DELEGATION_FAILURE_EXIT_CODE ? `lost connection to ${host}` : `could not check out on ${host}`;
|
|
152
|
+
}
|
|
201
153
|
function forwardChunk(chunk, stream, buffer) {
|
|
202
154
|
if (stream === undefined) {
|
|
203
155
|
buffer.append(chunk);
|
|
@@ -205,29 +157,15 @@ function forwardChunk(chunk, stream, buffer) {
|
|
|
205
157
|
}
|
|
206
158
|
stream.write(chunk);
|
|
207
159
|
}
|
|
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
160
|
function runProcess(argv, options) {
|
|
223
|
-
const { cwd, env, isStreaming = false, stderr, stdout } = options;
|
|
161
|
+
const { cwd, env, isStreaming = false, signal, stderr, stdout } = options;
|
|
224
162
|
const [file = '', ...args] = argv;
|
|
225
163
|
const buffer = createBoundedBuffer({
|
|
226
164
|
headLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
|
|
227
165
|
tailLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
|
|
228
166
|
});
|
|
229
167
|
return new Promise((resolve) => {
|
|
230
|
-
const child = spawn(file, args, { cwd, env, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
168
|
+
const child = spawn(file, args, { cwd, env, signal, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
231
169
|
child.stdout?.on('data', (chunk) => {
|
|
232
170
|
forwardChunk(chunk, isStreaming ? stdout : undefined, buffer);
|
|
233
171
|
});
|
|
@@ -246,6 +184,6 @@ function runProcess(argv, options) {
|
|
|
246
184
|
});
|
|
247
185
|
});
|
|
248
186
|
}
|
|
249
|
-
function
|
|
250
|
-
return
|
|
187
|
+
function toRemoteRelativeDir(relativeDir) {
|
|
188
|
+
return relativeDir === '' ? '.' : relativeDir.split(path.sep).join('/');
|
|
251
189
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
import { type DelegationHolder, type HostSessionParams, type SessionRecord } from './remote-host-session.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reads a host session's stderr: its records until the run starts, then the run's own output, and the session's
|
|
5
|
+
* report of the passes that the run recorded once it has ended.
|
|
6
|
+
*/
|
|
7
|
+
export interface SessionDemultiplexer {
|
|
8
|
+
/** Forwards what the stream held back as the possible start of a record, once the session has ended. */
|
|
9
|
+
flush: () => void;
|
|
10
|
+
/** Whether the session reported that the run started. */
|
|
11
|
+
hasStarted: () => boolean;
|
|
12
|
+
/** Returns what the session printed before the run started, other than its records. */
|
|
13
|
+
readOutput: () => string;
|
|
14
|
+
stream: Writable;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Composes the script that runs the host session from the checkout. `checkoutDir` is a shell expression, so that it
|
|
18
|
+
* can name the host's home directory.
|
|
19
|
+
*/
|
|
20
|
+
export declare function composeSessionScript(options: {
|
|
21
|
+
checkoutDir: string;
|
|
22
|
+
params: HostSessionParams;
|
|
23
|
+
}): string;
|
|
24
|
+
/**
|
|
25
|
+
* Returns a demultiplexer that passes each of the session's records to `onRecord` until the `run` record, keeps the
|
|
26
|
+
* other text that arrives before it as the session's output, and forwards everything after it to `stderr` as it
|
|
27
|
+
* arrives, except a `passes` record, which it passes to `onRecord` too. A `passes` record whose entries are not a list
|
|
28
|
+
* goes to `onMalformedRecord` instead.
|
|
29
|
+
*
|
|
30
|
+
* The run's output is forwarded as bytes, so that a character split across chunks arrives unchanged. Only trailing bytes
|
|
31
|
+
* that may still begin a record are held back, until they turn out not to or the record's newline arrives.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createSessionDemultiplexer(options: {
|
|
34
|
+
marker: string;
|
|
35
|
+
onMalformedRecord?: () => void;
|
|
36
|
+
onRecord: (record: SessionRecord) => void;
|
|
37
|
+
stderr: Writable;
|
|
38
|
+
}): SessionDemultiplexer;
|
|
39
|
+
/** Renders the line that a delegation prints when it starts waiting for another delegation's checkout. */
|
|
40
|
+
export declare function formatDelegationWaitingLine(holder: DelegationHolder | undefined, host: string): string;
|