@williamthorsen/nmr 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.meta/changelog.json +158 -0
  2. package/.readyup/kits/default.js +1527 -1460
  3. package/.readyup/manifest.json +96 -11
  4. package/CHANGELOG.md +41 -0
  5. package/README.md +36 -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 +178 -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 +9 -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 +31 -0
  79. package/dist/esm/remote-probe.js +78 -0
  80. package/dist/esm/remote-run.d.ts +28 -73
  81. package/dist/esm/remote-run.js +103 -165
  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 +96 -16
  107. package/docs/reporting.md +6 -1
  108. package/docs/scripts.md +2 -1
  109. package/package.json +3 -3
  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
@@ -0,0 +1,132 @@
1
+ import path from 'node:path';
2
+ import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
3
+ import { resolveConfigPath } from "./helpers/config-path.js";
4
+ import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
5
+ import { isHookName } from "./helpers/hook-name.js";
6
+ import { resolvePackageJsonPath } from "./helpers/package-json.js";
7
+ import { hasRunnableHook } from "./hooks.js";
8
+ import { expandScript, findChainedSelfReference, readPackageJsonScripts, } from "./resolver.js";
9
+ import { findNmrCrossing, readSoleNmrCommand, renderChain } from "./steps.js";
10
+ import { UserError } from "./UserError.js";
11
+ const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
12
+ export function assertNoSelfReference(options) {
13
+ const { anchorDir, command, isReading, isWorkspaceRoot, monorepoRoot, registry } = options;
14
+ if (isReading) {
15
+ return;
16
+ }
17
+ const script = findChainedSelfReference(anchorDir, command);
18
+ if (script === undefined) {
19
+ return;
20
+ }
21
+ const remedy = formatSelfReferenceRemedy({ command, isWorkspaceRoot, monorepoRoot, registry, script });
22
+ const site = path.relative(monorepoRoot, resolvePackageJsonPath(anchorDir));
23
+ throw new UserError(`${site}: \`scripts.${command}\` re-invokes \`nmr ${command}\` (\`${escapeControlCharacters(script)}\`), ` +
24
+ `so nmr cannot run the steps that it chains. ${remedy}`);
25
+ }
26
+ export function reportNmrCrossing(options) {
27
+ const crossingStep = findNmrCrossing(options.resolvedScript.steps);
28
+ if (options.isReading || crossingStep === undefined) {
29
+ return;
30
+ }
31
+ const warning = formatNmrCrossingWarning({
32
+ crossingStep,
33
+ isWorkspaceRoot: options.isWorkspaceRoot,
34
+ monorepoRoot: options.monorepoRoot,
35
+ origin: describeOrigin(options.resolvedScript.origin, options.config, options.shouldUseRoot),
36
+ registry: options.registry,
37
+ style: options.style,
38
+ });
39
+ options.stderr.write(`${warning}\n`);
40
+ }
41
+ function describeCrossingRemedy(options) {
42
+ const { crossingStep, isWorkspaceRoot, monorepoRoot, origin, registry } = options;
43
+ const configSite = path.relative(monorepoRoot, resolveConfigPath(monorepoRoot));
44
+ switch (origin.tier) {
45
+ case 'default':
46
+ return {
47
+ remedy: 'A built-in default reaching nmr through a shell is an nmr defect: please report it.',
48
+ subject: `nmr built-in \`${origin.key}\``,
49
+ };
50
+ case 'config':
51
+ return {
52
+ remedy: `Write the nmr steps as a step list, and move any others to ${describeStepDestination(origin.key)}.`,
53
+ subject: `${configSite}: \`${origin.field}.${origin.key}\``,
54
+ };
55
+ case 'package':
56
+ return {
57
+ remedy: formatInlineRemedy({ crossingStep, file: origin.file, isWorkspaceRoot, key: origin.key, registry }) ??
58
+ formatPackageRemedy({ configSite, crossingStep, isWorkspaceRoot, key: origin.key, registry }),
59
+ subject: `${path.relative(monorepoRoot, origin.file)}: \`scripts.${origin.key}\``,
60
+ };
61
+ default: {
62
+ const unhandledOrigin = origin;
63
+ throw new Error(`Unhandled script origin: ${JSON.stringify(unhandledOrigin)}`);
64
+ }
65
+ }
66
+ }
67
+ function describeOrigin(origin, config, shouldUseRoot) {
68
+ if (origin.tier === 'package') {
69
+ return origin;
70
+ }
71
+ const field = shouldUseRoot ? 'rootScripts' : 'workspaceScripts';
72
+ const configScripts = config[field];
73
+ return configScripts !== undefined && Object.hasOwn(configScripts, origin.key)
74
+ ? { tier: 'config', field, key: origin.key }
75
+ : { tier: 'default', key: origin.key };
76
+ }
77
+ function describeStepDestination(key) {
78
+ if (isHookName(key)) {
79
+ return 'a script of their own that the step list names, because a hook does not have a `:pre` or `:post` of its own';
80
+ }
81
+ return `a \`${key}:pre\` or \`${key}:post\` script`;
82
+ }
83
+ function formatInlineRemedy(options) {
84
+ const { crossingStep, file, isWorkspaceRoot, key, registry } = options;
85
+ const name = readSoleNmrCommand(crossingStep);
86
+ if (name === undefined || name === key) {
87
+ return undefined;
88
+ }
89
+ const scripts = readPackageJsonScripts(path.dirname(file));
90
+ const value = scripts !== undefined && Object.hasOwn(scripts, name) ? scripts[name]?.trim() : undefined;
91
+ if (value === undefined || ['', ':'].includes(value)) {
92
+ return undefined;
93
+ }
94
+ if (findNmrCrossing([{ kind: 'opaque', command: value }]) !== undefined) {
95
+ return undefined;
96
+ }
97
+ const anchorDir = path.dirname(file);
98
+ if (!isHookName(name) &&
99
+ [`${name}:pre`, `${name}:post`].some((hook) => hasRunnableHook(hook, registry, anchorDir, isWorkspaceRoot))) {
100
+ return undefined;
101
+ }
102
+ const inlinedValue = escapeControlCharacters(value);
103
+ return Object.hasOwn(registry, name)
104
+ ? `Replace the value with \`${inlinedValue}\`, and keep \`${name}\`, which overrides nmr's own.`
105
+ : `Replace the value with \`${inlinedValue}\` and delete \`${name}\`, the script that it names.`;
106
+ }
107
+ function formatNmrCrossingWarning(options) {
108
+ const { remedy, subject } = describeCrossingRemedy(options);
109
+ return (`${STATUS_GLYPHS[options.style].warning.text} ${subject} reaches nmr through a shell ` +
110
+ `(\`${escapeControlCharacters(options.crossingStep)}\`), ${CROSSING_CONSEQUENCE} ${remedy}`);
111
+ }
112
+ function formatPackageRemedy(options) {
113
+ const { configSite, crossingStep, isWorkspaceRoot, key, registry } = options;
114
+ const registryEntry = Object.hasOwn(registry, key) ? registry[key] : undefined;
115
+ if (registryEntry === undefined) {
116
+ return (`A \`package.json\` script cannot contain a step list: define \`${key}\` in \`${configSite}\` and move the ` +
117
+ `package-specific steps to ${describeStepDestination(key)}.`);
118
+ }
119
+ const registryChain = renderChain(expandScript(registryEntry, isWorkspaceRoot));
120
+ if (registryChain === crossingStep) {
121
+ return `Delete the entry: nmr's own \`${key}\` already runs \`${escapeControlCharacters(registryChain)}\`.`;
122
+ }
123
+ return `Delete the entry and move the steps that it adds to ${describeStepDestination(key)}.`;
124
+ }
125
+ function formatSelfReferenceRemedy(options) {
126
+ const { command, isWorkspaceRoot, monorepoRoot, registry, script } = options;
127
+ if (isHookName(command)) {
128
+ return `Delete the re-invocation: \`${command}\` runs the steps beside it.`;
129
+ }
130
+ const configSite = path.relative(monorepoRoot, resolveConfigPath(monorepoRoot));
131
+ return formatPackageRemedy({ configSite, crossingStep: script, isWorkspaceRoot, key: command, registry });
132
+ }
@@ -1,4 +1,4 @@
1
- import { quoteToken } from "./steps.js";
1
+ import { quoteToken } from "./shell-syntax.js";
2
2
  const GATE_PROJECTS = '--project unit --project tool';
3
3
  const TYPECHECK_STEP = { run: 'typecheck', shouldDeclineArguments: true };
4
4
  const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', shouldDeclineArguments: true };
@@ -0,0 +1,43 @@
1
+ import type { ExecutionTarget, Invocation, PhaseResult, RunCliResult, RunPlan } from './invocation.js';
2
+ import type { RetainedOutput } from './runner.js';
3
+ import type { VerdictOutcome } from './verdict.js';
4
+ /** How a run on the host ended, or that the invocation runs here after all. */
5
+ export type HostRunResult = {
6
+ kind: 'here';
7
+ } | {
8
+ kind: 'refused';
9
+ exitCode: number;
10
+ } | {
11
+ kind: 'ran';
12
+ durationMs: number;
13
+ exitCode: number;
14
+ host: string;
15
+ outcome: VerdictOutcome;
16
+ /** The host's pass-manifest entries, unvalidated, empty when the run did not complete or its host sent none. */
17
+ passes: unknown[];
18
+ retainedOutput: RetainedOutput;
19
+ /** The tree that was pushed, absent when the working tree could not be snapshotted. */
20
+ treeId: string | undefined;
21
+ };
22
+ /**
23
+ * Decides where the invocation runs, warning about a delegation flag that the command will receive as an argument.
24
+ * Settles an invocation whose delegation is refused.
25
+ */
26
+ export declare function resolveExecutionTarget(invocation: Invocation): Promise<PhaseResult<ExecutionTarget>>;
27
+ /**
28
+ * Runs the whole invocation on the host and reports one verdict for it, its connection, transfer, and install
29
+ * included. Returns `undefined` when the invocation runs here after all.
30
+ *
31
+ * Serves a `-F` or `-R` invocation, which does not have a gate of its own to consult first: the host fans it out.
32
+ */
33
+ export declare function runInvocationOnHost(invocation: Invocation, target: RemoteTarget): Promise<RunCliResult | undefined>;
34
+ /**
35
+ * Runs a plan's command on the host, once the gate has found no pass to recall. Returns what the run produced, its
36
+ * output included so that a pass can be recorded with its transcript, and leaves the verdict to the caller.
37
+ */
38
+ export declare function runPlanOnHost(plan: RunPlan, target: RemoteTarget): Promise<HostRunResult>;
39
+ /** A target that names its candidate hosts. */
40
+ type RemoteTarget = Extract<ExecutionTarget, {
41
+ kind: 'remote';
42
+ }>;
43
+ export {};
@@ -0,0 +1,178 @@
1
+ import os from 'node:os';
2
+ import { Writable } from 'node:stream';
3
+ import { reportError } from '@williamthorsen/nmr-core';
4
+ import { NO_CACHE_ENV_VAR, resolveRunId, writeDebugNote } from "./check-cache.js";
5
+ import { buildChildEnv, buildRemoteEnv } from "./child-env.js";
6
+ import { resolveDelegationTarget } from "./delegation-target.js";
7
+ import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
8
+ import { isDelegationFlag } from "./invocation-args.js";
9
+ import { reportVerdict } from "./presentation.js";
10
+ import { chooseHost, probeHost } from "./remote-probe.js";
11
+ import { DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATE_TO_FLAG, DELEGATION_FAILURE_EXIT_CODE, deriveCheckoutPath, formatMisplacedDelegationWarning, NO_DELEGATE_FLAG, runRemotely, } from "./remote-run.js";
12
+ import { createSnapshot } from "./remote-snapshot.js";
13
+ import { formatDisplayPath, loadUserConfig } from "./user-config.js";
14
+ export async function resolveExecutionTarget(invocation) {
15
+ const { command, context, env, parsedArgs, stderr, styles } = invocation;
16
+ if (parsedArgs.passthrough.some(isDelegationFlag)) {
17
+ stderr.write(`${formatMisplacedDelegationWarning(command, styles.stderr)}\n`);
18
+ }
19
+ const target = await resolveDelegationTarget({
20
+ command,
21
+ env,
22
+ extraAgentEnvVars: context.config.output?.extraAgentEnvVars,
23
+ flagValue: parsedArgs.delegateHosts,
24
+ isFanOut: parsedArgs.filter !== undefined || parsedArgs.recursive,
25
+ isOptedOut: parsedArgs.shouldRunLocally,
26
+ isReading: parsedArgs.shouldPrintLog,
27
+ isRepoOptedOut: context.config.delegate === false,
28
+ readUserConfig: () => loadUserConfig(env),
29
+ shouldDelegate: parsedArgs.shouldDelegate,
30
+ });
31
+ if (target.kind === 'refused') {
32
+ reportError(target.error, stderr);
33
+ return { settled: true, result: { exitCode: 1 } };
34
+ }
35
+ if (target.kind === 'remote') {
36
+ writeDebugNote(`delegating ${command} per ${describeDelegationSource(target, parsedArgs, env)}`, env, stderr);
37
+ }
38
+ return { settled: false, value: target };
39
+ }
40
+ export async function runInvocationOnHost(invocation, target) {
41
+ const { env, format, parsedArgs, styles } = invocation;
42
+ const childEnv = buildChildEnv({
43
+ env,
44
+ format,
45
+ passthrough: parsedArgs.passthrough,
46
+ runId: resolveRunId(env),
47
+ shouldBypassCache: parsedArgs.shouldBypassCache || env[NO_CACHE_ENV_VAR] === '1',
48
+ shouldBypassLock: parsedArgs.shouldBypassLock,
49
+ snapshot: undefined,
50
+ styles,
51
+ verbosity: invocation.verbosity,
52
+ });
53
+ const result = await delegate({ invocation, remoteEnv: buildRemoteEnv(childEnv), target });
54
+ if (result.kind === 'here') {
55
+ return undefined;
56
+ }
57
+ if (result.kind === 'ran') {
58
+ reportVerdict({ command: invocation.command, host: result.host, scope: invocation.scope, ...result.outcome }, invocation.stdout, format, styles.stdout);
59
+ }
60
+ return { exitCode: result.exitCode };
61
+ }
62
+ export async function runPlanOnHost(plan, target) {
63
+ return delegate({
64
+ invocation: plan.invocation,
65
+ remoteEnv: buildRemoteEnv(plan.runOptions.env ?? plan.invocation.env),
66
+ target,
67
+ });
68
+ }
69
+ const PROBE_RESULT_PHRASES = {
70
+ busy: 'is busy',
71
+ free: 'is free',
72
+ 'no-answer': 'did not answer the lock probe',
73
+ };
74
+ function composeForwardedArgs(command, parsedArgs) {
75
+ return [
76
+ ...(parsedArgs.filter === undefined ? [] : ['--filter', parsedArgs.filter]),
77
+ ...(parsedArgs.recursive ? ['--recursive'] : []),
78
+ ...(parsedArgs.isWorkspaceRoot ? ['--workspace-root'] : []),
79
+ ...(parsedArgs.quiet ? ['--quiet'] : []),
80
+ ...(parsedArgs.shouldEmitJson ? ['--json'] : []),
81
+ ...(parsedArgs.shouldBypassCache ? ['--no-cache'] : []),
82
+ ...(parsedArgs.shouldBypassLock ? ['--no-lock'] : []),
83
+ command,
84
+ ...parsedArgs.passthrough,
85
+ ];
86
+ }
87
+ function createTee(stream, buffer) {
88
+ return new Writable({
89
+ write(chunk, _encoding, callback) {
90
+ buffer.append(chunk);
91
+ stream.write(chunk);
92
+ callback();
93
+ },
94
+ });
95
+ }
96
+ function describeDelegationSource(target, parsedArgs, env) {
97
+ switch (target.source) {
98
+ case 'config':
99
+ return `${formatDisplayPath(target.configPath, env)} (when: ${target.when})`;
100
+ case 'env':
101
+ return DELEGATE_ENV_VAR;
102
+ case 'flag':
103
+ return parsedArgs.delegateHosts === undefined ? DELEGATE_FLAG : DELEGATE_TO_FLAG;
104
+ }
105
+ }
106
+ async function delegate(options) {
107
+ const { invocation, remoteEnv, target } = options;
108
+ const { command, context, cwd, env, parsedArgs, stderr, stdout } = invocation;
109
+ const listedHosts = target.hosts.join(', ');
110
+ const startedAtMs = Date.now();
111
+ const snapshot = createSnapshot(cwd, env);
112
+ if (!snapshot.ok && snapshot.isUnsupported) {
113
+ if (target.source === 'flag') {
114
+ reportError(`Cannot delegate to ${listedHosts}: ${snapshot.reason}`, stderr);
115
+ return { kind: 'refused', exitCode: 1 };
116
+ }
117
+ stderr.write(`nmr: running ${command} here rather than on ${listedHosts}: ${snapshot.reason}\n`);
118
+ return { kind: 'here' };
119
+ }
120
+ const host = snapshot.ok ? await resolveHost(target.hosts, snapshot.toplevel, env, stderr) : target.hosts[0];
121
+ const retained = { stderr: createBoundedBuffer(), stdout: createBoundedBuffer() };
122
+ const result = snapshot.ok
123
+ ? await runRemotely({
124
+ command,
125
+ cwd,
126
+ env,
127
+ host,
128
+ monorepoRoot: context.monorepoRoot,
129
+ nmrArgs: composeForwardedArgs(command, parsedArgs),
130
+ remoteEnv,
131
+ snapshot,
132
+ stderr: createTee(stderr, retained.stderr),
133
+ stdout: createTee(stdout, retained.stdout),
134
+ })
135
+ : { ok: false, message: 'could not snapshot the working tree', output: snapshot.reason, waitedMs: 0 };
136
+ const durationMs = Date.now() - startedAtMs - result.waitedMs;
137
+ const ran = {
138
+ kind: 'ran',
139
+ durationMs,
140
+ host,
141
+ retainedOutput: { stderr: retained.stderr.readBuffer(), stdout: retained.stdout.readBuffer() },
142
+ treeId: snapshot.ok ? snapshot.treeId : undefined,
143
+ };
144
+ if (!result.ok) {
145
+ if (result.output.trim() !== '') {
146
+ stderr.write(result.output.endsWith('\n') ? result.output : `${result.output}\n`);
147
+ }
148
+ if (target.source === 'config' && 'phase' in result && result.phase === 'prepare') {
149
+ const configPath = formatDisplayPath(target.configPath, env);
150
+ stderr.write(`nmr: ${configPath} sent ${command} to ${listedHosts}; ask for network access to ${listedHosts}, ` +
151
+ `or run it here with nmr ${NO_DELEGATE_FLAG} ${command}\n`);
152
+ }
153
+ return {
154
+ ...ran,
155
+ exitCode: DELEGATION_FAILURE_EXIT_CODE,
156
+ passes: [],
157
+ outcome: { outcome: 'failed', detail: result.message, durationMs, exitCode: DELEGATION_FAILURE_EXIT_CODE },
158
+ };
159
+ }
160
+ const outcome = result.exitCode === 0
161
+ ? { outcome: 'passed', durationMs }
162
+ : { outcome: 'failed', durationMs, exitCode: result.exitCode };
163
+ return { ...ran, exitCode: result.exitCode, outcome, passes: result.passes };
164
+ }
165
+ async function resolveHost(hosts, toplevel, env, stderr) {
166
+ const [first, ...rest] = hosts;
167
+ if (rest.length === 0) {
168
+ return first;
169
+ }
170
+ const checkoutPath = deriveCheckoutPath(os.hostname(), toplevel);
171
+ return chooseHost({
172
+ hosts,
173
+ onSettled(host, result) {
174
+ writeDebugNote(`${host} ${PROBE_RESULT_PHRASES[result]}`, env, stderr);
175
+ },
176
+ probe: (host, signal) => probeHost({ checkoutPath, env, host, signal }),
177
+ });
178
+ }
@@ -0,0 +1,52 @@
1
+ import { type HostList } from './remote-run.js';
2
+ import { type DelegateWhen, type LoadedUserConfig } from './user-config.js';
3
+ /**
4
+ * Where a delegation runs, or why the invocation cannot run where it asked to. A delegation that the user config
5
+ * decided names the file and the `when` that matched.
6
+ */
7
+ export type DelegationTarget = {
8
+ kind: 'local';
9
+ } | {
10
+ kind: 'remote';
11
+ hosts: HostList;
12
+ source: 'env' | 'flag';
13
+ } | {
14
+ kind: 'remote';
15
+ hosts: HostList;
16
+ source: 'config';
17
+ configPath: string;
18
+ when: DelegateWhen;
19
+ } | {
20
+ kind: 'refused';
21
+ error: string;
22
+ };
23
+ /**
24
+ * Decides where an invocation runs, by the first rule that applies:
25
+ *
26
+ * 1. `--no-delegate` runs it here.
27
+ * 2. `--delegate-to` runs it on the hosts that it names, and `--delegate` on `NMR_DELEGATE`'s, or else the user
28
+ * config's.
29
+ * 3. A repo config declaring `delegate: false` runs it here.
30
+ * 4. `NMR_DELEGATE` naming hosts runs it on them.
31
+ * 5. `NMR_DELEGATE` set without a host runs it here, which is how a step keeps to where its parent runs.
32
+ * 6. The user config's `when` matching runs it on the config's hosts, unless `CI` is set or the invocation fans out.
33
+ * 7. Otherwise, it runs here.
34
+ *
35
+ * A command that rewrites the tree, or that runs until it is stopped, cannot be delegated, and neither can `--log`,
36
+ * which reads this machine's recordings. Asked for by a flag, such an invocation is refused; asked for by the
37
+ * variable or the user config, which set a default for every invocation, it runs here.
38
+ *
39
+ * The user config is read only when a rule reaches it, so an invalid one fails only a run that consults it.
40
+ */
41
+ export declare function resolveDelegationTarget(options: {
42
+ command: string;
43
+ env: NodeJS.ProcessEnv;
44
+ extraAgentEnvVars: readonly string[] | undefined;
45
+ flagValue: string | undefined;
46
+ isFanOut: boolean;
47
+ isOptedOut: boolean;
48
+ isReading: boolean;
49
+ isRepoOptedOut: boolean;
50
+ readUserConfig: () => Promise<LoadedUserConfig | undefined>;
51
+ shouldDelegate: boolean;
52
+ }): Promise<DelegationTarget>;
@@ -0,0 +1,71 @@
1
+ import { DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATE_TO_FLAG } from "./remote-run.js";
2
+ import { formatDisplayPath, resolveUserConfigPath } from "./user-config.js";
3
+ import { hasAgentMarker } from "./verbosity.js";
4
+ export async function resolveDelegationTarget(options) {
5
+ const { command, env, extraAgentEnvVars, flagValue, isFanOut, isOptedOut, isReading } = options;
6
+ const { isRepoOptedOut, readUserConfig, shouldDelegate } = options;
7
+ if (isOptedOut) {
8
+ return { kind: 'local' };
9
+ }
10
+ const isDelegable = !isReading && !NON_DELEGABLE_COMMANDS.has(command);
11
+ const envValue = env[DELEGATE_ENV_VAR];
12
+ const envHosts = parseHostList(envValue ?? '');
13
+ if (flagValue !== undefined || shouldDelegate) {
14
+ return resolveRequestedTarget({ command, env, envHosts, flagValue, isDelegable, isReading, readUserConfig });
15
+ }
16
+ if (isRepoOptedOut) {
17
+ return { kind: 'local' };
18
+ }
19
+ if (envHosts !== undefined) {
20
+ return isDelegable ? { kind: 'remote', hosts: envHosts, source: 'env' } : { kind: 'local' };
21
+ }
22
+ if (envValue !== undefined || !isDelegable || isFanOut || hasValue(env['CI'])) {
23
+ return { kind: 'local' };
24
+ }
25
+ const loaded = await readUserConfig();
26
+ const delegate = loaded?.config.delegate;
27
+ if (loaded === undefined || delegate?.when === undefined) {
28
+ return { kind: 'local' };
29
+ }
30
+ if (delegate.when === 'agent' && !hasAgentMarker(env, extraAgentEnvVars)) {
31
+ return { kind: 'local' };
32
+ }
33
+ return { kind: 'remote', hosts: delegate.hosts, source: 'config', configPath: loaded.path, when: delegate.when };
34
+ }
35
+ const DEFAULT_USER_CONFIG_PATH = '~/.config/nmr/config.ts';
36
+ const NON_DELEGABLE_BASE_COMMANDS = ['build', 'clean', 'compile', 'fix', 'fmt', 'lint', 'test:watch', 'upgrade'];
37
+ const NON_DELEGABLE_COMMANDS = new Set([
38
+ ...NON_DELEGABLE_BASE_COMMANDS,
39
+ ...NON_DELEGABLE_BASE_COMMANDS.map((command) => `root:${command}`),
40
+ ]);
41
+ function hasValue(value) {
42
+ return value !== undefined && value !== '';
43
+ }
44
+ function parseHostList(value) {
45
+ const names = value.split(',').map((host) => host.trim());
46
+ const [first, ...rest] = new Set(names.filter((host) => host !== ''));
47
+ return first === undefined ? undefined : [first, ...rest];
48
+ }
49
+ async function resolveRequestedTarget(options) {
50
+ const { command, env, envHosts, flagValue, isDelegable, isReading, readUserConfig } = options;
51
+ const flag = flagValue === undefined ? DELEGATE_FLAG : DELEGATE_TO_FLAG;
52
+ const flagHosts = flagValue === undefined ? undefined : parseHostList(flagValue);
53
+ if (flagValue !== undefined && flagHosts === undefined) {
54
+ return { kind: 'refused', error: `${DELEGATE_TO_FLAG} requires a host: "${flagValue}" does not name a host.` };
55
+ }
56
+ if (!isDelegable) {
57
+ const subject = isReading ? '--log' : `\`${command}\``;
58
+ const reason = isReading ? "reads this machine's recordings" : 'rewrites the working tree or runs until stopped';
59
+ return { kind: 'refused', error: `${subject} cannot be delegated: it ${reason}. Run it without ${flag}.` };
60
+ }
61
+ const hosts = flagHosts ?? envHosts ?? (await readUserConfig())?.config.delegate?.hosts;
62
+ if (hosts === undefined) {
63
+ const configPath = formatDisplayPath(resolveUserConfigPath(env) ?? DEFAULT_USER_CONFIG_PATH, env);
64
+ return {
65
+ kind: 'refused',
66
+ error: `${DELEGATE_FLAG} needs hosts: set \`delegate.hosts\` in ${configPath}, or name them with ` +
67
+ `${DELEGATE_TO_FLAG} <host,...>.`,
68
+ };
69
+ }
70
+ return { kind: 'remote', hosts, source: 'flag' };
71
+ }
@@ -0,0 +1,57 @@
1
+ import type { Writable } from 'node:stream';
2
+ import type { ResolvedContext } from './context.js';
3
+ import { type RunCliResult } from './invocation.js';
4
+ import type { ParsedArgs } from './invocation-args.js';
5
+ import { type RunStepsOptions } from './runner.js';
6
+ import type { Step } from './steps.js';
7
+ /**
8
+ * Composes the delegation that an `-F` or `-R` calls for, or `undefined` when the invocation runs here.
9
+ *
10
+ * One structural step, to keep the pattern given to `-F` and the arguments passed on as argv tokens rather than
11
+ * text spliced into a shell string, and to make the nmr processes underneath write where this one writes although
12
+ * the binary spawned is `pnpm`. A flag of nmr's own precedes the command name, where the nmr underneath reads
13
+ * it as its own rather than passing it on to the command.
14
+ *
15
+ * A `--log` fan-out surveys the scopes selected, so a scope with nothing to show is a gap in the survey rather
16
+ * than a failure of it, however that scope was selected.
17
+ */
18
+ export declare function composeDelegation(options: {
19
+ childEnv: NodeJS.ProcessEnv;
20
+ command: string;
21
+ parsedArgs: ParsedArgs;
22
+ }): {
23
+ env: NodeJS.ProcessEnv;
24
+ step: Extract<Step, {
25
+ kind: 'structural';
26
+ }>;
27
+ } | undefined;
28
+ /**
29
+ * Reports whether any scope that a delegation can select defines the command. A package config does not define
30
+ * scripts, so the root config's registries and each `package.json` cover every scope.
31
+ */
32
+ export declare function isDefinedInAnyScope(command: string, context: ResolvedContext): boolean;
33
+ /**
34
+ * Runs a `-F` or `-R` delegation, or refuses it when it would not select any scope.
35
+ *
36
+ * The refusal precedes the delegate rather than reading its outcome, since a delegation that selected nothing
37
+ * has already run to completion, reporting nothing and exiting 0, by the time nmr sees it.
38
+ *
39
+ * A `-R` delegation withholds stdin from its packages, as does a filter that pnpm lists as several packages. pnpm 12
40
+ * starts each package of a concurrent `exec` in a background process group, which the system stops when it reads
41
+ * from the terminal or changes its mode, so a command such as `next build` would hang the run. pnpm keeps a serial
42
+ * run in the foreground, which nmr cannot observe; `-R` itself and a filter's package count stand in for it, and a
43
+ * `-R` or a dependency-chain filter that pnpm runs serially loses its input as well. A filter whose selection pnpm
44
+ * did not report keeps nmr's stdin.
45
+ */
46
+ export declare function runDelegation(options: {
47
+ context: ResolvedContext;
48
+ delegation: {
49
+ env: NodeJS.ProcessEnv;
50
+ step: Extract<Step, {
51
+ kind: 'structural';
52
+ }>;
53
+ };
54
+ parsedArgs: ParsedArgs;
55
+ runOptions: RunStepsOptions;
56
+ stderr: Writable;
57
+ }): Promise<RunCliResult>;