@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.
- package/.meta/changelog.json +168 -0
- package/.readyup/kits/default.js +1331 -136
- package/.readyup/manifest.json +148 -9
- package/CHANGELOG.md +51 -0
- package/README.md +17 -3
- package/agents/guidance/rulebooks/nmr.md +1 -1
- package/dist/esm/commands/fmt-cache.d.ts +26 -0
- package/dist/esm/commands/fmt-cache.js +71 -0
- package/dist/esm/commands/fmt.d.ts +10 -3
- package/dist/esm/commands/fmt.js +34 -6
- package/dist/esm/config.js +74 -9
- package/dist/esm/default-scripts.d.ts +10 -1
- package/dist/esm/default-scripts.js +45 -39
- package/dist/esm/defineConfig.d.ts +1 -1
- package/dist/esm/entry-chain.d.ts +24 -0
- package/dist/esm/entry-chain.js +57 -0
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/help.d.ts +1 -1
- package/dist/esm/help.js +3 -3
- package/dist/esm/helpers/createOutputRecorder.d.ts +17 -0
- package/dist/esm/helpers/createOutputRecorder.js +22 -0
- package/dist/esm/helpers/escapeControlCharacters.d.ts +10 -0
- package/dist/esm/helpers/escapeControlCharacters.js +12 -0
- package/dist/esm/resolve-scripts.d.ts +5 -3
- package/dist/esm/resolve-scripts.js +7 -3
- package/dist/esm/resolver.d.ts +5 -4
- package/dist/esm/resolver.js +16 -6
- package/dist/esm/runCli.js +146 -101
- package/dist/esm/runner.d.ts +7 -0
- package/dist/esm/runner.js +105 -15
- package/dist/esm/steps.d.ts +50 -18
- package/dist/esm/steps.js +82 -13
- package/dist/esm/taze.d.ts +5 -2
- package/dist/esm/taze.js +4 -1
- package/dist/esm/tiers.d.ts +5 -4
- package/dist/esm/types.d.ts +10 -0
- package/dist/esm/vitest-git-isolation.js +3 -1
- package/dist/esm/vitest.js +9 -5
- package/dist/esm/workspace-globs.d.ts +5 -0
- package/dist/esm/workspace-globs.js +8 -0
- package/docs/check-cache.md +2 -0
- package/docs/reporting.md +2 -0
- package/docs/scripts.md +36 -10
- package/docs/upgrades.md +1 -1
- package/docs/utilities.md +14 -2
- package/docs/vitest.md +20 -8
- package/package.json +7 -7
package/dist/esm/config.js
CHANGED
|
@@ -9,18 +9,20 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { pathToFileURL } from 'node:url';
|
|
11
11
|
import { resolveConfigPath } from "./helpers/config-path.js";
|
|
12
|
+
import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
|
|
12
13
|
import { findClosestName } from "./helpers/findClosestName.js";
|
|
13
14
|
import { isHookName } from "./helpers/hook-name.js";
|
|
14
15
|
import { isObject, isStringRecord } from "./helpers/type-guards.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
|
|
17
|
+
import { buildRootRegistry, buildWorkspaceRegistry, describeScript, readPackageJsonScripts } from "./resolver.js";
|
|
18
|
+
import { findUnexpressibleToken, readSelfReference, readStepListSelfReference, } from "./steps.js";
|
|
17
19
|
import { UserError } from "./UserError.js";
|
|
18
20
|
import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
|
|
19
21
|
import { getWorkspacePackageDirs, isMonorepoRoot } from "./workspace.js";
|
|
20
22
|
const CONFIG_TIERS = {
|
|
21
23
|
root: {
|
|
22
24
|
label: 'a monorepo-root config',
|
|
23
|
-
honoredKeys: ['checkCache', 'devBin', 'output', 'rootScripts', 'workspaceScripts'],
|
|
25
|
+
honoredKeys: ['checkCache', 'devBin', 'output', 'rootScripts', 'selfHostedRunner', 'workspaceScripts'],
|
|
24
26
|
elsewhere: "the package's own config",
|
|
25
27
|
},
|
|
26
28
|
workspace: {
|
|
@@ -33,7 +35,12 @@ const RECOGNIZED_KEYS = [...CONFIG_TIERS.root.honoredKeys, ...CONFIG_TIERS.works
|
|
|
33
35
|
const RECOGNIZED_BUILD_KEYS = ['extraIgnorePatterns'];
|
|
34
36
|
const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommands'];
|
|
35
37
|
const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
|
|
36
|
-
const
|
|
38
|
+
const RECOGNIZED_SELF_HOSTED_RUNNER_KEYS = ['exempt'];
|
|
39
|
+
const RECOGNIZED_STEP_KEYS = ['run', 'shouldDeclineArguments', 'shouldRunInBackground'];
|
|
40
|
+
const SCRIPT_FIELDS = [
|
|
41
|
+
{ field: 'rootScripts', scope: 'root' },
|
|
42
|
+
{ field: 'workspaceScripts', scope: 'workspaceRegistry' },
|
|
43
|
+
];
|
|
37
44
|
const RETIRED_STEP_KEYS = new Map([['declinesArgs', 'shouldDeclineArguments']]);
|
|
38
45
|
function isScriptRecord(value) {
|
|
39
46
|
if (!isObject(value))
|
|
@@ -51,8 +58,7 @@ function isScriptElement(value) {
|
|
|
51
58
|
return true;
|
|
52
59
|
if (!isObject(value) || typeof value['run'] !== 'string')
|
|
53
60
|
return false;
|
|
54
|
-
|
|
55
|
-
return shouldDeclineArguments === undefined || typeof shouldDeclineArguments === 'boolean';
|
|
61
|
+
return isOptionalBoolean(value['shouldDeclineArguments']) && isOptionalBoolean(value['shouldRunInBackground']);
|
|
56
62
|
}
|
|
57
63
|
function validateScriptField(value, fieldName, configPath) {
|
|
58
64
|
if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
|
|
@@ -61,7 +67,8 @@ function validateScriptField(value, fieldName, configPath) {
|
|
|
61
67
|
const scripts = value[fieldName];
|
|
62
68
|
if (!isScriptRecord(scripts)) {
|
|
63
69
|
throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | element[]>, ` +
|
|
64
|
-
'where an element is a command string or
|
|
70
|
+
'where an element is a command string or ' +
|
|
71
|
+
'`{ run: string, shouldDeclineArguments?: boolean, shouldRunInBackground?: boolean }`');
|
|
65
72
|
}
|
|
66
73
|
assertValidElements(scripts, fieldName, configPath);
|
|
67
74
|
return scripts;
|
|
@@ -85,6 +92,9 @@ function assertValidElements(scripts, fieldName, configPath) {
|
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
}
|
|
95
|
+
function isOptionalBoolean(value) {
|
|
96
|
+
return value === undefined || typeof value === 'boolean';
|
|
97
|
+
}
|
|
88
98
|
function isStringArray(value) {
|
|
89
99
|
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
90
100
|
}
|
|
@@ -132,6 +142,21 @@ function validateCheckCacheField(value, configPath) {
|
|
|
132
142
|
}
|
|
133
143
|
return config;
|
|
134
144
|
}
|
|
145
|
+
function validateSelfHostedRunnerField(value, configPath) {
|
|
146
|
+
const selfHostedRunner = value['selfHostedRunner'];
|
|
147
|
+
if (selfHostedRunner === undefined) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
if (!isObject(selfHostedRunner)) {
|
|
151
|
+
throw new UserError(`Invalid nmr config at ${configPath}: \`selfHostedRunner\` must be an object`);
|
|
152
|
+
}
|
|
153
|
+
assertRecognizedKeys(selfHostedRunner, RECOGNIZED_SELF_HOSTED_RUNNER_KEYS, configPath, 'selfHostedRunner.');
|
|
154
|
+
const exempt = selfHostedRunner['exempt'];
|
|
155
|
+
if (typeof exempt !== 'string' || exempt.trim() === '') {
|
|
156
|
+
throw new UserError(`Invalid nmr config at ${configPath}: \`selfHostedRunner.exempt\` must be a non-empty string giving the reason`);
|
|
157
|
+
}
|
|
158
|
+
return { exempt };
|
|
159
|
+
}
|
|
135
160
|
function assertResolvableCheckCacheCommands(config, configPath, baseDir) {
|
|
136
161
|
const { checkCache } = config;
|
|
137
162
|
if (checkCache === undefined || !isMonorepoRoot(baseDir)) {
|
|
@@ -145,7 +170,7 @@ function assertResolvableCheckCacheCommands(config, configPath, baseDir) {
|
|
|
145
170
|
'which is never gated on its own: It runs as part of the chain of the command that it wraps.');
|
|
146
171
|
}
|
|
147
172
|
const registeredNames = new Set([
|
|
148
|
-
...Object.keys(buildRootRegistry(config)),
|
|
173
|
+
...Object.keys(buildRootRegistry(config, baseDir)),
|
|
149
174
|
...Object.keys(buildWorkspaceRegistry(config)),
|
|
150
175
|
]);
|
|
151
176
|
const missingEntries = entries.filter(({ command }) => !registeredNames.has(command));
|
|
@@ -250,6 +275,9 @@ function validateConfig(value, configPath, baseDir) {
|
|
|
250
275
|
const rootScripts = validateScriptField(value, 'rootScripts', configPath);
|
|
251
276
|
if (rootScripts)
|
|
252
277
|
config.rootScripts = rootScripts;
|
|
278
|
+
const selfHostedRunner = validateSelfHostedRunnerField(value, configPath);
|
|
279
|
+
if (selfHostedRunner)
|
|
280
|
+
config.selfHostedRunner = selfHostedRunner;
|
|
253
281
|
assertResolvableCheckCacheCommands(config, configPath, baseDir);
|
|
254
282
|
return config;
|
|
255
283
|
}
|
|
@@ -264,7 +292,9 @@ export async function loadConfig(baseDir) {
|
|
|
264
292
|
}
|
|
265
293
|
export async function loadRootConfig(monorepoRoot) {
|
|
266
294
|
const config = await loadConfig(monorepoRoot);
|
|
267
|
-
|
|
295
|
+
const configPath = resolveConfigPath(monorepoRoot);
|
|
296
|
+
assertTierKeys(config, CONFIG_TIERS.root, configPath);
|
|
297
|
+
assertNoSelfReferences(config, configPath, monorepoRoot);
|
|
268
298
|
return config;
|
|
269
299
|
}
|
|
270
300
|
export async function loadWorkspaceConfig(packageDir) {
|
|
@@ -280,6 +310,30 @@ function assertNoRetiredKeys(value, retiredKeys, configPath, prefix) {
|
|
|
280
310
|
throw new UserError(`Invalid nmr config at ${configPath}: \`${prefix}${key}\` was renamed to \`${prefix}${replacement}\`.`);
|
|
281
311
|
}
|
|
282
312
|
}
|
|
313
|
+
function assertNoSelfReferences(config, configPath, monorepoRoot) {
|
|
314
|
+
for (const { field, scope } of SCRIPT_FIELDS) {
|
|
315
|
+
const scripts = config[field] ?? {};
|
|
316
|
+
for (const [key, script] of Object.entries(scripts)) {
|
|
317
|
+
const selfReference = typeof script === 'string'
|
|
318
|
+
? readSelfReference({ commandName: key, scope, script })
|
|
319
|
+
: readStepListSelfReference({
|
|
320
|
+
commandName: key,
|
|
321
|
+
elements: script.map((element) => (typeof element === 'string' ? element : element.run)),
|
|
322
|
+
scope,
|
|
323
|
+
});
|
|
324
|
+
if (selfReference === undefined)
|
|
325
|
+
continue;
|
|
326
|
+
const defaults = field === 'rootScripts' ? getDefaultRootScripts(monorepoRoot) : getDefaultWorkspaceScripts();
|
|
327
|
+
const remedy = formatSelfReferenceRemedy({
|
|
328
|
+
isDefault: !isHookName(key) && Object.hasOwn(defaults, key),
|
|
329
|
+
key,
|
|
330
|
+
selfReference,
|
|
331
|
+
});
|
|
332
|
+
throw new UserError(`Invalid nmr config at ${configPath}: \`${field}.${key}\` re-invokes \`${key}\` ` +
|
|
333
|
+
`(\`${escapeControlCharacters(describeScript(script))}\`), which would re-enter nmr without bound. ${remedy}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
283
337
|
function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = '') {
|
|
284
338
|
const unrecognizedKeys = Object.keys(value).filter((key) => !recognizedKeys.includes(key));
|
|
285
339
|
if (unrecognizedKeys.length === 0) {
|
|
@@ -302,3 +356,14 @@ function formatKeyList(keys, prefix) {
|
|
|
302
356
|
.map((key) => `\`${prefix}${key}\``)
|
|
303
357
|
.join(', ');
|
|
304
358
|
}
|
|
359
|
+
function formatSelfReferenceRemedy(options) {
|
|
360
|
+
const { isDefault, key, selfReference } = options;
|
|
361
|
+
if (isDefault) {
|
|
362
|
+
return selfReference === 'sole'
|
|
363
|
+
? `Delete the entry: nmr's own \`${key}\` then runs.`
|
|
364
|
+
: `Delete the entry and move the steps that it adds to a \`${key}:pre\` or \`${key}:post\` script.`;
|
|
365
|
+
}
|
|
366
|
+
return selfReference === 'sole'
|
|
367
|
+
? 'Delete the entry.'
|
|
368
|
+
: `Delete the re-invocation: \`${key}\` runs the steps beside it.`;
|
|
369
|
+
}
|
|
@@ -10,6 +10,11 @@ export interface StepSpec {
|
|
|
10
10
|
* narrowed steps run, or the tool that it invokes would be misled by them. A declining step runs unnarrowed.
|
|
11
11
|
*/
|
|
12
12
|
shouldDeclineArguments?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Set to start this step and run the steps after it without waiting for it. The composite waits for it before
|
|
15
|
+
* ending, and prints its output whole at the first step boundary after it ends.
|
|
16
|
+
*/
|
|
17
|
+
shouldRunInBackground?: boolean;
|
|
13
18
|
}
|
|
14
19
|
export type ScriptValue = string | ReadonlyArray<string | StepSpec>;
|
|
15
20
|
export type ScriptRegistry = Record<string, ScriptValue>;
|
|
@@ -19,4 +24,8 @@ export type ScriptRegistry = Record<string, ScriptValue>;
|
|
|
19
24
|
* collects every file that does not name any other tier.
|
|
20
25
|
*/
|
|
21
26
|
export declare const workspaceScripts: ScriptRegistry;
|
|
22
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Builds the root scripts. Each root-only lint command excludes the workspaces, given as globs relative to the root,
|
|
29
|
+
* so that the root ESLint config never lints a package.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildRootScripts(workspaceGlobs: readonly string[]): ScriptRegistry;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { quoteToken } from "./steps.js";
|
|
1
2
|
const GATE_PROJECTS = '--project unit --project tool';
|
|
2
3
|
const TYPECHECK_STEP = { run: 'typecheck', shouldDeclineArguments: true };
|
|
3
4
|
const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', shouldDeclineArguments: true };
|
|
5
|
+
const LINT_CHECK_STEP = { run: 'lint:check', shouldRunInBackground: true };
|
|
6
|
+
const LINT_STRICT_STEP = { run: 'lint:strict', shouldRunInBackground: true };
|
|
4
7
|
export const workspaceScripts = {
|
|
5
8
|
build: ['compile'],
|
|
6
|
-
check: [TYPECHECK_STEP, 'fmt:check', '
|
|
7
|
-
'check:strict': [TYPECHECK_STEP, 'fmt:check', '
|
|
9
|
+
check: [LINT_CHECK_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
|
|
10
|
+
'check:strict': [LINT_STRICT_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
|
|
8
11
|
clean: 'nmr-clean',
|
|
9
12
|
compile: 'nmr-compile',
|
|
10
13
|
fix: ['lint', 'fmt'],
|
|
@@ -25,40 +28,43 @@ export const workspaceScripts = {
|
|
|
25
28
|
upgrade: 'nmr-report-catalog && nmr-taze',
|
|
26
29
|
'view-coverage': 'open coverage/index.html',
|
|
27
30
|
};
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
31
|
+
export function buildRootScripts(workspaceGlobs) {
|
|
32
|
+
const ignorePatterns = workspaceGlobs.map((glob) => `--ignore-pattern ${quoteToken(glob)} `).join('');
|
|
33
|
+
return {
|
|
34
|
+
audit: ['audit:prod', 'audit:dev'],
|
|
35
|
+
'audit:dev': 'pnpm exec v11y --dev',
|
|
36
|
+
'audit:prod': 'pnpm exec v11y --prod',
|
|
37
|
+
build: ['-R build'],
|
|
38
|
+
check: [LINT_CHECK_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
|
|
39
|
+
'check:strict': [LINT_STRICT_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
|
|
40
|
+
ci: [{ run: 'build', shouldDeclineArguments: true }, 'check:strict'],
|
|
41
|
+
clean: 'nmr-clean',
|
|
42
|
+
fix: ['lint', 'fmt'],
|
|
43
|
+
'fix:check': ['fmt:check', 'lint:check'],
|
|
44
|
+
fmt: 'nmr-fmt --write',
|
|
45
|
+
'fmt:check': 'nmr-fmt --check',
|
|
46
|
+
lint: 'eslint --fix .',
|
|
47
|
+
'lint:check': 'eslint .',
|
|
48
|
+
'lint:strict': 'strict-lint',
|
|
49
|
+
prepush: [{ run: 'audit', shouldDeclineArguments: true }, 'ci'],
|
|
50
|
+
'report-overrides': 'nmr-report-overrides',
|
|
51
|
+
'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', 'root:test'],
|
|
52
|
+
'root:lint': `eslint --fix ${ignorePatterns}.`,
|
|
53
|
+
'root:lint:check': `eslint ${ignorePatterns}.`,
|
|
54
|
+
'root:lint:strict': `strict-lint ${ignorePatterns}.`,
|
|
55
|
+
'root:test': `vitest --config ./vitest.root.config.ts ${GATE_PROJECTS}`,
|
|
56
|
+
'root:test:all': 'vitest --config ./vitest.root.config.ts',
|
|
57
|
+
'root:test:tool': 'vitest --config ./vitest.root.config.ts --project tool',
|
|
58
|
+
'root:test:unit': 'vitest --config ./vitest.root.config.ts --project unit',
|
|
59
|
+
'root:typecheck': 'tsgo --noEmit',
|
|
60
|
+
'root:upgrade': 'nmr-report-overrides && nmr-taze',
|
|
61
|
+
test: ['root:test', '-R test'],
|
|
62
|
+
'test:all': ['root:test:all', '-R test:all'],
|
|
63
|
+
'test:coverage': ['root:test', '-R test:coverage'],
|
|
64
|
+
'test:tool': ['root:test:tool', '-R test:tool'],
|
|
65
|
+
'test:unit': ['root:test:unit', '-R test:unit'],
|
|
66
|
+
'test:watch': `vitest ${GATE_PROJECTS} --watch`,
|
|
67
|
+
typecheck: [ROOT_TYPECHECK_STEP, { run: '-R typecheck', shouldDeclineArguments: true }],
|
|
68
|
+
upgrade: 'nmr-report-overrides && nmr-taze --recursive',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { NmrConfig } from './types.js';
|
|
2
|
-
export type { BuildConfig, CheckCacheConfig, CommandVerbosity, NmrConfig, OutputConfig, ScriptValue, StepSpec, } from './types.js';
|
|
2
|
+
export type { BuildConfig, CheckCacheConfig, CommandVerbosity, NmrConfig, OutputConfig, ScriptValue, SelfHostedRunnerConfig, StepSpec, } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Returns `config` unchanged, typing a configuration file's default export as `NmrConfig`.
|
|
5
5
|
*
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carries the entries that a run is inside, from the outermost inward, down to every process that it spawns. An
|
|
3
|
+
* entry that finds itself already on the chain would re-enter itself without bound.
|
|
4
|
+
*/
|
|
5
|
+
export declare const ENTRY_CHAIN_ENV_VAR = "NMR_ENTRY_CHAIN";
|
|
6
|
+
/** A command that an invocation resolved, and the directory whose registry it resolved the command from. */
|
|
7
|
+
export interface ChainEntry {
|
|
8
|
+
command: string;
|
|
9
|
+
dir: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Admits an invocation onto the chain that it inherited, returning the environment that its steps inherit in turn.
|
|
13
|
+
* Throws a `UserError` when an ancestor in the run is already running the same command from the same directory, or
|
|
14
|
+
* when the inherited chain cannot be read.
|
|
15
|
+
*
|
|
16
|
+
* Records the directory's real path, so that a symlinked checkout or a `/private` prefix seen by one process and not
|
|
17
|
+
* another cannot hide a repeat.
|
|
18
|
+
*/
|
|
19
|
+
export declare function enterChain(options: {
|
|
20
|
+
anchorDir: string;
|
|
21
|
+
command: string;
|
|
22
|
+
env: NodeJS.ProcessEnv;
|
|
23
|
+
monorepoRoot: string;
|
|
24
|
+
}): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
4
|
+
import { UserError } from "./UserError.js";
|
|
5
|
+
export const ENTRY_CHAIN_ENV_VAR = 'NMR_ENTRY_CHAIN';
|
|
6
|
+
export function enterChain(options) {
|
|
7
|
+
const { anchorDir, command, env, monorepoRoot } = options;
|
|
8
|
+
const chain = readEntryChain(env[ENTRY_CHAIN_ENV_VAR]);
|
|
9
|
+
if (chain === undefined) {
|
|
10
|
+
throw new UserError(`${ENTRY_CHAIN_ENV_VAR} is not a chain that nmr wrote; unset it. nmr passes it to the processes that it starts.`);
|
|
11
|
+
}
|
|
12
|
+
const entry = { command, dir: realpathSync(anchorDir) };
|
|
13
|
+
const extendedChain = [...chain, entry];
|
|
14
|
+
if (chain.some((ancestor) => ancestor.command === entry.command && ancestor.dir === entry.dir)) {
|
|
15
|
+
throw new UserError(formatReentryError(extendedChain, realpathSync(monorepoRoot)));
|
|
16
|
+
}
|
|
17
|
+
return { ...env, [ENTRY_CHAIN_ENV_VAR]: JSON.stringify(extendedChain) };
|
|
18
|
+
}
|
|
19
|
+
function describeDir(dir, monorepoRoot) {
|
|
20
|
+
const relativeDir = path.relative(monorepoRoot, dir);
|
|
21
|
+
if (relativeDir === '') {
|
|
22
|
+
return 'root';
|
|
23
|
+
}
|
|
24
|
+
return relativeDir.startsWith('..') || path.isAbsolute(relativeDir) ? dir : relativeDir;
|
|
25
|
+
}
|
|
26
|
+
function formatReentryError(chain, monorepoRoot) {
|
|
27
|
+
const repeat = chain.at(-1);
|
|
28
|
+
if (repeat === undefined) {
|
|
29
|
+
throw new Error('A re-entry error needs the entry that repeats.');
|
|
30
|
+
}
|
|
31
|
+
const repeatDir = describeDir(repeat.dir, monorepoRoot);
|
|
32
|
+
const lines = chain.map((entry) => {
|
|
33
|
+
const isRepeat = entry.command === repeat.command && entry.dir === repeat.dir;
|
|
34
|
+
return ` ${entry.command} in ${describeDir(entry.dir, monorepoRoot)}${isRepeat ? ' (repeat)' : ''}`;
|
|
35
|
+
});
|
|
36
|
+
return [
|
|
37
|
+
`nmr re-entered \`${repeat.command}\` in ${repeatDir}, which this run is already running there, ` +
|
|
38
|
+
'so the run would never end. The entries that this run has entered, outermost first:',
|
|
39
|
+
...lines,
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
function isChainEntry(value) {
|
|
43
|
+
return isObject(value) && typeof value['command'] === 'string' && typeof value['dir'] === 'string';
|
|
44
|
+
}
|
|
45
|
+
function readEntryChain(value) {
|
|
46
|
+
if (value === undefined || value === '') {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(value);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return Array.isArray(parsed) && parsed.every(isChainEntry) ? parsed : undefined;
|
|
57
|
+
}
|
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" | "package" | "clean" | "catalog" | "noop" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
|
|
9
9
|
export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
|
package/dist/esm/help.d.ts
CHANGED
|
@@ -16,4 +16,4 @@ import type { NmrConfig } from './types.js';
|
|
|
16
16
|
* unless it is an npm or pnpm lifecycle script or the active registry
|
|
17
17
|
* contains its name.
|
|
18
18
|
*/
|
|
19
|
-
export declare function generateHelp(config: NmrConfig, packageDir: string | undefined, shouldUseRoot: boolean): string;
|
|
19
|
+
export declare function generateHelp(config: NmrConfig, monorepoRoot: string, packageDir: string | undefined, shouldUseRoot: boolean): string;
|
package/dist/esm/help.js
CHANGED
|
@@ -23,7 +23,7 @@ const LIFECYCLE_SCRIPT_NAMES = new Set([
|
|
|
23
23
|
'uninstall',
|
|
24
24
|
'version',
|
|
25
25
|
]);
|
|
26
|
-
export function generateHelp(config, packageDir, shouldUseRoot) {
|
|
26
|
+
export function generateHelp(config, monorepoRoot, packageDir, shouldUseRoot) {
|
|
27
27
|
const lines = [
|
|
28
28
|
'Usage: nmr [flags] <command> [args...]',
|
|
29
29
|
'',
|
|
@@ -54,12 +54,12 @@ export function generateHelp(config, packageDir, shouldUseRoot) {
|
|
|
54
54
|
hadOverride = true;
|
|
55
55
|
formatRegistry(workspaceRegistry, workspaceMarkedNames, lines);
|
|
56
56
|
lines.push('', 'Root commands:');
|
|
57
|
-
const rootRegistry = filterHooks(buildRootRegistry(config));
|
|
57
|
+
const rootRegistry = filterHooks(buildRootRegistry(config, monorepoRoot));
|
|
58
58
|
const rootMarkedNames = shouldUseRoot ? applyOverrides(rootRegistry, overrides) : new Set();
|
|
59
59
|
if (rootMarkedNames.size > 0)
|
|
60
60
|
hadOverride = true;
|
|
61
61
|
formatRegistry(rootRegistry, rootMarkedNames, lines);
|
|
62
|
-
const activeRegistry = shouldUseRoot ? buildRootRegistry(config) : buildWorkspaceRegistry(config);
|
|
62
|
+
const activeRegistry = shouldUseRoot ? buildRootRegistry(config, monorepoRoot) : buildWorkspaceRegistry(config);
|
|
63
63
|
const unregisteredScripts = collectUnregisteredScripts(packageScripts, activeRegistry);
|
|
64
64
|
if (Object.keys(unregisteredScripts).length > 0) {
|
|
65
65
|
lines.push('', 'Package scripts:');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
/** Which of a child's output streams a recorded chunk arrived on. */
|
|
3
|
+
type StreamName = 'stderr' | 'stdout';
|
|
4
|
+
export interface OutputRecorder {
|
|
5
|
+
/** Destination for the child's stderr. It does not have a descriptor, so a child writing to it is given a pipe. */
|
|
6
|
+
stderr: Writable;
|
|
7
|
+
/** Destination for the child's stdout. It does not have a descriptor, so a child writing to it is given a pipe. */
|
|
8
|
+
stdout: Writable;
|
|
9
|
+
/** Writes every recorded chunk to the destination matching its stream, in the order in which the chunks arrived. */
|
|
10
|
+
replay: (destinations: Record<StreamName, Writable>) => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Creates a pair of in-memory destinations that hold everything written to them, unbounded, so that output
|
|
14
|
+
* produced while other output is streaming can be printed whole afterwards.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createOutputRecorder(): OutputRecorder;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
export function createOutputRecorder() {
|
|
3
|
+
const entries = [];
|
|
4
|
+
function createSink(stream) {
|
|
5
|
+
return new Writable({
|
|
6
|
+
write(chunk, encoding, callback) {
|
|
7
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), encoding);
|
|
8
|
+
entries.push({ chunk: buffer, stream });
|
|
9
|
+
callback();
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
stderr: createSink('stderr'),
|
|
15
|
+
stdout: createSink('stdout'),
|
|
16
|
+
replay: (destinations) => {
|
|
17
|
+
for (const { chunk, stream } of entries) {
|
|
18
|
+
destinations[stream].write(chunk);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a declaration's text as the file stores it, so that a value written across lines is quoted on one line.
|
|
3
|
+
*
|
|
4
|
+
* A diagnostic names a declaration that the reader has to edit, and a JSON string contains an escape sequence where
|
|
5
|
+
* a shell would read a control character. The rendered escape keeps the diagnostic on one line, and it is the text
|
|
6
|
+
* for which the reader will search the file.
|
|
7
|
+
*
|
|
8
|
+
* Apply it only where the text is quoted: A comparison against the declaration reads the unescaped text.
|
|
9
|
+
*/
|
|
10
|
+
export declare function escapeControlCharacters(text: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const NAMED_ESCAPES = new Map([
|
|
2
|
+
['\n', String.raw `\n`],
|
|
3
|
+
['\r', String.raw `\r`],
|
|
4
|
+
['\t', String.raw `\t`],
|
|
5
|
+
]);
|
|
6
|
+
export function escapeControlCharacters(text) {
|
|
7
|
+
let escapedText = text;
|
|
8
|
+
for (const [char, escape] of NAMED_ESCAPES) {
|
|
9
|
+
escapedText = escapedText.split(char).join(escape);
|
|
10
|
+
}
|
|
11
|
+
return escapedText;
|
|
12
|
+
}
|
|
@@ -6,7 +6,9 @@ export type { ScriptRegistry, ScriptValue, StepSpec } from './default-scripts.js
|
|
|
6
6
|
*/
|
|
7
7
|
export declare function getDefaultWorkspaceScripts(): ScriptRegistry;
|
|
8
8
|
/**
|
|
9
|
-
* Returns
|
|
10
|
-
*
|
|
9
|
+
* Returns the default root scripts for the monorepo at `monorepoRoot`, whose root-only lint commands exclude every
|
|
10
|
+
* workspace package that its `pnpm-workspace.yaml` declares. A directory without that manifest yields commands
|
|
11
|
+
* without exclusions rather than an error, so that a caller reading only the command names need not stand in a
|
|
12
|
+
* workspace.
|
|
11
13
|
*/
|
|
12
|
-
export declare function getDefaultRootScripts(): ScriptRegistry;
|
|
14
|
+
export declare function getDefaultRootScripts(monorepoRoot: string): ScriptRegistry;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildRootScripts, workspaceScripts } from "./default-scripts.js";
|
|
2
|
+
import { resolveWorkspace } from "./workspace.js";
|
|
3
|
+
import { toWorkspacePackageGlobs } from "./workspace-globs.js";
|
|
2
4
|
export function getDefaultWorkspaceScripts() {
|
|
3
5
|
return { ...workspaceScripts };
|
|
4
6
|
}
|
|
5
|
-
export function getDefaultRootScripts() {
|
|
6
|
-
|
|
7
|
+
export function getDefaultRootScripts(monorepoRoot) {
|
|
8
|
+
const resolution = resolveWorkspace(monorepoRoot);
|
|
9
|
+
const packageDirs = resolution.kind === 'packages' ? resolution.packageDirs : [];
|
|
10
|
+
return buildRootScripts(toWorkspacePackageGlobs(monorepoRoot, packageDirs));
|
|
7
11
|
}
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -35,8 +35,9 @@ export interface ResolvedScript {
|
|
|
35
35
|
* Expands a script value into the ordered steps that it runs as: a string is one opaque step, and an array is one
|
|
36
36
|
* structural step per element.
|
|
37
37
|
*
|
|
38
|
-
* A bare string element and the `{ run }` spec compose the same step. The spec
|
|
39
|
-
*
|
|
38
|
+
* A bare string element and the `{ run }` spec compose the same step. The spec adds only declarations that
|
|
39
|
+
* position cannot express: what the step does with the invocation's trailing arguments, and whether it runs in
|
|
40
|
+
* the background.
|
|
40
41
|
*/
|
|
41
42
|
export declare function expandScript(script: ScriptValue, isWorkspaceRoot: boolean): readonly Step[];
|
|
42
43
|
/**
|
|
@@ -56,10 +57,10 @@ export declare function readPackageJsonScripts(packageDir: string): Record<strin
|
|
|
56
57
|
*/
|
|
57
58
|
export declare function buildWorkspaceRegistry(config: NmrConfig): ScriptRegistry;
|
|
58
59
|
/**
|
|
59
|
-
* Builds the merged root script registry
|
|
60
|
+
* Builds the merged root script registry for the monorepo at `monorepoRoot`:
|
|
60
61
|
* tier 1 (defaults) + tier 2 (config overrides)
|
|
61
62
|
*/
|
|
62
|
-
export declare function buildRootRegistry(config: NmrConfig): ScriptRegistry;
|
|
63
|
+
export declare function buildRootRegistry(config: NmrConfig, monorepoRoot: string): ScriptRegistry;
|
|
63
64
|
/**
|
|
64
65
|
* Returns a `package.json` entry that re-invokes the command under which it is declared alongside other steps,
|
|
65
66
|
* or `undefined` when the entry does not.
|
package/dist/esm/resolver.js
CHANGED
|
@@ -46,7 +46,10 @@ export function expandScript(script, isWorkspaceRoot) {
|
|
|
46
46
|
}
|
|
47
47
|
return script.map((element) => typeof element === 'string'
|
|
48
48
|
? composeNmrStep(element, isWorkspaceRoot)
|
|
49
|
-
: composeNmrStep(element.run, isWorkspaceRoot,
|
|
49
|
+
: composeNmrStep(element.run, isWorkspaceRoot, {
|
|
50
|
+
shouldDeclineArguments: element.shouldDeclineArguments,
|
|
51
|
+
shouldRunInBackground: element.shouldRunInBackground,
|
|
52
|
+
}));
|
|
50
53
|
}
|
|
51
54
|
export function describeScript(script) {
|
|
52
55
|
return typeof script === 'string' ? script : `[${script.map(describeElement).join(', ')}]`;
|
|
@@ -55,7 +58,11 @@ function describeElement(element) {
|
|
|
55
58
|
if (typeof element === 'string') {
|
|
56
59
|
return element;
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
const notes = [
|
|
62
|
+
...(element.shouldRunInBackground === true ? ['background'] : []),
|
|
63
|
+
...(element.shouldDeclineArguments === true ? ['no args'] : []),
|
|
64
|
+
];
|
|
65
|
+
return notes.length === 0 ? element.run : `${element.run} (${notes.join(', ')})`;
|
|
59
66
|
}
|
|
60
67
|
export function readPackageJsonScripts(packageDir) {
|
|
61
68
|
const file = resolvePackageJsonPath(packageDir);
|
|
@@ -83,9 +90,9 @@ export function buildWorkspaceRegistry(config) {
|
|
|
83
90
|
...config.workspaceScripts,
|
|
84
91
|
};
|
|
85
92
|
}
|
|
86
|
-
export function buildRootRegistry(config) {
|
|
93
|
+
export function buildRootRegistry(config, monorepoRoot) {
|
|
87
94
|
return {
|
|
88
|
-
...getDefaultRootScripts(),
|
|
95
|
+
...getDefaultRootScripts(monorepoRoot),
|
|
89
96
|
...config.rootScripts,
|
|
90
97
|
};
|
|
91
98
|
}
|
|
@@ -101,12 +108,12 @@ export function findChainedSelfReference(packageDir, commandName) {
|
|
|
101
108
|
if (script === undefined) {
|
|
102
109
|
return undefined;
|
|
103
110
|
}
|
|
104
|
-
return readSelfReference({
|
|
111
|
+
return readSelfReference({ commandName, scope: readPackageScope(packageDir), script }) === 'chained'
|
|
105
112
|
? script
|
|
106
113
|
: undefined;
|
|
107
114
|
}
|
|
108
115
|
export function isSelfReferential(script, commandName, packageDir) {
|
|
109
|
-
return readSelfReference({
|
|
116
|
+
return readSelfReference({ commandName, scope: readPackageScope(packageDir), script }) !== undefined;
|
|
110
117
|
}
|
|
111
118
|
export function resolveScript(commandName, registry, packageDir, isWorkspaceRoot) {
|
|
112
119
|
if (packageDir) {
|
|
@@ -130,3 +137,6 @@ export function resolveScript(commandName, registry, packageDir, isWorkspaceRoot
|
|
|
130
137
|
}
|
|
131
138
|
return { origin: { tier: 'registry', key: commandName }, steps: expandScript(registryEntry, isWorkspaceRoot) };
|
|
132
139
|
}
|
|
140
|
+
function readPackageScope(packageDir) {
|
|
141
|
+
return isMonorepoRoot(packageDir) ? 'root' : 'package';
|
|
142
|
+
}
|