@williamthorsen/nmr 0.47.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 +206 -0
- package/.readyup/kits/default.js +1534 -1466
- package/.readyup/manifest.json +142 -12
- package/CHANGELOG.md +55 -0
- package/README.md +44 -6
- package/agents/guidance/rulebooks/nmr.md +2 -1
- package/dist/esm/bin-path.js +4 -3
- 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 +10 -0
- package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
- package/dist/esm/helpers/deriveExcerpt.js +1 -1
- package/dist/esm/helpers/findClosestName.js +4 -3
- package/dist/esm/helpers/quoteForShell.d.ts +5 -0
- package/dist/esm/helpers/quoteForShell.js +3 -0
- package/dist/esm/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 +91 -0
- package/dist/esm/remote-run.js +189 -0
- 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 +41 -895
- 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 +3 -1
- package/dist/esm/verdict.js +4 -3
- package/dist/esm/vitest-source-resolution.js +2 -3
- package/docs/check-cache.md +30 -14
- package/docs/delegate.md +141 -0
- package/docs/machine-lock.md +2 -1
- package/docs/reporting.md +6 -1
- package/docs/scripts.md +2 -1
- package/package.json +5 -5
- /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
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
3
|
+
/** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
|
|
4
|
+
export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
|
|
5
|
+
/** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
|
|
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";
|
|
9
|
+
/** The exit status of a delegation that failed outside its command, which matches `ssh`'s own. */
|
|
10
|
+
export declare const DELEGATION_FAILURE_EXIT_CODE = 255;
|
|
11
|
+
/** The flag that runs the command here, whatever `NMR_DELEGATE` or the user config says. */
|
|
12
|
+
export declare const NO_DELEGATE_FLAG = "--no-delegate";
|
|
13
|
+
/** Keeps `ssh` from prompting or waiting on a terminal, and bounds the wait for a host that does not answer. */
|
|
14
|
+
export declare const SSH_OPTIONS: readonly string[];
|
|
15
|
+
/** Candidate hosts in order of preference. */
|
|
16
|
+
export type HostList = readonly [string, ...string[]];
|
|
17
|
+
/** The phases of a delegation that run on the host, each reported by name when it fails. */
|
|
18
|
+
export type RemotePhase = 'check out' | 'install' | 'prepare' | 'push' | 'run';
|
|
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
|
+
*/
|
|
24
|
+
export type RemoteRunResult = {
|
|
25
|
+
ok: true;
|
|
26
|
+
exitCode: number;
|
|
27
|
+
passes: unknown[];
|
|
28
|
+
waitedMs: number;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
message: string;
|
|
32
|
+
output: string;
|
|
33
|
+
phase: RemotePhase;
|
|
34
|
+
waitedMs: number;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
|
|
38
|
+
* repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export declare function deriveCheckoutPath(hostname: string, toplevel: string): string;
|
|
43
|
+
/**
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
export declare function formatMisplacedDelegationWarning(command: string, style: OutputStyle): string;
|
|
48
|
+
/**
|
|
49
|
+
* Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
|
|
50
|
+
* `stdout` and `stderr` and returning its exit status.
|
|
51
|
+
*
|
|
52
|
+
* The host keeps one checkout per local repository and host name, under `~/.cache/nmr/delegate/`, so concurrent
|
|
53
|
+
* delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
export declare function runRemotely(options: {
|
|
59
|
+
command: string;
|
|
60
|
+
cwd: string;
|
|
61
|
+
env: NodeJS.ProcessEnv;
|
|
62
|
+
host: string;
|
|
63
|
+
monorepoRoot: string;
|
|
64
|
+
nmrArgs: readonly string[];
|
|
65
|
+
remoteEnv: Readonly<Record<string, string>>;
|
|
66
|
+
snapshot: {
|
|
67
|
+
sha: string;
|
|
68
|
+
toplevel: string;
|
|
69
|
+
};
|
|
70
|
+
stderr: Writable;
|
|
71
|
+
stdout: Writable;
|
|
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
|
+
}>;
|
|
80
|
+
/**
|
|
81
|
+
* Wraps a script to run under `/bin/sh` with the `PATH` that the user's interactive login shell sets. pnpm's own
|
|
82
|
+
* installer and the common version managers set it up in the interactive startup file, such as `~/.zshrc`, which a
|
|
83
|
+
* non-interactive shell does not read. The script runs under `sh` rather than that shell itself, because the login
|
|
84
|
+
* shell, such as fish, may not accept the POSIX syntax in which the scripts are written.
|
|
85
|
+
*
|
|
86
|
+
* A startup file may print to stdout, so the shell prints `PATH` after a marker, and only what follows the last
|
|
87
|
+
* marker is read. When no marker arrives, the script keeps the `PATH` with which `ssh` started it.
|
|
88
|
+
*
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
91
|
+
export declare function wrapRemoteScript(script: string): string;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
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";
|
|
8
|
+
import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
|
|
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";
|
|
13
|
+
export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
|
|
14
|
+
export const DELEGATE_FLAG = '--delegate';
|
|
15
|
+
export const DELEGATE_TO_FLAG = '--delegate-to';
|
|
16
|
+
export const DELEGATION_FAILURE_EXIT_CODE = 255;
|
|
17
|
+
export const NO_DELEGATE_FLAG = '--no-delegate';
|
|
18
|
+
export const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
|
|
19
|
+
export function deriveCheckoutPath(hostname, toplevel) {
|
|
20
|
+
const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
|
|
21
|
+
return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
|
|
22
|
+
}
|
|
23
|
+
export function formatMisplacedDelegationWarning(command, style) {
|
|
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}\`.`);
|
|
27
|
+
}
|
|
28
|
+
export async function runRemotely(options) {
|
|
29
|
+
const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
|
|
30
|
+
const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
|
|
31
|
+
const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
|
|
32
|
+
const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
|
|
33
|
+
if (prepare.exitCode !== 0) {
|
|
34
|
+
return { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare', waitedMs: 0 };
|
|
35
|
+
}
|
|
36
|
+
const push = await runProcess([
|
|
37
|
+
'git',
|
|
38
|
+
'push',
|
|
39
|
+
'--force',
|
|
40
|
+
'--no-verify',
|
|
41
|
+
'--quiet',
|
|
42
|
+
`--receive-pack=${RECEIVE_PACK_COMMAND}`,
|
|
43
|
+
`${host}:${checkoutPath}`,
|
|
44
|
+
`${snapshot.sha}:${SNAPSHOT_REF}`,
|
|
45
|
+
], { cwd: snapshot.toplevel, env: { ...env, GIT_SSH_COMMAND: ['ssh', ...SSH_OPTIONS].join(' ') } });
|
|
46
|
+
if (push.exitCode !== 0) {
|
|
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)], {
|
|
96
|
+
env,
|
|
97
|
+
isStreaming: true,
|
|
98
|
+
stderr: demultiplexer.stream,
|
|
99
|
+
stdout,
|
|
100
|
+
});
|
|
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);
|
|
117
|
+
if (recorded.exitCode !== 0 || recorded.output.trim() !== String(DELEGATION_FAILURE_EXIT_CODE)) {
|
|
118
|
+
return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run', waitedMs };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
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 });
|
|
125
|
+
}
|
|
126
|
+
export function wrapRemoteScript(script) {
|
|
127
|
+
return `exec /bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}\n${script}`)}`;
|
|
128
|
+
}
|
|
129
|
+
const PATH_MARKER = '__NMR_LOGIN_PATH__=';
|
|
130
|
+
const LOGIN_PATH_PREAMBLE = [
|
|
131
|
+
`nmr_login_path=$("\${SHELL:-/bin/sh}" -ilc 'printf "\\n${PATH_MARKER}%s\\n" "$PATH"' </dev/null 2>/dev/null | sed -n 's/^${PATH_MARKER}//p' | tail -n 1)`,
|
|
132
|
+
'[ -n "$nmr_login_path" ] && PATH=$nmr_login_path',
|
|
133
|
+
'export PATH',
|
|
134
|
+
].join('; ');
|
|
135
|
+
const RECEIVE_PACK_COMMAND = `/bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}; exec git receive-pack "$1"`)} nmr-receive-pack`;
|
|
136
|
+
const COMMAND_NOT_FOUND_EXIT_CODE = 127;
|
|
137
|
+
const PHASE_OUTPUT_LIMIT_BYTES = 65_536;
|
|
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
|
+
}
|
|
153
|
+
function forwardChunk(chunk, stream, buffer) {
|
|
154
|
+
if (stream === undefined) {
|
|
155
|
+
buffer.append(chunk);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
stream.write(chunk);
|
|
159
|
+
}
|
|
160
|
+
function runProcess(argv, options) {
|
|
161
|
+
const { cwd, env, isStreaming = false, signal, stderr, stdout } = options;
|
|
162
|
+
const [file = '', ...args] = argv;
|
|
163
|
+
const buffer = createBoundedBuffer({
|
|
164
|
+
headLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
|
|
165
|
+
tailLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
|
|
166
|
+
});
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
const child = spawn(file, args, { cwd, env, signal, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
169
|
+
child.stdout?.on('data', (chunk) => {
|
|
170
|
+
forwardChunk(chunk, isStreaming ? stdout : undefined, buffer);
|
|
171
|
+
});
|
|
172
|
+
child.stderr?.on('data', (chunk) => {
|
|
173
|
+
forwardChunk(chunk, isStreaming ? stderr : undefined, buffer);
|
|
174
|
+
});
|
|
175
|
+
child.on('error', (error) => {
|
|
176
|
+
resolve({ exitCode: COMMAND_NOT_FOUND_EXIT_CODE, output: `${file}: ${error.message}` });
|
|
177
|
+
});
|
|
178
|
+
child.on('close', (code, signal) => {
|
|
179
|
+
child.stdin?.destroy();
|
|
180
|
+
resolve({
|
|
181
|
+
exitCode: code ?? 128 + (signal === null ? 0 : os.constants.signals[signal]),
|
|
182
|
+
output: buffer.readBuffer().toString('utf8'),
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function toRemoteRelativeDir(relativeDir) {
|
|
188
|
+
return relativeDir === '' ? '.' : relativeDir.split(path.sep).join('/');
|
|
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;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
import { quoteForShell } from "./helpers/quoteForShell.js";
|
|
3
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
4
|
+
import { runHostSession, } from "./remote-host-session.js";
|
|
5
|
+
export function composeSessionScript(options) {
|
|
6
|
+
const { checkoutDir, params } = options;
|
|
7
|
+
const source = `(${runHostSession.toString()})(JSON.parse(process.argv[1]));`;
|
|
8
|
+
return `cd ${checkoutDir} && exec node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`;
|
|
9
|
+
}
|
|
10
|
+
export function createSessionDemultiplexer(options) {
|
|
11
|
+
const { marker, onMalformedRecord, onRecord, stderr } = options;
|
|
12
|
+
const prefix = `${marker} `;
|
|
13
|
+
const prefixBytes = Buffer.from(prefix);
|
|
14
|
+
let pending = Buffer.alloc(0);
|
|
15
|
+
let output = '';
|
|
16
|
+
let hasStarted = false;
|
|
17
|
+
const stream = new Writable({
|
|
18
|
+
write(chunk, _encoding, callback) {
|
|
19
|
+
pending = pending.length === 0 ? chunk : Buffer.concat([pending, chunk]);
|
|
20
|
+
readSessionLines();
|
|
21
|
+
if (hasStarted) {
|
|
22
|
+
forwardRunOutput();
|
|
23
|
+
}
|
|
24
|
+
callback();
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
flush() {
|
|
29
|
+
if (!hasStarted || pending.length === 0) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
stderr.write(pending);
|
|
33
|
+
pending = Buffer.alloc(0);
|
|
34
|
+
},
|
|
35
|
+
hasStarted: () => hasStarted,
|
|
36
|
+
readOutput: () => (hasStarted ? output : output + pending.toString('utf8')),
|
|
37
|
+
stream,
|
|
38
|
+
};
|
|
39
|
+
function readSessionLines() {
|
|
40
|
+
let newlineIndex = pending.indexOf(NEWLINE);
|
|
41
|
+
while (!hasStarted && newlineIndex !== -1) {
|
|
42
|
+
const line = pending.subarray(0, newlineIndex).toString('utf8');
|
|
43
|
+
pending = pending.subarray(newlineIndex + 1);
|
|
44
|
+
const record = line.startsWith(prefix) ? parseRecord(line.slice(prefix.length)) : undefined;
|
|
45
|
+
if (record === undefined) {
|
|
46
|
+
output += `${line}\n`;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
onRecord(record);
|
|
50
|
+
hasStarted = record.kind === 'run';
|
|
51
|
+
}
|
|
52
|
+
newlineIndex = pending.indexOf(NEWLINE);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function forwardRunOutput() {
|
|
56
|
+
let segmentStart = 0;
|
|
57
|
+
let searchFrom = 0;
|
|
58
|
+
let markerIndex = pending.indexOf(prefixBytes, searchFrom);
|
|
59
|
+
while (markerIndex !== -1) {
|
|
60
|
+
const newlineIndex = pending.indexOf(NEWLINE, markerIndex);
|
|
61
|
+
if (newlineIndex === -1) {
|
|
62
|
+
writeRange(segmentStart, markerIndex);
|
|
63
|
+
pending = pending.subarray(markerIndex);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const kind = readPassesRecord(pending.subarray(markerIndex + prefixBytes.length, newlineIndex).toString('utf8'));
|
|
67
|
+
if (kind !== 'other') {
|
|
68
|
+
writeRange(segmentStart, markerIndex);
|
|
69
|
+
segmentStart = newlineIndex + 1;
|
|
70
|
+
}
|
|
71
|
+
searchFrom = newlineIndex + 1;
|
|
72
|
+
markerIndex = pending.indexOf(prefixBytes, searchFrom);
|
|
73
|
+
}
|
|
74
|
+
const holdFrom = findPartialPrefix(searchFrom);
|
|
75
|
+
writeRange(segmentStart, holdFrom);
|
|
76
|
+
pending = pending.subarray(holdFrom);
|
|
77
|
+
}
|
|
78
|
+
function findPartialPrefix(from) {
|
|
79
|
+
for (let index = Math.max(from, pending.length - prefixBytes.length + 1); index < pending.length; index++) {
|
|
80
|
+
if (prefixBytes.subarray(0, pending.length - index).equals(pending.subarray(index))) {
|
|
81
|
+
return index;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return pending.length;
|
|
85
|
+
}
|
|
86
|
+
function readPassesRecord(json) {
|
|
87
|
+
let value;
|
|
88
|
+
try {
|
|
89
|
+
value = JSON.parse(json);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return 'other';
|
|
93
|
+
}
|
|
94
|
+
if (!isObject(value) || value['kind'] !== 'passes') {
|
|
95
|
+
return 'other';
|
|
96
|
+
}
|
|
97
|
+
const entries = value['entries'];
|
|
98
|
+
if (!Array.isArray(entries)) {
|
|
99
|
+
onMalformedRecord?.();
|
|
100
|
+
return 'malformed';
|
|
101
|
+
}
|
|
102
|
+
onRecord({ kind: 'passes', entries });
|
|
103
|
+
return 'passes';
|
|
104
|
+
}
|
|
105
|
+
function writeRange(start, end) {
|
|
106
|
+
if (end > start) {
|
|
107
|
+
stderr.write(pending.subarray(start, end));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function formatDelegationWaitingLine(holder, host) {
|
|
112
|
+
if (holder === undefined) {
|
|
113
|
+
return `nmr: waiting for another delegation's checkout on ${host}`;
|
|
114
|
+
}
|
|
115
|
+
return `nmr: waiting for ${holder.command} in ${holder.dir} (pid ${holder.pid}) to finish on ${host}`;
|
|
116
|
+
}
|
|
117
|
+
const NEWLINE = 0x0a;
|
|
118
|
+
function isDelegationHolder(value) {
|
|
119
|
+
return (isObject(value) &&
|
|
120
|
+
typeof value['command'] === 'string' &&
|
|
121
|
+
typeof value['dir'] === 'string' &&
|
|
122
|
+
typeof value['pid'] === 'number');
|
|
123
|
+
}
|
|
124
|
+
function isSessionRecord(value) {
|
|
125
|
+
if (!isObject(value))
|
|
126
|
+
return false;
|
|
127
|
+
switch (value['kind']) {
|
|
128
|
+
case 'failed':
|
|
129
|
+
return (typeof value['exitCode'] === 'number' &&
|
|
130
|
+
typeof value['output'] === 'string' &&
|
|
131
|
+
(value['phase'] === 'check out' || value['phase'] === 'install'));
|
|
132
|
+
case 'locked':
|
|
133
|
+
case 'run':
|
|
134
|
+
return true;
|
|
135
|
+
case 'passes':
|
|
136
|
+
return Array.isArray(value['entries']);
|
|
137
|
+
case 'waiting':
|
|
138
|
+
return value['holder'] === undefined || isDelegationHolder(value['holder']);
|
|
139
|
+
default:
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function parseRecord(json) {
|
|
144
|
+
try {
|
|
145
|
+
const value = JSON.parse(json);
|
|
146
|
+
return isSessionRecord(value) ? value : undefined;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** A commit that records the working tree, or why one could not be made. */
|
|
2
|
+
export type SnapshotResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
sha: string;
|
|
5
|
+
toplevel: string;
|
|
6
|
+
treeId: string;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
isUnsupported: boolean;
|
|
10
|
+
reason: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Builds a commit whose tree is the working tree as it is on disk: tracked changes, staged or not, and untracked
|
|
14
|
+
* files that git does not ignore. The commit is built in a temporary index, so the repository's index, refs, and
|
|
15
|
+
* stash are untouched; the only write is to the object database.
|
|
16
|
+
*
|
|
17
|
+
* The identity and date are fixed, so that the same tree on the same `HEAD` always yields the same commit and a
|
|
18
|
+
* repeated push transfers nothing. A submodule, or a nested repository that `git add` would record as one, is
|
|
19
|
+
* refused: Its content is in a repository that the snapshot does not include.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createSnapshot(cwd: string, env: NodeJS.ProcessEnv): SnapshotResult;
|