@williamthorsen/nmr 0.43.0 → 0.45.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 (47) hide show
  1. package/.meta/changelog.json +168 -0
  2. package/.readyup/kits/default.js +1331 -136
  3. package/.readyup/manifest.json +148 -9
  4. package/CHANGELOG.md +51 -0
  5. package/README.md +17 -3
  6. package/agents/guidance/rulebooks/nmr.md +1 -1
  7. package/dist/esm/commands/fmt-cache.d.ts +26 -0
  8. package/dist/esm/commands/fmt-cache.js +71 -0
  9. package/dist/esm/commands/fmt.d.ts +10 -3
  10. package/dist/esm/commands/fmt.js +34 -6
  11. package/dist/esm/config.js +74 -9
  12. package/dist/esm/default-scripts.d.ts +10 -1
  13. package/dist/esm/default-scripts.js +45 -39
  14. package/dist/esm/defineConfig.d.ts +1 -1
  15. package/dist/esm/entry-chain.d.ts +24 -0
  16. package/dist/esm/entry-chain.js +57 -0
  17. package/dist/esm/glyphs.d.ts +1 -1
  18. package/dist/esm/help.d.ts +1 -1
  19. package/dist/esm/help.js +3 -3
  20. package/dist/esm/helpers/createOutputRecorder.d.ts +17 -0
  21. package/dist/esm/helpers/createOutputRecorder.js +22 -0
  22. package/dist/esm/helpers/escapeControlCharacters.d.ts +10 -0
  23. package/dist/esm/helpers/escapeControlCharacters.js +12 -0
  24. package/dist/esm/resolve-scripts.d.ts +5 -3
  25. package/dist/esm/resolve-scripts.js +7 -3
  26. package/dist/esm/resolver.d.ts +5 -4
  27. package/dist/esm/resolver.js +16 -6
  28. package/dist/esm/runCli.js +146 -101
  29. package/dist/esm/runner.d.ts +7 -0
  30. package/dist/esm/runner.js +105 -15
  31. package/dist/esm/steps.d.ts +50 -18
  32. package/dist/esm/steps.js +82 -13
  33. package/dist/esm/taze.d.ts +5 -2
  34. package/dist/esm/taze.js +4 -1
  35. package/dist/esm/tiers.d.ts +5 -4
  36. package/dist/esm/types.d.ts +10 -0
  37. package/dist/esm/vitest-git-isolation.js +3 -1
  38. package/dist/esm/vitest.js +9 -5
  39. package/dist/esm/workspace-globs.d.ts +5 -0
  40. package/dist/esm/workspace-globs.js +8 -0
  41. package/docs/check-cache.md +2 -0
  42. package/docs/reporting.md +2 -0
  43. package/docs/scripts.md +36 -10
  44. package/docs/upgrades.md +1 -1
  45. package/docs/utilities.md +14 -2
  46. package/docs/vitest.md +20 -8
  47. package/package.json +7 -7
@@ -1,12 +1,14 @@
1
1
  import path from 'node:path';
2
- import { describeInvalidOutputStyle, formatGlyphLine, readPackageVersion, reportError, STATUS_GLYPHS, } from '@williamthorsen/nmr-core';
2
+ import { describeInvalidOutputStyle, formatGlyphLine, reportError, resolveSelfVersion, STATUS_GLYPHS, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { certifyRetention, computeCacheKey, computeRetentionKey, CURRENT_RUNTIME, encodeTreeSnapshot, findStaleBuildOutput, formatMisplacedNoCacheWarning, isCacheableCommand, NO_CACHE_ENV_VAR, readBuildOutputState, readCheckCacheEntry, recordTranscript, resolveRunId, resolveTreeSnapshot, RUN_ID_ENV_VAR, TREE_SNAPSHOT_ENV_VAR, writeCheckCacheEntry, writeDebugNote, } from "./check-cache.js";
5
5
  import { resolveContext } from "./context.js";
6
+ import { enterChain } from "./entry-chain.js";
6
7
  import { NMR_GLYPHS } from "./glyphs.js";
7
8
  import { generateHelp } from "./help.js";
8
9
  import { resolveConfigPath } from "./helpers/config-path.js";
9
10
  import { deriveExcerpt } from "./helpers/deriveExcerpt.js";
11
+ import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
10
12
  import { readFilterSelection } from "./helpers/filter-selection.js";
11
13
  import { findClosestName } from "./helpers/findClosestName.js";
12
14
  import { isHookName } from "./helpers/hook-name.js";
@@ -18,27 +20,38 @@ import { assembleReplay } from "./replay-assembly.js";
18
20
  import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
19
21
  import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, findChainedSelfReference, readPackageJsonScripts, resolveScript, } from "./resolver.js";
20
22
  import { resolveChannel, runSteps } from "./runner.js";
21
- import { composeNmrStep, dropRecursiveSteps, findNmrCrossing, quoteToken, renderChain } from "./steps.js";
23
+ import { composeNmrStep, dropRecursiveSteps, findNmrCrossing, quoteToken, readSoleNmrCommand, renderChain, } from "./steps.js";
22
24
  import { UserError } from "./UserError.js";
23
25
  import { COMMAND_VERBOSITY_ENV_VAR, readVerbosityEnv, resolveVerbosity, } from "./verbosity.js";
24
26
  import { writeVerdict } from "./verdict.js";
25
27
  import { readWorkspacePackageNames, resolveWorkspace } from "./workspace.js";
26
- const VERSION = readPackageVersion(import.meta.url);
28
+ const VERSION = resolveSelfVersion(import.meta.url);
27
29
  const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
28
30
  const RECURSIVE_REJECTION = '-R/--recursive did not match any workspace:';
29
31
  const LIST_CEILING = 10;
30
- const NAMED_ESCAPES = new Map([
31
- ['\n', String.raw `\n`],
32
- ['\r', String.raw `\r`],
33
- ['\t', String.raw `\t`],
34
- ]);
35
32
  export const RUN_IF_PRESENT_ENV_VAR = 'NMR_RUN_IF_PRESENT';
36
33
  export async function runCli(options) {
37
- const { args, env, stdout, stderr } = options;
34
+ const established = await establishInvocation(options);
35
+ if (established.settled) {
36
+ return { exitCode: established.result.exitCode };
37
+ }
38
+ const invocation = established.value;
39
+ const planned = await planRun(invocation);
40
+ const { exitCode, isUnknownCommand = false } = planned.settled ? planned.result : await executePlan(planned.value);
41
+ if (exitCode !== 0 && !isUnknownCommand && isTopLevelInvocation(options.env)) {
42
+ const hint = formatMisplacedFlagHint(options.args, invocation.parsedArgs.passthrough, invocation.styles.stderr);
43
+ if (hint !== undefined) {
44
+ options.stderr.write(`${hint}\n`);
45
+ }
46
+ }
47
+ return { exitCode };
48
+ }
49
+ async function establishInvocation(options) {
50
+ const { args, cwd, env, stdout, stderr } = options;
38
51
  const parseResult = parseArgs(args);
39
52
  if (!parseResult.ok) {
40
53
  reportError(parseResult.error, stderr);
41
- return { exitCode: 1 };
54
+ return { settled: true, result: { exitCode: 1 } };
42
55
  }
43
56
  const { parsedArgs } = parseResult;
44
57
  const presentationRead = readPresentation({
@@ -49,24 +62,12 @@ export async function runCli(options) {
49
62
  });
50
63
  if (!presentationRead.ok) {
51
64
  reportError(presentationRead.error, stderr);
52
- return { exitCode: 1 };
65
+ return { settled: true, result: { exitCode: 1 } };
53
66
  }
54
67
  const { styles } = presentationRead;
55
- const { exitCode, isUnknownCommand = false } = await runInvocation({ ...options, parsedArgs, presentationRead });
56
- if (exitCode !== 0 && !isUnknownCommand && isTopLevelInvocation(env)) {
57
- const hint = formatMisplacedFlagHint(args, parsedArgs.passthrough, styles.stderr);
58
- if (hint !== undefined) {
59
- stderr.write(`${hint}\n`);
60
- }
61
- }
62
- return { exitCode };
63
- }
64
- async function runInvocation(options) {
65
- const { cwd, env, parsedArgs, presentationRead, stderr, stdout } = options;
66
- const { styles } = presentationRead;
67
68
  if (parsedArgs.shouldShowVersion) {
68
69
  stdout.write(`${VERSION}\n`);
69
- return { exitCode: 0 };
70
+ return { settled: true, result: { exitCode: 0 } };
70
71
  }
71
72
  const context = await resolveContext(cwd);
72
73
  const format = resolveReportFormat({ envFormat: presentationRead.format, hasJsonFlag: parsedArgs.shouldEmitJson });
@@ -77,15 +78,33 @@ async function runInvocation(options) {
77
78
  output: context.config.output,
78
79
  hasQuietFlag: parsedArgs.quiet,
79
80
  });
80
- const quiet = verbosity === 'quiet';
81
81
  const shouldUseRoot = parsedArgs.isWorkspaceRoot || context.isRoot;
82
82
  const anchorDir = shouldUseRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
83
83
  if (parsedArgs.shouldShowHelp || !parsedArgs.command) {
84
- stdout.write(`${generateHelp(context.config, anchorDir, shouldUseRoot)}\n`);
85
- return { exitCode: 0 };
84
+ stdout.write(`${generateHelp(context.config, context.monorepoRoot, anchorDir, shouldUseRoot)}\n`);
85
+ return { settled: true, result: { exitCode: 0 } };
86
86
  }
87
- const { command } = parsedArgs;
88
- const scope = path.basename(anchorDir);
87
+ return {
88
+ settled: false,
89
+ value: {
90
+ anchorDir,
91
+ command: parsedArgs.command,
92
+ context,
93
+ env,
94
+ format,
95
+ parsedArgs,
96
+ scope: path.basename(anchorDir),
97
+ shouldUseRoot,
98
+ stderr,
99
+ stdout,
100
+ styles,
101
+ verbosity,
102
+ },
103
+ };
104
+ }
105
+ async function planRun(invocation) {
106
+ const { anchorDir, command, context, env, parsedArgs, stderr, stdout, styles } = invocation;
107
+ const quiet = invocation.verbosity === 'quiet';
89
108
  const snapshot = openGate({
90
109
  command,
91
110
  config: context.config,
@@ -99,13 +118,13 @@ async function runInvocation(options) {
99
118
  const runId = resolveRunId(env);
100
119
  const childEnv = buildChildEnv({
101
120
  env,
102
- format,
121
+ format: invocation.format,
103
122
  shouldBypassCache,
104
123
  passthrough: parsedArgs.passthrough,
105
124
  runId,
106
125
  snapshot,
107
126
  styles,
108
- verbosity,
127
+ verbosity: invocation.verbosity,
109
128
  });
110
129
  const runOptions = {
111
130
  quiet,
@@ -116,9 +135,14 @@ async function runInvocation(options) {
116
135
  const delegation = composeDelegation({ childEnv, command, parsedArgs });
117
136
  if (delegation !== undefined) {
118
137
  const result = await runDelegation({ context, delegation, parsedArgs, runOptions, stderr });
119
- return { ...result, isUnknownCommand: result.exitCode !== 0 && !isDefinedInAnyScope(command, context) };
120
- }
121
- const registry = shouldUseRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
138
+ return {
139
+ settled: true,
140
+ result: { ...result, isUnknownCommand: result.exitCode !== 0 && !isDefinedInAnyScope(command, context) },
141
+ };
142
+ }
143
+ const registry = invocation.shouldUseRoot
144
+ ? buildRootRegistry(context.config, context.monorepoRoot)
145
+ : buildWorkspaceRegistry(context.config);
122
146
  assertNoSelfReference({
123
147
  anchorDir,
124
148
  command,
@@ -130,24 +154,50 @@ async function runInvocation(options) {
130
154
  const resolvedScript = resolveScript(command, registry, anchorDir, parsedArgs.isWorkspaceRoot);
131
155
  if (!resolvedScript) {
132
156
  if (env[RUN_IF_PRESENT_ENV_VAR] === '1') {
133
- return { exitCode: 0 };
157
+ return { settled: true, result: { exitCode: 0 } };
134
158
  }
135
159
  reportError(`Unknown command: ${command}`, stderr);
136
- return { exitCode: 1, isUnknownCommand: true };
160
+ return { settled: true, result: { exitCode: 1, isUnknownCommand: true } };
161
+ }
162
+ const entryRunOptions = parsedArgs.shouldPrintLog
163
+ ? runOptions
164
+ : {
165
+ ...runOptions,
166
+ env: enterChain({ anchorDir, command, env: childEnv, monorepoRoot: context.monorepoRoot }),
167
+ };
168
+ const chain = assembleChain({ invocation, registry, resolvedScript, snapshot });
169
+ if (chain.settled) {
170
+ return chain;
137
171
  }
172
+ return {
173
+ settled: false,
174
+ value: {
175
+ ...chain.value,
176
+ invocation,
177
+ overrideNotice: formatOverrideNotice(resolvedScript, registry, command, anchorDir, quiet, styles.stdout),
178
+ runId,
179
+ runOptions: entryRunOptions,
180
+ shouldBypassCache,
181
+ snapshot,
182
+ },
183
+ };
184
+ }
185
+ function assembleChain(options) {
186
+ const { invocation, registry, resolvedScript, snapshot } = options;
187
+ const { anchorDir, command, context, env, parsedArgs, stderr, stdout, styles } = invocation;
138
188
  const { isEmptiedByWorkspace, steps: runnableSteps } = readRunnableSteps(resolvedScript, context.workspacePackageDirs);
139
189
  const resolvedCommand = renderChain(runnableSteps);
140
190
  const noOpReason = findNoOpReason(resolvedCommand, isEmptiedByWorkspace);
141
191
  if (noOpReason !== undefined && !parsedArgs.shouldPrintLog) {
142
- reportVerdict({ command, scope, outcome: 'no-op', reason: noOpReason }, stdout, format, styles.stdout);
143
- return { exitCode: 0 };
192
+ reportVerdict({ command, scope: invocation.scope, outcome: 'no-op', reason: noOpReason }, stdout, invocation.format, styles.stdout);
193
+ return { settled: true, result: { exitCode: 0 } };
144
194
  }
145
195
  const substitutedSteps = applyDevBinToSteps(runnableSteps, context.config.devBin, context.monorepoRoot);
146
196
  const substitutedCommand = renderChain(substitutedSteps);
147
197
  const bindResult = bindPassthrough(substitutedSteps, parsedArgs.passthrough, command);
148
198
  if (!bindResult.ok) {
149
199
  reportError(bindResult.error, stderr);
150
- return { exitCode: 1 };
200
+ return { settled: true, result: { exitCode: 1 } };
151
201
  }
152
202
  const mainSteps = bindResult.steps;
153
203
  const isHookInvocation = isHookName(command);
@@ -162,7 +212,7 @@ async function runInvocation(options) {
162
212
  monorepoRoot: context.monorepoRoot,
163
213
  registry,
164
214
  resolvedScript,
165
- shouldUseRoot,
215
+ shouldUseRoot: invocation.shouldUseRoot,
166
216
  stderr,
167
217
  style: styles.stderr,
168
218
  });
@@ -176,40 +226,14 @@ async function runInvocation(options) {
176
226
  stderr,
177
227
  substitution: substitutedCommand === resolvedCommand ? undefined : substitutedCommand,
178
228
  });
229
+ return { settled: false, value: { commandString: fullCommand, key, ownSteps: mainSteps, steps: fullSteps } };
230
+ }
231
+ async function executePlan(plan) {
232
+ const { command, format, parsedArgs, scope, stdout, styles } = plan.invocation;
179
233
  if (parsedArgs.shouldPrintLog) {
180
- return reportRecording({
181
- anchorDir,
182
- command,
183
- commandString: fullCommand,
184
- config: context.config,
185
- env,
186
- key,
187
- monorepoRoot: context.monorepoRoot,
188
- scope,
189
- snapshot,
190
- stderr,
191
- stdout,
192
- styles,
193
- });
234
+ return reportRecording(plan);
194
235
  }
195
- const { exitCode, outcome } = await runGated({
196
- anchorDir,
197
- command,
198
- commandString: fullCommand,
199
- config: context.config,
200
- steps: fullSteps,
201
- env,
202
- key,
203
- monorepoRoot: context.monorepoRoot,
204
- overrideNotice: formatOverrideNotice(resolvedScript, registry, command, anchorDir, quiet, styles.stdout),
205
- ownSteps: mainSteps,
206
- runId,
207
- runOptions,
208
- shouldBypassCache,
209
- snapshot,
210
- stderr,
211
- stdout,
212
- });
236
+ const { exitCode, outcome } = await runGated(plan);
213
237
  reportVerdict({ command, scope, ...outcome }, stdout, format, styles.stdout);
214
238
  return { exitCode };
215
239
  }
@@ -323,7 +347,8 @@ function describeCrossingRemedy(options) {
323
347
  };
324
348
  case 'package':
325
349
  return {
326
- remedy: formatPackageRemedy({ configSite, crossingStep, isWorkspaceRoot, key: origin.key, registry }),
350
+ remedy: formatInlineRemedy({ crossingStep, file: origin.file, isWorkspaceRoot, key: origin.key, registry }) ??
351
+ formatPackageRemedy({ configSite, crossingStep, isWorkspaceRoot, key: origin.key, registry }),
327
352
  subject: `${path.relative(monorepoRoot, origin.file)}: \`scripts.${origin.key}\``,
328
353
  };
329
354
  default: {
@@ -348,12 +373,29 @@ function describeOrigin(origin, config, shouldUseRoot) {
348
373
  ? { tier: 'config', field, key: origin.key }
349
374
  : { tier: 'default', key: origin.key };
350
375
  }
351
- function escapeControlCharacters(text) {
352
- let escapedText = text;
353
- for (const [char, escape] of NAMED_ESCAPES) {
354
- escapedText = escapedText.split(char).join(escape);
376
+ function formatInlineRemedy(options) {
377
+ const { crossingStep, file, isWorkspaceRoot, key, registry } = options;
378
+ const name = readSoleNmrCommand(crossingStep);
379
+ if (name === undefined || name === key) {
380
+ return undefined;
381
+ }
382
+ const scripts = readPackageJsonScripts(path.dirname(file));
383
+ const value = scripts !== undefined && Object.hasOwn(scripts, name) ? scripts[name]?.trim() : undefined;
384
+ if (value === undefined || ['', ':'].includes(value)) {
385
+ return undefined;
355
386
  }
356
- return escapedText;
387
+ if (findNmrCrossing([{ kind: 'opaque', command: value }]) !== undefined) {
388
+ return undefined;
389
+ }
390
+ const anchorDir = path.dirname(file);
391
+ if (!isHookName(name) &&
392
+ [`${name}:pre`, `${name}:post`].some((hook) => hasRunnableHook(hook, registry, anchorDir, isWorkspaceRoot))) {
393
+ return undefined;
394
+ }
395
+ const inlinedValue = escapeControlCharacters(value);
396
+ return Object.hasOwn(registry, name)
397
+ ? `Replace the value with \`${inlinedValue}\`, and keep \`${name}\`, which overrides nmr's own.`
398
+ : `Replace the value with \`${inlinedValue}\` and delete \`${name}\`, the script that it names.`;
357
399
  }
358
400
  function formatNmrCrossingWarning(options) {
359
401
  const { remedy, subject } = describeCrossingRemedy(options);
@@ -633,26 +675,27 @@ function parseArgs(args) {
633
675
  const error = findArgError(parsedArgs);
634
676
  return error === undefined ? { ok: true, parsedArgs } : { ok: false, error };
635
677
  }
636
- async function reportRecording(options) {
637
- const { command, config, scope, styles } = options;
678
+ async function reportRecording(plan) {
679
+ const { anchorDir, command, context, env, scope, stderr, stdout, styles } = plan.invocation;
680
+ const { config, monorepoRoot } = context;
638
681
  const lookup = await resolveRecording({
639
- anchorDir: options.anchorDir,
682
+ anchorDir,
640
683
  command,
641
684
  currentIdentity: {
642
- commandString: options.commandString,
685
+ commandString: plan.commandString,
643
686
  nmrVersion: VERSION,
644
687
  nodeVersion: CURRENT_RUNTIME.nodeVersion,
645
- treeHash: options.snapshot?.hash,
688
+ treeHash: plan.snapshot?.hash,
646
689
  },
647
690
  isCacheable: isCacheableCommand(config.checkCache, command),
648
- key: options.key,
649
- monorepoRoot: options.monorepoRoot,
691
+ key: plan.key,
692
+ monorepoRoot,
650
693
  });
651
694
  if (!lookup.ok) {
652
- options.stderr.write(`${renderRefusal({ command, refusal: lookup.refusal, scope, style: styles.stderr })}\n`);
653
- return { exitCode: options.env[RUN_IF_PRESENT_ENV_VAR] === '1' ? 0 : 1 };
695
+ stderr.write(`${renderRefusal({ command, refusal: lookup.refusal, scope, style: styles.stderr })}\n`);
696
+ return { exitCode: env[RUN_IF_PRESENT_ENV_VAR] === '1' ? 0 : 1 };
654
697
  }
655
- options.stdout.write(renderRecording({ command, recording: lookup.recording, scope, style: styles.stdout }));
698
+ stdout.write(renderRecording({ command, recording: lookup.recording, scope, style: styles.stdout }));
656
699
  return { exitCode: 0 };
657
700
  }
658
701
  function readPresentation(options) {
@@ -812,12 +855,14 @@ async function runDelegation(options) {
812
855
  const step = shouldWithholdInput ? { ...delegation.step, shouldWithholdInput } : delegation.step;
813
856
  return runSteps([step], context.monorepoRoot, { ...runOptions, env: delegation.env });
814
857
  }
815
- async function runGated(options) {
816
- const { anchorDir, command, commandString, env, key, monorepoRoot, snapshot, stderr, stdout } = options;
817
- const quiet = options.runOptions.quiet === true;
858
+ async function runGated(plan) {
859
+ const { commandString, key, snapshot } = plan;
860
+ const { anchorDir, command, context, env, stderr, stdout } = plan.invocation;
861
+ const { config, monorepoRoot } = context;
862
+ const quiet = plan.runOptions.quiet === true;
818
863
  const gate = key !== undefined && snapshot !== undefined
819
864
  ? {
820
- buildOutputBefore: await readBuildOutputState(monorepoRoot, options.config),
865
+ buildOutputBefore: await readBuildOutputState(monorepoRoot, config),
821
866
  key,
822
867
  retentionKey: computeRetentionKey({
823
868
  channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
@@ -827,7 +872,7 @@ async function runGated(options) {
827
872
  snapshot,
828
873
  }
829
874
  : undefined;
830
- if (gate !== undefined && !options.shouldBypassCache) {
875
+ if (gate !== undefined && !plan.shouldBypassCache) {
831
876
  const recalledPass = await lookUpRecordedPass({
832
877
  anchorDir,
833
878
  buildOutput: gate.buildOutputBefore,
@@ -841,16 +886,16 @@ async function runGated(options) {
841
886
  if (recalledPass !== undefined) {
842
887
  const { entry, ...recall } = recalledPass;
843
888
  if (recall.replay !== undefined) {
844
- await certifyRetention({ anchorDir, command, entry, env, monorepoRoot, runId: options.runId, stderr });
889
+ await certifyRetention({ anchorDir, command, entry, env, monorepoRoot, runId: plan.runId, stderr });
845
890
  }
846
891
  return { exitCode: 0, outcome: { outcome: 'recalled', ...recall } };
847
892
  }
848
893
  }
849
- if (options.overrideNotice !== undefined) {
850
- stdout.write(options.overrideNotice);
894
+ if (plan.overrideNotice !== undefined) {
895
+ stdout.write(plan.overrideNotice);
851
896
  }
852
897
  const startedAtMs = Date.now();
853
- const { exitCode, retainedOutput } = await runSteps(options.steps, anchorDir, options.runOptions);
898
+ const { exitCode, retainedOutput } = await runSteps(plan.steps, anchorDir, plan.runOptions);
854
899
  const durationMs = Date.now() - startedAtMs;
855
900
  if (exitCode === 0 && gate !== undefined) {
856
901
  await recordPass({
@@ -858,15 +903,15 @@ async function runGated(options) {
858
903
  buildOutputBefore: gate.buildOutputBefore,
859
904
  command,
860
905
  commandString,
861
- config: options.config,
906
+ config,
862
907
  durationMs,
863
908
  env,
864
909
  key: gate.key,
865
910
  monorepoRoot,
866
- ownSteps: options.ownSteps,
911
+ ownSteps: plan.ownSteps,
867
912
  retainedOutput,
868
913
  retentionKey: gate.retentionKey,
869
- runId: options.runId,
914
+ runId: plan.runId,
870
915
  snapshot: gate.snapshot,
871
916
  stderr,
872
917
  });
@@ -894,7 +939,7 @@ function formatMisplacedFlagHint(args, passthrough, style) {
894
939
  `nmr reads its flags only before the command: \`${correctedTokens.map(quoteToken).join(' ')}\``);
895
940
  }
896
941
  function isDefinedInAnyScope(command, context) {
897
- const registries = [buildRootRegistry(context.config), buildWorkspaceRegistry(context.config)];
942
+ const registries = [buildRootRegistry(context.config, context.monorepoRoot), buildWorkspaceRegistry(context.config)];
898
943
  if (registries.some((registry) => Object.hasOwn(registry, command))) {
899
944
  return true;
900
945
  }
@@ -75,6 +75,13 @@ export declare function resolveInheritedChannel(stream: Writable): OutputChannel
75
75
  * makes a signal to nmr end the run: The steps after it are never reached, whereas a shell would have gone on
76
76
  * running them unsupervised.
77
77
  *
78
+ * A background step is started and left running while the steps after it run. Its output is recorded rather
79
+ * than streamed, and printed whole at the first step boundary after it ends, so that it never interleaves with
80
+ * another step's. A failing foreground step still ends the sequence, but the list waits for every background step
81
+ * that it started and reports the first failure among them when the foreground did not fail. While a background
82
+ * step runs, a SIGINT or SIGTERM to nmr is forwarded to every running child, so that a signal sent to nmr alone
83
+ * still ends the children.
84
+ *
78
85
  * Returns a bounded copy of what the list's own commands wrote, so that a caller can retain it. Only opaque
79
86
  * steps contribute: A structural step is another nmr process reporting for the subtree beneath it, and its
80
87
  * output is that subtree's to account for.
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import process from 'node:process';
4
4
  import { reportError } from '@williamthorsen/nmr-core';
5
5
  import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
6
+ import { createOutputRecorder } from "./helpers/createOutputRecorder.js";
6
7
  const SIGNAL_EXIT_BASE = 128;
7
8
  const PIPE_DRAIN_GRACE_MS = 2_000;
8
9
  export function resolveChannel(stream, quiet) {
@@ -22,26 +23,81 @@ export async function runSteps(steps, cwd, options) {
22
23
  };
23
24
  const parts = [];
24
25
  let isCaptureWhole = true;
25
- for (const step of steps) {
26
- const result = await runStep(step, cwd, resolvedOptions);
27
- if (step.kind === 'opaque') {
28
- if (result.stderr === undefined || result.stdout === undefined) {
29
- isCaptureWhole = false;
26
+ const backgroundRuns = [];
27
+ const runningChildren = new Set();
28
+ let receivedSignal;
29
+ let foregroundExitCode = 0;
30
+ function forwardSignal(signal) {
31
+ receivedSignal ??= signal;
32
+ for (const child of runningChildren)
33
+ child.kill(signal);
34
+ }
35
+ function launch(step, launchOptions) {
36
+ const started = startStep(step, cwd, launchOptions);
37
+ runningChildren.add(started.child);
38
+ void started.result.finally(() => runningChildren.delete(started.child));
39
+ return started;
40
+ }
41
+ function flushEndedBackgroundRuns() {
42
+ for (const run of backgroundRuns) {
43
+ if (run.result === undefined || run.isFlushed)
44
+ continue;
45
+ run.recorder.replay({ stderr: resolvedOptions.stderr, stdout: resolvedOptions.stdout });
46
+ run.isFlushed = true;
47
+ }
48
+ }
49
+ try {
50
+ for (const step of steps) {
51
+ if (receivedSignal !== undefined) {
52
+ foregroundExitCode = describeSignalExitCode(receivedSignal);
53
+ break;
30
54
  }
31
- else {
32
- parts.push({ stderr: result.stderr, stdout: result.stdout });
55
+ if (step.kind === 'structural' && step.shouldRunInBackground === true) {
56
+ if (backgroundRuns.length === 0) {
57
+ process.on('SIGINT', forwardSignal);
58
+ process.on('SIGTERM', forwardSignal);
59
+ }
60
+ backgroundRuns.push(startBackgroundRun(step, resolvedOptions, launch));
61
+ continue;
62
+ }
63
+ const result = await launch(step, resolvedOptions).result;
64
+ flushEndedBackgroundRuns();
65
+ if (step.kind === 'opaque') {
66
+ if (result.stderr === undefined || result.stdout === undefined) {
67
+ isCaptureWhole = false;
68
+ }
69
+ else {
70
+ parts.push({ stderr: result.stderr, stdout: result.stdout });
71
+ }
72
+ }
73
+ if (result.exitCode !== 0) {
74
+ foregroundExitCode = result.exitCode;
75
+ if (result.outcome === 'signaled') {
76
+ for (const run of backgroundRuns)
77
+ run.child.kill(result.signal);
78
+ }
79
+ break;
33
80
  }
34
81
  }
35
- if (result.exitCode !== 0) {
36
- return { exitCode: result.exitCode, ...composeRetainedOutput(parts, isCaptureWhole) };
82
+ for (const run of backgroundRuns) {
83
+ await run.settled;
84
+ flushEndedBackgroundRuns();
37
85
  }
38
86
  }
39
- return { exitCode: 0, ...composeRetainedOutput(parts, isCaptureWhole) };
87
+ finally {
88
+ process.off('SIGINT', forwardSignal);
89
+ process.off('SIGTERM', forwardSignal);
90
+ }
91
+ const exitCode = selectExitCode(foregroundExitCode, receivedSignal, backgroundRuns);
92
+ return { exitCode, ...composeRetainedOutput(parts, isCaptureWhole) };
40
93
  }
41
94
  export async function runCommand(command, cwd, options) {
42
95
  return runSpawned({ args: [], file: command, input: 'inherit', shouldUseShell: true }, cwd, options);
43
96
  }
44
- async function runSpawned({ args, file, input, shouldUseShell }, cwd, options) {
97
+ function runSpawned(spec, cwd, options) {
98
+ return startSpawned(spec, cwd, options).result;
99
+ }
100
+ function startSpawned({ args, file, input, shouldUseShell }, cwd, options) {
45
101
  const quiet = options.quiet === true;
46
102
  const stdout = options.stdout ?? process.stdout;
47
103
  const stderr = options.stderr ?? process.stderr;
@@ -52,6 +108,9 @@ async function runSpawned({ args, file, input, shouldUseShell }, cwd, options) {
52
108
  cwd,
53
109
  env,
54
110
  });
111
+ return { child, result: collectResult(child, { quiet, stderr, stdout }) };
112
+ }
113
+ async function collectResult(child, { quiet, stderr, stdout }) {
55
114
  const stdoutCapture = child.stdout === null ? undefined : captureStream(child.stdout, quiet ? undefined : stdout);
56
115
  const stderrCapture = child.stderr === null ? undefined : captureStream(child.stderr, quiet ? undefined : stderr);
57
116
  const commandCompletion = await awaitCompletion(child, () => {
@@ -75,17 +134,17 @@ async function runSpawned({ args, file, input, shouldUseShell }, cwd, options) {
75
134
  }
76
135
  return result;
77
136
  }
78
- function runStep(step, cwd, options) {
137
+ function startStep(step, cwd, options) {
79
138
  const { quiet, stderr, stdout } = options;
80
139
  if (step.kind === 'opaque') {
81
- return runSpawned({ args: [], file: step.command, input: 'inherit', shouldUseShell: true }, cwd, {
140
+ return startSpawned({ args: [], file: step.command, input: 'inherit', shouldUseShell: true }, cwd, {
82
141
  ...options,
83
142
  channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
84
143
  });
85
144
  }
86
145
  const [file, ...args] = step.argv;
87
146
  const input = step.shouldWithholdInput === true ? 'ignore' : 'inherit';
88
- return runSpawned({ args, file, input, shouldUseShell: false }, cwd, {
147
+ return startSpawned({ args, file, input, shouldUseShell: false }, cwd, {
89
148
  ...options,
90
149
  quiet: false,
91
150
  channels: { stderr: resolveInheritedChannel(stderr), stdout: resolveInheritedChannel(stdout) },
@@ -157,18 +216,49 @@ function awaitCompletion(child, stopReading) {
157
216
  });
158
217
  });
159
218
  }
219
+ function buildBackgroundEnv(options) {
220
+ const env = options.env ?? process.env;
221
+ const shouldForceColor = isTerminal(options.stdout) && (env['NO_COLOR'] ?? '') === '' && env['FORCE_COLOR'] === undefined;
222
+ return shouldForceColor ? { ...env, FORCE_COLOR: '1' } : env;
223
+ }
160
224
  function describeExit(code, signal) {
161
225
  if (signal !== null) {
162
- return { outcome: 'signaled', exitCode: SIGNAL_EXIT_BASE + os.constants.signals[signal], signal };
226
+ return { outcome: 'signaled', exitCode: describeSignalExitCode(signal), signal };
163
227
  }
164
228
  return { outcome: 'exited', exitCode: code ?? 1 };
165
229
  }
230
+ function describeSignalExitCode(signal) {
231
+ return SIGNAL_EXIT_BASE + os.constants.signals[signal];
232
+ }
166
233
  function hasDescriptor(stream) {
167
234
  return 'fd' in stream && typeof stream.fd === 'number';
168
235
  }
169
236
  function isTerminal(stream) {
170
237
  return 'isTTY' in stream && stream.isTTY === true && hasDescriptor(stream);
171
238
  }
239
+ async function recordResult(run, result) {
240
+ run.result = await result;
241
+ }
242
+ function selectExitCode(foregroundExitCode, receivedSignal, backgroundRuns) {
243
+ if (foregroundExitCode !== 0)
244
+ return foregroundExitCode;
245
+ if (receivedSignal !== undefined)
246
+ return describeSignalExitCode(receivedSignal);
247
+ return backgroundRuns.find((run) => run.result !== undefined && run.result.exitCode !== 0)?.result?.exitCode ?? 0;
248
+ }
249
+ function startBackgroundRun(step, options, launch) {
250
+ const recorder = createOutputRecorder();
251
+ const started = launch({ ...step, shouldWithholdInput: true }, { ...options, env: buildBackgroundEnv(options), stderr: recorder.stderr, stdout: recorder.stdout });
252
+ const run = {
253
+ child: started.child,
254
+ isFlushed: false,
255
+ recorder,
256
+ result: undefined,
257
+ settled: Promise.resolve(),
258
+ };
259
+ run.settled = recordResult(run, started.result);
260
+ return run;
261
+ }
172
262
  function writeBuffer(buffer, destination) {
173
263
  if (buffer !== undefined && buffer.length > 0)
174
264
  destination.write(buffer);