@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
@@ -1,24 +1,5 @@
1
- import type { Writable } from 'node:stream';
1
+ import { type RunCommandOptions, type RunCommandResult } from './child-process.js';
2
2
  import type { Step } from './steps.js';
3
- /** Where one of a child's output streams goes: a descriptor to which the child writes, or a pipe read by nmr. */
4
- export type OutputChannel = number | 'pipe';
5
- /** The channel on which each of a child's output streams runs. */
6
- export interface OutputChannels {
7
- stderr: OutputChannel;
8
- stdout: OutputChannel;
9
- }
10
- export interface RunCommandOptions {
11
- /** The channel on which each of the child's output streams runs. */
12
- channels: OutputChannels;
13
- /** When true, suppresses output on success and writes captured output to stderr on failure. */
14
- quiet?: boolean;
15
- /** Stream that subprocess stdout flows to in non-quiet mode. Defaults to `process.stdout`. */
16
- stdout?: Writable;
17
- /** Stream that subprocess stderr flows to (and that quiet-mode failure output is written to). Defaults to `process.stderr`. */
18
- stderr?: Writable;
19
- /** Environment for the subprocess. Defaults to `process.env`. */
20
- env?: NodeJS.ProcessEnv;
21
- }
22
3
  /** What a step list needs to run. Each step's channels follow its kind, so the caller chooses none of them. */
23
4
  export type RunStepsOptions = Omit<RunCommandOptions, 'channels'>;
24
5
  /** A bounded copy of what a step list's own commands wrote, each stream kept apart from the other. */
@@ -34,39 +15,6 @@ export interface RunStepsResult {
34
15
  */
35
16
  retainedOutput?: RetainedOutput;
36
17
  }
37
- /** How a command ended, and the exit code that reports that ending to nmr's own caller. */
38
- type CommandCompletion = {
39
- outcome: 'exited';
40
- exitCode: number;
41
- } | {
42
- outcome: 'signaled';
43
- exitCode: number;
44
- signal: NodeJS.Signals;
45
- } | {
46
- outcome: 'spawn-failed';
47
- exitCode: number;
48
- error: Error;
49
- };
50
- export type RunCommandResult = CommandCompletion & {
51
- /**
52
- * Output retained while the command ran, elided in the middle when it overran the bound.
53
- * `undefined` when the stream was handed to the child directly, or when capture stopped early
54
- * and the retained copy would understate what the command produced.
55
- */
56
- stdout: Buffer | undefined;
57
- stderr: Buffer | undefined;
58
- };
59
- /**
60
- * Returns the channel on which a stream runs: the stream's own descriptor when it is a terminal and output is
61
- * not being withheld, so that the child's terminal detection matches nmr's, and a pipe everywhere else.
62
- */
63
- export declare function resolveChannel(stream: Writable, quiet: boolean): OutputChannel;
64
- /**
65
- * Returns the channel for a step that runs an nmr process below this one: nmr's own descriptor whenever it has
66
- * one, so that the child writes where nmr writes, without an ancestor between them relaying bytes. A stream
67
- * without a descriptor falls back to a pipe.
68
- */
69
- export declare function resolveInheritedChannel(stream: Writable): OutputChannel;
70
18
  /**
71
19
  * Runs a step list in order and resolves once one fails or all have run, implementing the `&&` semantics that
72
20
  * nmr promises: The first non-zero exit ends the sequence and is the code returned.
@@ -100,4 +48,3 @@ export declare function runSteps(steps: readonly Step[], cwd: string | undefined
100
48
  * is approximate.
101
49
  */
102
50
  export declare function runCommand(command: string, cwd: string | undefined, options: RunCommandOptions): Promise<RunCommandResult>;
103
- export {};
@@ -1,19 +1,7 @@
1
- import { spawn } from 'node:child_process';
2
- import os from 'node:os';
3
1
  import process from 'node:process';
4
- import { reportError } from '@williamthorsen/nmr-core';
5
- import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
2
+ import { describeSignalExitCode, startSpawned, } from "./child-process.js";
6
3
  import { createOutputRecorder } from "./helpers/createOutputRecorder.js";
7
- const SIGNAL_EXIT_BASE = 128;
8
- const PIPE_DRAIN_GRACE_MS = 2_000;
9
- export function resolveChannel(stream, quiet) {
10
- if (quiet)
11
- return 'pipe';
12
- return isTerminal(stream) ? stream.fd : 'pipe';
13
- }
14
- export function resolveInheritedChannel(stream) {
15
- return hasDescriptor(stream) ? stream.fd : 'pipe';
16
- }
4
+ import { isTerminal, resolveChannel, resolveInheritedChannel } from "./output-channel.js";
17
5
  export async function runSteps(steps, cwd, options) {
18
6
  const resolvedOptions = {
19
7
  ...options,
@@ -97,43 +85,6 @@ export async function runCommand(command, cwd, options) {
97
85
  function runSpawned(spec, cwd, options) {
98
86
  return startSpawned(spec, cwd, options).result;
99
87
  }
100
- function startSpawned({ args, file, input, shouldUseShell }, cwd, options) {
101
- const quiet = options.quiet === true;
102
- const stdout = options.stdout ?? process.stdout;
103
- const stderr = options.stderr ?? process.stderr;
104
- const env = options.env ?? process.env;
105
- const child = spawn(file, args, {
106
- shell: shouldUseShell,
107
- stdio: [input, options.channels.stdout, options.channels.stderr],
108
- cwd,
109
- env,
110
- });
111
- return { child, result: collectResult(child, { quiet, stderr, stdout }) };
112
- }
113
- async function collectResult(child, { quiet, stderr, stdout }) {
114
- const stdoutCapture = child.stdout === null ? undefined : captureStream(child.stdout, quiet ? undefined : stdout);
115
- const stderrCapture = child.stderr === null ? undefined : captureStream(child.stderr, quiet ? undefined : stderr);
116
- const commandCompletion = await awaitCompletion(child, () => {
117
- stdoutCapture?.stopReading();
118
- stderrCapture?.stopReading();
119
- });
120
- stdoutCapture?.detach();
121
- stderrCapture?.detach();
122
- const result = {
123
- ...commandCompletion,
124
- stdout: stdoutCapture?.readBuffer(),
125
- stderr: stderrCapture?.readBuffer(),
126
- };
127
- if (result.outcome === 'spawn-failed') {
128
- reportError(result.error.message, stderr);
129
- return result;
130
- }
131
- if (quiet && result.exitCode !== 0) {
132
- writeBuffer(result.stdout, stderr);
133
- writeBuffer(result.stderr, stderr);
134
- }
135
- return result;
136
- }
137
88
  function startStep(step, cwd, options) {
138
89
  const { quiet, stderr, stdout } = options;
139
90
  if (step.kind === 'opaque') {
@@ -161,81 +112,11 @@ function composeRetainedOutput(parts, isCaptureWhole) {
161
112
  },
162
113
  };
163
114
  }
164
- function captureStream(source, destination) {
165
- const retainedCopy = createBoundedBuffer();
166
- let isComplete = true;
167
- function stopReading() {
168
- if (destination !== undefined)
169
- source.unpipe(destination);
170
- source.destroy();
171
- }
172
- function abandon() {
173
- isComplete = false;
174
- stopReading();
175
- }
176
- source.on('error', abandon);
177
- if (destination !== undefined) {
178
- destination.on('error', abandon);
179
- source.pipe(destination, { end: false });
180
- }
181
- source.on('data', (chunk) => retainedCopy.append(chunk));
182
- return {
183
- readBuffer: () => (isComplete ? retainedCopy.readBuffer() : undefined),
184
- detach: () => {
185
- destination?.off('error', abandon);
186
- },
187
- stopReading,
188
- };
189
- }
190
- function awaitCompletion(child, stopReading) {
191
- return new Promise((resolve) => {
192
- let exitCompletion;
193
- let graceTimer;
194
- let isSettled = false;
195
- function settle(completion) {
196
- if (isSettled)
197
- return;
198
- isSettled = true;
199
- if (graceTimer !== undefined)
200
- clearTimeout(graceTimer);
201
- resolve(completion);
202
- }
203
- child.on('error', (error) => {
204
- settle({ outcome: 'spawn-failed', exitCode: 1, error });
205
- });
206
- child.on('exit', (code, signal) => {
207
- const exitDescription = describeExit(code, signal);
208
- exitCompletion = exitDescription;
209
- graceTimer = setTimeout(() => {
210
- stopReading();
211
- settle(exitDescription);
212
- }, PIPE_DRAIN_GRACE_MS);
213
- });
214
- child.on('close', () => {
215
- settle(exitCompletion ?? { outcome: 'exited', exitCode: 1 });
216
- });
217
- });
218
- }
219
115
  function buildBackgroundEnv(options) {
220
116
  const env = options.env ?? process.env;
221
117
  const shouldForceColor = isTerminal(options.stdout) && (env['NO_COLOR'] ?? '') === '' && env['FORCE_COLOR'] === undefined;
222
118
  return shouldForceColor ? { ...env, FORCE_COLOR: '1' } : env;
223
119
  }
224
- function describeExit(code, signal) {
225
- if (signal !== null) {
226
- return { outcome: 'signaled', exitCode: describeSignalExitCode(signal), signal };
227
- }
228
- return { outcome: 'exited', exitCode: code ?? 1 };
229
- }
230
- function describeSignalExitCode(signal) {
231
- return SIGNAL_EXIT_BASE + os.constants.signals[signal];
232
- }
233
- function hasDescriptor(stream) {
234
- return 'fd' in stream && typeof stream.fd === 'number';
235
- }
236
- function isTerminal(stream) {
237
- return 'isTTY' in stream && stream.isTTY === true && hasDescriptor(stream);
238
- }
239
120
  async function recordResult(run, result) {
240
121
  run.result = await result;
241
122
  }
@@ -259,7 +140,3 @@ function startBackgroundRun(step, options, launch) {
259
140
  run.settled = recordResult(run, started.result);
260
141
  return run;
261
142
  }
262
- function writeBuffer(buffer, destination) {
263
- if (buffer !== undefined && buffer.length > 0)
264
- destination.write(buffer);
265
- }
@@ -0,0 +1,30 @@
1
+ /** Drops the leading `NAME=value` assignments, leaving the program name at the head. */
2
+ export declare function dropLeadingAssignments(tokens: readonly string[]): readonly string[];
3
+ /** Reports whether a POSIX shell reads the token literally, so that it does not need any quoting. */
4
+ export declare function isShellSafeToken(token: string): boolean;
5
+ /** Quotes a token that the shell would not read literally, and leaves every other token bare. */
6
+ export declare function quoteToken(token: string): string;
7
+ /**
8
+ * Splits a command into the segments that a shell would run as separate commands, breaking on `&&`, `||`, `;`,
9
+ * `|`, and a newline, outside quotes.
10
+ *
11
+ * Quote and escape state are tracked character by character rather than by matching tokens, so a separator
12
+ * standing inside an argument stays part of the segment containing it. A backslash escapes outside quotes and
13
+ * inside a double-quoted run; inside a single-quoted run the shell reads it literally, and so does this.
14
+ * `tokenizeSegment` tracks the same state on the same rules.
15
+ *
16
+ * A separator standing inside a redirection operator does not end a command: `nmr build 2>&1` is one segment.
17
+ */
18
+ export declare function splitSegments(command: string): string[];
19
+ /** Splits a composite element into argv tokens, dropping the empty tokens that surrounding whitespace would leave. */
20
+ export declare function tokenize(element: string): string[];
21
+ /**
22
+ * Splits a shell segment into the tokens that a shell reads, keeping a quoted run attached to the token
23
+ * containing it, so that an environment assignment containing a quoted space stays one token.
24
+ *
25
+ * Tracks quote and escape state as `splitSegments` does, the two sharing one set of rules.
26
+ *
27
+ * Keeps the quotes rather than stripping them: Both questions asked of a token, whether it assigns an
28
+ * environment variable and whether it names the program, read the same on the raw token.
29
+ */
30
+ export declare function tokenizeSegment(segment: string): string[];
@@ -0,0 +1,120 @@
1
+ const ENV_ASSIGNMENT = /^\w+=/;
2
+ const QUOTES = new Set(['"', "'"]);
3
+ const SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
4
+ const SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
5
+ const TOKEN_SEPARATORS = new Set([' ', '\t', '\n', '\r']);
6
+ export function dropLeadingAssignments(tokens) {
7
+ const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
8
+ return start === -1 ? [] : tokens.slice(start);
9
+ }
10
+ export function isShellSafeToken(token) {
11
+ return SHELL_SAFE_TOKEN.test(token);
12
+ }
13
+ export function quoteToken(token) {
14
+ if (isShellSafeToken(token)) {
15
+ return token;
16
+ }
17
+ return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
18
+ }
19
+ export function splitSegments(command) {
20
+ const segments = [];
21
+ let currentSegment = '';
22
+ let quote;
23
+ let index = 0;
24
+ while (index < command.length) {
25
+ const char = command[index] ?? '';
26
+ if (quote !== undefined) {
27
+ if (char === '\\' && quote === '"') {
28
+ currentSegment += char + (command[index + 1] ?? '');
29
+ index += 2;
30
+ continue;
31
+ }
32
+ currentSegment += char;
33
+ if (char === quote)
34
+ quote = undefined;
35
+ index += 1;
36
+ continue;
37
+ }
38
+ if (char === '\\') {
39
+ currentSegment += char + (command[index + 1] ?? '');
40
+ index += 2;
41
+ continue;
42
+ }
43
+ if (QUOTES.has(char)) {
44
+ quote = char;
45
+ currentSegment += char;
46
+ index += 1;
47
+ continue;
48
+ }
49
+ if (SEGMENT_SEPARATORS.has(char)) {
50
+ if (isRedirectionOperator(char, currentSegment, command[index + 1])) {
51
+ currentSegment += char;
52
+ index += 1;
53
+ continue;
54
+ }
55
+ index += command[index + 1] === char ? 2 : 1;
56
+ segments.push(currentSegment);
57
+ currentSegment = '';
58
+ continue;
59
+ }
60
+ currentSegment += char;
61
+ index += 1;
62
+ }
63
+ segments.push(currentSegment);
64
+ return segments;
65
+ }
66
+ export function tokenize(element) {
67
+ return element.split(/\s+/).filter((token) => token.length > 0);
68
+ }
69
+ export function tokenizeSegment(segment) {
70
+ const tokens = [];
71
+ let currentToken = '';
72
+ let quote;
73
+ let index = 0;
74
+ while (index < segment.length) {
75
+ const char = segment[index] ?? '';
76
+ if (quote !== undefined) {
77
+ if (char === '\\' && quote === '"') {
78
+ currentToken += char + (segment[index + 1] ?? '');
79
+ index += 2;
80
+ continue;
81
+ }
82
+ currentToken += char;
83
+ if (char === quote)
84
+ quote = undefined;
85
+ index += 1;
86
+ continue;
87
+ }
88
+ if (char === '\\') {
89
+ currentToken += char + (segment[index + 1] ?? '');
90
+ index += 2;
91
+ continue;
92
+ }
93
+ if (QUOTES.has(char)) {
94
+ quote = char;
95
+ currentToken += char;
96
+ index += 1;
97
+ continue;
98
+ }
99
+ if (TOKEN_SEPARATORS.has(char)) {
100
+ if (currentToken !== '') {
101
+ tokens.push(currentToken);
102
+ }
103
+ currentToken = '';
104
+ index += 1;
105
+ continue;
106
+ }
107
+ currentToken += char;
108
+ index += 1;
109
+ }
110
+ if (currentToken !== '') {
111
+ tokens.push(currentToken);
112
+ }
113
+ return tokens;
114
+ }
115
+ function isRedirectionOperator(char, precedingText, nextChar) {
116
+ if (char !== '&' && char !== '|') {
117
+ return false;
118
+ }
119
+ return (char === '&' && nextChar === '>') || /[<>]\s*$/.test(precedingText);
120
+ }
@@ -90,8 +90,6 @@ export declare function findNmrCrossing(steps: readonly Step[]): string | undefi
90
90
  * literally would be quoted whole by the rendering, turning the element into a command that nobody wrote.
91
91
  */
92
92
  export declare function findUnexpressibleToken(element: string): string | undefined;
93
- /** Quotes a token that the shell would not read literally, and leaves every other token bare. */
94
- export declare function quoteToken(token: string): string;
95
93
  /**
96
94
  * Returns the command that a structural step's nmr process runs, whether the step hands that command to other
97
95
  * scopes rather than running it in its own scope, and whether it anchors the command at the monorepo root.
package/dist/esm/steps.js CHANGED
@@ -1,4 +1,4 @@
1
- const ENV_ASSIGNMENT = /^\w+=/;
1
+ import { dropLeadingAssignments, isShellSafeToken, quoteToken, splitSegments, tokenize, tokenizeSegment, } from "./shell-syntax.js";
2
2
  const LAUNCHERS = new Map([
3
3
  ['bun', new Set(['x'])],
4
4
  ['bunx', new Set()],
@@ -7,10 +7,6 @@ const LAUNCHERS = new Map([
7
7
  ['pnpm', new Set(['dlx', 'exec'])],
8
8
  ['yarn', new Set(['dlx', 'exec'])],
9
9
  ]);
10
- const QUOTES = new Set(['"', "'"]);
11
- const SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
12
- const SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
13
- const TOKEN_SEPARATORS = new Set([' ', '\t', '\n', '\r']);
14
10
  export function composeNmrStep(element, isWorkspaceRoot, flags = {}) {
15
11
  const { shouldDeclineArguments = false, shouldRunInBackground = false } = flags;
16
12
  const nmrFlags = isWorkspaceRoot ? ['-w'] : [];
@@ -33,13 +29,7 @@ export function findNmrCrossing(steps) {
33
29
  return undefined;
34
30
  }
35
31
  export function findUnexpressibleToken(element) {
36
- return tokenize(element).find((token) => !SHELL_SAFE_TOKEN.test(token));
37
- }
38
- export function quoteToken(token) {
39
- if (SHELL_SAFE_TOKEN.test(token)) {
40
- return token;
41
- }
42
- return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
32
+ return tokenize(element).find((token) => !isShellSafeToken(token));
43
33
  }
44
34
  export function readNmrStep(step) {
45
35
  if (step.kind !== 'structural') {
@@ -80,7 +70,7 @@ export function readSoleNmrCommand(script) {
80
70
  if (program !== 'nmr' || command === undefined || rest.length > 0) {
81
71
  return undefined;
82
72
  }
83
- return SHELL_SAFE_TOKEN.test(command) && !command.startsWith('-') ? command : undefined;
73
+ return isShellSafeToken(command) && !command.startsWith('-') ? command : undefined;
84
74
  }
85
75
  export function readStepListSelfReference(options) {
86
76
  const { commandName, elements, scope } = options;
@@ -96,16 +86,6 @@ export function readStepListSelfReference(options) {
96
86
  export function renderChain(steps) {
97
87
  return steps.map(renderStep).join(' && ');
98
88
  }
99
- function dropLeadingAssignments(tokens) {
100
- const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
101
- return start === -1 ? [] : tokens.slice(start);
102
- }
103
- function isRedirectionOperator(char, precedingText, nextChar) {
104
- if (char !== '&' && char !== '|') {
105
- return false;
106
- }
107
- return (char === '&' && nextChar === '>') || /[<>]\s*$/.test(precedingText);
108
- }
109
89
  function isReentry(target, commandName, scope) {
110
90
  if (target.command !== commandName) {
111
91
  return false;
@@ -196,99 +176,3 @@ function readNmrTarget(tokens) {
196
176
  function renderStep(step) {
197
177
  return step.kind === 'opaque' ? step.command : step.argv.map(quoteToken).join(' ');
198
178
  }
199
- function splitSegments(command) {
200
- const segments = [];
201
- let currentSegment = '';
202
- let quote;
203
- let index = 0;
204
- while (index < command.length) {
205
- const char = command[index] ?? '';
206
- if (quote !== undefined) {
207
- if (char === '\\' && quote === '"') {
208
- currentSegment += char + (command[index + 1] ?? '');
209
- index += 2;
210
- continue;
211
- }
212
- currentSegment += char;
213
- if (char === quote)
214
- quote = undefined;
215
- index += 1;
216
- continue;
217
- }
218
- if (char === '\\') {
219
- currentSegment += char + (command[index + 1] ?? '');
220
- index += 2;
221
- continue;
222
- }
223
- if (QUOTES.has(char)) {
224
- quote = char;
225
- currentSegment += char;
226
- index += 1;
227
- continue;
228
- }
229
- if (SEGMENT_SEPARATORS.has(char)) {
230
- if (isRedirectionOperator(char, currentSegment, command[index + 1])) {
231
- currentSegment += char;
232
- index += 1;
233
- continue;
234
- }
235
- index += command[index + 1] === char ? 2 : 1;
236
- segments.push(currentSegment);
237
- currentSegment = '';
238
- continue;
239
- }
240
- currentSegment += char;
241
- index += 1;
242
- }
243
- segments.push(currentSegment);
244
- return segments;
245
- }
246
- function tokenize(element) {
247
- return element.split(/\s+/).filter((token) => token.length > 0);
248
- }
249
- function tokenizeSegment(segment) {
250
- const tokens = [];
251
- let currentToken = '';
252
- let quote;
253
- let index = 0;
254
- while (index < segment.length) {
255
- const char = segment[index] ?? '';
256
- if (quote !== undefined) {
257
- if (char === '\\' && quote === '"') {
258
- currentToken += char + (segment[index + 1] ?? '');
259
- index += 2;
260
- continue;
261
- }
262
- currentToken += char;
263
- if (char === quote)
264
- quote = undefined;
265
- index += 1;
266
- continue;
267
- }
268
- if (char === '\\') {
269
- currentToken += char + (segment[index + 1] ?? '');
270
- index += 2;
271
- continue;
272
- }
273
- if (QUOTES.has(char)) {
274
- quote = char;
275
- currentToken += char;
276
- index += 1;
277
- continue;
278
- }
279
- if (TOKEN_SEPARATORS.has(char)) {
280
- if (currentToken !== '') {
281
- tokens.push(currentToken);
282
- }
283
- currentToken = '';
284
- index += 1;
285
- continue;
286
- }
287
- currentToken += char;
288
- index += 1;
289
- }
290
- if (currentToken !== '') {
291
- tokens.push(currentToken);
292
- }
293
- return tokens;
294
- }
@@ -54,6 +54,11 @@ export interface OutputConfig {
54
54
  export interface NmrConfig {
55
55
  build?: BuildConfig;
56
56
  checkCache?: CheckCacheConfig;
57
+ /**
58
+ * Set to `false` to keep every check on the machine that has the tree, whatever `NMR_DELEGATE` or the user
59
+ * config says. A delegation flag still sends a run elsewhere.
60
+ */
61
+ delegate?: false;
57
62
  devBin?: Record<string, string>;
58
63
  output?: OutputConfig;
59
64
  workspaceScripts?: Record<string, ScriptValue>;
@@ -0,0 +1,34 @@
1
+ import type { HostList } from './remote-run.js';
2
+ /** The values that `delegate.when` accepts, each naming the runs that delegate without being asked. */
3
+ export declare const DELEGATE_WHEN_VALUES: readonly ["agent", "always"];
4
+ export type DelegateWhen = (typeof DELEGATE_WHEN_VALUES)[number];
5
+ /**
6
+ * Renders a path for a message, with `~` standing for the home directory that `env` names when the path lies under
7
+ * it.
8
+ */
9
+ export declare function formatDisplayPath(filePath: string, env: NodeJS.ProcessEnv): string;
10
+ /** A user config, with the path from which it was loaded. */
11
+ export interface LoadedUserConfig {
12
+ config: UserConfig;
13
+ path: string;
14
+ }
15
+ /**
16
+ * Loads and validates the user config that `env` locates, or returns `undefined` when `env` locates none or the file
17
+ * does not exist.
18
+ */
19
+ export declare function loadUserConfig(env: NodeJS.ProcessEnv): Promise<LoadedUserConfig | undefined>;
20
+ /**
21
+ * Returns the user config's path, resolved from `env` alone so that a caller's environment decides which file is
22
+ * read: under `XDG_CONFIG_HOME` when that is an absolute path, as the XDG base-directory spec requires, otherwise
23
+ * under `HOME`'s `.config`. Returns `undefined` when `env` names neither.
24
+ */
25
+ export declare function resolveUserConfigPath(env: NodeJS.ProcessEnv): string | undefined;
26
+ /** The machine-level settings that `$XDG_CONFIG_HOME/nmr/config.ts` declares. */
27
+ export interface UserConfig {
28
+ delegate?: UserDelegateConfig;
29
+ }
30
+ /** This machine's delegation hosts, and which runs delegate to them without being asked. */
31
+ export interface UserDelegateConfig {
32
+ hosts: HostList;
33
+ when?: DelegateWhen;
34
+ }
@@ -0,0 +1,93 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { existsSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { assertRecognizedKeys } from "./config-validation.js";
13
+ import { isObject } from "./helpers/type-guards.js";
14
+ import { UserError } from "./UserError.js";
15
+ export const DELEGATE_WHEN_VALUES = ['agent', 'always'];
16
+ export function formatDisplayPath(filePath, env) {
17
+ const home = env['HOME'];
18
+ if (home === undefined || !path.isAbsolute(home)) {
19
+ return filePath;
20
+ }
21
+ const relativePath = path.relative(home, filePath);
22
+ if (relativePath === '' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
23
+ return filePath;
24
+ }
25
+ return path.join('~', relativePath);
26
+ }
27
+ export async function loadUserConfig(env) {
28
+ const configPath = resolveUserConfigPath(env);
29
+ if (configPath === undefined || !existsSync(configPath)) {
30
+ return undefined;
31
+ }
32
+ const importedModule = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
33
+ const loadedConfig = isObject(importedModule) ? importedModule['default'] : undefined;
34
+ return { config: validateUserConfig(loadedConfig, configPath), path: configPath };
35
+ }
36
+ export function resolveUserConfigPath(env) {
37
+ const configHome = env['XDG_CONFIG_HOME'];
38
+ if (configHome !== undefined && path.isAbsolute(configHome)) {
39
+ return path.join(configHome, USER_CONFIG_RELATIVE_PATH);
40
+ }
41
+ const home = env['HOME'];
42
+ if (home !== undefined && path.isAbsolute(home)) {
43
+ return path.join(home, '.config', USER_CONFIG_RELATIVE_PATH);
44
+ }
45
+ return undefined;
46
+ }
47
+ const USER_CONFIG_RELATIVE_PATH = path.join('nmr', 'config.ts');
48
+ const RECOGNIZED_KEYS = ['delegate'];
49
+ const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'when'];
50
+ function isDelegateWhen(value) {
51
+ const values = DELEGATE_WHEN_VALUES;
52
+ return values.includes(value);
53
+ }
54
+ function validateDelegateField(value, configPath) {
55
+ const delegate = value['delegate'];
56
+ if (delegate === undefined) {
57
+ return undefined;
58
+ }
59
+ if (!isObject(delegate)) {
60
+ throw new UserError(`Invalid nmr config at ${configPath}: \`delegate\` must be an object`);
61
+ }
62
+ assertRecognizedKeys(delegate, RECOGNIZED_DELEGATE_KEYS, configPath, 'delegate.');
63
+ const hosts = validateHosts(delegate['hosts'], configPath);
64
+ const when = delegate['when'];
65
+ if (when === undefined) {
66
+ return { hosts };
67
+ }
68
+ if (!isDelegateWhen(when)) {
69
+ const renderedValue = typeof when === 'string' ? when : JSON.stringify(when);
70
+ throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.when\` is \`${renderedValue}\`, which is not one of: ` +
71
+ DELEGATE_WHEN_VALUES.join(', '));
72
+ }
73
+ return { hosts, when };
74
+ }
75
+ function validateHosts(value, configPath) {
76
+ const names = Array.isArray(value) && value.every((host) => typeof host === 'string')
77
+ ? value.map((host) => host.trim())
78
+ : [];
79
+ const [first, ...rest] = new Set(names);
80
+ if (first === undefined || names.includes('')) {
81
+ throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.hosts\` must be a non-empty array of host names, ` +
82
+ 'in order of preference');
83
+ }
84
+ return [first, ...rest];
85
+ }
86
+ function validateUserConfig(value, configPath) {
87
+ if (!isObject(value)) {
88
+ throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
89
+ }
90
+ assertRecognizedKeys(value, RECOGNIZED_KEYS, configPath);
91
+ const delegate = validateDelegateField(value, configPath);
92
+ return delegate === undefined ? {} : { delegate };
93
+ }