@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.
- package/.meta/changelog.json +158 -0
- package/.readyup/kits/default.js +1527 -1460
- package/.readyup/manifest.json +96 -11
- package/CHANGELOG.md +41 -0
- package/README.md +36 -8
- package/agents/guidance/rulebooks/nmr.md +2 -2
- package/dist/esm/check-cache-build-output.d.ts +24 -0
- package/dist/esm/check-cache-build-output.js +49 -0
- package/dist/esm/check-cache-delegation.d.ts +27 -0
- package/dist/esm/check-cache-delegation.js +92 -0
- package/dist/esm/check-cache-entries.d.ts +109 -0
- package/dist/esm/check-cache-entries.js +87 -0
- package/dist/esm/check-cache-gate.d.ts +64 -0
- package/dist/esm/check-cache-gate.js +226 -0
- package/dist/esm/check-cache-key.d.ts +47 -0
- package/dist/esm/check-cache-key.js +77 -0
- package/dist/esm/check-cache-record.d.ts +69 -0
- package/dist/esm/check-cache-record.js +167 -0
- package/dist/esm/check-cache.d.ts +8 -176
- package/dist/esm/check-cache.js +9 -214
- package/dist/esm/child-env.d.ts +57 -0
- package/dist/esm/child-env.js +59 -0
- package/dist/esm/child-process.d.ts +55 -0
- package/dist/esm/child-process.js +112 -0
- package/dist/esm/commands/build.d.ts +0 -16
- package/dist/esm/commands/build.js +9 -214
- package/dist/esm/commands/clean.js +3 -2
- package/dist/esm/commands/compiler.d.ts +18 -0
- package/dist/esm/commands/compiler.js +49 -0
- package/dist/esm/commands/fmt.d.ts +0 -39
- package/dist/esm/commands/fmt.js +4 -81
- package/dist/esm/commands/format-targets.d.ts +39 -0
- package/dist/esm/commands/format-targets.js +83 -0
- package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
- package/dist/esm/commands/rewrite-specifiers.js +122 -0
- package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
- package/dist/esm/commands/tsconfig-chain.js +63 -0
- package/dist/esm/config-check-cache.d.ts +17 -0
- package/dist/esm/config-check-cache.js +63 -0
- package/dist/esm/config-validation.d.ts +20 -0
- package/dist/esm/config-validation.js +236 -0
- package/dist/esm/config.js +4 -286
- package/dist/esm/crossing-diagnostics.d.ts +39 -0
- package/dist/esm/crossing-diagnostics.js +132 -0
- package/dist/esm/default-scripts.js +1 -1
- package/dist/esm/delegate-host.d.ts +43 -0
- package/dist/esm/delegate-host.js +178 -0
- package/dist/esm/delegation-target.d.ts +52 -0
- package/dist/esm/delegation-target.js +71 -0
- package/dist/esm/fan-out.d.ts +57 -0
- package/dist/esm/fan-out.js +146 -0
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/help.js +9 -3
- package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
- package/dist/esm/helpers/deriveExcerpt.js +1 -1
- package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
- package/dist/esm/helpers/renderQuotedList.js +9 -0
- package/dist/esm/hooks.d.ts +17 -0
- package/dist/esm/hooks.js +23 -0
- package/dist/esm/invocation-args.d.ts +40 -0
- package/dist/esm/invocation-args.js +178 -0
- package/dist/esm/invocation.d.ts +67 -0
- package/dist/esm/invocation.js +3 -0
- package/dist/esm/machine-lock.d.ts +2 -0
- package/dist/esm/machine-lock.js +1 -1
- package/dist/esm/output-channel.d.ts +23 -0
- package/dist/esm/output-channel.js +14 -0
- package/dist/esm/pass-manifest.d.ts +53 -0
- package/dist/esm/pass-manifest.js +89 -0
- package/dist/esm/presentation.d.ts +47 -0
- package/dist/esm/presentation.js +32 -0
- package/dist/esm/recording.d.ts +1 -1
- package/dist/esm/recording.js +2 -2
- package/dist/esm/remote-host-session.d.ts +62 -0
- package/dist/esm/remote-host-session.js +277 -0
- package/dist/esm/remote-probe-host.d.ts +22 -0
- package/dist/esm/remote-probe-host.js +50 -0
- package/dist/esm/remote-probe.d.ts +31 -0
- package/dist/esm/remote-probe.js +78 -0
- package/dist/esm/remote-run.d.ts +28 -73
- package/dist/esm/remote-run.js +103 -165
- package/dist/esm/remote-session.d.ts +40 -0
- package/dist/esm/remote-session.js +151 -0
- package/dist/esm/remote-snapshot.d.ts +21 -0
- package/dist/esm/remote-snapshot.js +80 -0
- package/dist/esm/replay-assembly.d.ts +2 -2
- package/dist/esm/replay-assembly.js +1 -1
- package/dist/esm/run-chain.d.ts +21 -0
- package/dist/esm/run-chain.js +108 -0
- package/dist/esm/runCli.d.ts +1 -9
- package/dist/esm/runCli.js +39 -1002
- package/dist/esm/runner.d.ts +1 -54
- package/dist/esm/runner.js +2 -125
- package/dist/esm/shell-syntax.d.ts +30 -0
- package/dist/esm/shell-syntax.js +120 -0
- package/dist/esm/steps.d.ts +0 -2
- package/dist/esm/steps.js +3 -119
- package/dist/esm/types.d.ts +5 -0
- package/dist/esm/user-config.d.ts +34 -0
- package/dist/esm/user-config.js +93 -0
- package/dist/esm/verbosity.d.ts +6 -0
- package/dist/esm/verbosity.js +7 -7
- package/dist/esm/verdict.d.ts +1 -1
- package/dist/esm/verdict.js +1 -1
- package/docs/check-cache.md +28 -12
- package/docs/delegate.md +96 -16
- package/docs/reporting.md +6 -1
- package/docs/scripts.md +2 -1
- package/package.json +3 -3
- /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
- /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { reportError } from '@williamthorsen/nmr-core';
|
|
2
|
+
import { readFilterSelection } from "./helpers/filter-selection.js";
|
|
3
|
+
import { findClosestName } from "./helpers/findClosestName.js";
|
|
4
|
+
import { renderQuotedList } from "./helpers/renderQuotedList.js";
|
|
5
|
+
import { RUN_IF_PRESENT_ENV_VAR } from "./invocation.js";
|
|
6
|
+
import { buildRootRegistry, buildWorkspaceRegistry, readPackageJsonScripts } from "./resolver.js";
|
|
7
|
+
import { runSteps } from "./runner.js";
|
|
8
|
+
import { readWorkspacePackageNames, resolveWorkspace } from "./workspace.js";
|
|
9
|
+
const RECURSIVE_REJECTION = '-R/--recursive did not match any workspace:';
|
|
10
|
+
export function composeDelegation(options) {
|
|
11
|
+
const { childEnv, command, parsedArgs } = options;
|
|
12
|
+
let scope;
|
|
13
|
+
let shouldRunIfPresent = parsedArgs.shouldPrintLog;
|
|
14
|
+
if (parsedArgs.filter) {
|
|
15
|
+
scope = ['--filter', parsedArgs.filter];
|
|
16
|
+
}
|
|
17
|
+
else if (parsedArgs.recursive) {
|
|
18
|
+
scope = ['--recursive'];
|
|
19
|
+
shouldRunIfPresent = true;
|
|
20
|
+
}
|
|
21
|
+
if (scope === undefined) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const flags = parsedArgs.shouldPrintLog ? ['--log'] : [];
|
|
25
|
+
return {
|
|
26
|
+
env: shouldRunIfPresent ? { ...childEnv, [RUN_IF_PRESENT_ENV_VAR]: '1' } : childEnv,
|
|
27
|
+
step: {
|
|
28
|
+
kind: 'structural',
|
|
29
|
+
argv: ['pnpm', ...scope, 'exec', 'nmr', ...flags, command, ...parsedArgs.passthrough],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function isDefinedInAnyScope(command, context) {
|
|
34
|
+
const registries = [buildRootRegistry(context.config, context.monorepoRoot), buildWorkspaceRegistry(context.config)];
|
|
35
|
+
if (registries.some((registry) => Object.hasOwn(registry, command))) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return [context.monorepoRoot, ...context.workspacePackageDirs].some((dir) => Object.hasOwn(readPackageJsonScripts(dir) ?? {}, command));
|
|
39
|
+
}
|
|
40
|
+
export async function runDelegation(options) {
|
|
41
|
+
const { context, delegation, parsedArgs, runOptions, stderr } = options;
|
|
42
|
+
const selection = parsedArgs.filter === undefined ? undefined : readFilterSelection(parsedArgs.filter, context.monorepoRoot);
|
|
43
|
+
const refusal = findEmptySelectionRefusal({ context, parsedArgs, selection });
|
|
44
|
+
if (refusal !== undefined) {
|
|
45
|
+
reportError(refusal, stderr);
|
|
46
|
+
return { exitCode: 1 };
|
|
47
|
+
}
|
|
48
|
+
const shouldWithholdInput = parsedArgs.filter === undefined || selection === 'multiple';
|
|
49
|
+
const step = shouldWithholdInput ? { ...delegation.step, shouldWithholdInput } : delegation.step;
|
|
50
|
+
return runSteps([step], context.monorepoRoot, { ...runOptions, env: delegation.env });
|
|
51
|
+
}
|
|
52
|
+
function describeDeclaredPatterns(patterns) {
|
|
53
|
+
const quotablePatterns = patterns.filter((pattern) => pattern.trim() !== '');
|
|
54
|
+
const emptiedCount = patterns.length - quotablePatterns.length;
|
|
55
|
+
if (emptiedCount === 0) {
|
|
56
|
+
return `declares ${renderQuotedList(patterns)}`;
|
|
57
|
+
}
|
|
58
|
+
const emptiedClause = `${emptiedCount} ${emptiedCount === 1 ? 'entry' : 'entries'} that YAML left empty`;
|
|
59
|
+
return quotablePatterns.length === 0
|
|
60
|
+
? `declares ${emptiedClause}`
|
|
61
|
+
: `declares ${renderQuotedList(quotablePatterns)}, beside ${emptiedClause}`;
|
|
62
|
+
}
|
|
63
|
+
function describeEmptyWorkspace(monorepoRoot) {
|
|
64
|
+
const resolution = resolveWorkspace(monorepoRoot);
|
|
65
|
+
if (resolution.kind !== 'empty') {
|
|
66
|
+
return `pnpm-workspace.yaml at ${monorepoRoot} resolves differently now than it did when the run started.`;
|
|
67
|
+
}
|
|
68
|
+
const { cause, patterns } = resolution;
|
|
69
|
+
const declaredClause = describeDeclaredPatterns(patterns);
|
|
70
|
+
switch (cause) {
|
|
71
|
+
case 'all-excluded':
|
|
72
|
+
return (`pnpm-workspace.yaml ${declaredClause}, whose \`!\` entries exclude every directory matched by the positive ` +
|
|
73
|
+
'patterns. Drop or narrow the exclusion.');
|
|
74
|
+
case 'no-package':
|
|
75
|
+
return (`pnpm-workspace.yaml ${declaredClause}, and the matcher did not find any directory containing a \`package.json\`. ` +
|
|
76
|
+
'nmr counts a directory as a package only when it contains `package.json`; unlike pnpm, it recognizes ' +
|
|
77
|
+
'neither `package.yaml` nor `package.json5`. Add a `package.json` to the directory that should be a ' +
|
|
78
|
+
'package, or declare a pattern reaching a directory that contains one.');
|
|
79
|
+
case 'no-packages-list':
|
|
80
|
+
return (`pnpm-workspace.yaml at ${monorepoRoot} does not declare a \`packages\` list, so the workspace contains the root ` +
|
|
81
|
+
'package alone. Declare a positive pattern such as `packages/*` to reach the packages beneath it.');
|
|
82
|
+
case 'no-pattern':
|
|
83
|
+
return (`pnpm-workspace.yaml ${declaredClause}, so the matcher does not receive any pattern. Declare a positive ` +
|
|
84
|
+
'pattern such as `packages/*`, and quote any `!` entry, which YAML reads as a tag rather than a string ' +
|
|
85
|
+
'when it is unquoted.');
|
|
86
|
+
case 'unreadable-packages':
|
|
87
|
+
return (`pnpm-workspace.yaml at ${monorepoRoot} declares a \`packages\` value that is not a list of strings, so ` +
|
|
88
|
+
'nothing it declares reaches the matcher. Make it a list whose every entry is a quoted pattern; a bare ' +
|
|
89
|
+
'value and an entry YAML read as a number or a map are the usual ones.');
|
|
90
|
+
case 'unreadable-manifest':
|
|
91
|
+
return (`pnpm-workspace.yaml at ${monorepoRoot} is not valid YAML, so the matcher does not receive anything that it declares. ` +
|
|
92
|
+
'Repair the syntax error and run the command again. An unterminated quoted string and a mis-indented ' +
|
|
93
|
+
'entry are the usual ones.');
|
|
94
|
+
default: {
|
|
95
|
+
const unhandledCause = cause;
|
|
96
|
+
throw new Error(`Unhandled empty-workspace cause: ${String(unhandledCause)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function findEmptySelectionRefusal(options) {
|
|
101
|
+
const { context, parsedArgs, selection } = options;
|
|
102
|
+
const isPackageless = context.workspacePackageDirs.length === 0;
|
|
103
|
+
if (parsedArgs.filter !== undefined) {
|
|
104
|
+
if (selection !== 'empty') {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (isPackageless) {
|
|
108
|
+
return `${formatFilterRejection(parsedArgs.filter)} ${describeEmptyWorkspace(context.monorepoRoot)}`;
|
|
109
|
+
}
|
|
110
|
+
return formatEmptyFilterError(parsedArgs.filter, readWorkspacePackageNames(context.workspacePackageDirs));
|
|
111
|
+
}
|
|
112
|
+
return isPackageless ? `${RECURSIVE_REJECTION} ${describeEmptyWorkspace(context.monorepoRoot)}` : undefined;
|
|
113
|
+
}
|
|
114
|
+
function formatEmptyFilterError(pattern, names) {
|
|
115
|
+
const rejection = formatFilterRejection(pattern);
|
|
116
|
+
if (pattern.startsWith('!')) {
|
|
117
|
+
return `${rejection} A pattern beginning with \`!\` excludes what it matches, and this one excludes every package.`;
|
|
118
|
+
}
|
|
119
|
+
if (pattern.includes('[')) {
|
|
120
|
+
return `${rejection} A pattern containing \`[<ref>]\` selects the packages changed since a git ref, and none has changed.`;
|
|
121
|
+
}
|
|
122
|
+
if (pattern.startsWith('.') || pattern.startsWith('/') || pattern.startsWith('{')) {
|
|
123
|
+
return (`${rejection} ` +
|
|
124
|
+
'A pattern written `./dir`, `/dir`, or `{dir}` selects the packages under a directory, ' +
|
|
125
|
+
'and this directory does not contain any package.');
|
|
126
|
+
}
|
|
127
|
+
return (`${rejection} A pattern matches a package's manifest \`name\`, ` +
|
|
128
|
+
`not its directory name.${suggestWorkspaceNames(pattern, names)}`);
|
|
129
|
+
}
|
|
130
|
+
function formatFilterRejection(pattern) {
|
|
131
|
+
return `-F/--filter did not match any workspace: \`${pattern}\`.`;
|
|
132
|
+
}
|
|
133
|
+
function suggestWorkspaceNames(pattern, names) {
|
|
134
|
+
if (names.length === 0) {
|
|
135
|
+
return '';
|
|
136
|
+
}
|
|
137
|
+
const containingNames = names.filter((name) => name.toLowerCase().includes(pattern.toLowerCase()));
|
|
138
|
+
if (containingNames.length > 0) {
|
|
139
|
+
return ` Did you mean ${renderQuotedList(containingNames)}?`;
|
|
140
|
+
}
|
|
141
|
+
const closestName = findClosestName(pattern, names);
|
|
142
|
+
if (closestName !== undefined) {
|
|
143
|
+
return ` Did you mean \`${closestName}\`?`;
|
|
144
|
+
}
|
|
145
|
+
return ` The workspace declares ${renderQuotedList(names)}.`;
|
|
146
|
+
}
|
package/dist/esm/glyphs.d.ts
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* `noop` is a neutral circle: Nothing ran because the repo asked for nothing to run, which is neither a
|
|
6
6
|
* failure nor a block.
|
|
7
7
|
*/
|
|
8
|
-
export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "
|
|
8
|
+
export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "clean" | "catalog" | "noop" | "package" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
|
|
9
9
|
export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
|
package/dist/esm/help.js
CHANGED
|
@@ -33,21 +33,27 @@ export function generateHelp(config, monorepoRoot, packageDir, shouldUseRoot) {
|
|
|
33
33
|
' -R, --recursive Run command in all packages',
|
|
34
34
|
' -w, --workspace-root Use root scripts, running at the monorepo root',
|
|
35
35
|
" -q, --quiet Suppress command output, keeping nmr's verdicts",
|
|
36
|
-
' --delegate
|
|
36
|
+
' --delegate Run over SSH on the first free host that NMR_DELEGATE or the user config names',
|
|
37
|
+
' --delegate-to <hosts> Run over SSH on the first free of <hosts>, comma-separated',
|
|
37
38
|
' --json Report one JSON object per command, withholding command output',
|
|
38
39
|
' --log Print the recorded run instead of running (up to 256 KiB)',
|
|
39
40
|
' --no-cache Run even if this tree already passed; record the result',
|
|
40
|
-
' --no-delegate Run here,
|
|
41
|
+
' --no-delegate Run here, whatever NMR_DELEGATE or the user config says',
|
|
41
42
|
' --no-lock Run without waiting for another heavy command on this machine',
|
|
42
43
|
` ${OUTPUT_STYLE_FLAG} <style> Output style: auto (default), plain, or rich`,
|
|
43
44
|
' -?, --help Show this help',
|
|
44
45
|
' -V, --version Show version number',
|
|
45
46
|
'',
|
|
46
47
|
'Environment:',
|
|
47
|
-
` ${DELEGATE_ENV_VAR}
|
|
48
|
+
` ${DELEGATE_ENV_VAR} Hosts on which to run each command that can be delegated,`,
|
|
49
|
+
' comma-separated; nmr runs on the first free one',
|
|
48
50
|
` ${OUTPUT_STYLE_ENV_VAR} Output style: auto (default), plain, or rich. auto prints`,
|
|
49
51
|
' plain, without emoji, when CI is set or the stream is not a terminal.',
|
|
50
52
|
'',
|
|
53
|
+
'Files:',
|
|
54
|
+
" ~/.config/nmr/config.ts User config: this machine's delegation hosts, and when to use them",
|
|
55
|
+
' ($XDG_CONFIG_HOME/nmr/config.ts when that is set)',
|
|
56
|
+
'',
|
|
51
57
|
'Workspace commands:',
|
|
52
58
|
];
|
|
53
59
|
const packageScripts = packageDir === undefined ? {} : readRunnableScripts(packageDir);
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bound on the line returned by `deriveExcerpt`, and therefore on what a caller persists beside a recorded pass.
|
|
3
|
+
*
|
|
4
|
+
* The line cap bounds how many lines the reduction keeps, not how long one of them is: a tool whose closing
|
|
5
|
+
* block is a single long line -- a one-line JSON report, a wide table collapsed by the whitespace fold -- would
|
|
6
|
+
* otherwise pass the whole retained tail to whatever stores it, to show at most a verdict line's worth.
|
|
7
|
+
* Generous enough to leave every summary measured here untouched.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_EXCERPT_BYTES = 2048;
|
|
1
10
|
/**
|
|
2
11
|
* Reduces a transcript to the one line shown in a verdict's detail slot, or `undefined` when it contained nothing.
|
|
3
12
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { clampToBytes } from "./clampToBytes.js";
|
|
2
2
|
import { cleanTranscript } from "./transcript.js";
|
|
3
3
|
const MAX_BLOCK_LINES = 8;
|
|
4
|
-
const MAX_EXCERPT_BYTES = 2_048;
|
|
4
|
+
export const MAX_EXCERPT_BYTES = 2_048;
|
|
5
5
|
const NON_RULE_CHARACTER = /[^-=_~*+.|:#]/u;
|
|
6
6
|
export function deriveExcerpt(transcript) {
|
|
7
7
|
const lines = cleanTranscript(transcript).split('\n');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const LIST_CEILING = 10;
|
|
2
|
+
export function renderQuotedList(items) {
|
|
3
|
+
const shownItems = items
|
|
4
|
+
.slice(0, LIST_CEILING)
|
|
5
|
+
.map((item) => `\`${item}\``)
|
|
6
|
+
.join(', ');
|
|
7
|
+
const remainder = items.length - LIST_CEILING;
|
|
8
|
+
return remainder > 0 ? `${shownItems}, and ${remainder} more` : shownItems;
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ScriptRegistry } from './resolve-scripts.js';
|
|
2
|
+
import { type Step } from './steps.js';
|
|
3
|
+
/**
|
|
4
|
+
* Returns true when a hook resolves to a chain other than the skip values `""` and `":"`, or when its
|
|
5
|
+
* `package.json` entry chains steps onto a re-invocation of the hook.
|
|
6
|
+
*/
|
|
7
|
+
export declare function hasRunnableHook(hookName: string, registry: ScriptRegistry, anchorDir: string, isWorkspaceRoot: boolean): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Wraps a resolved main command's steps with `nmr <command>:pre` and `nmr <command>:post`
|
|
10
|
+
* steps when the corresponding hooks resolve to non-skip values.
|
|
11
|
+
*
|
|
12
|
+
* Omits a hook that `hasRunnableHook` rejects, so a missing or skipped hook does not add a step or any output.
|
|
13
|
+
*
|
|
14
|
+
* `-w` is propagated to hook subprocesses so that each hook selects the root registry
|
|
15
|
+
* on its own, independent of where the child derives its context from.
|
|
16
|
+
*/
|
|
17
|
+
export declare function wrapWithHooks(command: string, mainSteps: readonly Step[], registry: ScriptRegistry, anchorDir: string, isWorkspaceRoot: boolean): readonly Step[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { findChainedSelfReference, resolveScript } from "./resolver.js";
|
|
2
|
+
import { composeNmrStep, renderChain } from "./steps.js";
|
|
3
|
+
export function hasRunnableHook(hookName, registry, anchorDir, isWorkspaceRoot) {
|
|
4
|
+
if (findChainedSelfReference(anchorDir, hookName) !== undefined) {
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
const resolvedScript = resolveScript(hookName, registry, anchorDir, isWorkspaceRoot);
|
|
8
|
+
if (!resolvedScript)
|
|
9
|
+
return false;
|
|
10
|
+
const chain = renderChain(resolvedScript.steps);
|
|
11
|
+
return chain !== '' && chain !== ':';
|
|
12
|
+
}
|
|
13
|
+
export function wrapWithHooks(command, mainSteps, registry, anchorDir, isWorkspaceRoot) {
|
|
14
|
+
const steps = [];
|
|
15
|
+
if (hasRunnableHook(`${command}:pre`, registry, anchorDir, isWorkspaceRoot)) {
|
|
16
|
+
steps.push(composeNmrStep(`${command}:pre`, isWorkspaceRoot));
|
|
17
|
+
}
|
|
18
|
+
steps.push(...mainSteps);
|
|
19
|
+
if (hasRunnableHook(`${command}:post`, registry, anchorDir, isWorkspaceRoot)) {
|
|
20
|
+
steps.push(composeNmrStep(`${command}:post`, isWorkspaceRoot));
|
|
21
|
+
}
|
|
22
|
+
return steps;
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type OutputStyle } from '@williamthorsen/nmr-core';
|
|
2
|
+
/**
|
|
3
|
+
* Renders the hint for nmr flags written after the command name, or `undefined` when the arguments do not
|
|
4
|
+
* contain any. The corrected invocation keeps every other argument where it was written.
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatMisplacedFlagHint(args: readonly string[], passthrough: readonly string[], style: OutputStyle): string | undefined;
|
|
7
|
+
/** Reports whether an argument is one of nmr's delegation flags, in any spelling. */
|
|
8
|
+
export declare function isDelegationFlag(arg: string): boolean;
|
|
9
|
+
/** Parses the CLI arguments into nmr's flags, the command name, and the arguments passed through to it. */
|
|
10
|
+
export declare function parseArgs(args: string[]): ParseResult;
|
|
11
|
+
/** @internal */
|
|
12
|
+
export interface ParsedArgs {
|
|
13
|
+
/** The comma-separated hosts that `--delegate-to` names, as written. */
|
|
14
|
+
delegateHosts?: string;
|
|
15
|
+
filter?: string;
|
|
16
|
+
isWorkspaceRoot: boolean;
|
|
17
|
+
/** The flag's value as it was written, which the style resolver rather than the parser narrows. */
|
|
18
|
+
outputStyle?: string;
|
|
19
|
+
quiet: boolean;
|
|
20
|
+
recursive: boolean;
|
|
21
|
+
shouldBypassCache: boolean;
|
|
22
|
+
shouldBypassLock: boolean;
|
|
23
|
+
/** Set by `--delegate`, which leaves the hosts to `NMR_DELEGATE` or the user config. */
|
|
24
|
+
shouldDelegate: boolean;
|
|
25
|
+
shouldEmitJson: boolean;
|
|
26
|
+
shouldPrintLog: boolean;
|
|
27
|
+
shouldRunLocally: boolean;
|
|
28
|
+
shouldShowHelp: boolean;
|
|
29
|
+
shouldShowVersion: boolean;
|
|
30
|
+
command?: string;
|
|
31
|
+
passthrough: string[];
|
|
32
|
+
}
|
|
33
|
+
type ParseResult = {
|
|
34
|
+
ok: true;
|
|
35
|
+
parsedArgs: ParsedArgs;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
error: string;
|
|
39
|
+
};
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
|
|
2
|
+
import { renderQuotedList } from "./helpers/renderQuotedList.js";
|
|
3
|
+
import { OUTPUT_STYLE_FLAG } from "./output-style.js";
|
|
4
|
+
import { DELEGATE_FLAG, DELEGATE_TO_FLAG, NO_DELEGATE_FLAG } from "./remote-run.js";
|
|
5
|
+
import { quoteToken } from "./shell-syntax.js";
|
|
6
|
+
const FILTER_FLAGS = new Set(['-F', '--filter']);
|
|
7
|
+
const BOOLEAN_FLAGS = new Map([
|
|
8
|
+
['-?', 'shouldShowHelp'],
|
|
9
|
+
[DELEGATE_FLAG, 'shouldDelegate'],
|
|
10
|
+
['--help', 'shouldShowHelp'],
|
|
11
|
+
['--json', 'shouldEmitJson'],
|
|
12
|
+
['--log', 'shouldPrintLog'],
|
|
13
|
+
['--no-cache', 'shouldBypassCache'],
|
|
14
|
+
[NO_DELEGATE_FLAG, 'shouldRunLocally'],
|
|
15
|
+
['--no-lock', 'shouldBypassLock'],
|
|
16
|
+
['-q', 'quiet'],
|
|
17
|
+
['--quiet', 'quiet'],
|
|
18
|
+
['-R', 'recursive'],
|
|
19
|
+
['--recursive', 'recursive'],
|
|
20
|
+
['-V', 'shouldShowVersion'],
|
|
21
|
+
['--version', 'shouldShowVersion'],
|
|
22
|
+
['-w', 'isWorkspaceRoot'],
|
|
23
|
+
['--workspace-root', 'isWorkspaceRoot'],
|
|
24
|
+
]);
|
|
25
|
+
export function formatMisplacedFlagHint(args, passthrough, style) {
|
|
26
|
+
const { flagNames, movedTokens, remainingTokens } = splitMisplacedFlags(passthrough);
|
|
27
|
+
if (flagNames.length === 0) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const commandIndex = args.length - passthrough.length - 1;
|
|
31
|
+
const correctedTokens = [
|
|
32
|
+
'nmr',
|
|
33
|
+
...args.slice(0, commandIndex),
|
|
34
|
+
...movedTokens,
|
|
35
|
+
...args.slice(commandIndex, commandIndex + 1),
|
|
36
|
+
...remainingTokens,
|
|
37
|
+
];
|
|
38
|
+
const subject = flagNames.length === 1 ? 'is an nmr flag' : 'are nmr flags';
|
|
39
|
+
return (`${STATUS_GLYPHS[style].warning.text} ${renderQuotedList(flagNames)} ${subject}; ` +
|
|
40
|
+
`nmr reads its flags only before the command: \`${correctedTokens.map(quoteToken).join(' ')}\``);
|
|
41
|
+
}
|
|
42
|
+
export function isDelegationFlag(arg) {
|
|
43
|
+
return (arg === DELEGATE_FLAG ||
|
|
44
|
+
arg === DELEGATE_TO_FLAG ||
|
|
45
|
+
arg === NO_DELEGATE_FLAG ||
|
|
46
|
+
arg.startsWith(`${DELEGATE_FLAG}=`) ||
|
|
47
|
+
arg.startsWith(`${DELEGATE_TO_FLAG}=`));
|
|
48
|
+
}
|
|
49
|
+
export function parseArgs(args) {
|
|
50
|
+
const parsedArgs = {
|
|
51
|
+
isWorkspaceRoot: false,
|
|
52
|
+
quiet: false,
|
|
53
|
+
recursive: false,
|
|
54
|
+
shouldBypassCache: false,
|
|
55
|
+
shouldBypassLock: false,
|
|
56
|
+
shouldDelegate: false,
|
|
57
|
+
shouldEmitJson: false,
|
|
58
|
+
shouldPrintLog: false,
|
|
59
|
+
shouldRunLocally: false,
|
|
60
|
+
shouldShowHelp: false,
|
|
61
|
+
shouldShowVersion: false,
|
|
62
|
+
passthrough: [],
|
|
63
|
+
};
|
|
64
|
+
let index = 0;
|
|
65
|
+
while (index < args.length) {
|
|
66
|
+
const arg = args[index];
|
|
67
|
+
if (arg === undefined)
|
|
68
|
+
break;
|
|
69
|
+
if (FILTER_FLAGS.has(arg)) {
|
|
70
|
+
index++;
|
|
71
|
+
const filterValue = args[index];
|
|
72
|
+
if (!filterValue) {
|
|
73
|
+
return { ok: false, error: '-F/--filter requires a pattern argument' };
|
|
74
|
+
}
|
|
75
|
+
parsedArgs.filter = filterValue;
|
|
76
|
+
index++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const booleanFlag = BOOLEAN_FLAGS.get(arg);
|
|
80
|
+
if (booleanFlag !== undefined) {
|
|
81
|
+
parsedArgs[booleanFlag] = true;
|
|
82
|
+
index++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (arg.startsWith(`${DELEGATE_FLAG}=`)) {
|
|
86
|
+
const value = arg.slice(DELEGATE_FLAG.length + 1);
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: `${DELEGATE_FLAG} does not take a value; name hosts with ${DELEGATE_TO_FLAG} ${value}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const styleArgument = readOutputStyleArgument(args, index);
|
|
93
|
+
if (styleArgument !== undefined) {
|
|
94
|
+
if (!styleArgument.ok) {
|
|
95
|
+
return styleArgument;
|
|
96
|
+
}
|
|
97
|
+
parsedArgs.outputStyle = styleArgument.value;
|
|
98
|
+
index += styleArgument.consumedCount;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const delegateArgument = readDelegateArgument(args, index);
|
|
102
|
+
if (delegateArgument !== undefined) {
|
|
103
|
+
if (!delegateArgument.ok) {
|
|
104
|
+
return delegateArgument;
|
|
105
|
+
}
|
|
106
|
+
parsedArgs.delegateHosts = delegateArgument.value;
|
|
107
|
+
index += delegateArgument.consumedCount;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
parsedArgs.command = arg;
|
|
111
|
+
parsedArgs.passthrough = args.slice(index + 1);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
const error = findArgError(parsedArgs);
|
|
115
|
+
return error === undefined ? { ok: true, parsedArgs } : { ok: false, error };
|
|
116
|
+
}
|
|
117
|
+
function findArgError(parsedArgs) {
|
|
118
|
+
if (parsedArgs.shouldPrintLog &&
|
|
119
|
+
parsedArgs.command === undefined &&
|
|
120
|
+
!parsedArgs.shouldShowHelp &&
|
|
121
|
+
!parsedArgs.shouldShowVersion) {
|
|
122
|
+
return '--log requires a command name: `nmr --log <command>`';
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
function readDelegateArgument(args, index) {
|
|
127
|
+
return readFlagValue(args, index, DELEGATE_TO_FLAG, 'a host argument');
|
|
128
|
+
}
|
|
129
|
+
function readFlagValue(args, index, flag, requirement) {
|
|
130
|
+
const arg = args[index] ?? '';
|
|
131
|
+
const assignment = arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : undefined;
|
|
132
|
+
if (arg !== flag && assignment === undefined) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const value = assignment ?? args[index + 1];
|
|
136
|
+
if (!value) {
|
|
137
|
+
return { ok: false, error: `${flag} requires ${requirement}` };
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, value, consumedCount: assignment === undefined ? 2 : 1 };
|
|
140
|
+
}
|
|
141
|
+
function readMisplacedFlagLength(args, index) {
|
|
142
|
+
const arg = args[index] ?? '';
|
|
143
|
+
if (FILTER_FLAGS.has(arg)) {
|
|
144
|
+
return (args[index + 1] ?? '') === '' ? undefined : 2;
|
|
145
|
+
}
|
|
146
|
+
const booleanFlag = BOOLEAN_FLAGS.get(arg);
|
|
147
|
+
if (booleanFlag !== undefined) {
|
|
148
|
+
return booleanFlag === 'shouldShowHelp' || booleanFlag === 'shouldShowVersion' ? undefined : 1;
|
|
149
|
+
}
|
|
150
|
+
const valueArgument = readOutputStyleArgument(args, index) ?? readDelegateArgument(args, index);
|
|
151
|
+
return valueArgument?.ok === true ? valueArgument.consumedCount : undefined;
|
|
152
|
+
}
|
|
153
|
+
function readOutputStyleArgument(args, index) {
|
|
154
|
+
return readFlagValue(args, index, OUTPUT_STYLE_FLAG, 'a value argument: auto, plain, or rich');
|
|
155
|
+
}
|
|
156
|
+
function splitMisplacedFlags(passthrough) {
|
|
157
|
+
const flagNames = new Set();
|
|
158
|
+
const movedTokens = [];
|
|
159
|
+
const remainingTokens = [];
|
|
160
|
+
let index = 0;
|
|
161
|
+
while (index < passthrough.length) {
|
|
162
|
+
const arg = passthrough[index] ?? '';
|
|
163
|
+
if (arg === '--') {
|
|
164
|
+
remainingTokens.push(...passthrough.slice(index));
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
const flagLength = readMisplacedFlagLength(passthrough, index);
|
|
168
|
+
if (flagLength === undefined) {
|
|
169
|
+
remainingTokens.push(arg);
|
|
170
|
+
index++;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
flagNames.add(arg.split('=', 1)[0] ?? arg);
|
|
174
|
+
movedTokens.push(...passthrough.slice(index, index + flagLength));
|
|
175
|
+
index += flagLength;
|
|
176
|
+
}
|
|
177
|
+
return { flagNames: [...flagNames], movedTokens, remainingTokens };
|
|
178
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type StreamStyles } from '@williamthorsen/nmr-core';
|
|
3
|
+
import type { TreeSnapshot } from './check-cache.js';
|
|
4
|
+
import type { ResolvedContext } from './context.js';
|
|
5
|
+
import type { DelegationTarget } from './delegation-target.js';
|
|
6
|
+
import type { ParsedArgs } from './invocation-args.js';
|
|
7
|
+
import type { ReportFormat } from './report-format.js';
|
|
8
|
+
import type { RunStepsOptions } from './runner.js';
|
|
9
|
+
import type { Step } from './steps.js';
|
|
10
|
+
import type { CommandVerbosity } from './verbosity.js';
|
|
11
|
+
/** An invocation that reached a command: what it asks for, how it presents itself, and where it runs. */
|
|
12
|
+
export interface Invocation {
|
|
13
|
+
anchorDir: string;
|
|
14
|
+
command: string;
|
|
15
|
+
context: ResolvedContext;
|
|
16
|
+
cwd: string;
|
|
17
|
+
env: NodeJS.ProcessEnv;
|
|
18
|
+
format: ReportFormat;
|
|
19
|
+
parsedArgs: ParsedArgs;
|
|
20
|
+
scope: string;
|
|
21
|
+
shouldUseRoot: boolean;
|
|
22
|
+
stderr: Writable;
|
|
23
|
+
stdout: Writable;
|
|
24
|
+
styles: StreamStyles;
|
|
25
|
+
verbosity: CommandVerbosity;
|
|
26
|
+
}
|
|
27
|
+
/** Where an invocation runs, once a delegation that cannot be honored has been refused. */
|
|
28
|
+
export type ExecutionTarget = Exclude<DelegationTarget, {
|
|
29
|
+
kind: 'refused';
|
|
30
|
+
}>;
|
|
31
|
+
/** Either the outcome with which a phase settled the invocation, or the input of the next phase. */
|
|
32
|
+
export type PhaseResult<T> = {
|
|
33
|
+
settled: true;
|
|
34
|
+
result: SettledResult;
|
|
35
|
+
} | {
|
|
36
|
+
settled: false;
|
|
37
|
+
value: T;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Marks a run made on a delegating caller's behalf, in which a command that the registry does not define, and a
|
|
41
|
+
* `--log` with nothing to show, exit 0 rather than failing.
|
|
42
|
+
*/
|
|
43
|
+
export declare const RUN_IF_PRESENT_ENV_VAR = "NMR_RUN_IF_PRESENT";
|
|
44
|
+
/** @internal */
|
|
45
|
+
export interface RunCliResult {
|
|
46
|
+
exitCode: number;
|
|
47
|
+
}
|
|
48
|
+
/** What an invocation would run, fully determined: the chain, the key under which it records, and how it runs. */
|
|
49
|
+
export interface RunPlan {
|
|
50
|
+
commandString: string;
|
|
51
|
+
/** Where a miss runs: here, or on the host to which the invocation is delegated. */
|
|
52
|
+
target: ExecutionTarget;
|
|
53
|
+
invocation: Invocation;
|
|
54
|
+
key: string | undefined;
|
|
55
|
+
overrideNotice: string | undefined;
|
|
56
|
+
ownSteps: readonly Step[];
|
|
57
|
+
runId: string;
|
|
58
|
+
runOptions: RunStepsOptions;
|
|
59
|
+
shouldBypassCache: boolean;
|
|
60
|
+
snapshot: TreeSnapshot | undefined;
|
|
61
|
+
steps: readonly Step[];
|
|
62
|
+
}
|
|
63
|
+
/** An invocation's exit code, and whether it failed because its command is not defined in any scope that it reaches. */
|
|
64
|
+
export type SettledResult = RunCliResult & {
|
|
65
|
+
isUnknownCommand?: boolean;
|
|
66
|
+
};
|
|
67
|
+
export declare const VERSION: string;
|
|
@@ -10,6 +10,8 @@ export declare const HEARTBEAT_INTERVAL_MS = 10000;
|
|
|
10
10
|
export declare const HEAVY_COMMANDS: ReadonlySet<string>;
|
|
11
11
|
/** Overrides the directory that contains the lock file. */
|
|
12
12
|
export declare const LOCK_DIR_ENV_VAR = "NMR_LOCK_DIR";
|
|
13
|
+
/** The lock file's name in the lock directory. */
|
|
14
|
+
export declare const LOCK_FILE = "heavy.lock";
|
|
13
15
|
/** Passes the holder's token to the processes below it, which run under its hold rather than waiting on it. */
|
|
14
16
|
export declare const LOCK_TOKEN_ENV_VAR = "NMR_MACHINE_LOCK_TOKEN";
|
|
15
17
|
/** Set to `1` to run without the lock, for the whole chain. */
|
package/dist/esm/machine-lock.js
CHANGED
|
@@ -6,7 +6,6 @@ import process from 'node:process';
|
|
|
6
6
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
7
|
import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
|
|
8
8
|
import { isObject } from "./helpers/type-guards.js";
|
|
9
|
-
const LOCK_FILE = 'heavy.lock';
|
|
10
9
|
const RECLAIM_GUARD = 'heavy.lock.reclaim';
|
|
11
10
|
const RECLAIM_GUARD_STALE_AFTER_MS = 10_000;
|
|
12
11
|
export const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
@@ -26,6 +25,7 @@ export const HEAVY_COMMANDS = new Set([
|
|
|
26
25
|
'test:unit',
|
|
27
26
|
]);
|
|
28
27
|
export const LOCK_DIR_ENV_VAR = 'NMR_LOCK_DIR';
|
|
28
|
+
export const LOCK_FILE = 'heavy.lock';
|
|
29
29
|
export const LOCK_TOKEN_ENV_VAR = 'NMR_MACHINE_LOCK_TOKEN';
|
|
30
30
|
export const NO_LOCK_ENV_VAR = 'NMR_NO_LOCK';
|
|
31
31
|
export const POLL_INTERVAL_MS = 1_000;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
/** Where one of a child's output streams goes: a descriptor to which the child writes, or a pipe read by nmr. */
|
|
3
|
+
export type OutputChannel = number | 'pipe';
|
|
4
|
+
/** The channel on which each of a child's output streams runs. */
|
|
5
|
+
export interface OutputChannels {
|
|
6
|
+
stderr: OutputChannel;
|
|
7
|
+
stdout: OutputChannel;
|
|
8
|
+
}
|
|
9
|
+
/** Narrows a stream to one backed by a terminal, whose descriptor the child can be handed. */
|
|
10
|
+
export declare function isTerminal(stream: Writable): stream is Writable & {
|
|
11
|
+
fd: number;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Returns the channel on which a stream runs: the stream's own descriptor when it is a terminal and output is
|
|
15
|
+
* not being withheld, so that the child's terminal detection matches nmr's, and a pipe everywhere else.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveChannel(stream: Writable, quiet: boolean): OutputChannel;
|
|
18
|
+
/**
|
|
19
|
+
* Returns the channel for a step that runs an nmr process below this one: nmr's own descriptor whenever it has
|
|
20
|
+
* one, so that the child writes where nmr writes, without an ancestor between them relaying bytes. A stream
|
|
21
|
+
* without a descriptor falls back to a pipe.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveInheritedChannel(stream: Writable): OutputChannel;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function isTerminal(stream) {
|
|
2
|
+
return 'isTTY' in stream && stream.isTTY === true && hasDescriptor(stream);
|
|
3
|
+
}
|
|
4
|
+
export function resolveChannel(stream, quiet) {
|
|
5
|
+
if (quiet)
|
|
6
|
+
return 'pipe';
|
|
7
|
+
return isTerminal(stream) ? stream.fd : 'pipe';
|
|
8
|
+
}
|
|
9
|
+
export function resolveInheritedChannel(stream) {
|
|
10
|
+
return hasDescriptor(stream) ? stream.fd : 'pipe';
|
|
11
|
+
}
|
|
12
|
+
function hasDescriptor(stream) {
|
|
13
|
+
return 'fd' in stream && typeof stream.fd === 'number';
|
|
14
|
+
}
|