@williamthorsen/nmr 0.29.0 → 0.30.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/.readyup/kits/default.js +8 -8
- package/.readyup/manifest.json +1 -1
- package/README.md +41 -29
- package/dist/esm/check-cache.js +2 -1
- package/dist/esm/commands/clean.js +9 -5
- package/dist/esm/config.js +19 -2
- package/dist/esm/default-scripts.js +7 -7
- package/dist/esm/resolver.d.ts +4 -2
- package/dist/esm/resolver.js +11 -5
- package/dist/esm/runCli.d.ts +1 -0
- package/dist/esm/runCli.js +70 -33
- package/dist/esm/runner.d.ts +14 -1
- package/dist/esm/runner.js +51 -12
- package/dist/esm/steps.d.ts +11 -0
- package/dist/esm/steps.js +31 -0
- package/dist/esm/verbosity.d.ts +11 -0
- package/dist/esm/verbosity.js +17 -0
- package/package.json +2 -2
package/.readyup/kits/default.js
CHANGED
|
@@ -48,7 +48,7 @@ var rootScripts = {
|
|
|
48
48
|
audit: ["audit:prod", "audit:dev"],
|
|
49
49
|
"audit:dev": "pnpm exec v11y --dev",
|
|
50
50
|
"audit:prod": "pnpm exec v11y --prod",
|
|
51
|
-
build: "
|
|
51
|
+
build: ["-R build"],
|
|
52
52
|
check: ["typecheck", "fmt:check", "lint:check", "test"],
|
|
53
53
|
"check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage"],
|
|
54
54
|
// Excludes the audit, which in CI has a workflow of its own.
|
|
@@ -74,13 +74,13 @@ var rootScripts = {
|
|
|
74
74
|
"root:test:unit": "vitest --config ./vitest.root.config.ts --project unit",
|
|
75
75
|
"root:typecheck": "tsgo --noEmit",
|
|
76
76
|
"root:upgrade": "nmr-taze --include-locked",
|
|
77
|
-
test: "
|
|
78
|
-
"test:all": "
|
|
79
|
-
"test:coverage": "
|
|
80
|
-
"test:tool": "
|
|
81
|
-
"test:unit": "
|
|
77
|
+
test: ["root:test", "-R test"],
|
|
78
|
+
"test:all": ["root:test:all", "-R test:all"],
|
|
79
|
+
"test:coverage": ["root:test", "-R test:coverage"],
|
|
80
|
+
"test:tool": ["root:test:tool", "-R test:tool"],
|
|
81
|
+
"test:unit": ["root:test:unit", "-R test:unit"],
|
|
82
82
|
"test:watch": `vitest ${GATE_PROJECTS} --watch`,
|
|
83
|
-
typecheck: "
|
|
83
|
+
typecheck: ["root:typecheck", "-R typecheck"],
|
|
84
84
|
// The command must be a string rather than a composite: Passthrough args attach to the chain's last command.
|
|
85
85
|
upgrade: "nmr-report-overrides && nmr-taze --include-locked --recursive"
|
|
86
86
|
};
|
|
@@ -376,7 +376,7 @@ function formatPaths(paths) {
|
|
|
376
376
|
${paths.map((path2) => ` ${path2}`).join("\n")}`;
|
|
377
377
|
}
|
|
378
378
|
function getMinVersion() {
|
|
379
|
-
const picked = { "version": "0.
|
|
379
|
+
const picked = { "version": "0.30.0" };
|
|
380
380
|
if (typeof picked["version"] !== "string") {
|
|
381
381
|
throw new TypeError("nmr/package.json: 'version' must be a string");
|
|
382
382
|
}
|
package/.readyup/manifest.json
CHANGED
package/README.md
CHANGED
|
@@ -3,23 +3,25 @@
|
|
|
3
3
|
Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
|
|
4
4
|
|
|
5
5
|
<!-- section:release-notes -->
|
|
6
|
-
## Release notes — v0.
|
|
6
|
+
## Release notes — v0.30.0 (2026-08-10)
|
|
7
7
|
|
|
8
8
|
### 🎉 Features
|
|
9
9
|
|
|
10
|
-
-
|
|
10
|
+
- 🚨 **Breaking:** Resolve a command to a step list and reject a script element carrying shell syntax (#648)
|
|
11
11
|
|
|
12
|
-
`nmr
|
|
12
|
+
A script defined in `.config/nmr.config.ts` as a list of nmr commands now holds one command name per entry, optionally preceded by nmr's own flags. An entry that carries a quoted argument or any other shell syntax previously ran as written and is now rejected when the config loads. Before upgrading, a repo relying on such an entry should define that command as a script of its own and reference it by name from the list.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
- 🚨 **Breaking:** Sequence a command's steps in nmr rather than in a shell (#649)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
When a run in quiet mode fails, it now shows only the output of the command that failed instead of everything the run had produced up to that point. Quiet mode can now be set for a whole shell session with the `NMR_COMMAND_VERBOSITY` environment variable, which accepts `full` or `quiet`; `-q` still takes precedence for a single run, and an unrecognized value is reported as an error. Interrupting a run now stops the commands that would have followed it.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Breaking: a `devBin` entry that maps `nmr` itself no longer takes effect inside a command that runs other nmr commands. Mapping any other tool (the documented use) is unaffected.
|
|
19
19
|
|
|
20
|
-
-
|
|
20
|
+
- 🚨 **Breaking:** Express the seven root defaults as steps (#650)
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Running `nmr build`, `nmr test`, `nmr test:all`, `nmr test:coverage`, `nmr test:tool`, `nmr test:unit`, or `nmr typecheck` from a monorepo root now passes over a package that does not define the command, instead of failing there. The first run of `test`, `test:coverage`, `test:tool`, `test:unit`, or `typecheck` after the upgrade no longer skips as already passed; skipping resumes on the run after.
|
|
23
|
+
|
|
24
|
+
`nmr` now also warns when a command it is about to run invokes `nmr` again through a shell, where a failure reports the whole inner run's output instead of the failing command's. The warning catches only a direct `nmr` call, so a command that reaches `nmr` through another program goes unreported.
|
|
23
25
|
<!-- /section:release-notes -->
|
|
24
26
|
|
|
25
27
|
## Installation
|
|
@@ -67,9 +69,9 @@ nmr's key feature is that the same command runs different scripts depending on w
|
|
|
67
69
|
|
|
68
70
|
| Where you run `nmr` | Registry used | Working directory | `nmr test` runs |
|
|
69
71
|
| --------------------------------- | ----------------- | ----------------- | -------------------------------------------------------------------- |
|
|
70
|
-
| Monorepo root | Root scripts | Monorepo root |
|
|
72
|
+
| Monorepo root | Root scripts | Monorepo root | `root:test`, then `-R test` |
|
|
71
73
|
| Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest --project unit --project tool` (that package only) |
|
|
72
|
-
| Anywhere, with `--workspace-root` | Root scripts | Monorepo root |
|
|
74
|
+
| Anywhere, with `--workspace-root` | Root scripts | Monorepo root | `root:test`, then `-R test` |
|
|
73
75
|
|
|
74
76
|
Relative paths in a script resolve against that working directory, not the invocation directory.
|
|
75
77
|
|
|
@@ -116,7 +118,13 @@ Script values can be `string` or `string[]`. Arrays expand to chained `nmr` sub-
|
|
|
116
118
|
// expands to: nmr lint && nmr fmt
|
|
117
119
|
```
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
Each array element is a command name, optionally preceded by nmr's own flags, split on whitespace — `-R test` runs the element in every package, the way `nmr -R test` does. An element carrying a quoted argument or shell syntax is rejected when the config loads: name that command as a script of its own and reference the name.
|
|
122
|
+
|
|
123
|
+
Passthrough arguments attach to the final step alone: `nmr fix --dry-run` runs `nmr lint`, then `nmr fmt --dry-run`.
|
|
124
|
+
|
|
125
|
+
A string value is spawned through a shell as one command, so an `nmr` invocation inside it is invisible to the process above: a quiet failure surrenders the whole nested subtree rather than the failing step, and a loud run relays the nested tree through a forwarding pipe. nmr writes one line to stderr, in every verbosity, when a command resolves to a step whose first token is `nmr`.
|
|
126
|
+
|
|
127
|
+
That line reports a boundary rather than enforcing a rule, and it is partial: a step reaching nmr through another program, such as `pnpm --recursive exec nmr build`, goes unreported. Only the built-in defaults and `.config/nmr.config.ts` carry arrays, so a `package.json` script embedding nmr has nowhere to move but up a tier.
|
|
120
128
|
|
|
121
129
|
## Configuration
|
|
122
130
|
|
|
@@ -186,7 +194,7 @@ For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites
|
|
|
186
194
|
|
|
187
195
|
## Pre and post hooks
|
|
188
196
|
|
|
189
|
-
Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same three tiers as any other script (built-in defaults, then `.config/nmr.config.ts`, then per-package `package.json`). Wrapping is uniform: nested invocations from composite expansion get their own hook treatment.
|
|
197
|
+
Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same three tiers as any other script (built-in defaults, then `.config/nmr.config.ts`, then per-package `package.json`). Wrapping is uniform: nested invocations from composite expansion get their own hook treatment. A failing hook ends the sequence, and its exit code propagates.
|
|
190
198
|
|
|
191
199
|
Behaviors worth knowing:
|
|
192
200
|
|
|
@@ -315,6 +323,10 @@ Reach for these in order; the first is almost always the right one.
|
|
|
315
323
|
|
|
316
324
|
`NMR_TREE_SNAPSHOT` is nmr's own: it carries one observation of the tree from a top-level invocation down to the processes it spawns, so a chain hashes the tree once rather than at every link. nmr trusts an inherited value only while `HEAD` still stands where it did when the observation was taken, which bounds a process that outlives the run that spawned it. That bound does not extend to a tree edited without committing, so **a process that survives its run and later invokes nmr should clear `NMR_TREE_SNAPSHOT`** — a test suite that shells out to `nmr` is the case worth checking.
|
|
317
325
|
|
|
326
|
+
`NMR_COMMAND_VERBOSITY` is nmr's own as well, and carries how loudly a run reports the output of the commands it runs. Its values are `full` and `quiet`; any other non-empty value is reported and exits 1, rather than falling back to a mode nobody chose, and an empty value reads as unset. Each process in a chain suppresses the output of the command it runs rather than of everything below it, so a failure still surrenders the failing command's output. nmr's own messages — the skip line, the override notice, and the `""`/`":"` skip notices — keep the suppression `-q` has always given them.
|
|
327
|
+
|
|
328
|
+
`-q` sets it for the run and outranks an inherited value. Both values are spelled out so either direction is expressible: export `NMR_COMMAND_VERBOSITY=quiet` for a quiet shell, and set `full` on one invocation to opt back out.
|
|
329
|
+
|
|
318
330
|
### Configuring
|
|
319
331
|
|
|
320
332
|
```ts
|
|
@@ -427,12 +439,12 @@ A run that collects no test files passes. That is what lets `nmr test:tool` fan
|
|
|
427
439
|
|
|
428
440
|
#### Build and CI
|
|
429
441
|
|
|
430
|
-
| Command | Runs
|
|
431
|
-
| --------- |
|
|
432
|
-
| `build` |
|
|
433
|
-
| `ci` | `build`, `check:strict`
|
|
434
|
-
| `clean` | `nmr-clean`
|
|
435
|
-
| `prepush` | `audit`, `ci`
|
|
442
|
+
| Command | Runs |
|
|
443
|
+
| --------- | ----------------------- |
|
|
444
|
+
| `build` | `-R build` |
|
|
445
|
+
| `ci` | `build`, `check:strict` |
|
|
446
|
+
| `clean` | `nmr-clean` |
|
|
447
|
+
| `prepush` | `audit`, `ci` |
|
|
436
448
|
|
|
437
449
|
`ci` is what a code-quality workflow runs; it leaves out the audit, which belongs in a workflow of its own. `prepush` is what a developer runs before pushing: both gates, audit first. The audit takes seconds and `ci` takes minutes, so a vulnerability surfaces before the long gate runs. It composes `ci` rather than restating its stages, so a stage added to `ci` joins the pre-push run too.
|
|
438
450
|
|
|
@@ -457,14 +469,14 @@ Neither is bound to a git hook. `prepush` is named for when you run it, not for
|
|
|
457
469
|
|
|
458
470
|
The same six names the workspace registry carries, so a command means the same thing from the monorepo root as from inside a package. Each fans out to root-level files and every workspace package.
|
|
459
471
|
|
|
460
|
-
| Command | Runs
|
|
461
|
-
| --------------- |
|
|
462
|
-
| `test` | `
|
|
463
|
-
| `test:all` | `
|
|
464
|
-
| `test:coverage` | `
|
|
465
|
-
| `test:tool` | `
|
|
466
|
-
| `test:unit` | `
|
|
467
|
-
| `test:watch` | `vitest --project unit --project tool --watch`
|
|
472
|
+
| Command | Runs |
|
|
473
|
+
| --------------- | ---------------------------------------------- |
|
|
474
|
+
| `test` | `root:test`, `-R test` |
|
|
475
|
+
| `test:all` | `root:test:all`, `-R test:all` |
|
|
476
|
+
| `test:coverage` | `root:test`, `-R test:coverage` |
|
|
477
|
+
| `test:tool` | `root:test:tool`, `-R test:tool` |
|
|
478
|
+
| `test:unit` | `root:test:unit`, `-R test:unit` |
|
|
479
|
+
| `test:watch` | `vitest --project unit --project tool --watch` |
|
|
468
480
|
|
|
469
481
|
`test:coverage` chains `root:test` rather than a `root:test:coverage`, because the root config reports no coverage of its own; packages cover their own sources.
|
|
470
482
|
|
|
@@ -472,9 +484,9 @@ The same six names the workspace registry carries, so a command means the same t
|
|
|
472
484
|
|
|
473
485
|
#### Typecheck
|
|
474
486
|
|
|
475
|
-
| Command | Runs
|
|
476
|
-
| ----------- |
|
|
477
|
-
| `typecheck` | `
|
|
487
|
+
| Command | Runs |
|
|
488
|
+
| ----------- | -------------------------------- |
|
|
489
|
+
| `typecheck` | `root:typecheck`, `-R typecheck` |
|
|
478
490
|
|
|
479
491
|
#### Lint
|
|
480
492
|
|
package/dist/esm/check-cache.js
CHANGED
|
@@ -9,6 +9,7 @@ import { formatDuration, formatSaving } from "./helpers/duration.js";
|
|
|
9
9
|
import { isObject, isStringRecord } from "./helpers/type-guards.js";
|
|
10
10
|
import { getDefaultWorkspaceScripts } from "./resolve-scripts.js";
|
|
11
11
|
import { buildWorkspaceRegistry, resolveScript } from "./resolver.js";
|
|
12
|
+
import { renderChain } from "./steps.js";
|
|
12
13
|
import { getWorkspacePackageDirs } from "./workspace.js";
|
|
13
14
|
export const CURRENT_RUNTIME = {
|
|
14
15
|
arch: process.arch,
|
|
@@ -196,7 +197,7 @@ function isProbeSubject(packageDir, registry) {
|
|
|
196
197
|
return false;
|
|
197
198
|
}
|
|
198
199
|
const compile = resolveScript('compile', registry, packageDir, false);
|
|
199
|
-
return compile !== undefined && compile.
|
|
200
|
+
return compile !== undefined && renderChain(compile.steps) === BUILT_IN_COMPILE;
|
|
200
201
|
}
|
|
201
202
|
function resolveEntryPath(options) {
|
|
202
203
|
const anchorDir = path.resolve(options.anchorDir);
|
|
@@ -4,7 +4,8 @@ import { removeCheckCache } from "../check-cache.js";
|
|
|
4
4
|
import { loadRootConfig } from "../config.js";
|
|
5
5
|
import { findContainingPackageDir } from "../context.js";
|
|
6
6
|
import { applyDevBin, buildWorkspaceRegistry, resolveScript } from "../resolver.js";
|
|
7
|
-
import { runCommand } from "../runner.js";
|
|
7
|
+
import { resolveChannel, runCommand } from "../runner.js";
|
|
8
|
+
import { renderChain } from "../steps.js";
|
|
8
9
|
import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
|
|
9
10
|
import { resolveBuildCachePath } from "./build-output.js";
|
|
10
11
|
const CLEAN_ICON = '🧹';
|
|
@@ -44,15 +45,18 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
|
|
|
44
45
|
const registry = buildWorkspaceRegistry(config);
|
|
45
46
|
for (const packageDir of workspacePackageDirs) {
|
|
46
47
|
const resolved = resolveScript(CLEAN_COMMAND, registry, packageDir, false);
|
|
47
|
-
|
|
48
|
+
const resolvedCommand = resolved === undefined ? undefined : renderChain(resolved.steps);
|
|
49
|
+
if (resolvedCommand === undefined || resolvedCommand === '') {
|
|
48
50
|
continue;
|
|
49
51
|
}
|
|
50
|
-
if (
|
|
52
|
+
if (resolvedCommand === BUILT_IN_CLEAN) {
|
|
51
53
|
await cleanPackage(packageDir);
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
54
|
-
const command = applyDevBin(
|
|
55
|
-
const { exitCode } = await runCommand(command, packageDir
|
|
56
|
+
const command = applyDevBin(resolvedCommand, config.devBin, monorepoRoot);
|
|
57
|
+
const { exitCode } = await runCommand(command, packageDir, {
|
|
58
|
+
channels: { stderr: resolveChannel(process.stderr, false), stdout: resolveChannel(process.stdout, false) },
|
|
59
|
+
});
|
|
56
60
|
if (exitCode !== 0) {
|
|
57
61
|
throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
|
|
58
62
|
}
|
package/dist/esm/config.js
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync } from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { isObject, isStringRecord } from "./helpers/type-guards.js";
|
|
13
|
+
import { findUnexpressibleToken } from "./steps.js";
|
|
13
14
|
const CONFIG_FILENAME = 'nmr.config.ts';
|
|
14
15
|
const CONFIG_DIR = '.config';
|
|
15
16
|
const CONFIG_TIERS = {
|
|
@@ -45,10 +46,26 @@ function validateScriptField(value, fieldName, configPath) {
|
|
|
45
46
|
if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
|
|
46
47
|
return undefined;
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
+
const scripts = value[fieldName];
|
|
50
|
+
if (!isScriptRecord(scripts)) {
|
|
49
51
|
throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | string[]>`);
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
assertExpressibleElements(scripts, fieldName, configPath);
|
|
54
|
+
return scripts;
|
|
55
|
+
}
|
|
56
|
+
function assertExpressibleElements(scripts, fieldName, configPath) {
|
|
57
|
+
for (const [command, script] of Object.entries(scripts)) {
|
|
58
|
+
if (typeof script === 'string')
|
|
59
|
+
continue;
|
|
60
|
+
for (const element of script) {
|
|
61
|
+
const token = findUnexpressibleToken(element);
|
|
62
|
+
if (token === undefined)
|
|
63
|
+
continue;
|
|
64
|
+
throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}.${command}\` element \`${element}\` carries ` +
|
|
65
|
+
`\`${token}\`, which is neither a command name nor an nmr flag. Name it as a script of its own and ` +
|
|
66
|
+
`reference that name here.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
52
69
|
}
|
|
53
70
|
function isStringArray(value) {
|
|
54
71
|
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
@@ -26,7 +26,7 @@ export const rootScripts = {
|
|
|
26
26
|
audit: ['audit:prod', 'audit:dev'],
|
|
27
27
|
'audit:dev': 'pnpm exec v11y --dev',
|
|
28
28
|
'audit:prod': 'pnpm exec v11y --prod',
|
|
29
|
-
build: '
|
|
29
|
+
build: ['-R build'],
|
|
30
30
|
check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
|
|
31
31
|
'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
|
|
32
32
|
ci: ['build', 'check:strict'],
|
|
@@ -50,12 +50,12 @@ export const rootScripts = {
|
|
|
50
50
|
'root:test:unit': 'vitest --config ./vitest.root.config.ts --project unit',
|
|
51
51
|
'root:typecheck': 'tsgo --noEmit',
|
|
52
52
|
'root:upgrade': 'nmr-taze --include-locked',
|
|
53
|
-
test: '
|
|
54
|
-
'test:all': '
|
|
55
|
-
'test:coverage': '
|
|
56
|
-
'test:tool': '
|
|
57
|
-
'test:unit': '
|
|
53
|
+
test: ['root:test', '-R test'],
|
|
54
|
+
'test:all': ['root:test:all', '-R test:all'],
|
|
55
|
+
'test:coverage': ['root:test', '-R test:coverage'],
|
|
56
|
+
'test:tool': ['root:test:tool', '-R test:tool'],
|
|
57
|
+
'test:unit': ['root:test:unit', '-R test:unit'],
|
|
58
58
|
'test:watch': `vitest ${GATE_PROJECTS} --watch`,
|
|
59
|
-
typecheck: '
|
|
59
|
+
typecheck: ['root:typecheck', '-R typecheck'],
|
|
60
60
|
upgrade: 'nmr-report-overrides && nmr-taze --include-locked --recursive',
|
|
61
61
|
};
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { ScriptRegistry, ScriptValue } from './resolve-scripts.js';
|
|
2
|
+
import type { Step } from './steps.js';
|
|
2
3
|
import type { NmrConfig } from './types.js';
|
|
3
4
|
export declare function applyDevBin(command: string, devBin: Record<string, string> | undefined, monorepoRoot: string): string;
|
|
5
|
+
export declare function applyDevBinToSteps(steps: readonly Step[], devBin: Record<string, string> | undefined, monorepoRoot: string): readonly Step[];
|
|
4
6
|
export interface ResolvedScript {
|
|
5
|
-
command: string;
|
|
6
7
|
source: 'default' | 'package';
|
|
8
|
+
steps: readonly Step[];
|
|
7
9
|
}
|
|
8
|
-
export declare function expandScript(script: ScriptValue, workspaceRoot: boolean):
|
|
10
|
+
export declare function expandScript(script: ScriptValue, workspaceRoot: boolean): readonly Step[];
|
|
9
11
|
export declare function describeScript(script: ScriptValue): string;
|
|
10
12
|
export declare function readPackageJsonScripts(packageDir: string): Record<string, string> | undefined;
|
|
11
13
|
export declare function buildWorkspaceRegistry(config: NmrConfig): ScriptRegistry;
|
package/dist/esm/resolver.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { isObject } from "./helpers/type-guards.js";
|
|
4
4
|
import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
|
|
5
|
+
import { composeNmrStep } from "./steps.js";
|
|
5
6
|
export function applyDevBin(command, devBin, monorepoRoot) {
|
|
6
7
|
if (!devBin) {
|
|
7
8
|
return command;
|
|
@@ -16,6 +17,12 @@ export function applyDevBin(command, devBin, monorepoRoot) {
|
|
|
16
17
|
const resolvedReplacement = resolveReplacementPaths(replacement, monorepoRoot);
|
|
17
18
|
return resolvedReplacement + rest;
|
|
18
19
|
}
|
|
20
|
+
export function applyDevBinToSteps(steps, devBin, monorepoRoot) {
|
|
21
|
+
if (!devBin) {
|
|
22
|
+
return steps;
|
|
23
|
+
}
|
|
24
|
+
return steps.map((step) => step.kind === 'opaque' ? { kind: 'opaque', command: applyDevBin(step.command, devBin, monorepoRoot) } : step);
|
|
25
|
+
}
|
|
19
26
|
function resolveReplacementPaths(replacement, monorepoRoot) {
|
|
20
27
|
const tokens = replacement.split(' ');
|
|
21
28
|
return tokens
|
|
@@ -32,10 +39,9 @@ function resolveReplacementPaths(replacement, monorepoRoot) {
|
|
|
32
39
|
}
|
|
33
40
|
export function expandScript(script, workspaceRoot) {
|
|
34
41
|
if (typeof script === 'string') {
|
|
35
|
-
return script;
|
|
42
|
+
return [{ kind: 'opaque', command: script }];
|
|
36
43
|
}
|
|
37
|
-
|
|
38
|
-
return script.map((s) => `nmr ${flag}${s}`).join(' && ');
|
|
44
|
+
return script.map((element) => composeNmrStep(element, workspaceRoot));
|
|
39
45
|
}
|
|
40
46
|
export function describeScript(script) {
|
|
41
47
|
return typeof script === 'string' ? script : `[${script.join(', ')}]`;
|
|
@@ -85,7 +91,7 @@ export function resolveScript(commandName, registry, packageDir, workspaceRoot)
|
|
|
85
91
|
if (pkgScripts && Object.hasOwn(pkgScripts, commandName)) {
|
|
86
92
|
const override = pkgScripts[commandName];
|
|
87
93
|
if (override !== undefined && !isSelfReferential(override, commandName)) {
|
|
88
|
-
return {
|
|
94
|
+
return { source: 'package', steps: [{ kind: 'opaque', command: override }] };
|
|
89
95
|
}
|
|
90
96
|
}
|
|
91
97
|
}
|
|
@@ -96,5 +102,5 @@ export function resolveScript(commandName, registry, packageDir, workspaceRoot)
|
|
|
96
102
|
if (registryEntry === undefined) {
|
|
97
103
|
return undefined;
|
|
98
104
|
}
|
|
99
|
-
return {
|
|
105
|
+
return { source: 'default', steps: expandScript(registryEntry, workspaceRoot) };
|
|
100
106
|
}
|
package/dist/esm/runCli.d.ts
CHANGED
package/dist/esm/runCli.js
CHANGED
|
@@ -4,9 +4,12 @@ import { computeCacheKey, CURRENT_RUNTIME, encodeTreeSnapshot, findStaleBuildOut
|
|
|
4
4
|
import { resolveContext } from "./context.js";
|
|
5
5
|
import { generateHelp } from "./help.js";
|
|
6
6
|
import { isHookName } from "./helpers/hook-name.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
|
|
8
|
+
import { runSteps } from "./runner.js";
|
|
9
|
+
import { composeNmrStep, findShelledNmrStep, renderChain } from "./steps.js";
|
|
10
|
+
import { COMMAND_VERBOSITY_ENV_VAR, resolveVerbosity } from "./verbosity.js";
|
|
9
11
|
const VERSION = readPackageVersion(import.meta.url);
|
|
12
|
+
export const RUN_IF_PRESENT_ENV_VAR = 'NMR_RUN_IF_PRESENT';
|
|
10
13
|
export async function runCli(options) {
|
|
11
14
|
const { args, cwd, env, stdout, stderr } = options;
|
|
12
15
|
const parseResult = parseArgs(args);
|
|
@@ -15,10 +18,16 @@ export async function runCli(options) {
|
|
|
15
18
|
return { exitCode: 1 };
|
|
16
19
|
}
|
|
17
20
|
const { parsed } = parseResult;
|
|
21
|
+
const verbosity = resolveVerbosity(env, parsed.quiet);
|
|
22
|
+
if (!verbosity.ok) {
|
|
23
|
+
reportError(verbosity.error, stderr);
|
|
24
|
+
return { exitCode: 1 };
|
|
25
|
+
}
|
|
18
26
|
if (parsed.version) {
|
|
19
27
|
stdout.write(`${VERSION}\n`);
|
|
20
28
|
return { exitCode: 0 };
|
|
21
29
|
}
|
|
30
|
+
const quiet = verbosity.verbosity === 'quiet';
|
|
22
31
|
const context = await resolveContext(cwd);
|
|
23
32
|
const useRoot = parsed.workspaceRoot || context.isRoot;
|
|
24
33
|
const anchorDir = useRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
|
|
@@ -27,7 +36,6 @@ export async function runCli(options) {
|
|
|
27
36
|
return { exitCode: 0 };
|
|
28
37
|
}
|
|
29
38
|
const { command } = parsed;
|
|
30
|
-
const passthrough = parsed.passthrough.length > 0 ? ' ' + parsed.passthrough.map(shellQuote).join(' ') : '';
|
|
31
39
|
const snapshot = openGate({
|
|
32
40
|
command,
|
|
33
41
|
config: context.config,
|
|
@@ -37,38 +45,48 @@ export async function runCli(options) {
|
|
|
37
45
|
stderr,
|
|
38
46
|
});
|
|
39
47
|
const noCache = parsed.noCache || env[NO_CACHE_ENV_VAR] === '1';
|
|
40
|
-
const childEnv = buildChildEnv(env, snapshot, noCache);
|
|
41
|
-
const runOptions = {
|
|
48
|
+
const childEnv = buildChildEnv(env, snapshot, noCache, verbosity.verbosity);
|
|
49
|
+
const runOptions = {
|
|
50
|
+
quiet,
|
|
51
|
+
stdout,
|
|
52
|
+
stderr,
|
|
53
|
+
env: childEnv,
|
|
54
|
+
};
|
|
42
55
|
if (parsed.filter) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
return { exitCode };
|
|
56
|
+
const delegate = composeDelegate(['--filter', parsed.filter], command, parsed.passthrough);
|
|
57
|
+
return runSteps([delegate], context.monorepoRoot, runOptions);
|
|
46
58
|
}
|
|
47
59
|
if (parsed.recursive) {
|
|
48
|
-
const delegateEnv = { ...childEnv,
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
return { exitCode };
|
|
60
|
+
const delegateEnv = { ...childEnv, [RUN_IF_PRESENT_ENV_VAR]: '1' };
|
|
61
|
+
const delegate = composeDelegate(['--recursive'], command, parsed.passthrough);
|
|
62
|
+
return runSteps([delegate], context.monorepoRoot, { ...runOptions, env: delegateEnv });
|
|
52
63
|
}
|
|
53
64
|
const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
|
|
54
65
|
const resolved = resolveScript(command, registry, anchorDir, parsed.workspaceRoot);
|
|
55
66
|
if (!resolved) {
|
|
56
|
-
if (env[
|
|
67
|
+
if (env[RUN_IF_PRESENT_ENV_VAR] === '1') {
|
|
57
68
|
return { exitCode: 0 };
|
|
58
69
|
}
|
|
59
70
|
reportError(`Unknown command: ${command}`, stderr);
|
|
60
71
|
return { exitCode: 1 };
|
|
61
72
|
}
|
|
62
|
-
const
|
|
73
|
+
const resolvedCommand = renderChain(resolved.steps);
|
|
74
|
+
const skipExitCode = handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout);
|
|
63
75
|
if (skipExitCode !== undefined) {
|
|
64
76
|
return { exitCode: skipExitCode };
|
|
65
77
|
}
|
|
66
|
-
const
|
|
67
|
-
const
|
|
78
|
+
const substitutedSteps = applyDevBinToSteps(resolved.steps, context.config.devBin, context.monorepoRoot);
|
|
79
|
+
const substitutedCommand = renderChain(substitutedSteps);
|
|
80
|
+
const mainSteps = appendPassthrough(substitutedSteps, parsed.passthrough);
|
|
68
81
|
const isHookInvocation = isHookName(command);
|
|
69
|
-
const
|
|
70
|
-
?
|
|
71
|
-
: wrapWithHooks(command,
|
|
82
|
+
const fullSteps = isHookInvocation
|
|
83
|
+
? mainSteps
|
|
84
|
+
: wrapWithHooks(command, mainSteps, registry, anchorDir, parsed.workspaceRoot);
|
|
85
|
+
const fullCommand = renderChain(fullSteps);
|
|
86
|
+
const shelledStep = findShelledNmrStep(fullSteps);
|
|
87
|
+
if (shelledStep !== undefined) {
|
|
88
|
+
stderr.write(`${formatShelledNmrWarning(command, shelledStep)}\n`);
|
|
89
|
+
}
|
|
72
90
|
const key = resolveCacheKey({
|
|
73
91
|
anchorDir,
|
|
74
92
|
command,
|
|
@@ -77,19 +95,20 @@ export async function runCli(options) {
|
|
|
77
95
|
monorepoRoot: context.monorepoRoot,
|
|
78
96
|
snapshot,
|
|
79
97
|
stderr,
|
|
80
|
-
substitution: substitutedCommand ===
|
|
98
|
+
substitution: substitutedCommand === resolvedCommand ? undefined : substitutedCommand,
|
|
81
99
|
});
|
|
82
100
|
const exitCode = await runGated({
|
|
83
101
|
anchorDir,
|
|
84
102
|
command,
|
|
85
103
|
commandString: fullCommand,
|
|
86
104
|
config: context.config,
|
|
105
|
+
steps: fullSteps,
|
|
87
106
|
env,
|
|
88
107
|
key,
|
|
89
108
|
monorepoRoot: context.monorepoRoot,
|
|
90
109
|
noCache,
|
|
91
|
-
overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir,
|
|
92
|
-
quiet
|
|
110
|
+
overrideNotice: formatOverrideNotice(resolved, registry, command, anchorDir, quiet),
|
|
111
|
+
quiet,
|
|
93
112
|
runOptions,
|
|
94
113
|
snapshot,
|
|
95
114
|
stderr,
|
|
@@ -97,19 +116,37 @@ export async function runCli(options) {
|
|
|
97
116
|
});
|
|
98
117
|
return { exitCode };
|
|
99
118
|
}
|
|
100
|
-
function buildChildEnv(env, snapshot, noCache) {
|
|
119
|
+
function buildChildEnv(env, snapshot, noCache, verbosity) {
|
|
101
120
|
return {
|
|
102
121
|
...env,
|
|
103
122
|
...(snapshot !== undefined && { [TREE_SNAPSHOT_ENV_VAR]: encodeTreeSnapshot(snapshot) }),
|
|
104
123
|
...(noCache && { [NO_CACHE_ENV_VAR]: '1' }),
|
|
124
|
+
[COMMAND_VERBOSITY_ENV_VAR]: verbosity,
|
|
105
125
|
};
|
|
106
126
|
}
|
|
127
|
+
function appendPassthrough(steps, passthrough) {
|
|
128
|
+
const last = steps.at(-1);
|
|
129
|
+
if (passthrough.length === 0 || last === undefined) {
|
|
130
|
+
return steps;
|
|
131
|
+
}
|
|
132
|
+
const bound = last.kind === 'structural'
|
|
133
|
+
? { kind: 'structural', argv: [...last.argv, ...passthrough] }
|
|
134
|
+
: { kind: 'opaque', command: `${last.command} ${passthrough.map(shellQuote).join(' ')}` };
|
|
135
|
+
return [...steps.slice(0, -1), bound];
|
|
136
|
+
}
|
|
137
|
+
function composeDelegate(scope, command, passthrough) {
|
|
138
|
+
return { kind: 'structural', argv: ['pnpm', ...scope, 'exec', 'nmr', command, ...passthrough] };
|
|
139
|
+
}
|
|
107
140
|
function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
|
|
108
141
|
const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
|
|
109
142
|
if (quiet || resolved.source !== 'package' || registryEntry === undefined) {
|
|
110
143
|
return undefined;
|
|
111
144
|
}
|
|
112
|
-
return `📦 ${path.basename(anchorDir)}: Using override script: ${resolved.
|
|
145
|
+
return `📦 ${path.basename(anchorDir)}: Using override script: ${renderChain(resolved.steps)}\n`;
|
|
146
|
+
}
|
|
147
|
+
function formatShelledNmrWarning(command, step) {
|
|
148
|
+
return (`⚠️ ${command}: \`${step}\` runs nmr behind a shell, so nmr handles its output as a tool's rather than a ` +
|
|
149
|
+
`nested run's. A step list in \`.config/nmr.config.ts\` avoids it.`);
|
|
113
150
|
}
|
|
114
151
|
function handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout) {
|
|
115
152
|
if (resolvedCommand === '') {
|
|
@@ -130,7 +167,8 @@ function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
|
|
|
130
167
|
const resolved = resolveScript(hookName, registry, anchorDir, workspaceRoot);
|
|
131
168
|
if (!resolved)
|
|
132
169
|
return false;
|
|
133
|
-
|
|
170
|
+
const chain = renderChain(resolved.steps);
|
|
171
|
+
return chain !== '' && chain !== ':';
|
|
134
172
|
}
|
|
135
173
|
async function lookUpRecordedPass(options) {
|
|
136
174
|
const { anchorDir, buildOutput, command, env, key, monorepoRoot, stderr } = options;
|
|
@@ -332,7 +370,7 @@ async function runGated(options) {
|
|
|
332
370
|
stdout.write(options.overrideNotice);
|
|
333
371
|
}
|
|
334
372
|
const startedAt = Date.now();
|
|
335
|
-
const { exitCode } = await
|
|
373
|
+
const { exitCode } = await runSteps(options.steps, anchorDir, options.runOptions);
|
|
336
374
|
if (exitCode === 0 && gate !== undefined) {
|
|
337
375
|
await recordPass({
|
|
338
376
|
anchorDir,
|
|
@@ -353,15 +391,14 @@ async function runGated(options) {
|
|
|
353
391
|
function shellQuote(arg) {
|
|
354
392
|
return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
|
|
355
393
|
}
|
|
356
|
-
function wrapWithHooks(command,
|
|
357
|
-
const
|
|
358
|
-
const flag = workspaceRoot ? '-w ' : '';
|
|
394
|
+
function wrapWithHooks(command, mainSteps, registry, anchorDir, workspaceRoot) {
|
|
395
|
+
const steps = [];
|
|
359
396
|
if (hasRunnableHook(`${command}:pre`, registry, anchorDir, workspaceRoot)) {
|
|
360
|
-
|
|
397
|
+
steps.push(composeNmrStep(`${command}:pre`, workspaceRoot));
|
|
361
398
|
}
|
|
362
|
-
|
|
399
|
+
steps.push(...mainSteps);
|
|
363
400
|
if (hasRunnableHook(`${command}:post`, registry, anchorDir, workspaceRoot)) {
|
|
364
|
-
|
|
401
|
+
steps.push(composeNmrStep(`${command}:post`, workspaceRoot));
|
|
365
402
|
}
|
|
366
|
-
return
|
|
403
|
+
return steps;
|
|
367
404
|
}
|
package/dist/esm/runner.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { Writable } from 'node:stream';
|
|
2
|
+
import type { Step } from './steps.js';
|
|
3
|
+
export type OutputChannel = number | 'pipe';
|
|
4
|
+
export interface OutputChannels {
|
|
5
|
+
stderr: OutputChannel;
|
|
6
|
+
stdout: OutputChannel;
|
|
7
|
+
}
|
|
2
8
|
export interface RunCommandOptions {
|
|
9
|
+
channels: OutputChannels;
|
|
3
10
|
quiet?: boolean;
|
|
4
11
|
stdout?: Writable;
|
|
5
12
|
stderr?: Writable;
|
|
6
13
|
env?: NodeJS.ProcessEnv;
|
|
7
14
|
}
|
|
15
|
+
export type RunStepsOptions = Omit<RunCommandOptions, 'channels'>;
|
|
8
16
|
type CommandCompletion = {
|
|
9
17
|
outcome: 'exited';
|
|
10
18
|
exitCode: number;
|
|
@@ -21,5 +29,10 @@ export type RunCommandResult = CommandCompletion & {
|
|
|
21
29
|
stdout: Buffer | undefined;
|
|
22
30
|
stderr: Buffer | undefined;
|
|
23
31
|
};
|
|
24
|
-
export declare function
|
|
32
|
+
export declare function resolveChannel(stream: Writable, quiet: boolean): OutputChannel;
|
|
33
|
+
export declare function resolveInheritedChannel(stream: Writable): OutputChannel;
|
|
34
|
+
export declare function runSteps(steps: readonly Step[], cwd: string | undefined, options: RunStepsOptions): Promise<{
|
|
35
|
+
exitCode: number;
|
|
36
|
+
}>;
|
|
37
|
+
export declare function runCommand(command: string, cwd: string | undefined, options: RunCommandOptions): Promise<RunCommandResult>;
|
|
25
38
|
export {};
|
package/dist/esm/runner.js
CHANGED
|
@@ -5,14 +5,40 @@ import { reportError } from '@williamthorsen/nmr-core';
|
|
|
5
5
|
import { createBoundedBuffer } from "./helpers/createBoundedBuffer.js";
|
|
6
6
|
const SIGNAL_EXIT_BASE = 128;
|
|
7
7
|
const PIPE_DRAIN_GRACE_MS = 2_000;
|
|
8
|
+
export function resolveChannel(stream, quiet) {
|
|
9
|
+
if (quiet)
|
|
10
|
+
return 'pipe';
|
|
11
|
+
return isTerminal(stream) ? stream.fd : 'pipe';
|
|
12
|
+
}
|
|
13
|
+
export function resolveInheritedChannel(stream) {
|
|
14
|
+
return hasDescriptor(stream) ? stream.fd : 'pipe';
|
|
15
|
+
}
|
|
16
|
+
export async function runSteps(steps, cwd, options) {
|
|
17
|
+
const resolved = {
|
|
18
|
+
...options,
|
|
19
|
+
quiet: options.quiet === true,
|
|
20
|
+
stdout: options.stdout ?? process.stdout,
|
|
21
|
+
stderr: options.stderr ?? process.stderr,
|
|
22
|
+
};
|
|
23
|
+
for (const step of steps) {
|
|
24
|
+
const { exitCode } = await runStep(step, cwd, resolved);
|
|
25
|
+
if (exitCode !== 0) {
|
|
26
|
+
return { exitCode };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { exitCode: 0 };
|
|
30
|
+
}
|
|
8
31
|
export async function runCommand(command, cwd, options) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
32
|
+
return runSpawned({ args: [], file: command, useShell: true }, cwd, options);
|
|
33
|
+
}
|
|
34
|
+
async function runSpawned({ args, file, useShell }, cwd, options) {
|
|
35
|
+
const quiet = options.quiet === true;
|
|
36
|
+
const stdout = options.stdout ?? process.stdout;
|
|
37
|
+
const stderr = options.stderr ?? process.stderr;
|
|
38
|
+
const env = options.env ?? process.env;
|
|
39
|
+
const child = spawn(file, args, {
|
|
40
|
+
shell: useShell,
|
|
41
|
+
stdio: ['inherit', options.channels.stdout, options.channels.stderr],
|
|
16
42
|
cwd,
|
|
17
43
|
env,
|
|
18
44
|
});
|
|
@@ -39,6 +65,21 @@ export async function runCommand(command, cwd, options) {
|
|
|
39
65
|
}
|
|
40
66
|
return result;
|
|
41
67
|
}
|
|
68
|
+
function runStep(step, cwd, options) {
|
|
69
|
+
const { quiet, stderr, stdout } = options;
|
|
70
|
+
if (step.kind === 'opaque') {
|
|
71
|
+
return runSpawned({ args: [], file: step.command, useShell: true }, cwd, {
|
|
72
|
+
...options,
|
|
73
|
+
channels: { stderr: resolveChannel(stderr, quiet), stdout: resolveChannel(stdout, quiet) },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const [file, ...args] = step.argv;
|
|
77
|
+
return runSpawned({ args, file, useShell: false }, cwd, {
|
|
78
|
+
...options,
|
|
79
|
+
quiet: false,
|
|
80
|
+
channels: { stderr: resolveInheritedChannel(stderr), stdout: resolveInheritedChannel(stdout) },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
42
83
|
function captureStream(source, destination) {
|
|
43
84
|
const retained = createBoundedBuffer();
|
|
44
85
|
let isComplete = true;
|
|
@@ -100,13 +141,11 @@ function describeExit(code, signal) {
|
|
|
100
141
|
}
|
|
101
142
|
return { outcome: 'exited', exitCode: code ?? 1 };
|
|
102
143
|
}
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
return 'pipe';
|
|
106
|
-
return isTerminal(stream) ? stream.fd : 'pipe';
|
|
144
|
+
function hasDescriptor(stream) {
|
|
145
|
+
return 'fd' in stream && typeof stream.fd === 'number';
|
|
107
146
|
}
|
|
108
147
|
function isTerminal(stream) {
|
|
109
|
-
return 'isTTY' in stream && stream.isTTY === true &&
|
|
148
|
+
return 'isTTY' in stream && stream.isTTY === true && hasDescriptor(stream);
|
|
110
149
|
}
|
|
111
150
|
function writeBuffer(buffer, destination) {
|
|
112
151
|
if (buffer !== undefined && buffer.length > 0)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Step = {
|
|
2
|
+
kind: 'opaque';
|
|
3
|
+
command: string;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'structural';
|
|
6
|
+
argv: readonly [string, ...(readonly string[])];
|
|
7
|
+
};
|
|
8
|
+
export declare function composeNmrStep(element: string, workspaceRoot: boolean): Step;
|
|
9
|
+
export declare function findShelledNmrStep(steps: readonly Step[]): string | undefined;
|
|
10
|
+
export declare function findUnexpressibleToken(element: string): string | undefined;
|
|
11
|
+
export declare function renderChain(steps: readonly Step[]): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
|
|
2
|
+
export function composeNmrStep(element, workspaceRoot) {
|
|
3
|
+
const flags = workspaceRoot ? ['-w'] : [];
|
|
4
|
+
return { kind: 'structural', argv: ['nmr', ...flags, ...tokenize(element)] };
|
|
5
|
+
}
|
|
6
|
+
export function findShelledNmrStep(steps) {
|
|
7
|
+
for (const step of steps) {
|
|
8
|
+
if (step.kind === 'opaque' && tokenize(step.command)[0] === 'nmr') {
|
|
9
|
+
return step.command;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
export function findUnexpressibleToken(element) {
|
|
15
|
+
return tokenize(element).find((token) => !SHELL_SAFE_TOKEN.test(token));
|
|
16
|
+
}
|
|
17
|
+
export function renderChain(steps) {
|
|
18
|
+
return steps.map(renderStep).join(' && ');
|
|
19
|
+
}
|
|
20
|
+
function quoteToken(token) {
|
|
21
|
+
if (SHELL_SAFE_TOKEN.test(token)) {
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
|
|
25
|
+
}
|
|
26
|
+
function renderStep(step) {
|
|
27
|
+
return step.kind === 'opaque' ? step.command : step.argv.map(quoteToken).join(' ');
|
|
28
|
+
}
|
|
29
|
+
function tokenize(element) {
|
|
30
|
+
return element.split(/\s+/).filter((token) => token.length > 0);
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const COMMAND_VERBOSITY_ENV_VAR = "NMR_COMMAND_VERBOSITY";
|
|
2
|
+
export declare const COMMAND_VERBOSITIES: readonly ["full", "quiet"];
|
|
3
|
+
export type CommandVerbosity = (typeof COMMAND_VERBOSITIES)[number];
|
|
4
|
+
export type VerbosityResolution = {
|
|
5
|
+
ok: true;
|
|
6
|
+
verbosity: CommandVerbosity;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function resolveVerbosity(env: NodeJS.ProcessEnv, quietFlag: boolean): VerbosityResolution;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const COMMAND_VERBOSITY_ENV_VAR = 'NMR_COMMAND_VERBOSITY';
|
|
2
|
+
export const COMMAND_VERBOSITIES = ['full', 'quiet'];
|
|
3
|
+
export function resolveVerbosity(env, quietFlag) {
|
|
4
|
+
const raw = env[COMMAND_VERBOSITY_ENV_VAR];
|
|
5
|
+
const inherited = raw === undefined || raw === '' ? 'full' : raw;
|
|
6
|
+
if (!isCommandVerbosity(inherited)) {
|
|
7
|
+
return {
|
|
8
|
+
ok: false,
|
|
9
|
+
error: `${COMMAND_VERBOSITY_ENV_VAR} is \`${inherited}\`, which is not one of: ${COMMAND_VERBOSITIES.join(', ')}`,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
return { ok: true, verbosity: quietFlag ? 'quiet' : inherited };
|
|
13
|
+
}
|
|
14
|
+
function isCommandVerbosity(value) {
|
|
15
|
+
const names = COMMAND_VERBOSITIES;
|
|
16
|
+
return names.includes(value);
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"glob": "13.0.6",
|
|
68
68
|
"prettier-plugin-sh": "0.19.0",
|
|
69
|
-
"taze": "19.
|
|
69
|
+
"taze": "19.17.1",
|
|
70
70
|
"yaml": "2.9.0",
|
|
71
71
|
"@williamthorsen/nmr-core": "0.9.3"
|
|
72
72
|
},
|