@williamthorsen/nmr 0.48.0 → 0.50.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 +218 -0
- package/.readyup/kits/default.js +1527 -1460
- package/.readyup/manifest.json +96 -11
- package/CHANGELOG.md +65 -0
- package/README.md +19 -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 +191 -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 -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 +63 -0
- package/dist/esm/remote-probe.js +88 -0
- package/dist/esm/remote-run.d.ts +45 -65
- package/dist/esm/remote-run.js +128 -162
- 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 +103 -17
- package/docs/reporting.md +6 -1
- package/docs/scripts.md +2 -1
- package/package.json +4 -4
- /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
package/dist/esm/remote-run.js
CHANGED
|
@@ -1,124 +1,61 @@
|
|
|
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';
|
|
18
|
+
export const SSH_COMMAND_ENV_VAR = 'GIT_SSH_COMMAND';
|
|
13
19
|
export const SSH_OPTIONS = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
|
|
14
|
-
export function
|
|
15
|
-
|
|
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');
|
|
20
|
+
export function composeGitSshCommand(env) {
|
|
21
|
+
return [resolveSshCommand(env) ?? 'ssh', ...SSH_OPTIONS].join(' ');
|
|
36
22
|
}
|
|
37
|
-
export function
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
}
|
|
23
|
+
export function composeSshArgv(env, host, remoteCommand) {
|
|
24
|
+
const sshCommand = resolveSshCommand(env);
|
|
25
|
+
const sshArgs = [...SSH_OPTIONS, host, remoteCommand];
|
|
26
|
+
return sshCommand === undefined
|
|
27
|
+
? ['ssh', ...sshArgs]
|
|
28
|
+
: ['/bin/sh', '-c', `${sshCommand} "$@"`, sshCommand, ...sshArgs];
|
|
84
29
|
}
|
|
85
30
|
export function deriveCheckoutPath(hostname, toplevel) {
|
|
86
31
|
const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
|
|
87
32
|
return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
|
|
88
33
|
}
|
|
89
34
|
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.
|
|
35
|
+
return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG}, ${DELEGATE_TO_FLAG}, and ${NO_DELEGATE_FLAG} after the ` +
|
|
36
|
+
`command name are passed to \`${command}\`, not read by nmr. Write them before it: ` +
|
|
37
|
+
`\`nmr ${DELEGATE_TO_FLAG} <host> ${command}\`.`);
|
|
92
38
|
}
|
|
93
|
-
export function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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' };
|
|
39
|
+
export function readLastLine(output) {
|
|
40
|
+
return (output
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map((line) => line.trim())
|
|
43
|
+
.findLast((line) => line !== '') ?? '');
|
|
114
44
|
}
|
|
115
45
|
export async function runRemotely(options) {
|
|
116
|
-
const { cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
|
|
46
|
+
const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
|
|
117
47
|
const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
|
|
118
48
|
const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
|
|
119
49
|
const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
|
|
120
50
|
if (prepare.exitCode !== 0) {
|
|
121
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
isUnreachable: prepare.exitCode === DELEGATION_FAILURE_EXIT_CODE,
|
|
54
|
+
message: `could not prepare ${host}`,
|
|
55
|
+
output: prepare.output,
|
|
56
|
+
phase: 'prepare',
|
|
57
|
+
waitedMs: 0,
|
|
58
|
+
};
|
|
122
59
|
}
|
|
123
60
|
const push = await runProcess([
|
|
124
61
|
'git',
|
|
@@ -129,37 +66,86 @@ export async function runRemotely(options) {
|
|
|
129
66
|
`--receive-pack=${RECEIVE_PACK_COMMAND}`,
|
|
130
67
|
`${host}:${checkoutPath}`,
|
|
131
68
|
`${snapshot.sha}:${SNAPSHOT_REF}`,
|
|
132
|
-
], { cwd: snapshot.toplevel, env: { ...env,
|
|
69
|
+
], { cwd: snapshot.toplevel, env: { ...env, [SSH_COMMAND_ENV_VAR]: composeGitSshCommand(env) } });
|
|
133
70
|
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
|
-
|
|
71
|
+
return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push', waitedMs: 0 };
|
|
72
|
+
}
|
|
73
|
+
const nonce = randomUUID();
|
|
74
|
+
const marker = `__NMR_DELEGATE_${nonce}__`;
|
|
75
|
+
const statusFile = `.git/nmr-run-status-${nonce}`;
|
|
76
|
+
const passes = [];
|
|
77
|
+
let waitStartedAtMs;
|
|
78
|
+
let waitedMs = 0;
|
|
79
|
+
let failure;
|
|
80
|
+
const demultiplexer = createSessionDemultiplexer({
|
|
81
|
+
marker,
|
|
82
|
+
onRecord(record) {
|
|
83
|
+
if (record.kind === 'waiting') {
|
|
84
|
+
waitStartedAtMs ??= Date.now();
|
|
85
|
+
stderr.write(`${formatDelegationWaitingLine(record.holder, host)}\n`);
|
|
86
|
+
}
|
|
87
|
+
else if (record.kind === 'locked' && waitStartedAtMs !== undefined) {
|
|
88
|
+
waitedMs = Date.now() - waitStartedAtMs;
|
|
89
|
+
}
|
|
90
|
+
else if (record.kind === 'failed') {
|
|
91
|
+
failure = record;
|
|
92
|
+
}
|
|
93
|
+
else if (record.kind === 'passes') {
|
|
94
|
+
passes.push(...record.entries);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
onMalformedRecord() {
|
|
98
|
+
writeDebugNote(`discarded a malformed pass manifest from ${host}`, env, stderr);
|
|
99
|
+
},
|
|
100
|
+
stderr,
|
|
101
|
+
});
|
|
102
|
+
const script = composeSessionScript({
|
|
103
|
+
checkoutDir,
|
|
104
|
+
params: {
|
|
105
|
+
holder: { command, dir: cwd, pid: process.pid },
|
|
106
|
+
lockPath: CHECKOUT_LOCK_PATH,
|
|
107
|
+
marker,
|
|
108
|
+
nmrArgs,
|
|
109
|
+
passManifestDir: `.git/nmr-passes-${nonce}`,
|
|
110
|
+
passManifestEnvVar: PASS_MANIFEST_DIR_ENV_VAR,
|
|
111
|
+
remoteEnv,
|
|
112
|
+
rootDir: toRemoteRelativeDir(path.relative(snapshot.toplevel, monorepoRoot)),
|
|
113
|
+
runDir: toRemoteRelativeDir(path.relative(snapshot.toplevel, cwd)),
|
|
114
|
+
sha: snapshot.sha,
|
|
115
|
+
staleAfterMs: STALE_AFTER_MS,
|
|
116
|
+
statusFile,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const session = await runProcess(composeSshArgv(env, host, wrapRemoteScript(script)), {
|
|
151
120
|
env,
|
|
152
121
|
isStreaming: true,
|
|
153
|
-
stderr,
|
|
122
|
+
stderr: demultiplexer.stream,
|
|
154
123
|
stdout,
|
|
155
124
|
});
|
|
156
|
-
|
|
157
|
-
|
|
125
|
+
demultiplexer.flush();
|
|
126
|
+
if (failure !== undefined) {
|
|
127
|
+
const output = [demultiplexer.readOutput(), failure.output].filter((text) => text !== '').join('\n');
|
|
128
|
+
return { ok: false, message: describeFailedPhase(failure, host), output, phase: failure.phase, waitedMs };
|
|
129
|
+
}
|
|
130
|
+
if (!demultiplexer.hasStarted()) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
message: describeUnfinishedSession(session.exitCode, host),
|
|
134
|
+
output: demultiplexer.readOutput(),
|
|
135
|
+
phase: 'check out',
|
|
136
|
+
waitedMs,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (session.exitCode === DELEGATION_FAILURE_EXIT_CODE) {
|
|
140
|
+
const recorded = await runSsh(host, `cd ${checkoutDir} && cat ${quoteForShell(statusFile)} && rm -f ${quoteForShell(statusFile)}`, env);
|
|
158
141
|
if (recorded.exitCode !== 0 || recorded.output.trim() !== String(DELEGATION_FAILURE_EXIT_CODE)) {
|
|
159
|
-
return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run' };
|
|
142
|
+
return { ok: false, message: `lost connection to ${host}`, output: '', phase: 'run', waitedMs };
|
|
160
143
|
}
|
|
161
144
|
}
|
|
162
|
-
return { ok: true, exitCode:
|
|
145
|
+
return { ok: true, exitCode: session.exitCode, passes, waitedMs };
|
|
146
|
+
}
|
|
147
|
+
export function runSsh(host, script, env, signal) {
|
|
148
|
+
return runProcess(composeSshArgv(env, host, wrapRemoteScript(script)), { env, signal });
|
|
163
149
|
}
|
|
164
150
|
export function wrapRemoteScript(script) {
|
|
165
151
|
return `exec /bin/sh -c ${quoteForShell(`${LOGIN_PATH_PREAMBLE}\n${script}`)}`;
|
|
@@ -171,33 +157,23 @@ const LOGIN_PATH_PREAMBLE = [
|
|
|
171
157
|
'export PATH',
|
|
172
158
|
].join('; ');
|
|
173
159
|
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
160
|
const COMMAND_NOT_FOUND_EXIT_CODE = 127;
|
|
186
161
|
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
162
|
const SNAPSHOT_REF = 'refs/nmr/delegate';
|
|
163
|
+
function describeFailedPhase(failure, host) {
|
|
164
|
+
if (failure.phase === 'check out') {
|
|
165
|
+
return `could not check out on ${host}`;
|
|
166
|
+
}
|
|
167
|
+
return failure.exitCode === COMMAND_NOT_FOUND_EXIT_CODE
|
|
168
|
+
? `could not install on ${host}: pnpm is not on the PATH that the interactive login shell sets there`
|
|
169
|
+
: `could not install on ${host}`;
|
|
170
|
+
}
|
|
171
|
+
function describeUnfinishedSession(exitCode, host) {
|
|
172
|
+
if (exitCode === COMMAND_NOT_FOUND_EXIT_CODE) {
|
|
173
|
+
return `could not check out on ${host}: node is not on the PATH that the interactive login shell sets there`;
|
|
174
|
+
}
|
|
175
|
+
return exitCode === DELEGATION_FAILURE_EXIT_CODE ? `lost connection to ${host}` : `could not check out on ${host}`;
|
|
176
|
+
}
|
|
201
177
|
function forwardChunk(chunk, stream, buffer) {
|
|
202
178
|
if (stream === undefined) {
|
|
203
179
|
buffer.append(chunk);
|
|
@@ -205,29 +181,19 @@ function forwardChunk(chunk, stream, buffer) {
|
|
|
205
181
|
}
|
|
206
182
|
stream.write(chunk);
|
|
207
183
|
}
|
|
208
|
-
function
|
|
209
|
-
const
|
|
210
|
-
return
|
|
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 };
|
|
184
|
+
function resolveSshCommand(env) {
|
|
185
|
+
const sshCommand = env[SSH_COMMAND_ENV_VAR];
|
|
186
|
+
return sshCommand === undefined || sshCommand === '' ? undefined : sshCommand;
|
|
221
187
|
}
|
|
222
188
|
function runProcess(argv, options) {
|
|
223
|
-
const { cwd, env, isStreaming = false, stderr, stdout } = options;
|
|
189
|
+
const { cwd, env, isStreaming = false, signal, stderr, stdout } = options;
|
|
224
190
|
const [file = '', ...args] = argv;
|
|
225
191
|
const buffer = createBoundedBuffer({
|
|
226
192
|
headLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
|
|
227
193
|
tailLimitBytes: PHASE_OUTPUT_LIMIT_BYTES,
|
|
228
194
|
});
|
|
229
195
|
return new Promise((resolve) => {
|
|
230
|
-
const child = spawn(file, args, { cwd, env, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
196
|
+
const child = spawn(file, args, { cwd, env, signal, stdio: [isStreaming ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
231
197
|
child.stdout?.on('data', (chunk) => {
|
|
232
198
|
forwardChunk(chunk, isStreaming ? stdout : undefined, buffer);
|
|
233
199
|
});
|
|
@@ -246,6 +212,6 @@ function runProcess(argv, options) {
|
|
|
246
212
|
});
|
|
247
213
|
});
|
|
248
214
|
}
|
|
249
|
-
function
|
|
250
|
-
return
|
|
215
|
+
function toRemoteRelativeDir(relativeDir) {
|
|
216
|
+
return relativeDir === '' ? '.' : relativeDir.split(path.sep).join('/');
|
|
251
217
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { GIT_OUTPUT_LIMIT } from '@williamthorsen/nmr-core';
|
|
6
|
+
export function createSnapshot(cwd, env) {
|
|
7
|
+
const toplevelResult = runGit(['rev-parse', '--show-toplevel'], cwd, env);
|
|
8
|
+
if (!toplevelResult.ok) {
|
|
9
|
+
return { ok: false, isUnsupported: true, reason: `not a git repository: ${toplevelResult.error}` };
|
|
10
|
+
}
|
|
11
|
+
const toplevel = toplevelResult.stdout.trim();
|
|
12
|
+
if (fs.existsSync(path.join(toplevel, '.gitmodules'))) {
|
|
13
|
+
return { ok: false, isUnsupported: true, reason: 'the repository declares submodules' };
|
|
14
|
+
}
|
|
15
|
+
const indexPathResult = runGit(['rev-parse', '--git-path', 'index'], toplevel, env);
|
|
16
|
+
if (!indexPathResult.ok) {
|
|
17
|
+
return { ok: false, isUnsupported: false, reason: indexPathResult.error };
|
|
18
|
+
}
|
|
19
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nmr-snapshot-'));
|
|
20
|
+
try {
|
|
21
|
+
const tempIndex = path.join(tempDir, 'index');
|
|
22
|
+
const indexPath = path.resolve(toplevel, indexPathResult.stdout.trim());
|
|
23
|
+
if (fs.existsSync(indexPath)) {
|
|
24
|
+
copyIndex(indexPath, tempIndex);
|
|
25
|
+
}
|
|
26
|
+
const indexEnv = { ...env, GIT_INDEX_FILE: tempIndex };
|
|
27
|
+
const addResult = runGit(['add', '--all'], toplevel, indexEnv);
|
|
28
|
+
if (!addResult.ok) {
|
|
29
|
+
return { ok: false, isUnsupported: false, reason: addResult.error };
|
|
30
|
+
}
|
|
31
|
+
const stageResult = runGit(['ls-files', '--stage', '-z'], toplevel, indexEnv);
|
|
32
|
+
if (!stageResult.ok) {
|
|
33
|
+
return { ok: false, isUnsupported: false, reason: stageResult.error };
|
|
34
|
+
}
|
|
35
|
+
if (stageResult.stdout.split('\0').some((entry) => entry.startsWith('160000 '))) {
|
|
36
|
+
return { ok: false, isUnsupported: true, reason: 'the working tree contains a submodule or a nested repository' };
|
|
37
|
+
}
|
|
38
|
+
const treeResult = runGit(['write-tree'], toplevel, indexEnv);
|
|
39
|
+
if (!treeResult.ok) {
|
|
40
|
+
return { ok: false, isUnsupported: false, reason: treeResult.error };
|
|
41
|
+
}
|
|
42
|
+
const headResult = runGit(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], toplevel, env);
|
|
43
|
+
const parentArgs = headResult.ok ? ['-p', headResult.stdout.trim()] : [];
|
|
44
|
+
const treeId = treeResult.stdout.trim();
|
|
45
|
+
const commitResult = runGit(['commit-tree', treeId, ...parentArgs, '-m', 'nmr delegate snapshot'], toplevel, {
|
|
46
|
+
...env,
|
|
47
|
+
...SNAPSHOT_IDENTITY_ENV,
|
|
48
|
+
});
|
|
49
|
+
if (!commitResult.ok) {
|
|
50
|
+
return { ok: false, isUnsupported: false, reason: commitResult.error };
|
|
51
|
+
}
|
|
52
|
+
return { ok: true, sha: commitResult.stdout.trim(), toplevel, treeId };
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
fs.rmSync(tempDir, { force: true, recursive: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const SNAPSHOT_IDENTITY_ENV = {
|
|
59
|
+
GIT_AUTHOR_DATE: '@0 +0000',
|
|
60
|
+
GIT_AUTHOR_EMAIL: 'nmr@localhost',
|
|
61
|
+
GIT_AUTHOR_NAME: 'nmr delegate',
|
|
62
|
+
GIT_COMMITTER_DATE: '@0 +0000',
|
|
63
|
+
GIT_COMMITTER_EMAIL: 'nmr@localhost',
|
|
64
|
+
GIT_COMMITTER_NAME: 'nmr delegate',
|
|
65
|
+
};
|
|
66
|
+
function copyIndex(source, destination) {
|
|
67
|
+
fs.copyFileSync(source, destination);
|
|
68
|
+
const { atime, mtime } = fs.statSync(source);
|
|
69
|
+
fs.utimesSync(destination, atime, mtime);
|
|
70
|
+
}
|
|
71
|
+
function runGit(args, cwd, env) {
|
|
72
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, maxBuffer: GIT_OUTPUT_LIMIT });
|
|
73
|
+
if (result.error !== undefined) {
|
|
74
|
+
return { ok: false, error: result.error.message };
|
|
75
|
+
}
|
|
76
|
+
if (result.status !== 0) {
|
|
77
|
+
return { ok: false, error: result.stderr.trim() || `git ${args[0] ?? ''} exited ${result.status ?? 'abnormally'}` };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, stdout: result.stdout };
|
|
80
|
+
}
|