@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.
Files changed (111) hide show
  1. package/.meta/changelog.json +218 -0
  2. package/.readyup/kits/default.js +1527 -1460
  3. package/.readyup/manifest.json +96 -11
  4. package/CHANGELOG.md +65 -0
  5. package/README.md +19 -8
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache-build-output.d.ts +24 -0
  8. package/dist/esm/check-cache-build-output.js +49 -0
  9. package/dist/esm/check-cache-delegation.d.ts +27 -0
  10. package/dist/esm/check-cache-delegation.js +92 -0
  11. package/dist/esm/check-cache-entries.d.ts +109 -0
  12. package/dist/esm/check-cache-entries.js +87 -0
  13. package/dist/esm/check-cache-gate.d.ts +64 -0
  14. package/dist/esm/check-cache-gate.js +226 -0
  15. package/dist/esm/check-cache-key.d.ts +47 -0
  16. package/dist/esm/check-cache-key.js +77 -0
  17. package/dist/esm/check-cache-record.d.ts +69 -0
  18. package/dist/esm/check-cache-record.js +167 -0
  19. package/dist/esm/check-cache.d.ts +8 -176
  20. package/dist/esm/check-cache.js +9 -214
  21. package/dist/esm/child-env.d.ts +57 -0
  22. package/dist/esm/child-env.js +59 -0
  23. package/dist/esm/child-process.d.ts +55 -0
  24. package/dist/esm/child-process.js +112 -0
  25. package/dist/esm/commands/build.d.ts +0 -16
  26. package/dist/esm/commands/build.js +9 -214
  27. package/dist/esm/commands/clean.js +3 -2
  28. package/dist/esm/commands/compiler.d.ts +18 -0
  29. package/dist/esm/commands/compiler.js +49 -0
  30. package/dist/esm/commands/fmt.d.ts +0 -39
  31. package/dist/esm/commands/fmt.js +4 -81
  32. package/dist/esm/commands/format-targets.d.ts +39 -0
  33. package/dist/esm/commands/format-targets.js +83 -0
  34. package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
  35. package/dist/esm/commands/rewrite-specifiers.js +122 -0
  36. package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
  37. package/dist/esm/commands/tsconfig-chain.js +63 -0
  38. package/dist/esm/config-check-cache.d.ts +17 -0
  39. package/dist/esm/config-check-cache.js +63 -0
  40. package/dist/esm/config-validation.d.ts +20 -0
  41. package/dist/esm/config-validation.js +236 -0
  42. package/dist/esm/config.js +4 -286
  43. package/dist/esm/crossing-diagnostics.d.ts +39 -0
  44. package/dist/esm/crossing-diagnostics.js +132 -0
  45. package/dist/esm/default-scripts.js +1 -1
  46. package/dist/esm/delegate-host.d.ts +43 -0
  47. package/dist/esm/delegate-host.js +191 -0
  48. package/dist/esm/delegation-target.d.ts +52 -0
  49. package/dist/esm/delegation-target.js +71 -0
  50. package/dist/esm/fan-out.d.ts +57 -0
  51. package/dist/esm/fan-out.js +146 -0
  52. package/dist/esm/glyphs.d.ts +1 -1
  53. package/dist/esm/help.js +10 -3
  54. package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
  55. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  56. package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
  57. package/dist/esm/helpers/renderQuotedList.js +9 -0
  58. package/dist/esm/hooks.d.ts +17 -0
  59. package/dist/esm/hooks.js +23 -0
  60. package/dist/esm/invocation-args.d.ts +40 -0
  61. package/dist/esm/invocation-args.js +178 -0
  62. package/dist/esm/invocation.d.ts +67 -0
  63. package/dist/esm/invocation.js +3 -0
  64. package/dist/esm/machine-lock.d.ts +2 -0
  65. package/dist/esm/machine-lock.js +1 -1
  66. package/dist/esm/output-channel.d.ts +23 -0
  67. package/dist/esm/output-channel.js +14 -0
  68. package/dist/esm/pass-manifest.d.ts +53 -0
  69. package/dist/esm/pass-manifest.js +89 -0
  70. package/dist/esm/presentation.d.ts +47 -0
  71. package/dist/esm/presentation.js +32 -0
  72. package/dist/esm/recording.d.ts +1 -1
  73. package/dist/esm/recording.js +2 -2
  74. package/dist/esm/remote-host-session.d.ts +62 -0
  75. package/dist/esm/remote-host-session.js +277 -0
  76. package/dist/esm/remote-probe-host.d.ts +22 -0
  77. package/dist/esm/remote-probe-host.js +50 -0
  78. package/dist/esm/remote-probe.d.ts +63 -0
  79. package/dist/esm/remote-probe.js +88 -0
  80. package/dist/esm/remote-run.d.ts +45 -65
  81. package/dist/esm/remote-run.js +128 -162
  82. package/dist/esm/remote-session.d.ts +40 -0
  83. package/dist/esm/remote-session.js +151 -0
  84. package/dist/esm/remote-snapshot.d.ts +21 -0
  85. package/dist/esm/remote-snapshot.js +80 -0
  86. package/dist/esm/replay-assembly.d.ts +2 -2
  87. package/dist/esm/replay-assembly.js +1 -1
  88. package/dist/esm/run-chain.d.ts +21 -0
  89. package/dist/esm/run-chain.js +108 -0
  90. package/dist/esm/runCli.d.ts +1 -9
  91. package/dist/esm/runCli.js +39 -1002
  92. package/dist/esm/runner.d.ts +1 -54
  93. package/dist/esm/runner.js +2 -125
  94. package/dist/esm/shell-syntax.d.ts +30 -0
  95. package/dist/esm/shell-syntax.js +120 -0
  96. package/dist/esm/steps.d.ts +0 -2
  97. package/dist/esm/steps.js +3 -119
  98. package/dist/esm/types.d.ts +5 -0
  99. package/dist/esm/user-config.d.ts +34 -0
  100. package/dist/esm/user-config.js +93 -0
  101. package/dist/esm/verbosity.d.ts +6 -0
  102. package/dist/esm/verbosity.js +7 -7
  103. package/dist/esm/verdict.d.ts +1 -1
  104. package/dist/esm/verdict.js +1 -1
  105. package/docs/check-cache.md +28 -12
  106. package/docs/delegate.md +103 -17
  107. package/docs/reporting.md +6 -1
  108. package/docs/scripts.md +2 -1
  109. package/package.json +4 -4
  110. /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
  111. /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
@@ -1,124 +1,61 @@
1
- import { spawn, spawnSync } from 'node:child_process';
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 { GIT_OUTPUT_LIMIT, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
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 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');
20
+ export function composeGitSshCommand(env) {
21
+ return [resolveSshCommand(env) ?? 'ssh', ...SSH_OPTIONS].join(' ');
36
22
  }
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
- }
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 command name are passed ` +
91
- `to \`${command}\`, not read by nmr. Did you mean \`nmr ${DELEGATE_FLAG} <host> ${command}\`?`);
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 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' };
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 { ok: false, message: `could not prepare ${host}`, output: prepare.output, phase: 'prepare' };
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, GIT_SSH_COMMAND: ['ssh', ...SSH_OPTIONS].join(' ') } });
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 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 }))], {
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
- if (run.exitCode === DELEGATION_FAILURE_EXIT_CODE) {
157
- const recorded = await runSsh(host, `cat ${statusPath}`, env);
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: run.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 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 };
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 runSsh(host, script, env) {
250
- return runProcess(['ssh', ...SSH_OPTIONS, host, wrapRemoteScript(script)], { env });
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
+ }