@williamthorsen/nmr 0.31.0 → 0.32.1
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 +31 -2
- package/.readyup/manifest.json +34 -3
- package/README.md +17 -63
- package/agents/guidance/rulebooks/nmr.md +2 -1
- package/dist/esm/cli-ensure-prepublish-hooks.js +2 -23
- package/dist/esm/commands/build-output.d.ts +1 -0
- package/dist/esm/commands/build-output.js +25 -2
- package/dist/esm/commands/build.d.ts +5 -1
- package/dist/esm/commands/build.js +22 -8
- package/dist/esm/commands/clean.js +12 -0
- package/dist/esm/commands/ensure-prepublish-hooks.d.ts +1 -0
- package/dist/esm/commands/ensure-prepublish-hooks.js +43 -0
- package/dist/esm/commands/report-catalog.js +9 -3
- package/dist/esm/commands/report-overrides.js +9 -1
- package/dist/esm/help.js +1 -1
- package/dist/esm/helpers/reportClosing.d.ts +1 -0
- package/dist/esm/helpers/reportClosing.js +3 -0
- package/dist/esm/reportCliFailure.js +2 -2
- package/dist/esm/resolver.d.ts +2 -1
- package/dist/esm/resolver.js +23 -6
- package/dist/esm/runCli.js +60 -7
- package/dist/esm/steps.d.ts +6 -0
- package/dist/esm/steps.js +61 -36
- package/package.json +7 -5
package/.readyup/kits/default.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @noformat — @generated. Do not edit. Compiled by rdy. */
|
|
2
2
|
/* eslint-disable */
|
|
3
|
-
export const __readyupVersion = "0.
|
|
3
|
+
export const __readyupVersion = "0.29.0";
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
// .readyup/kits/default.ts
|
|
@@ -179,6 +179,13 @@ var default_default = defineRdyKit({
|
|
|
179
179
|
check: toolVersionsHasNoPnpm,
|
|
180
180
|
fix: "Remove pnpm from .tool-versions \u2014 manage via packageManager field and corepack"
|
|
181
181
|
},
|
|
182
|
+
{
|
|
183
|
+
name: "no package.json declares a pnpm field",
|
|
184
|
+
severity: "error",
|
|
185
|
+
quiet: true,
|
|
186
|
+
check: () => noPnpmFieldInPackageJson(),
|
|
187
|
+
fix: "Move these settings into pnpm-workspace.yaml, quoting each version under `overrides`, or run `pnpx codemod run pnpm-v10-to-v11`. pnpm 11 reads no key from the `pnpm` field, so an override left there pins nothing while an upgrade run with `--write` goes on rewriting it"
|
|
188
|
+
},
|
|
182
189
|
{
|
|
183
190
|
name: ".config/nmr.config.ts uses defineConfig",
|
|
184
191
|
severity: "recommend",
|
|
@@ -387,7 +394,7 @@ function formatPaths(paths) {
|
|
|
387
394
|
${paths.map((path2) => ` ${path2}`).join("\n")}`;
|
|
388
395
|
}
|
|
389
396
|
function getMinVersion() {
|
|
390
|
-
const picked = { "version": "0.
|
|
397
|
+
const picked = { "version": "0.32.1" };
|
|
391
398
|
if (typeof picked["version"] !== "string") {
|
|
392
399
|
throw new TypeError("nmr/package.json: 'version' must be a string");
|
|
393
400
|
}
|
|
@@ -413,6 +420,15 @@ function isReExportOnly(content) {
|
|
|
413
420
|
const statements = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
414
421
|
return statements.length > 0 && statements.every((line) => RE_EXPORT_LINE_PATTERN.test(line));
|
|
415
422
|
}
|
|
423
|
+
function noPnpmFieldInPackageJson(cwd = process.cwd()) {
|
|
424
|
+
const declaring = findFiles(["**/package.json"], cwd).flatMap((relativePath) => {
|
|
425
|
+
const keys = readPnpmFieldKeys(readFileIn(cwd, relativePath));
|
|
426
|
+
if (keys === void 0) return [];
|
|
427
|
+
return [keys.length > 0 ? `${relativePath} (${keys.join(", ")})` : relativePath];
|
|
428
|
+
});
|
|
429
|
+
if (declaring.length === 0) return true;
|
|
430
|
+
return { ok: false, detail: formatPaths(declaring) };
|
|
431
|
+
}
|
|
416
432
|
function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
|
|
417
433
|
const nonRootConfigs = findFiles([`**/vitest.config.${CONFIG_EXTENSIONS}`], cwd).filter((path2) => path2.includes("/"));
|
|
418
434
|
const reExports = nonRootConfigs.filter((path2) => isReExportOnly(readFileIn(cwd, path2)));
|
|
@@ -464,6 +480,18 @@ function readFileIn(cwd, relativePath) {
|
|
|
464
480
|
return void 0;
|
|
465
481
|
}
|
|
466
482
|
}
|
|
483
|
+
function readPnpmFieldKeys(content) {
|
|
484
|
+
if (content === void 0) return void 0;
|
|
485
|
+
let parsed;
|
|
486
|
+
try {
|
|
487
|
+
parsed = JSON.parse(content);
|
|
488
|
+
} catch {
|
|
489
|
+
return void 0;
|
|
490
|
+
}
|
|
491
|
+
if (!isRecord(parsed)) return void 0;
|
|
492
|
+
const pnpm = parsed["pnpm"];
|
|
493
|
+
return isRecord(pnpm) ? Object.keys(pnpm).toSorted() : void 0;
|
|
494
|
+
}
|
|
467
495
|
function resolvesVersionViaWorkspace(range) {
|
|
468
496
|
return WORKSPACE_VERSION_MARKERS.some((marker) => range.startsWith(marker));
|
|
469
497
|
}
|
|
@@ -483,6 +511,7 @@ export {
|
|
|
483
511
|
everyTestFileNamesItsTier,
|
|
484
512
|
hasSupportedEslintVersion,
|
|
485
513
|
hasSupportedStrictLintVersion,
|
|
514
|
+
noPnpmFieldInPackageJson,
|
|
486
515
|
noReExportOnlyVitestConfigs,
|
|
487
516
|
noRetiredVitestConfigs,
|
|
488
517
|
prettierConfigBuildsOnSharedConfig,
|
package/.readyup/manifest.json
CHANGED
|
@@ -5,12 +5,43 @@
|
|
|
5
5
|
"checklists": [
|
|
6
6
|
"nmr"
|
|
7
7
|
],
|
|
8
|
+
"esbuildVersion": "0.28.1",
|
|
9
|
+
"inputs": [
|
|
10
|
+
{
|
|
11
|
+
"hash": "67afdcb4",
|
|
12
|
+
"kind": "module",
|
|
13
|
+
"path": "kits/default.ts"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"hash": "01e1f853",
|
|
17
|
+
"kind": "inline",
|
|
18
|
+
"path": "../package.json",
|
|
19
|
+
"paths": [
|
|
20
|
+
"version"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"hash": "3dfc2ee7",
|
|
25
|
+
"kind": "module",
|
|
26
|
+
"path": "../src/default-scripts.ts"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"hash": "b42c7523",
|
|
30
|
+
"kind": "module",
|
|
31
|
+
"path": "../src/resolve-scripts.ts"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"hash": "c2d21075",
|
|
35
|
+
"kind": "module",
|
|
36
|
+
"path": "../src/tiers.ts"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
8
39
|
"name": "default",
|
|
9
40
|
"path": "kits/default.js",
|
|
10
|
-
"readyupVersion": "0.
|
|
41
|
+
"readyupVersion": "0.29.0",
|
|
11
42
|
"source": "kits/default.ts",
|
|
12
|
-
"sourceHash": "
|
|
13
|
-
"targetHash": "
|
|
43
|
+
"sourceHash": "67afdcb4",
|
|
44
|
+
"targetHash": "1fad8728"
|
|
14
45
|
}
|
|
15
46
|
]
|
|
16
47
|
}
|
package/README.md
CHANGED
|
@@ -3,69 +3,15 @@
|
|
|
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.
|
|
7
|
-
|
|
8
|
-
### 🎉 Features
|
|
9
|
-
|
|
10
|
-
- Report a verdict for every command nmr runs (#659)
|
|
11
|
-
|
|
12
|
-
`nmr` now reports one line for every command it runs, naming the scope, command, outcome, and timing. The lines print in every verbosity, which makes `-q` the low-noise way to run rather than a silent one: a passing quiet run reports its verdicts and nothing else, so a pass, a cached skip, and a no-op override stay distinguishable.
|
|
13
|
-
|
|
14
|
-
Verdicts nest. A composite reports alongside every command it expands into, while a `:pre`/`:post` hook leaf and a `--filter`/`--recursive` delegate report nothing, since each is already covered by the levels around it.
|
|
15
|
-
|
|
16
|
-
- Resolve a run's verbosity from repo config and agent-harness detection (#660)
|
|
17
|
-
|
|
18
|
-
A verbosity preference for `nmr` can now be set in the repo's root config, and `nmr` now automatically switches to quiet mode when it detects a known agent harness. This makes four ways to set verbosity: the `-q` option, the `NMR_COMMAND_VERBOSITY` env var, config, and harness detection, consulted in that order and defaulting to `full` when none applies.
|
|
19
|
-
|
|
20
|
-
- 🚨 **Breaking:** Refine nmr upgrade to support catalogs (#661)
|
|
21
|
-
|
|
22
|
-
Adds `nmr-report-catalog`, which names the dependencies a package takes from a pnpm catalog, their `catalog:` specifiers, and the monorepo root a covering pass runs from. The workspace `upgrade` default chains it ahead of the upgrade tool, and `nmr report-catalog` runs it alone. Because taze reads `pnpm-workspace.yaml` only when that file sits at its working directory, changes to the catalog are not attempted during a package-scoped upgrade.
|
|
23
|
-
|
|
24
|
-
Separately, `nmr-report-overrides` now reports the `overrides` block in `pnpm-workspace.yaml` and fails on a `pnpm.overrides` block in the root `package.json`. Both `upgrade` and `root:upgrade` run the reporter ahead of taze. This avoids a scenario where taze would meaninglessly write to the latter block.
|
|
25
|
-
|
|
26
|
-
Migration: Move the block to `overrides` in `pnpm-workspace.yaml`, quoting each version, or run `pnpx codemod run pnpm-v10-to-v11`. `engines` declares `pnpm: ">=11"`.
|
|
27
|
-
|
|
28
|
-
- Replay an excerpt of the run a skip recalls (#667)
|
|
29
|
-
|
|
30
|
-
`nmr` can now show the result of the previous check when a check is skipped. When `nmr` runs a command, it stores the result, keyed by presentation environment. The result is deemed to be the last blank-line-delimited block written by the command. If the command is run again in the same presentation environment, the stored result is shown, labeled "replayed:" for clarity.
|
|
31
|
-
|
|
32
|
-
- Assemble a skipped composite's replay from its constituents' excerpts (#668)
|
|
33
|
-
|
|
34
|
-
A skipped composite now replays what its constituents recorded, with each line attributed to the scope and command that produced it:
|
|
35
|
-
|
|
36
|
-
```console
|
|
37
|
-
⏭️ nmr: check: passed 35.7s ago on this tree, saved ~10s — replayed: nmr: fmt:check: All matched files use Prettier code style!; nmr: test: Test Files 52 passed (52) Tests 987 passed (987)
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Nesting collapses. A constituent that fans out across packages contributes a line from every package that recorded one; a constituent that recorded nothing is omitted.
|
|
41
|
-
|
|
42
|
-
A new reserved variable, `NMR_RUN_ID`, carries a run's identity to every process below it. Every recorded excerpt names the run that last certified it. A composite summary includes saved results with the same status as a fresh result.
|
|
43
|
-
|
|
44
|
-
- Print a retained run with nmr --log (#669)
|
|
45
|
-
|
|
46
|
-
Adds `nmr --log <command>`, which prints what the last recorded pass wrote instead of running the command again. The whole transcript, up to a limit of 256 KiB, is now saved for any passing command that is not writing straight to a terminal; `--log` prints it under a header giving the instant, the duration, and the command chain that succeeded. The log of a composite is the sum of its constituents' excerpts. If a change disqualifies the recording, `--log` instead reports the change.
|
|
47
|
-
|
|
48
|
-
- Emit one JSON object per command for machine consumers (#672)
|
|
49
|
-
|
|
50
|
-
Adds `--json` to `nmr`, allowing the caller to request output in JSON format instead of prose. An env var carries the choice of `json` or `text` down a spawned chain. When the output is JSON, the consumer receives one JSON object per command; each object carries the command, scope, and outcome, and a recalled pass carries the recorded excerpts.
|
|
51
|
-
|
|
52
|
-
The mode implies quiet. Stdout carries the objects alone (with no prose verdict line and no override notice); a failing command still displays its output on stderr.
|
|
53
|
-
|
|
54
|
-
- 🚨 **Breaking:** Bind trailing arguments to every step that accepts them (#675)
|
|
55
|
-
|
|
56
|
-
Routes an argument passed to an `nmr` command to every step of that command that accepts it, in place of the last step alone. Thus, `nmr test <file>` therefore runs only that file's tests; previously, the root suite ran in full alongside the narrowed package runs. Steps by default accept passed arguments, but can be configured to ignore them. If no step of a command accepts the argument, `nmr` names the command and runs nothing.
|
|
57
|
-
|
|
58
|
-
A command carrying an argument also reads the check-result cache nowhere in its chain, so it no longer skips part of its work from a pass recorded for an unnarrowed run.
|
|
59
|
-
|
|
60
|
-
Migration: Existing configs need no change, since a bare string element keeps its present meaning. Two behaviors differ: A composite passes its arguments to every accepting step rather than to its final step alone; and `nmr typecheck <file>` at the repo root is refused, where it previously checked that file under default compiler options at the root and hunted for it in every package.
|
|
6
|
+
## Release notes — v0.32.1 (2026-08-18)
|
|
61
7
|
|
|
62
8
|
### 🐛 Bug fixes
|
|
63
9
|
|
|
64
|
-
-
|
|
10
|
+
- Reject a `package.json` entry that chains steps onto a self-reference (#707)
|
|
65
11
|
|
|
66
|
-
Fixes
|
|
67
|
-
|
|
68
|
-
Separately,
|
|
12
|
+
Fixes an issue where a `package.json` script re-invoking its own command alongside other steps (such as `"build": "nmr build && rdy compile"`) silently lost those steps: nmr discarded the entry and ran its own `build` in its place, so `rdy compile` never ran. nmr now refuses to run the entry and reports the error.
|
|
13
|
+
|
|
14
|
+
Separately, fixes an issue where the same chain written the other way round (e.g., `"build": "rdy compile && nmr build"`) was honored as an ordinary override, causing infinite recursion.
|
|
69
15
|
<!-- /section:release-notes -->
|
|
70
16
|
|
|
71
17
|
## Installation
|
|
@@ -153,6 +99,10 @@ If no per-package override exists, the highest-tier value that is set wins. Set
|
|
|
153
99
|
|
|
154
100
|
> **Tip:** If your repo uses `eslint-plugin-package-json/valid-scripts`, empty strings are flagged as invalid. Use `":"` (the POSIX null command) instead — nmr treats it as a regular override that exits successfully and does nothing.
|
|
155
101
|
|
|
102
|
+
A `package.json` entry that re-invokes the command it is declared under is never honored. Honoring `"build": "nmr build"` would spawn a shell running `nmr build` in the same directory, reaching the same entry again without bound, so nmr discards the entry and resolves the command from the tier below. An entry that carries only the self-reference, trailing arguments included, is discarded in silence: it declares no step to lose. An entry that chains anything else onto it — `"build": "nmr build && rdy compile"`, or `"build": "rdy compile && nmr build"` — would lose those steps, so nmr rejects the invocation instead, naming the file, the key, and where the steps belong. For an ordinary command that is a `<command>:pre` or `<command>:post` script: `"build:pre": "rdy verify"` and `"build:post": "rdy compile"` wrap nmr's own `build` rather than replacing it. For a hook entry it is the entry itself, since nmr wraps a `:pre` or `:post` script in no hooks of its own — `"build:post": "nmr build:post && rdy compile"` becomes `"build:post": "rdy compile"`.
|
|
103
|
+
|
|
104
|
+
The re-invocation is recognized wherever it stands in the entry, on the grammar [the shelled-nmr line](#script-values) uses, and a segment carrying `-F` or `-R` is not one — it hands the command to other scopes rather than back to this one. Nor is `-w` from inside a package, which reaches the root's registry and the root's `package.json`. A filter that selects the declaring package is the hole in that exemption: `nmr -F own-pkg build` in `own-pkg`'s own `build` re-enters without bound, and nmr does not recognize it.
|
|
105
|
+
|
|
156
106
|
### Script values
|
|
157
107
|
|
|
158
108
|
A script value is a string or an array of steps. Only the built-in defaults and `.config/nmr.config.ts` carry an array; a `package.json` script must be a string, and any other value is rejected when the scripts are read. Arrays expand to chained `nmr` sub-invocations:
|
|
@@ -183,9 +133,11 @@ An invocation carrying trailing arguments also serves no part of its work from t
|
|
|
183
133
|
|
|
184
134
|
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 reaching nmr through a shell.
|
|
185
135
|
|
|
186
|
-
The line names the declaration it is an edit to — the file, the field, and the key — and its remedy follows from that site. A `.config/nmr.config.ts` entry keeps its nmr steps as a step list and moves anything else to a `<command>:pre` or `<command>:post` script: a step-list element is composed into `nmr <element>`, so it can name only an nmr command. A `package.json` entry has to go, since `package.json` holds no step list: delete it outright when it restates what nmr already runs for that command, and move the steps it adds to a `<command>:pre` or `<command>:post` script when they are the package's own. Where the registry defines no such command, both apply: name the command in `.config/nmr.config.ts`, and put the package-specific steps in a hook. The pair is named rather than `:post` alone because an entry's extra steps may run on either side of the chain: `rdy verify && nmr compile` puts them first.
|
|
136
|
+
The line names the declaration it is an edit to — the file, the field, and the key — and its remedy follows from that site. A `.config/nmr.config.ts` entry keeps its nmr steps as a step list and moves anything else to a `<command>:pre` or `<command>:post` script: a step-list element is composed into `nmr <element>`, so it can name only an nmr command. A `package.json` entry has to go, since `package.json` holds no step list: delete it outright when it restates what nmr already runs for that command, and move the steps it adds to a `<command>:pre` or `<command>:post` script when they are the package's own. Where the registry defines no such command, both apply: name the command in `.config/nmr.config.ts`, and put the package-specific steps in a hook. The pair is named rather than `:post` alone because an entry's extra steps may run on either side of the chain: `rdy verify && nmr compile` puts them first. A hook is the exception at both tiers: nmr wraps a `:pre` or `:post` script in no hooks of its own, so a hook's other steps become a script of their own that the step list names.
|
|
137
|
+
|
|
138
|
+
nmr recognizes `nmr` in command position: at the start of a step, after an unquoted `&&`, `||`, `;`, `|`, or a newline, and behind `npx`, `bunx`, or an `exec`, `dlx`, or `x` subcommand of `npm`, `pnpm`, `yarn`, or `bun`. This line reports a boundary rather than enforcing a rule — it covers a step nmr runs as written, and only the reporting of the nested run is degraded — and it stays partial in stated ways: a value-taking flag standing immediately before the program name hides it (`npx -p foo nmr`), a launcher outside that set goes unreported, and a `devBin` substitution that introduces `nmr` itself is not reported, the line being an edit to a declaration rather than to a substitution.
|
|
187
139
|
|
|
188
|
-
|
|
140
|
+
A [self-referential `package.json` entry](#three-tier-override-system) is the shape nmr cannot run as written, and it is rejected rather than reported. The same recognition finds it, so the same residuals apply — what goes unrecognized there re-enters nmr without bound, which hangs rather than passing quietly.
|
|
189
141
|
|
|
190
142
|
## Configuration
|
|
191
143
|
|
|
@@ -883,7 +835,7 @@ nmr-clean
|
|
|
883
835
|
|
|
884
836
|
### `nmr-compile`
|
|
885
837
|
|
|
886
|
-
Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (
|
|
838
|
+
Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (the key is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means — `nmr clean`, `rm -rf dist`, `git clean` — therefore forces a rebuild rather than a skip. This is the default `compile` script — run it from a package directory.
|
|
887
839
|
|
|
888
840
|
**What it compiles.** Entry points are `src/**/*.ts` less the directories that hold test scaffolding rather than shipped code: `__fixtures__/`, `__mocks__/`, `__tests__/`, and `test-utils/`. Add to that list per package with [`build.extraIgnorePatterns`](#package-level-configuration). Ignoring a directory drops it as an _entry point_, not from the emit: the compiler still emits whatever the surviving entry points import, so a helper that production code uses is still compiled and its importer never emits a dangling specifier. This list is deliberately not the Vitest [coverage exclusions](#what-the-config-excludes) — helpers live in `test-utils/` precisely so they stay covered.
|
|
889
841
|
|
|
@@ -891,6 +843,8 @@ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler
|
|
|
891
843
|
|
|
892
844
|
**The replacement is atomic.** The emit is buffered in memory, written to a staging directory beside the output directory, and swapped into place by rename. A build that fails for any reason -- a malformed tsconfig, an unresolvable alias, a full disk -- therefore leaves the previous output exactly as it was. `dist/esm` is never observed mid-write: it holds the previous build or the new one, and is absent only between the two renames. This matters most when the package being rebuilt supplies a binary that other packages in the same recursive build invoke.
|
|
893
845
|
|
|
846
|
+
**What busts the cache.** The key folds each input's content and path, the emit options, the resolved TypeScript version, and a fingerprint of the nmr running the build -- its own build digest in a workspace checkout, its package version otherwise. Upgrading nmr in a consuming repo therefore rebuilds every package nmr compiles, and an edit to nmr's own build sources rebuilds each sibling once nmr itself has been rebuilt. A build of nmr's own package folds the version rather than the digest, which is the cache entry that build is about to write.
|
|
847
|
+
|
|
894
848
|
`typescript` is a peer dependency (`>=5.7.0 <7`); the consuming repo provides it. The floor is what `rewriteRelativeImportExtensions` requires; the ceiling is because TypeScript 7 ships no compiler API — its root export is a version constant, so `nmr-compile` cannot run on it. Relative imports in source must carry explicit `.ts` extensions for them to be rewritten.
|
|
895
849
|
|
|
896
850
|
```bash
|
|
@@ -940,7 +894,7 @@ ensure-prepublish-hooks
|
|
|
940
894
|
|
|
941
895
|
## Conformance checks
|
|
942
896
|
|
|
943
|
-
nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry,
|
|
897
|
+
nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry, the [test-tier convention](#test-tiers), and that no `package.json` in the tree declares a `pnpm` field, which pnpm 11 reads no key from. Those settings belong in `pnpm-workspace.yaml`, and `pnpx codemod run pnpm-v10-to-v11` moves them. The kit ships inside the package, so it checks against the nmr version installed rather than whatever a repository ref happens to point at, and a tier added or renamed in nmr reaches the repo on upgrade.
|
|
944
898
|
|
|
945
899
|
Add `readyup` as a devDependency, then name nmr in its config:
|
|
946
900
|
|
|
@@ -13,4 +13,5 @@ delivery: ambient
|
|
|
13
13
|
- A check that already passed on the current working tree skips, printing `⏭️ <scope>: <command>: passed …` and, where the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it, so read the replayed excerpt rather than paying for the answer again: never pass `--no-cache` to get a "real" run, which costs the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`. A composite replays its constituents' excerpts, each attributed as `<scope>: <command>: <excerpt>` and cut at the line's ceiling where there are too many to fit. A skip carrying no excerpt is one whose run wrote to a terminal, or one whose constituents recorded none.
|
|
14
14
|
- `nmr --log <command>` prints the whole run behind that excerpt -- up to 256 KiB, led by when it was recorded, how long it ran, and the command string that produced it -- so output you need in full is a recording to read rather than a run to pay for again. It runs nothing, and it is held to the tree rather than to the terminal: a recording a piped run made prints for a reader at one. A composite prints its constituents' excerpts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. Nothing to show is said on one line and exits non-zero, naming which it is.
|
|
15
15
|
- Every command nmr runs reports one line -- `✅ <scope>: <command>: passed in 12.4s`, `❌ … failed in 1.2s (exit 1)`, `⏭️ … passed 4m ago on this tree`, or `⛔ … skipped, the override is empty` -- and a composite reports alongside every command it expands into. The lines print in every verbosity, so `-q` is the low-noise way to run rather than a silent one: it withholds the output of the commands nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
|
|
16
|
-
-
|
|
16
|
+
- Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. Neither a file outside `__tests__` nor one whose tier segment is missing or misspelt fails anything: the first is collected by nothing and the second runs under `unit`, and no test run distinguishes either from a conformant file. `rdy run --packages` reports the misspelt tier, and nothing reports the misplaced file.
|
|
17
|
+
- The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), the `nmr-compile` build (there is no repo-local build script), the test tiers' timeout budgets and the `tiers` seam that raises one, which script reaches which tier, and the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
|
|
2
2
|
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
3
|
-
import { DEFAULT_HOOK, ensurePrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
|
|
3
|
+
import { DEFAULT_HOOK, ensurePrepublishHooks, reportPrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
|
|
4
4
|
import { findMonorepoRoot } from "./workspace.js";
|
|
5
5
|
const flagSchema = {
|
|
6
6
|
fix: { long: '--fix', type: 'boolean' },
|
|
@@ -10,31 +10,10 @@ const flagSchema = {
|
|
|
10
10
|
const { fix, dryRun, command } = parseArgsOrExit(process.argv.slice(2), flagSchema).flags;
|
|
11
11
|
try {
|
|
12
12
|
const monorepoRoot = findMonorepoRoot();
|
|
13
|
-
const hookCommand = command ?? DEFAULT_HOOK;
|
|
14
13
|
const options = command ? { fix, dryRun, command } : { fix, dryRun };
|
|
15
14
|
const result = ensurePrepublishHooks(monorepoRoot, options);
|
|
16
|
-
|
|
17
|
-
if (pkg.isPrivate) {
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
switch (pkg.action) {
|
|
21
|
-
case 'ok':
|
|
22
|
-
console.info(`✓ ${pkg.packageName}: prepublishOnly = "${pkg.prepublishOnly}"`);
|
|
23
|
-
break;
|
|
24
|
-
case 'missing':
|
|
25
|
-
console.warn(`✗ ${pkg.packageName}: missing prepublishOnly`);
|
|
26
|
-
break;
|
|
27
|
-
case 'fixed':
|
|
28
|
-
console.info(`✓ ${pkg.packageName}: added prepublishOnly = "${hookCommand}"`);
|
|
29
|
-
break;
|
|
30
|
-
case 'would-fix':
|
|
31
|
-
console.info(`~ ${pkg.packageName}: would add prepublishOnly = "${hookCommand}"`);
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
15
|
+
reportPrepublishHooks(result, command ?? DEFAULT_HOOK);
|
|
35
16
|
if (result.hasFailures) {
|
|
36
|
-
const missing = result.packages.filter((p) => p.action === 'missing').length;
|
|
37
|
-
process.stderr.write(`\n${missing} package(s) missing prepublishOnly. Use --fix to add it.\n`);
|
|
38
17
|
process.exitCode = 1;
|
|
39
18
|
}
|
|
40
19
|
}
|
|
@@ -16,3 +16,4 @@ export declare function hasExpectedBuildOutput(packageDir: string, outdir: strin
|
|
|
16
16
|
export declare function readBuildDigest(packageDir: string): Promise<string | undefined>;
|
|
17
17
|
export declare function resolveBuildCachePath(packageDir: string): string;
|
|
18
18
|
export declare function resolveScratchDirs(emitDir: string): ScratchDirs;
|
|
19
|
+
export declare function resolveToolchainFingerprint(packageDir: string, selfDir?: string): Promise<string>;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { readCacheEntry, resolveCacheEntryPath } from '@williamthorsen/nmr-core';
|
|
3
|
+
import { findPackageRoot, readCacheEntry, resolveCacheEntryPath } from '@williamthorsen/nmr-core';
|
|
4
4
|
import { glob } from 'glob';
|
|
5
|
+
import { readPackageJson } from "../helpers/package-json.js";
|
|
5
6
|
export const DEFAULT_ENTRY_GLOBS = ['src/**/*.ts'];
|
|
6
7
|
export const DEFAULT_IGNORE_PATTERNS = ['**/__fixtures__/**', '**/__mocks__/**', '**/__tests__/**', '**/test-utils/**'];
|
|
7
8
|
export const DEFAULT_OUTDIR = 'dist/esm/';
|
|
8
9
|
const BUILD_CACHE_TOOL = 'nmr-compile';
|
|
10
|
+
const SELF_DIR = findPackageRoot(import.meta.url);
|
|
9
11
|
export async function hasBuildOutput(packageDir, options = {}) {
|
|
10
12
|
const outdir = options.outdir ?? DEFAULT_OUTDIR;
|
|
11
13
|
const entryPoints = await glob(options.entryGlobs ?? DEFAULT_ENTRY_GLOBS, {
|
|
@@ -42,3 +44,24 @@ export function resolveScratchDirs(emitDir) {
|
|
|
42
44
|
staging: path.join(parent, `.${name}.staging`),
|
|
43
45
|
};
|
|
44
46
|
}
|
|
47
|
+
export async function resolveToolchainFingerprint(packageDir, selfDir = SELF_DIR) {
|
|
48
|
+
if (!isSameDir(packageDir, selfDir)) {
|
|
49
|
+
const digest = await readBuildDigest(selfDir);
|
|
50
|
+
if (digest !== undefined) {
|
|
51
|
+
return digest;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return readPackageJson(selfDir).version ?? '';
|
|
55
|
+
}
|
|
56
|
+
function isSameDir(left, right) {
|
|
57
|
+
return toRealPath(left) === toRealPath(right);
|
|
58
|
+
}
|
|
59
|
+
function toRealPath(dir) {
|
|
60
|
+
const absolute = path.resolve(dir);
|
|
61
|
+
try {
|
|
62
|
+
return realpathSync(absolute);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return absolute;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { BuildOptions } from './build-output.js';
|
|
2
|
+
export interface BuildToolchain {
|
|
3
|
+
compilerVersion: string;
|
|
4
|
+
fingerprint: string;
|
|
5
|
+
}
|
|
2
6
|
export declare function buildPackage(packageDir: string, options?: BuildOptions): Promise<void>;
|
|
3
|
-
export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object,
|
|
7
|
+
export declare function computeBuildHash(packageDir: string, files: string[], emitConfig: object, toolchain: BuildToolchain): Promise<string>;
|
|
4
8
|
export declare function resolveTsconfigChain(packageDir: string, configFileName?: string): string[];
|
|
@@ -6,7 +6,7 @@ import { readCacheEntry, writeCacheEntry } from '@williamthorsen/nmr-core';
|
|
|
6
6
|
import { glob } from 'glob';
|
|
7
7
|
import * as ts from 'typescript';
|
|
8
8
|
import { resolveConfigPath } from "../config.js";
|
|
9
|
-
import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, } from "./build-output.js";
|
|
9
|
+
import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, } from "./build-output.js";
|
|
10
10
|
const PACKAGE_ICON = '📦';
|
|
11
11
|
const SKIPPED_ICON = '⏭️';
|
|
12
12
|
const SOURCE_ROOT = 'src';
|
|
@@ -30,15 +30,20 @@ export async function buildPackage(packageDir, options = {}) {
|
|
|
30
30
|
...(existsSync(configPath) ? [path.relative(packageDir, configPath)] : []),
|
|
31
31
|
...resolveTsconfigChain(packageDir),
|
|
32
32
|
];
|
|
33
|
-
const
|
|
33
|
+
const toolchain = {
|
|
34
|
+
compilerVersion: ts.version,
|
|
35
|
+
fingerprint: await resolveToolchainFingerprint(packageDir),
|
|
36
|
+
};
|
|
37
|
+
const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, toolchain, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
|
|
34
38
|
if (!changed) {
|
|
35
39
|
await discardScratchDirs(resolveScratchDirs(emitDir));
|
|
36
40
|
return;
|
|
37
41
|
}
|
|
38
|
-
await emitPackage(packageDir, entryPoints, outdir);
|
|
42
|
+
const emittedFileCount = await emitPackage(packageDir, entryPoints, outdir);
|
|
39
43
|
await writeCacheEntry(cachePath, currentHash);
|
|
44
|
+
console.info(`${PACKAGE_ICON} ${path.basename(packageDir)}: ${describeEmit(emittedFileCount, outdir)}`);
|
|
40
45
|
}
|
|
41
|
-
export async function computeBuildHash(packageDir, files, emitConfig,
|
|
46
|
+
export async function computeBuildHash(packageDir, files, emitConfig, toolchain) {
|
|
42
47
|
const hash = createHash('sha256');
|
|
43
48
|
for (const file of files.toSorted()) {
|
|
44
49
|
hash.update(file);
|
|
@@ -47,7 +52,9 @@ export async function computeBuildHash(packageDir, files, emitConfig, compilerVe
|
|
|
47
52
|
}
|
|
48
53
|
hash.update(JSON.stringify(emitConfig));
|
|
49
54
|
hash.update('\0');
|
|
50
|
-
hash.update(compilerVersion);
|
|
55
|
+
hash.update(toolchain.compilerVersion);
|
|
56
|
+
hash.update('\0');
|
|
57
|
+
hash.update(toolchain.fingerprint);
|
|
51
58
|
return hash.digest('hex');
|
|
52
59
|
}
|
|
53
60
|
export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json') {
|
|
@@ -92,10 +99,11 @@ async function emitPackage(packageDir, entryPoints, outdir) {
|
|
|
92
99
|
}
|
|
93
100
|
if (staged.size === 0) {
|
|
94
101
|
await rm(emitDir, { force: true, recursive: true });
|
|
95
|
-
return;
|
|
102
|
+
return 0;
|
|
96
103
|
}
|
|
97
104
|
writeStagedOutput(staged, emitDir, scratchDirs.staging);
|
|
98
105
|
await swapIntoPlace(emitDir, scratchDirs);
|
|
106
|
+
return staged.size;
|
|
99
107
|
}
|
|
100
108
|
async function discardScratchDirs(scratchDirs) {
|
|
101
109
|
try {
|
|
@@ -247,10 +255,10 @@ function getModuleSpecifier(node) {
|
|
|
247
255
|
}
|
|
248
256
|
return undefined;
|
|
249
257
|
}
|
|
250
|
-
async function detectBuildChanges(packageDir, files, emitConfig,
|
|
258
|
+
async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath, outputPresent) {
|
|
251
259
|
const packageName = path.basename(packageDir);
|
|
252
260
|
const previousHash = await readCacheEntry(cachePath);
|
|
253
|
-
const currentHash = await computeBuildHash(packageDir, files, emitConfig,
|
|
261
|
+
const currentHash = await computeBuildHash(packageDir, files, emitConfig, toolchain);
|
|
254
262
|
if (previousHash === currentHash) {
|
|
255
263
|
if (outputPresent) {
|
|
256
264
|
console.info(`${SKIPPED_ICON} ${packageName}: No changes detected. Skipping build.`);
|
|
@@ -278,6 +286,12 @@ function collectAliasPrefixes(compilerOptions) {
|
|
|
278
286
|
}
|
|
279
287
|
return Object.keys(compilerOptions.paths).map((pattern) => pattern.replace(/\*$/, ''));
|
|
280
288
|
}
|
|
289
|
+
function describeEmit(fileCount, outdir) {
|
|
290
|
+
if (fileCount === 0) {
|
|
291
|
+
return 'Emitted no output.';
|
|
292
|
+
}
|
|
293
|
+
return `Compiled ${fileCount} files to ${outdir.replace(/\/+$/u, '')}.`;
|
|
294
|
+
}
|
|
281
295
|
function formatDiagnostics(diagnostics) {
|
|
282
296
|
return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
|
|
283
297
|
getCurrentDirectory: () => process.cwd(),
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { removeCheckCache } from "../check-cache.js";
|
|
4
4
|
import { loadRootConfig } from "../config.js";
|
|
5
5
|
import { findContainingPackageDir } from "../context.js";
|
|
6
|
+
import { reportClosing } from "../helpers/reportClosing.js";
|
|
6
7
|
import { applyDevBin, buildWorkspaceRegistry, resolveScript } from "../resolver.js";
|
|
7
8
|
import { resolveChannel, runCommand } from "../runner.js";
|
|
8
9
|
import { renderChain } from "../steps.js";
|
|
@@ -40,17 +41,26 @@ async function clearCheckCache(scopeDir) {
|
|
|
40
41
|
await removeCheckCache(scopeDir);
|
|
41
42
|
console.info(`${CLEAN_ICON} Removed all recorded check results.`);
|
|
42
43
|
}
|
|
44
|
+
function describeSweep(cleaned, skipped) {
|
|
45
|
+
const packages = cleaned === 1 ? '1 package' : `${cleaned} packages`;
|
|
46
|
+
const skipClause = skipped === 0 ? '' : `, skipping ${skipped} with an empty clean override`;
|
|
47
|
+
return `Cleaned ${packages}${skipClause}.`;
|
|
48
|
+
}
|
|
43
49
|
async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
|
|
44
50
|
const config = await loadRootConfig(monorepoRoot);
|
|
45
51
|
const registry = buildWorkspaceRegistry(config);
|
|
52
|
+
let cleaned = 0;
|
|
53
|
+
let skipped = 0;
|
|
46
54
|
for (const packageDir of workspacePackageDirs) {
|
|
47
55
|
const resolved = resolveScript(CLEAN_COMMAND, registry, packageDir, false);
|
|
48
56
|
const resolvedCommand = resolved === undefined ? undefined : renderChain(resolved.steps);
|
|
49
57
|
if (resolvedCommand === undefined || resolvedCommand === '') {
|
|
58
|
+
skipped++;
|
|
50
59
|
continue;
|
|
51
60
|
}
|
|
52
61
|
if (resolvedCommand === BUILT_IN_CLEAN) {
|
|
53
62
|
await cleanPackage(packageDir);
|
|
63
|
+
cleaned++;
|
|
54
64
|
continue;
|
|
55
65
|
}
|
|
56
66
|
const command = applyDevBin(resolvedCommand, config.devBin, monorepoRoot);
|
|
@@ -60,5 +70,7 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs) {
|
|
|
60
70
|
if (exitCode !== 0) {
|
|
61
71
|
throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
|
|
62
72
|
}
|
|
73
|
+
cleaned++;
|
|
63
74
|
}
|
|
75
|
+
reportClosing(`${CLEAN_ICON} ${describeSweep(cleaned, skipped)}`);
|
|
64
76
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readPackageJson } from "../helpers/package-json.js";
|
|
4
|
+
import { reportClosing } from "../helpers/reportClosing.js";
|
|
4
5
|
import { isObject } from "../helpers/type-guards.js";
|
|
5
6
|
import { getWorkspacePackageDirs } from "../workspace.js";
|
|
6
7
|
export const DEFAULT_HOOK = 'npm run build';
|
|
@@ -59,6 +60,17 @@ export function ensurePrepublishHooks(monorepoRoot, options) {
|
|
|
59
60
|
const hasFailures = packages.some((p) => p.action === 'missing');
|
|
60
61
|
return { packages, hasFailures };
|
|
61
62
|
}
|
|
63
|
+
export function reportPrepublishHooks(result, hookCommand) {
|
|
64
|
+
const publishable = result.packages.filter((pkg) => !pkg.isPrivate);
|
|
65
|
+
if (publishable.length === 0) {
|
|
66
|
+
console.info('No publishable packages found.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
for (const pkg of publishable) {
|
|
70
|
+
console.info(renderHookStatus(pkg, hookCommand));
|
|
71
|
+
}
|
|
72
|
+
reportClosing(describeHookRun(publishable));
|
|
73
|
+
}
|
|
62
74
|
function addPrepublishOnly(packageDir, command) {
|
|
63
75
|
const filePath = path.join(packageDir, 'package.json');
|
|
64
76
|
const raw = readFileSync(filePath, 'utf8');
|
|
@@ -71,3 +83,34 @@ function addPrepublishOnly(packageDir, command) {
|
|
|
71
83
|
parsed['scripts'] = scripts;
|
|
72
84
|
writeFileSync(filePath, JSON.stringify(parsed, null, 2) + '\n', 'utf8');
|
|
73
85
|
}
|
|
86
|
+
function countAction(publishable, action) {
|
|
87
|
+
return publishable.filter((pkg) => pkg.action === action).length;
|
|
88
|
+
}
|
|
89
|
+
function describeHookRun(publishable) {
|
|
90
|
+
const packages = publishable.length === 1 ? '1 publishable package' : `${publishable.length} publishable packages`;
|
|
91
|
+
const missing = countAction(publishable, 'missing');
|
|
92
|
+
if (missing > 0) {
|
|
93
|
+
return `${missing} of ${packages} ${missing === 1 ? 'is' : 'are'} missing prepublishOnly. Run with --fix to add it.`;
|
|
94
|
+
}
|
|
95
|
+
const fixed = countAction(publishable, 'fixed');
|
|
96
|
+
if (fixed > 0) {
|
|
97
|
+
return `Added prepublishOnly to ${fixed} of ${packages}.`;
|
|
98
|
+
}
|
|
99
|
+
const wouldFix = countAction(publishable, 'would-fix');
|
|
100
|
+
if (wouldFix > 0) {
|
|
101
|
+
return `Would add prepublishOnly to ${wouldFix} of ${packages}.`;
|
|
102
|
+
}
|
|
103
|
+
return `${packages} ${publishable.length === 1 ? 'has' : 'have'} prepublishOnly.`;
|
|
104
|
+
}
|
|
105
|
+
function renderHookStatus(pkg, hookCommand) {
|
|
106
|
+
switch (pkg.action) {
|
|
107
|
+
case 'ok':
|
|
108
|
+
return `✓ ${pkg.packageName}: prepublishOnly = "${pkg.prepublishOnly}"`;
|
|
109
|
+
case 'missing':
|
|
110
|
+
return `✗ ${pkg.packageName}: missing prepublishOnly`;
|
|
111
|
+
case 'fixed':
|
|
112
|
+
return `✓ ${pkg.packageName}: added prepublishOnly = "${hookCommand}"`;
|
|
113
|
+
case 'would-fix':
|
|
114
|
+
return `~ ${pkg.packageName}: would add prepublishOnly = "${hookCommand}"`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { findContainingPackageDir } from "../context.js";
|
|
2
2
|
import { DEPENDENCY_FIELDS, readPackageJson } from "../helpers/package-json.js";
|
|
3
|
+
import { reportClosing } from "../helpers/reportClosing.js";
|
|
3
4
|
import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
|
|
5
|
+
const CATALOG_ICON = '📚';
|
|
4
6
|
const CATALOG_PROTOCOL = 'catalog:';
|
|
5
7
|
export function reportCatalog(cwd) {
|
|
6
8
|
const monorepoRoot = findMonorepoRoot(cwd);
|
|
@@ -12,12 +14,16 @@ export function reportCatalog(cwd) {
|
|
|
12
14
|
if (dependencies.length === 0) {
|
|
13
15
|
return;
|
|
14
16
|
}
|
|
15
|
-
|
|
16
|
-
console.warn(`📚 WARN: ${subject} from a catalog, which a package-scoped upgrade does not read:`);
|
|
17
|
+
console.warn(`${CATALOG_ICON} WARN: A package-scoped upgrade does not read the catalogs these come from:`);
|
|
17
18
|
for (const { name, specifier } of dependencies) {
|
|
18
19
|
console.warn(`- ${name} → ${specifier}`);
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
reportClosing(`${CATALOG_ICON} ${describeCatalog(dependencies.length, monorepoRoot)}`, console.warn);
|
|
22
|
+
}
|
|
23
|
+
function describeCatalog(count, monorepoRoot) {
|
|
24
|
+
const subject = count === 1 ? '1 catalogued dependency went' : `${count} catalogued dependencies went`;
|
|
25
|
+
const object = count === 1 ? 'it' : 'them';
|
|
26
|
+
return `${subject} unread. Run \`nmr upgrade\` from ${monorepoRoot} to include ${object}.`;
|
|
21
27
|
}
|
|
22
28
|
function findCataloguedDependencies(packageDir) {
|
|
23
29
|
const pkg = readPackageJson(packageDir);
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { getPnpmOverrides, readPackageJson } from "../helpers/package-json.js";
|
|
2
|
+
import { reportClosing } from "../helpers/reportClosing.js";
|
|
2
3
|
import { UserError } from "../UserError.js";
|
|
3
4
|
import { readWorkspaceOverrides } from "../workspace.js";
|
|
5
|
+
const OVERRIDES_ICON = '🔒';
|
|
4
6
|
export function reportOverrides(monorepoRoot) {
|
|
5
7
|
const declared = listEntries(readWorkspaceOverrides(monorepoRoot));
|
|
6
8
|
if (declared.length > 0) {
|
|
7
|
-
console.warn(
|
|
9
|
+
console.warn(`${OVERRIDES_ICON} WARN: pnpm overrides are active:`);
|
|
8
10
|
for (const [name, version] of declared) {
|
|
9
11
|
console.warn(`- ${name} → ${version}`);
|
|
10
12
|
}
|
|
13
|
+
reportClosing(`${OVERRIDES_ICON} ${describeOverrides(declared.length)}`, console.warn);
|
|
11
14
|
}
|
|
12
15
|
rejectLegacyOverrides(monorepoRoot);
|
|
13
16
|
}
|
|
17
|
+
function describeOverrides(count) {
|
|
18
|
+
return count === 1
|
|
19
|
+
? '1 override is active. Check whether it is still needed.'
|
|
20
|
+
: `${count} overrides are active. Check whether they are still needed.`;
|
|
21
|
+
}
|
|
14
22
|
function listEntries(overrides) {
|
|
15
23
|
return Object.entries(overrides ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
|
|
16
24
|
}
|
package/dist/esm/help.js
CHANGED
|
@@ -43,7 +43,7 @@ function collectOverrides(packageDir) {
|
|
|
43
43
|
for (const [name, value] of Object.entries(scripts)) {
|
|
44
44
|
if (isHookName(name))
|
|
45
45
|
continue;
|
|
46
|
-
if (isSelfReferential(value, name))
|
|
46
|
+
if (isSelfReferential(value, name, packageDir))
|
|
47
47
|
continue;
|
|
48
48
|
filtered[name] = value;
|
|
49
49
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function reportClosing(message: string, log?: (line: string) => void): void;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { reportError } from '@williamthorsen/nmr-core';
|
|
2
|
-
import { describeError } from '@williamthorsen/toolbelt.errors';
|
|
2
|
+
import { describeError, isError } from '@williamthorsen/toolbelt.errors';
|
|
3
3
|
import { UserError } from "./UserError.js";
|
|
4
4
|
export function reportCliFailure(error, stderr) {
|
|
5
5
|
if (error instanceof UserError) {
|
|
6
6
|
reportError(error.message, stderr);
|
|
7
7
|
return;
|
|
8
8
|
}
|
|
9
|
-
const detail = error
|
|
9
|
+
const detail = isError(error) && error.stack !== undefined ? error.stack : describeError(error);
|
|
10
10
|
stderr.write(`${detail}\n`);
|
|
11
11
|
}
|
package/dist/esm/resolver.d.ts
CHANGED
|
@@ -20,5 +20,6 @@ export declare function describeScript(script: ScriptValue): string;
|
|
|
20
20
|
export declare function readPackageJsonScripts(packageDir: string): Record<string, string> | undefined;
|
|
21
21
|
export declare function buildWorkspaceRegistry(config: NmrConfig): ScriptRegistry;
|
|
22
22
|
export declare function buildRootRegistry(config: NmrConfig): ScriptRegistry;
|
|
23
|
-
export declare function
|
|
23
|
+
export declare function findChainedSelfReference(packageDir: string | undefined, commandName: string): string | undefined;
|
|
24
|
+
export declare function isSelfReferential(script: string, commandName: string, packageDir: string): boolean;
|
|
24
25
|
export declare function resolveScript(commandName: string, registry: ScriptRegistry, packageDir: string | undefined, workspaceRoot: boolean): ResolvedScript | undefined;
|
package/dist/esm/resolver.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { hasErrnoCode } from '@williamthorsen/nmr-core';
|
|
3
4
|
import { parsePackageJson, readScriptRecord, resolvePackageJsonPath } from "./helpers/package-json.js";
|
|
4
5
|
import { isObject } from "./helpers/type-guards.js";
|
|
5
6
|
import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
|
|
6
|
-
import { composeNmrStep } from "./steps.js";
|
|
7
|
+
import { composeNmrStep, readSelfReference } from "./steps.js";
|
|
8
|
+
import { isMonorepoRoot } from "./workspace.js";
|
|
7
9
|
export function applyDevBin(command, devBin, monorepoRoot) {
|
|
8
10
|
if (!devBin) {
|
|
9
11
|
return command;
|
|
@@ -62,7 +64,7 @@ export function readPackageJsonScripts(packageDir) {
|
|
|
62
64
|
raw = readFileSync(file, 'utf8');
|
|
63
65
|
}
|
|
64
66
|
catch (error) {
|
|
65
|
-
if (error
|
|
67
|
+
if (hasErrnoCode(error, 'ENOENT')) {
|
|
66
68
|
return undefined;
|
|
67
69
|
}
|
|
68
70
|
throw error;
|
|
@@ -87,16 +89,31 @@ export function buildRootRegistry(config) {
|
|
|
87
89
|
...config.rootScripts,
|
|
88
90
|
};
|
|
89
91
|
}
|
|
90
|
-
export function
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
export function findChainedSelfReference(packageDir, commandName) {
|
|
93
|
+
if (packageDir === undefined) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const scripts = readPackageJsonScripts(packageDir);
|
|
97
|
+
if (scripts === undefined || !Object.hasOwn(scripts, commandName)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const script = scripts[commandName];
|
|
101
|
+
if (script === undefined) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
return readSelfReference({ anchoredAtRoot: isMonorepoRoot(packageDir), commandName, script }) === 'chained'
|
|
105
|
+
? script
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
export function isSelfReferential(script, commandName, packageDir) {
|
|
109
|
+
return readSelfReference({ anchoredAtRoot: isMonorepoRoot(packageDir), commandName, script }) !== undefined;
|
|
93
110
|
}
|
|
94
111
|
export function resolveScript(commandName, registry, packageDir, workspaceRoot) {
|
|
95
112
|
if (packageDir) {
|
|
96
113
|
const pkgScripts = readPackageJsonScripts(packageDir);
|
|
97
114
|
if (pkgScripts && Object.hasOwn(pkgScripts, commandName)) {
|
|
98
115
|
const override = pkgScripts[commandName];
|
|
99
|
-
if (override !== undefined && !isSelfReferential(override, commandName)) {
|
|
116
|
+
if (override !== undefined && !isSelfReferential(override, commandName, packageDir)) {
|
|
100
117
|
return {
|
|
101
118
|
origin: { tier: 'package', file: resolvePackageJsonPath(packageDir), key: commandName },
|
|
102
119
|
steps: [{ kind: 'opaque', command: override }],
|
package/dist/esm/runCli.js
CHANGED
|
@@ -7,17 +7,24 @@ import { resolveContext } from "./context.js";
|
|
|
7
7
|
import { generateHelp } from "./help.js";
|
|
8
8
|
import { deriveExcerpt } from "./helpers/deriveExcerpt.js";
|
|
9
9
|
import { isHookName } from "./helpers/hook-name.js";
|
|
10
|
+
import { resolvePackageJsonPath } from "./helpers/package-json.js";
|
|
10
11
|
import { composeTranscript } from "./helpers/transcript.js";
|
|
11
12
|
import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
|
|
12
13
|
import { assembleReplay } from "./replay-assembly.js";
|
|
13
14
|
import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
|
|
14
|
-
import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, resolveScript, } from "./resolver.js";
|
|
15
|
+
import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, findChainedSelfReference, resolveScript, } from "./resolver.js";
|
|
15
16
|
import { resolveChannel, runSteps } from "./runner.js";
|
|
16
17
|
import { composeNmrStep, findNmrCrossing, renderChain } from "./steps.js";
|
|
18
|
+
import { UserError } from "./UserError.js";
|
|
17
19
|
import { COMMAND_VERBOSITY_ENV_VAR, readVerbosityEnv, resolveVerbosity } from "./verbosity.js";
|
|
18
20
|
import { writeVerdict } from "./verdict.js";
|
|
19
21
|
const VERSION = readPackageVersion(import.meta.url);
|
|
20
22
|
const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
|
|
23
|
+
const NAMED_ESCAPES = new Map([
|
|
24
|
+
['\n', String.raw `\n`],
|
|
25
|
+
['\r', String.raw `\r`],
|
|
26
|
+
['\t', String.raw `\t`],
|
|
27
|
+
]);
|
|
21
28
|
export const RUN_IF_PRESENT_ENV_VAR = 'NMR_RUN_IF_PRESENT';
|
|
22
29
|
export async function runCli(options) {
|
|
23
30
|
const { args, cwd, env, stdout, stderr } = options;
|
|
@@ -84,6 +91,14 @@ export async function runCli(options) {
|
|
|
84
91
|
return runSteps([delegation.step], context.monorepoRoot, { ...runOptions, env: delegation.env });
|
|
85
92
|
}
|
|
86
93
|
const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
|
|
94
|
+
assertNoSelfReference({
|
|
95
|
+
anchorDir,
|
|
96
|
+
command,
|
|
97
|
+
isReading: parsed.log,
|
|
98
|
+
monorepoRoot: context.monorepoRoot,
|
|
99
|
+
registry,
|
|
100
|
+
workspaceRoot: parsed.workspaceRoot,
|
|
101
|
+
});
|
|
87
102
|
const resolved = resolveScript(command, registry, anchorDir, parsed.workspaceRoot);
|
|
88
103
|
if (!resolved) {
|
|
89
104
|
if (env[RUN_IF_PRESENT_ENV_VAR] === '1') {
|
|
@@ -255,8 +270,7 @@ function describeCrossingRemedy(options) {
|
|
|
255
270
|
};
|
|
256
271
|
case 'config':
|
|
257
272
|
return {
|
|
258
|
-
remedy: `Write the nmr steps as a step list, and move any others to
|
|
259
|
-
`\`${origin.key}:post\` script.`,
|
|
273
|
+
remedy: `Write the nmr steps as a step list, and move any others to ${describeStepDestination(origin.key)}.`,
|
|
260
274
|
subject: `${configSite}: \`${origin.field}.${origin.key}\``,
|
|
261
275
|
};
|
|
262
276
|
case 'package':
|
|
@@ -270,6 +284,12 @@ function describeCrossingRemedy(options) {
|
|
|
270
284
|
}
|
|
271
285
|
}
|
|
272
286
|
}
|
|
287
|
+
function describeStepDestination(key) {
|
|
288
|
+
if (isHookName(key)) {
|
|
289
|
+
return 'a script of their own that the step list names, because a hook has no `:pre` or `:post` of its own';
|
|
290
|
+
}
|
|
291
|
+
return `a \`${key}:pre\` or \`${key}:post\` script`;
|
|
292
|
+
}
|
|
273
293
|
function describeOrigin(origin, config, useRoot) {
|
|
274
294
|
if (origin.tier === 'package') {
|
|
275
295
|
return origin;
|
|
@@ -280,9 +300,17 @@ function describeOrigin(origin, config, useRoot) {
|
|
|
280
300
|
? { tier: 'config', field, key: origin.key }
|
|
281
301
|
: { tier: 'default', key: origin.key };
|
|
282
302
|
}
|
|
303
|
+
function escapeControlCharacters(text) {
|
|
304
|
+
let escaped = text;
|
|
305
|
+
for (const [char, escape] of NAMED_ESCAPES) {
|
|
306
|
+
escaped = escaped.split(char).join(escape);
|
|
307
|
+
}
|
|
308
|
+
return escaped;
|
|
309
|
+
}
|
|
283
310
|
function formatNmrCrossingWarning(options) {
|
|
284
311
|
const { remedy, subject } = describeCrossingRemedy(options);
|
|
285
|
-
return `⚠️ ${subject} reaches nmr through a shell (\`${options.crossing}\`),
|
|
312
|
+
return (`⚠️ ${subject} reaches nmr through a shell (\`${escapeControlCharacters(options.crossing)}\`), ` +
|
|
313
|
+
`${CROSSING_CONSEQUENCE} ${remedy}`);
|
|
286
314
|
}
|
|
287
315
|
function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
|
|
288
316
|
const registryEntry = Object.hasOwn(registry, command) ? registry[command] : undefined;
|
|
@@ -291,18 +319,26 @@ function formatOverrideNotice(resolved, registry, command, anchorDir, quiet) {
|
|
|
291
319
|
}
|
|
292
320
|
return `📦 ${path.basename(anchorDir)}: Using override script: ${renderChain(resolved.steps)}\n`;
|
|
293
321
|
}
|
|
322
|
+
function formatSelfReferenceRemedy(options) {
|
|
323
|
+
const { command, monorepoRoot, registry, script, workspaceRoot } = options;
|
|
324
|
+
if (isHookName(command)) {
|
|
325
|
+
return `Delete the re-invocation: \`${command}\` runs the steps standing beside it.`;
|
|
326
|
+
}
|
|
327
|
+
const configSite = path.relative(monorepoRoot, resolveConfigPath(monorepoRoot));
|
|
328
|
+
return formatPackageRemedy({ configSite, crossing: script, key: command, registry, workspaceRoot });
|
|
329
|
+
}
|
|
294
330
|
function formatPackageRemedy(options) {
|
|
295
331
|
const { configSite, crossing, key, registry, workspaceRoot } = options;
|
|
296
332
|
const registryEntry = Object.hasOwn(registry, key) ? registry[key] : undefined;
|
|
297
333
|
if (registryEntry === undefined) {
|
|
298
334
|
return (`A \`package.json\` script holds no step list: define \`${key}\` in \`${configSite}\` and move the ` +
|
|
299
|
-
`package-specific steps to
|
|
335
|
+
`package-specific steps to ${describeStepDestination(key)}.`);
|
|
300
336
|
}
|
|
301
337
|
const registryChain = renderChain(expandScript(registryEntry, workspaceRoot));
|
|
302
338
|
if (registryChain === crossing) {
|
|
303
|
-
return `Delete the entry: nmr's own \`${key}\` already runs \`${registryChain}\`.`;
|
|
339
|
+
return `Delete the entry: nmr's own \`${key}\` already runs \`${escapeControlCharacters(registryChain)}\`.`;
|
|
304
340
|
}
|
|
305
|
-
return `Delete the entry and move the steps it adds to
|
|
341
|
+
return `Delete the entry and move the steps it adds to ${describeStepDestination(key)}.`;
|
|
306
342
|
}
|
|
307
343
|
function findArgError(parsed) {
|
|
308
344
|
if (parsed.log && parsed.command === undefined && !parsed.help && !parsed.version) {
|
|
@@ -319,7 +355,24 @@ function findNoOpReason(resolvedCommand) {
|
|
|
319
355
|
}
|
|
320
356
|
return undefined;
|
|
321
357
|
}
|
|
358
|
+
function assertNoSelfReference(options) {
|
|
359
|
+
const { anchorDir, command, isReading, monorepoRoot, registry, workspaceRoot } = options;
|
|
360
|
+
if (isReading) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const script = findChainedSelfReference(anchorDir, command);
|
|
364
|
+
if (script === undefined) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const remedy = formatSelfReferenceRemedy({ command, monorepoRoot, registry, script, workspaceRoot });
|
|
368
|
+
const site = path.relative(monorepoRoot, resolvePackageJsonPath(anchorDir));
|
|
369
|
+
throw new UserError(`${site}: \`scripts.${command}\` re-invokes \`nmr ${command}\` (\`${escapeControlCharacters(script)}\`), ` +
|
|
370
|
+
`so nmr cannot run the steps it chains. ${remedy}`);
|
|
371
|
+
}
|
|
322
372
|
function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
|
|
373
|
+
if (findChainedSelfReference(anchorDir, hookName) !== undefined) {
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
323
376
|
const resolved = resolveScript(hookName, registry, anchorDir, workspaceRoot);
|
|
324
377
|
if (!resolved)
|
|
325
378
|
return false;
|
package/dist/esm/steps.d.ts
CHANGED
|
@@ -11,8 +11,14 @@ export interface NmrStepTarget {
|
|
|
11
11
|
isDelegate: boolean;
|
|
12
12
|
isWorkspaceRoot: boolean;
|
|
13
13
|
}
|
|
14
|
+
export type SelfReference = 'chained' | 'sole';
|
|
14
15
|
export declare function composeNmrStep(element: string, workspaceRoot: boolean, declinesArgs?: boolean): Step;
|
|
15
16
|
export declare function findNmrCrossing(steps: readonly Step[]): string | undefined;
|
|
16
17
|
export declare function findUnexpressibleToken(element: string): string | undefined;
|
|
17
18
|
export declare function readNmrStep(step: Step): NmrStepTarget | undefined;
|
|
19
|
+
export declare function readSelfReference(options: {
|
|
20
|
+
anchoredAtRoot: boolean;
|
|
21
|
+
commandName: string;
|
|
22
|
+
script: string;
|
|
23
|
+
}): SelfReference | undefined;
|
|
18
24
|
export declare function renderChain(steps: readonly Step[]): string;
|
package/dist/esm/steps.js
CHANGED
|
@@ -8,7 +8,7 @@ const LAUNCHERS = new Map([
|
|
|
8
8
|
['yarn', new Set(['dlx', 'exec'])],
|
|
9
9
|
]);
|
|
10
10
|
const QUOTES = new Set(['"', "'"]);
|
|
11
|
-
const SEGMENT_SEPARATORS = new Set([';', '|', '&']);
|
|
11
|
+
const SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
|
|
12
12
|
const SHELL_SAFE_TOKEN = /^[\w@%+=:,./-]+$/;
|
|
13
13
|
export function composeNmrStep(element, workspaceRoot, declinesArgs = false) {
|
|
14
14
|
const flags = workspaceRoot ? ['-w'] : [];
|
|
@@ -16,7 +16,7 @@ export function composeNmrStep(element, workspaceRoot, declinesArgs = false) {
|
|
|
16
16
|
}
|
|
17
17
|
export function findNmrCrossing(steps) {
|
|
18
18
|
for (const step of steps) {
|
|
19
|
-
if (step.kind === 'opaque' && splitSegments(step.command).some(
|
|
19
|
+
if (step.kind === 'opaque' && splitSegments(step.command).some((segment) => readNmrTail(segment) !== undefined)) {
|
|
20
20
|
return step.command;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -30,14 +30,65 @@ export function readNmrStep(step) {
|
|
|
30
30
|
return undefined;
|
|
31
31
|
}
|
|
32
32
|
const [file, ...rest] = step.argv;
|
|
33
|
-
|
|
33
|
+
return file === 'nmr' ? readNmrTarget(rest) : undefined;
|
|
34
|
+
}
|
|
35
|
+
export function readSelfReference(options) {
|
|
36
|
+
const { anchoredAtRoot, commandName, script } = options;
|
|
37
|
+
const segments = splitSegments(script).filter((segment) => segment.trim() !== '');
|
|
38
|
+
const reenters = segments.some((segment) => {
|
|
39
|
+
const tail = readNmrTail(segment);
|
|
40
|
+
const target = tail === undefined ? undefined : readNmrTarget(tail);
|
|
41
|
+
return target?.command === commandName && !target.isDelegate && (anchoredAtRoot || !target.isWorkspaceRoot);
|
|
42
|
+
});
|
|
43
|
+
if (!reenters) {
|
|
34
44
|
return undefined;
|
|
35
45
|
}
|
|
46
|
+
return segments.length === 1 ? 'sole' : 'chained';
|
|
47
|
+
}
|
|
48
|
+
export function renderChain(steps) {
|
|
49
|
+
return steps.map(renderStep).join(' && ');
|
|
50
|
+
}
|
|
51
|
+
function dropLeadingAssignments(tokens) {
|
|
52
|
+
const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
|
|
53
|
+
return start === -1 ? [] : tokens.slice(start);
|
|
54
|
+
}
|
|
55
|
+
function isRedirectionOperator(char, precedingText, nextChar) {
|
|
56
|
+
if (char !== '&' && char !== '|') {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return (char === '&' && nextChar === '>') || /[<>]\s*$/.test(precedingText);
|
|
60
|
+
}
|
|
61
|
+
function quoteToken(token) {
|
|
62
|
+
if (SHELL_SAFE_TOKEN.test(token)) {
|
|
63
|
+
return token;
|
|
64
|
+
}
|
|
65
|
+
return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
|
|
66
|
+
}
|
|
67
|
+
function readNmrTail(segment) {
|
|
68
|
+
const [head, ...rest] = dropLeadingAssignments(tokenize(segment));
|
|
69
|
+
if (head === undefined) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (head === 'nmr') {
|
|
73
|
+
return rest;
|
|
74
|
+
}
|
|
75
|
+
const subcommands = LAUNCHERS.get(head);
|
|
76
|
+
if (subcommands === undefined) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
if (subcommands.size === 0) {
|
|
80
|
+
const nameIndex = rest.findIndex((token) => !token.startsWith('-'));
|
|
81
|
+
return nameIndex !== -1 && rest[nameIndex] === 'nmr' ? rest.slice(nameIndex + 1) : undefined;
|
|
82
|
+
}
|
|
83
|
+
const subcommandIndex = rest.findIndex((token) => subcommands.has(token));
|
|
84
|
+
return subcommandIndex !== -1 && rest[subcommandIndex + 1] === 'nmr' ? rest.slice(subcommandIndex + 2) : undefined;
|
|
85
|
+
}
|
|
86
|
+
function readNmrTarget(tokens) {
|
|
36
87
|
let isDelegate = false;
|
|
37
88
|
let isWorkspaceRoot = false;
|
|
38
89
|
let index = 0;
|
|
39
|
-
while (index <
|
|
40
|
-
const token =
|
|
90
|
+
while (index < tokens.length) {
|
|
91
|
+
const token = tokens[index] ?? '';
|
|
41
92
|
switch (token) {
|
|
42
93
|
case '-F':
|
|
43
94
|
case '--filter':
|
|
@@ -63,37 +114,6 @@ export function readNmrStep(step) {
|
|
|
63
114
|
}
|
|
64
115
|
return undefined;
|
|
65
116
|
}
|
|
66
|
-
export function renderChain(steps) {
|
|
67
|
-
return steps.map(renderStep).join(' && ');
|
|
68
|
-
}
|
|
69
|
-
function dropLeadingAssignments(tokens) {
|
|
70
|
-
const start = tokens.findIndex((token) => !ENV_ASSIGNMENT.test(token));
|
|
71
|
-
return start === -1 ? [] : tokens.slice(start);
|
|
72
|
-
}
|
|
73
|
-
function quoteToken(token) {
|
|
74
|
-
if (SHELL_SAFE_TOKEN.test(token)) {
|
|
75
|
-
return token;
|
|
76
|
-
}
|
|
77
|
-
return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
|
|
78
|
-
}
|
|
79
|
-
function reachesNmr(segment) {
|
|
80
|
-
const [head, ...rest] = dropLeadingAssignments(tokenize(segment));
|
|
81
|
-
if (head === undefined) {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
if (head === 'nmr') {
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
const subcommands = LAUNCHERS.get(head);
|
|
88
|
-
if (subcommands === undefined) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
if (subcommands.size === 0) {
|
|
92
|
-
return rest.find((token) => !token.startsWith('-')) === 'nmr';
|
|
93
|
-
}
|
|
94
|
-
const subcommandIndex = rest.findIndex((token) => subcommands.has(token));
|
|
95
|
-
return subcommandIndex !== -1 && rest[subcommandIndex + 1] === 'nmr';
|
|
96
|
-
}
|
|
97
117
|
function renderStep(step) {
|
|
98
118
|
return step.kind === 'opaque' ? step.command : step.argv.map(quoteToken).join(' ');
|
|
99
119
|
}
|
|
@@ -128,6 +148,11 @@ function splitSegments(command) {
|
|
|
128
148
|
continue;
|
|
129
149
|
}
|
|
130
150
|
if (SEGMENT_SEPARATORS.has(char)) {
|
|
151
|
+
if (isRedirectionOperator(char, current, command[index + 1])) {
|
|
152
|
+
current += char;
|
|
153
|
+
index += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
131
156
|
index += command[index + 1] === char ? 2 : 1;
|
|
132
157
|
segments.push(current);
|
|
133
158
|
current = '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -68,16 +68,18 @@
|
|
|
68
68
|
"dist"
|
|
69
69
|
],
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@williamthorsen/toolbelt.errors": "0.
|
|
71
|
+
"@williamthorsen/toolbelt.errors": "0.5.0",
|
|
72
72
|
"glob": "13.0.6",
|
|
73
73
|
"prettier-plugin-sh": "0.19.0",
|
|
74
|
-
"taze": "20.0.
|
|
74
|
+
"taze": "20.0.1",
|
|
75
75
|
"yaml": "2.9.0",
|
|
76
|
-
"@williamthorsen/nmr-core": "0.10.
|
|
76
|
+
"@williamthorsen/nmr-core": "0.10.1"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
+
"@williamthorsen/toolbelt.testing": "0.3.0",
|
|
80
|
+
"@williamthorsen/toolbelt.vitest": "0.4.0",
|
|
79
81
|
"prettier": "3.9.6",
|
|
80
|
-
"readyup": "0.
|
|
82
|
+
"readyup": "0.29.0",
|
|
81
83
|
"tinyglobby": "0.2.17",
|
|
82
84
|
"typescript": "6.0.3",
|
|
83
85
|
"vitest": "4.1.10"
|