@williamthorsen/nmr 0.46.2 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.meta/changelog.json +85 -0
- package/.readyup/kits/default.js +11 -10
- package/.readyup/manifest.json +53 -8
- package/CHANGELOG.md +24 -0
- package/README.md +29 -3
- package/agents/guidance/rulebooks/nmr.md +3 -1
- package/dist/esm/bin-path.js +4 -3
- package/dist/esm/commands/fmt.js +1 -1
- package/dist/esm/help.js +5 -0
- package/dist/esm/helpers/findClosestName.js +4 -3
- package/dist/esm/helpers/quoteForShell.d.ts +5 -0
- package/dist/esm/helpers/quoteForShell.js +3 -0
- package/dist/esm/machine-lock.d.ts +64 -0
- package/dist/esm/machine-lock.js +229 -0
- package/dist/esm/remote-run.d.ts +136 -0
- package/dist/esm/remote-run.js +251 -0
- package/dist/esm/runCli.js +247 -70
- package/dist/esm/verdict.d.ts +2 -0
- package/dist/esm/verdict.js +3 -2
- package/dist/esm/vitest-max-workers.d.ts +11 -0
- package/dist/esm/vitest-max-workers.js +35 -0
- package/dist/esm/vitest-source-resolution.js +2 -3
- package/dist/esm/vitest.js +10 -8
- package/docs/check-cache.md +2 -2
- package/docs/delegate.md +61 -0
- package/docs/machine-lock.md +62 -0
- package/docs/reporting.md +1 -1
- package/docs/vitest.md +17 -3
- package/package.json +4 -4
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
|
|
8
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
9
|
+
const LOCK_FILE = 'heavy.lock';
|
|
10
|
+
const RECLAIM_GUARD = 'heavy.lock.reclaim';
|
|
11
|
+
const RECLAIM_GUARD_STALE_AFTER_MS = 10_000;
|
|
12
|
+
export const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
13
|
+
export const HEAVY_COMMANDS = new Set([
|
|
14
|
+
'check',
|
|
15
|
+
'check:strict',
|
|
16
|
+
'ci',
|
|
17
|
+
'root:check',
|
|
18
|
+
'root:test',
|
|
19
|
+
'root:test:all',
|
|
20
|
+
'root:test:tool',
|
|
21
|
+
'root:test:unit',
|
|
22
|
+
'test',
|
|
23
|
+
'test:all',
|
|
24
|
+
'test:coverage',
|
|
25
|
+
'test:tool',
|
|
26
|
+
'test:unit',
|
|
27
|
+
]);
|
|
28
|
+
export const LOCK_DIR_ENV_VAR = 'NMR_LOCK_DIR';
|
|
29
|
+
export const LOCK_TOKEN_ENV_VAR = 'NMR_MACHINE_LOCK_TOKEN';
|
|
30
|
+
export const NO_LOCK_ENV_VAR = 'NMR_NO_LOCK';
|
|
31
|
+
export const POLL_INTERVAL_MS = 1_000;
|
|
32
|
+
export const STALE_AFTER_MS = 60_000;
|
|
33
|
+
export async function acquireMachineLock(options) {
|
|
34
|
+
const { command, dir, env, stderr } = options;
|
|
35
|
+
if (env[NO_LOCK_ENV_VAR] === '1') {
|
|
36
|
+
return createHoldFreeLock({ isChainUnlocked: true, waited: false });
|
|
37
|
+
}
|
|
38
|
+
const lockDir = options.lockDir ?? resolveLockDir({ env, platform: process.platform, uid: process.getuid?.() });
|
|
39
|
+
if (await isUnderAncestorHold(lockDir, env)) {
|
|
40
|
+
return createHoldFreeLock({ isChainUnlocked: false, waited: false });
|
|
41
|
+
}
|
|
42
|
+
const lockPath = path.join(lockDir, LOCK_FILE);
|
|
43
|
+
try {
|
|
44
|
+
await fs.mkdir(lockDir, { recursive: true, mode: 0o700 });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
reportUnwritableDir(lockDir, error, stderr);
|
|
48
|
+
return createHoldFreeLock({ isChainUnlocked: true, waited: false });
|
|
49
|
+
}
|
|
50
|
+
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
51
|
+
const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
52
|
+
const staleAfterMs = options.staleAfterMs ?? STALE_AFTER_MS;
|
|
53
|
+
const record = {
|
|
54
|
+
command,
|
|
55
|
+
dir,
|
|
56
|
+
pid: process.pid,
|
|
57
|
+
startedAt: new Date().toISOString(),
|
|
58
|
+
token: randomUUID(),
|
|
59
|
+
};
|
|
60
|
+
let waited = false;
|
|
61
|
+
let reportedHolder;
|
|
62
|
+
for (;;) {
|
|
63
|
+
try {
|
|
64
|
+
await fs.writeFile(lockPath, JSON.stringify(record), { flag: 'wx' });
|
|
65
|
+
return createHeldLock({ heartbeatIntervalMs, lockPath, token: record.token, waited });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (readErrorCode(error) !== 'EEXIST') {
|
|
69
|
+
reportUnwritableDir(lockDir, error, stderr);
|
|
70
|
+
return createHoldFreeLock({ isChainUnlocked: true, waited });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const state = await readLockState(lockPath);
|
|
74
|
+
if (state === undefined) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (isStale(state, staleAfterMs)) {
|
|
78
|
+
await reclaimStaleLock({ lockDir, lockPath, observed: state, staleAfterMs });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const holderIdentity = state.holder?.token ?? 'unreadable';
|
|
82
|
+
if (holderIdentity !== reportedHolder) {
|
|
83
|
+
stderr.write(`${formatWaitingLine(state.holder, lockPath)}\n`);
|
|
84
|
+
reportedHolder = holderIdentity;
|
|
85
|
+
}
|
|
86
|
+
waited = true;
|
|
87
|
+
await delay(pollIntervalMs);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function formatMisplacedNoLockWarning(command, style) {
|
|
91
|
+
return (`${STATUS_GLYPHS[style].warning.text} --no-lock after the command name is passed to \`${command}\`, ` +
|
|
92
|
+
`not read by nmr. Did you mean \`nmr --no-lock ${command}\`?`);
|
|
93
|
+
}
|
|
94
|
+
export function resolveLockDir(options) {
|
|
95
|
+
const { env, platform, uid } = options;
|
|
96
|
+
const override = env[LOCK_DIR_ENV_VAR];
|
|
97
|
+
if (override !== undefined && override !== '') {
|
|
98
|
+
return override;
|
|
99
|
+
}
|
|
100
|
+
if (platform === 'win32' || uid === undefined) {
|
|
101
|
+
return path.join(os.tmpdir(), 'nmr');
|
|
102
|
+
}
|
|
103
|
+
return `/tmp/nmr-${uid}`;
|
|
104
|
+
}
|
|
105
|
+
function createHeldLock(options) {
|
|
106
|
+
const { heartbeatIntervalMs, lockPath, token, waited } = options;
|
|
107
|
+
const heartbeat = setInterval(() => void refreshMtime(lockPath), heartbeatIntervalMs);
|
|
108
|
+
heartbeat.unref();
|
|
109
|
+
let isReleased = false;
|
|
110
|
+
return {
|
|
111
|
+
isChainUnlocked: false,
|
|
112
|
+
async release() {
|
|
113
|
+
if (isReleased)
|
|
114
|
+
return;
|
|
115
|
+
isReleased = true;
|
|
116
|
+
clearInterval(heartbeat);
|
|
117
|
+
const state = await readLockState(lockPath);
|
|
118
|
+
if (state?.holder?.token === token) {
|
|
119
|
+
await fs.rm(lockPath, { force: true });
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
token,
|
|
123
|
+
waited,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function createHoldFreeLock(options) {
|
|
127
|
+
return { ...options, release: () => Promise.resolve(), token: undefined };
|
|
128
|
+
}
|
|
129
|
+
function formatWaitingLine(holder, lockPath) {
|
|
130
|
+
if (holder === undefined) {
|
|
131
|
+
return `nmr: waiting for the machine lock at ${lockPath}`;
|
|
132
|
+
}
|
|
133
|
+
return `nmr: waiting for ${holder.command} in ${holder.dir} (pid ${holder.pid}) to finish`;
|
|
134
|
+
}
|
|
135
|
+
function isHolderRecord(value) {
|
|
136
|
+
return (isObject(value) &&
|
|
137
|
+
typeof value['command'] === 'string' &&
|
|
138
|
+
typeof value['dir'] === 'string' &&
|
|
139
|
+
typeof value['pid'] === 'number' &&
|
|
140
|
+
typeof value['startedAt'] === 'string' &&
|
|
141
|
+
typeof value['token'] === 'string');
|
|
142
|
+
}
|
|
143
|
+
async function isUnderAncestorHold(lockDir, env) {
|
|
144
|
+
const inheritedToken = env[LOCK_TOKEN_ENV_VAR];
|
|
145
|
+
if (inheritedToken === undefined || inheritedToken === '') {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const state = await readLockState(path.join(lockDir, LOCK_FILE));
|
|
149
|
+
return state?.holder?.token === inheritedToken;
|
|
150
|
+
}
|
|
151
|
+
function isProcessGone(pid) {
|
|
152
|
+
try {
|
|
153
|
+
process.kill(pid, 0);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
return readErrorCode(error) === 'ESRCH';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function isStale(state, staleAfterMs) {
|
|
161
|
+
if (Date.now() - state.mtimeMs > staleAfterMs) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return state.holder !== undefined && isProcessGone(state.holder.pid);
|
|
165
|
+
}
|
|
166
|
+
function parseHolder(content) {
|
|
167
|
+
try {
|
|
168
|
+
const value = JSON.parse(content);
|
|
169
|
+
return isHolderRecord(value) ? value : undefined;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function readErrorCode(error) {
|
|
176
|
+
return isObject(error) && typeof error['code'] === 'string' ? error['code'] : undefined;
|
|
177
|
+
}
|
|
178
|
+
async function readLockState(lockPath) {
|
|
179
|
+
try {
|
|
180
|
+
const [content, stats] = await Promise.all([fs.readFile(lockPath, 'utf8'), fs.stat(lockPath)]);
|
|
181
|
+
return { holder: parseHolder(content), mtimeMs: stats.mtimeMs };
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function reclaimStaleLock(options) {
|
|
188
|
+
const { lockDir, lockPath, observed, staleAfterMs } = options;
|
|
189
|
+
const guardPath = path.join(lockDir, RECLAIM_GUARD);
|
|
190
|
+
try {
|
|
191
|
+
await fs.mkdir(guardPath);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
await removeAbandonedGuard(guardPath);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const current = await readLockState(lockPath);
|
|
199
|
+
if (current !== undefined && current.holder?.token === observed.holder?.token && isStale(current, staleAfterMs)) {
|
|
200
|
+
await fs.rm(lockPath, { force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await fs.rm(guardPath, { force: true, recursive: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function refreshMtime(lockPath) {
|
|
208
|
+
const now = new Date();
|
|
209
|
+
try {
|
|
210
|
+
await fs.utimes(lockPath, now, now);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function removeAbandonedGuard(guardPath) {
|
|
216
|
+
try {
|
|
217
|
+
const stats = await fs.stat(guardPath);
|
|
218
|
+
if (Date.now() - stats.mtimeMs > RECLAIM_GUARD_STALE_AFTER_MS) {
|
|
219
|
+
await fs.rm(guardPath, { force: true, recursive: true });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function reportUnwritableDir(lockDir, error, stderr) {
|
|
226
|
+
const code = readErrorCode(error) ?? 'unknown error';
|
|
227
|
+
stderr.write(`nmr: running without the machine lock: cannot write ${lockDir} (${code}). ` +
|
|
228
|
+
`Grant write access to it, or set ${LOCK_DIR_ENV_VAR} to a writable directory.\n`);
|
|
229
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
3
|
+
/** Names the host to which nmr delegates a command when the invocation does not say otherwise. */
|
|
4
|
+
export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
|
|
5
|
+
/** The flag that names the host on which to run the command. */
|
|
6
|
+
export declare const DELEGATE_FLAG = "--delegate";
|
|
7
|
+
/** The exit status of a delegation that failed outside its command, which matches `ssh`'s own. */
|
|
8
|
+
export declare const DELEGATION_FAILURE_EXIT_CODE = 255;
|
|
9
|
+
/** The flag that runs the command here, whatever `NMR_DELEGATE` names. */
|
|
10
|
+
export declare const NO_DELEGATE_FLAG = "--no-delegate";
|
|
11
|
+
/** Keeps `ssh` from prompting or waiting on a terminal, and bounds the wait for a host that does not answer. */
|
|
12
|
+
export declare const SSH_OPTIONS: readonly string[];
|
|
13
|
+
/** Where a delegation runs, or why the invocation cannot run where it asked to. */
|
|
14
|
+
export type DelegationTarget = {
|
|
15
|
+
kind: 'local';
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'remote';
|
|
18
|
+
host: string;
|
|
19
|
+
source: 'env' | 'flag';
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'refused';
|
|
22
|
+
error: string;
|
|
23
|
+
};
|
|
24
|
+
/** The phases of a delegation that run on the host, each reported by name when it fails. */
|
|
25
|
+
export type RemotePhase = 'check out' | 'install' | 'prepare' | 'push' | 'run';
|
|
26
|
+
/** The status of a delegated command, or the phase that kept it from finishing and what that phase printed. */
|
|
27
|
+
export type RemoteRunResult = {
|
|
28
|
+
ok: true;
|
|
29
|
+
exitCode: number;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
message: string;
|
|
33
|
+
output: string;
|
|
34
|
+
phase: RemotePhase;
|
|
35
|
+
};
|
|
36
|
+
/** A commit that records the working tree, or why one could not be made. */
|
|
37
|
+
export type SnapshotResult = {
|
|
38
|
+
ok: true;
|
|
39
|
+
sha: string;
|
|
40
|
+
toplevel: string;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
isUnsupported: boolean;
|
|
44
|
+
reason: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Composes the script that runs nmr on the host, from the directory that corresponds to the local one.
|
|
48
|
+
*
|
|
49
|
+
* A Node launcher starts nmr in its own process group, with stdin closed, and reads the connection's stdin, which the
|
|
50
|
+
* local `ssh` holds open and closes when it exits, as on Ctrl-C; on that end of input, the launcher stops nmr's whole
|
|
51
|
+
* group, so that an interrupted run does not continue on the host. The launcher rather than the shell's job control
|
|
52
|
+
* makes the group, because a shell such as dash refuses job control without a terminal. `--no-delegate` keeps a host
|
|
53
|
+
* whose environment sets `NMR_DELEGATE` from passing the command on again.
|
|
54
|
+
*
|
|
55
|
+
* The script writes nmr's status to `statusPath` before it exits, so that a 255 from `ssh` can be told apart: It is
|
|
56
|
+
* nmr's own when the file records it, and a dropped connection otherwise.
|
|
57
|
+
*
|
|
58
|
+
* @internal
|
|
59
|
+
*/
|
|
60
|
+
export declare function composeRunScript(options: {
|
|
61
|
+
nmrArgs: readonly string[];
|
|
62
|
+
remoteEnv: Readonly<Record<string, string>>;
|
|
63
|
+
runDir: string;
|
|
64
|
+
statusPath: string;
|
|
65
|
+
}): string;
|
|
66
|
+
/**
|
|
67
|
+
* Builds a commit whose tree is the working tree as it is on disk: tracked changes, staged or not, and untracked
|
|
68
|
+
* files that git does not ignore. The commit is built in a temporary index, so the repository's index, refs, and
|
|
69
|
+
* stash are untouched; the only write is to the object database.
|
|
70
|
+
*
|
|
71
|
+
* The identity and date are fixed, so that the same tree on the same `HEAD` always yields the same commit and a
|
|
72
|
+
* repeated push transfers nothing. A submodule, or a nested repository that `git add` would record as one, is
|
|
73
|
+
* refused: Its content is in a repository that the snapshot does not include.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createSnapshot(cwd: string, env: NodeJS.ProcessEnv): SnapshotResult;
|
|
76
|
+
/**
|
|
77
|
+
* Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
|
|
78
|
+
* repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
|
|
79
|
+
*
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
export declare function deriveCheckoutPath(hostname: string, toplevel: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* Renders the warning for a `--delegate` or `--no-delegate` that appears after the command name, which makes it an
|
|
85
|
+
* argument to the command rather than a flag to nmr.
|
|
86
|
+
*/
|
|
87
|
+
export declare function formatMisplacedDelegationWarning(command: string, style: OutputStyle): string;
|
|
88
|
+
/**
|
|
89
|
+
* Decides where an invocation runs. `--no-delegate` outranks `--delegate`, which outranks `NMR_DELEGATE`.
|
|
90
|
+
*
|
|
91
|
+
* A command that rewrites the tree, or that runs until it is stopped, cannot be delegated, and neither can `--log`,
|
|
92
|
+
* which reads this machine's recordings. Asked for by the flag, such an invocation is refused; asked for by the
|
|
93
|
+
* variable, which sets a default for every invocation, it runs here.
|
|
94
|
+
*/
|
|
95
|
+
export declare function resolveDelegationTarget(options: {
|
|
96
|
+
command: string;
|
|
97
|
+
env: NodeJS.ProcessEnv;
|
|
98
|
+
flagHost: string | undefined;
|
|
99
|
+
isOptedOut: boolean;
|
|
100
|
+
isReading: boolean;
|
|
101
|
+
}): DelegationTarget;
|
|
102
|
+
/**
|
|
103
|
+
* Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
|
|
104
|
+
* `stdout` and `stderr` and returning its exit status.
|
|
105
|
+
*
|
|
106
|
+
* The host keeps one checkout per local repository and host name, under `~/.cache/nmr/delegate/`, so concurrent
|
|
107
|
+
* delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
|
|
108
|
+
* between runs. Each phase is its own connection, so that a failure names its phase; what a phase other than `run`
|
|
109
|
+
* prints is returned rather than streamed, because it matters only when the phase fails.
|
|
110
|
+
*/
|
|
111
|
+
export declare function runRemotely(options: {
|
|
112
|
+
cwd: string;
|
|
113
|
+
env: NodeJS.ProcessEnv;
|
|
114
|
+
host: string;
|
|
115
|
+
monorepoRoot: string;
|
|
116
|
+
nmrArgs: readonly string[];
|
|
117
|
+
remoteEnv: Readonly<Record<string, string>>;
|
|
118
|
+
snapshot: {
|
|
119
|
+
sha: string;
|
|
120
|
+
toplevel: string;
|
|
121
|
+
};
|
|
122
|
+
stderr: Writable;
|
|
123
|
+
stdout: Writable;
|
|
124
|
+
}): Promise<RemoteRunResult>;
|
|
125
|
+
/**
|
|
126
|
+
* Wraps a script to run under `/bin/sh` with the `PATH` that the user's interactive login shell sets. pnpm's own
|
|
127
|
+
* installer and the common version managers set it up in the interactive startup file, such as `~/.zshrc`, which a
|
|
128
|
+
* non-interactive shell does not read. The script runs under `sh` rather than that shell itself, because the login
|
|
129
|
+
* shell, such as fish, may not accept the POSIX syntax in which the scripts are written.
|
|
130
|
+
*
|
|
131
|
+
* A startup file may print to stdout, so the shell prints `PATH` after a marker, and only what follows the last
|
|
132
|
+
* marker is read. When no marker arrives, the script keeps the `PATH` with which `ssh` started it.
|
|
133
|
+
*
|
|
134
|
+
* @internal
|
|
135
|
+
*/
|
|
136
|
+
export declare function wrapRemoteScript(script: string): string;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { GIT_OUTPUT_LIMIT, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
|
|
7
|
+
import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
|
|
8
|
+
import { quoteForShell } from "./helpers/quoteForShell.js";
|
|
9
|
+
export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
|
|
10
|
+
export const DELEGATE_FLAG = '--delegate';
|
|
11
|
+
export const DELEGATION_FAILURE_EXIT_CODE = 255;
|
|
12
|
+
export const NO_DELEGATE_FLAG = '--no-delegate';
|
|
13
|
+
export const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
|
|
14
|
+
export function composeRunScript(options) {
|
|
15
|
+
const { nmrArgs, remoteEnv, runDir, statusPath } = options;
|
|
16
|
+
const assignments = Object.entries(remoteEnv).map(([name, value]) => `${name}=${quoteForShell(value)}`);
|
|
17
|
+
const invocation = [
|
|
18
|
+
...assignments,
|
|
19
|
+
'node',
|
|
20
|
+
'-e',
|
|
21
|
+
quoteForShell(RUN_LAUNCHER_SOURCE),
|
|
22
|
+
'pnpm',
|
|
23
|
+
'exec',
|
|
24
|
+
'nmr',
|
|
25
|
+
'--no-delegate',
|
|
26
|
+
...nmrArgs.map(quoteForShell),
|
|
27
|
+
].join(' ');
|
|
28
|
+
return [
|
|
29
|
+
`rm -f ${statusPath}`,
|
|
30
|
+
`mkdir -p ${runDir} && cd ${runDir} || exit 1`,
|
|
31
|
+
invocation,
|
|
32
|
+
'nmr_status=$?',
|
|
33
|
+
String.raw `printf '%s\n' "$nmr_status" > ${statusPath}`,
|
|
34
|
+
'exit "$nmr_status"',
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
export function createSnapshot(cwd, env) {
|
|
38
|
+
const toplevelResult = runGit(['rev-parse', '--show-toplevel'], cwd, env);
|
|
39
|
+
if (!toplevelResult.ok) {
|
|
40
|
+
return { ok: false, isUnsupported: true, reason: `not a git repository: ${toplevelResult.error}` };
|
|
41
|
+
}
|
|
42
|
+
const toplevel = toplevelResult.stdout.trim();
|
|
43
|
+
if (fs.existsSync(path.join(toplevel, '.gitmodules'))) {
|
|
44
|
+
return { ok: false, isUnsupported: true, reason: 'the repository declares submodules' };
|
|
45
|
+
}
|
|
46
|
+
const indexPathResult = runGit(['rev-parse', '--git-path', 'index'], toplevel, env);
|
|
47
|
+
if (!indexPathResult.ok) {
|
|
48
|
+
return { ok: false, isUnsupported: false, reason: indexPathResult.error };
|
|
49
|
+
}
|
|
50
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nmr-snapshot-'));
|
|
51
|
+
try {
|
|
52
|
+
const tempIndex = path.join(tempDir, 'index');
|
|
53
|
+
const indexPath = path.resolve(toplevel, indexPathResult.stdout.trim());
|
|
54
|
+
if (fs.existsSync(indexPath)) {
|
|
55
|
+
fs.copyFileSync(indexPath, tempIndex);
|
|
56
|
+
}
|
|
57
|
+
const indexEnv = { ...env, GIT_INDEX_FILE: tempIndex };
|
|
58
|
+
const addResult = runGit(['add', '--all'], toplevel, indexEnv);
|
|
59
|
+
if (!addResult.ok) {
|
|
60
|
+
return { ok: false, isUnsupported: false, reason: addResult.error };
|
|
61
|
+
}
|
|
62
|
+
const stageResult = runGit(['ls-files', '--stage', '-z'], toplevel, indexEnv);
|
|
63
|
+
if (!stageResult.ok) {
|
|
64
|
+
return { ok: false, isUnsupported: false, reason: stageResult.error };
|
|
65
|
+
}
|
|
66
|
+
if (stageResult.stdout.split('\0').some((entry) => entry.startsWith('160000 '))) {
|
|
67
|
+
return { ok: false, isUnsupported: true, reason: 'the working tree contains a submodule or a nested repository' };
|
|
68
|
+
}
|
|
69
|
+
const treeResult = runGit(['write-tree'], toplevel, indexEnv);
|
|
70
|
+
if (!treeResult.ok) {
|
|
71
|
+
return { ok: false, isUnsupported: false, reason: treeResult.error };
|
|
72
|
+
}
|
|
73
|
+
const headResult = runGit(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], toplevel, env);
|
|
74
|
+
const parentArgs = headResult.ok ? ['-p', headResult.stdout.trim()] : [];
|
|
75
|
+
const commitResult = runGit(['commit-tree', treeResult.stdout.trim(), ...parentArgs, '-m', 'nmr delegate snapshot'], toplevel, { ...env, ...SNAPSHOT_IDENTITY_ENV });
|
|
76
|
+
if (!commitResult.ok) {
|
|
77
|
+
return { ok: false, isUnsupported: false, reason: commitResult.error };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, sha: commitResult.stdout.trim(), toplevel };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
fs.rmSync(tempDir, { force: true, recursive: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function deriveCheckoutPath(hostname, toplevel) {
|
|
86
|
+
const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
|
|
87
|
+
return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
|
|
88
|
+
}
|
|
89
|
+
export function formatMisplacedDelegationWarning(command, style) {
|
|
90
|
+
return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG} and ${NO_DELEGATE_FLAG} after the command name are passed ` +
|
|
91
|
+
`to \`${command}\`, not read by nmr. Did you mean \`nmr ${DELEGATE_FLAG} <host> ${command}\`?`);
|
|
92
|
+
}
|
|
93
|
+
export function resolveDelegationTarget(options) {
|
|
94
|
+
const { command, env, flagHost, isOptedOut, isReading } = options;
|
|
95
|
+
if (isOptedOut) {
|
|
96
|
+
return { kind: 'local' };
|
|
97
|
+
}
|
|
98
|
+
const isDelegable = !isReading && !NON_DELEGABLE_COMMANDS.has(command);
|
|
99
|
+
if (flagHost !== undefined) {
|
|
100
|
+
if (isDelegable) {
|
|
101
|
+
return { kind: 'remote', host: flagHost, source: 'flag' };
|
|
102
|
+
}
|
|
103
|
+
const subject = isReading ? '--log' : `\`${command}\``;
|
|
104
|
+
return {
|
|
105
|
+
kind: 'refused',
|
|
106
|
+
error: `${subject} cannot be delegated: it ${isReading ? "reads this machine's recordings" : 'rewrites the working tree or runs until stopped'}. Run it without ${DELEGATE_FLAG}.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const envHost = env[DELEGATE_ENV_VAR];
|
|
110
|
+
if (envHost !== undefined && envHost !== '' && isDelegable) {
|
|
111
|
+
return { kind: 'remote', host: envHost, source: 'env' };
|
|
112
|
+
}
|
|
113
|
+
return { kind: 'local' };
|
|
114
|
+
}
|
|
115
|
+
export async function runRemotely(options) {
|
|
116
|
+
const { cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
|
|
117
|
+
const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
|
|
118
|
+
const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
|
|
119
|
+
const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
|
|
120
|
+
if (prepare.exitCode !== 0) {
|
|
121
|
+
return { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare' };
|
|
122
|
+
}
|
|
123
|
+
const push = await runProcess([
|
|
124
|
+
'git',
|
|
125
|
+
'push',
|
|
126
|
+
'--force',
|
|
127
|
+
'--no-verify',
|
|
128
|
+
'--quiet',
|
|
129
|
+
`--receive-pack=${RECEIVE_PACK_COMMAND}`,
|
|
130
|
+
`${host}:${checkoutPath}`,
|
|
131
|
+
`${snapshot.sha}:${SNAPSHOT_REF}`,
|
|
132
|
+
], { cwd: snapshot.toplevel, env: { ...env, GIT_SSH_COMMAND: ['ssh', ...SSH_OPTIONS].join(' ') } });
|
|
133
|
+
if (push.exitCode !== 0) {
|
|
134
|
+
return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push' };
|
|
135
|
+
}
|
|
136
|
+
const checkout = await runSsh(host, `cd ${checkoutDir} && git checkout --quiet --force --detach ${snapshot.sha} && git clean --force -d --quiet`, env);
|
|
137
|
+
if (checkout.exitCode !== 0) {
|
|
138
|
+
return { ok: false, message: `could not check out on ${host}`, output: checkout.output, phase: 'check out' };
|
|
139
|
+
}
|
|
140
|
+
const rootDir = joinRemotePath(checkoutDir, path.relative(snapshot.toplevel, monorepoRoot));
|
|
141
|
+
const install = await runSsh(host, `cd ${rootDir} && CI=true pnpm install --frozen-lockfile`, env);
|
|
142
|
+
if (install.exitCode !== 0) {
|
|
143
|
+
const message = install.exitCode === COMMAND_NOT_FOUND_EXIT_CODE
|
|
144
|
+
? `could not install on ${host}: pnpm is not on the PATH that the interactive login shell sets there`
|
|
145
|
+
: `could not install on ${host}`;
|
|
146
|
+
return { ok: false, message, output: install.output, phase: 'install' };
|
|
147
|
+
}
|
|
148
|
+
const runDir = joinRemotePath(checkoutDir, path.relative(snapshot.toplevel, cwd));
|
|
149
|
+
const statusPath = `${checkoutDir}/.git/${quoteForShell(RUN_STATUS_FILE)}`;
|
|
150
|
+
const run = await runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(composeRunScript({ nmrArgs, remoteEnv, runDir, statusPath }))], {
|
|
151
|
+
env,
|
|
152
|
+
isStreaming: true,
|
|
153
|
+
stderr,
|
|
154
|
+
stdout,
|
|
155
|
+
});
|
|
156
|
+
if (run.exitCode === DELEGATION_FAILURE_EXIT_CODE) {
|
|
157
|
+
const recorded = await runSsh(host, `cat ${statusPath}`, env);
|
|
158
|
+
if (recorded.exitCode !== 0 || recorded.output.trim() !== String(DELEGATION_FAILURE_EXIT_CODE)) {
|
|
159
|
+
return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run' };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, exitCode: run.exitCode };
|
|
163
|
+
}
|
|
164
|
+
export function wrapRemoteScript(script) {
|
|
165
|
+
return `exec /bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}\n${script}`)}`;
|
|
166
|
+
}
|
|
167
|
+
const PATH_MARKER = '__NMR_LOGIN_PATH__=';
|
|
168
|
+
const LOGIN_PATH_PREAMBLE = [
|
|
169
|
+
`nmr_login_path=$("\${SHELL:-/bin/sh}" -ilc 'printf "\\n${PATH_MARKER}%s\\n" "$PATH"' </dev/null 2>/dev/null | sed -n 's/^${PATH_MARKER}//p' | tail -n 1)`,
|
|
170
|
+
'[ -n "$nmr_login_path" ] && PATH=$nmr_login_path',
|
|
171
|
+
'export PATH',
|
|
172
|
+
].join('; ');
|
|
173
|
+
const RECEIVE_PACK_COMMAND = `/bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}; exec git receive-pack "$1"`)} nmr-receive-pack`;
|
|
174
|
+
const RUN_LAUNCHER_SOURCE = [
|
|
175
|
+
"const { spawn } = require('node:child_process');",
|
|
176
|
+
"const { constants } = require('node:os');",
|
|
177
|
+
'const [file, ...args] = process.argv.slice(1);',
|
|
178
|
+
"const child = spawn(file, args, { detached: true, stdio: ['ignore', 'inherit', 'inherit'] });",
|
|
179
|
+
"const stop = () => { try { process.kill(-child.pid, 'SIGTERM'); } catch {} };",
|
|
180
|
+
"process.stdin.on('end', stop).on('error', stop).resume();",
|
|
181
|
+
"child.on('error', (error) => { console.error(file + ': ' + error.message); process.exit(127); });",
|
|
182
|
+
"child.on('close', (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] ?? 0)));",
|
|
183
|
+
].join('\n');
|
|
184
|
+
const RUN_STATUS_FILE = 'nmr-run-status';
|
|
185
|
+
const COMMAND_NOT_FOUND_EXIT_CODE = 127;
|
|
186
|
+
const PHASE_OUTPUT_LIMIT_BYTES = 65_536;
|
|
187
|
+
const NON_DELEGABLE_BASE_COMMANDS = ['build', 'clean', 'compile', 'fix', 'fmt', 'lint', 'test:watch', 'upgrade'];
|
|
188
|
+
const NON_DELEGABLE_COMMANDS = new Set([
|
|
189
|
+
...NON_DELEGABLE_BASE_COMMANDS,
|
|
190
|
+
...NON_DELEGABLE_BASE_COMMANDS.map((command) => `root:${command}`),
|
|
191
|
+
]);
|
|
192
|
+
const SNAPSHOT_IDENTITY_ENV = {
|
|
193
|
+
GIT_AUTHOR_DATE: '@0 +0000',
|
|
194
|
+
GIT_AUTHOR_EMAIL: 'nmr@localhost',
|
|
195
|
+
GIT_AUTHOR_NAME: 'nmr delegate',
|
|
196
|
+
GIT_COMMITTER_DATE: '@0 +0000',
|
|
197
|
+
GIT_COMMITTER_EMAIL: 'nmr@localhost',
|
|
198
|
+
GIT_COMMITTER_NAME: 'nmr delegate',
|
|
199
|
+
};
|
|
200
|
+
const SNAPSHOT_REF = 'refs/nmr/delegate';
|
|
201
|
+
function forwardChunk(chunk, stream, buffer) {
|
|
202
|
+
if (stream === undefined) {
|
|
203
|
+
buffer.append(chunk);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
stream.write(chunk);
|
|
207
|
+
}
|
|
208
|
+
function joinRemotePath(checkoutDir, relativeDir) {
|
|
209
|
+
const posixDir = relativeDir.split(path.sep).join('/');
|
|
210
|
+
return posixDir === '' ? checkoutDir : `${checkoutDir}/${quoteForShell(posixDir)}`;
|
|
211
|
+
}
|
|
212
|
+
function runGit(args, cwd, env) {
|
|
213
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT });
|
|
214
|
+
if (result.error !== undefined) {
|
|
215
|
+
return { ok: false, error: result.error.message };
|
|
216
|
+
}
|
|
217
|
+
if (result.status !== 0) {
|
|
218
|
+
return { ok: false, error: result.stderr.trim() || `git ${args[0] ?? ''} exited ${result.status ?? 'abnormally'}` };
|
|
219
|
+
}
|
|
220
|
+
return { ok: true, stdout: result.stdout };
|
|
221
|
+
}
|
|
222
|
+
function runProcess(argv, options) {
|
|
223
|
+
const { cwd, env, isStreaming = false, stderr, stdout } = options;
|
|
224
|
+
const [file = '', ...args] = argv;
|
|
225
|
+
const buffer = createBoundedBuffer({
|
|
226
|
+
headLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
|
|
227
|
+
tailLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
|
|
228
|
+
});
|
|
229
|
+
return new Promise((resolve) => {
|
|
230
|
+
const child = spawn(file, args, { cwd, env, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
231
|
+
child.stdout?.on('data', (chunk) => {
|
|
232
|
+
forwardChunk(chunk, isStreaming ? stdout : undefined, buffer);
|
|
233
|
+
});
|
|
234
|
+
child.stderr?.on('data', (chunk) => {
|
|
235
|
+
forwardChunk(chunk, isStreaming ? stderr : undefined, buffer);
|
|
236
|
+
});
|
|
237
|
+
child.on('error', (error) => {
|
|
238
|
+
resolve({ exitCode: COMMAND_NOT_FOUND_EXIT_CODE, output: `${file}: ${error.message}` });
|
|
239
|
+
});
|
|
240
|
+
child.on('close', (code, signal) => {
|
|
241
|
+
child.stdin?.destroy();
|
|
242
|
+
resolve({
|
|
243
|
+
exitCode: code ?? 128 + (signal === null ? 0 : os.constants.signals[signal]),
|
|
244
|
+
output: buffer.readBuffer().toString('utf8'),
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function runSsh(host, script, env) {
|
|
250
|
+
return runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], { env });
|
|
251
|
+
}
|