@williamthorsen/nmr 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.meta/changelog.json +80 -0
- package/.readyup/kits/default.js +419 -129
- package/.readyup/manifest.json +13 -17
- package/CHANGELOG.md +24 -0
- package/README.md +10 -17
- package/agents/guidance/rulebooks/nmr.md +1 -1
- package/dist/esm/commands/fmt.d.ts +4 -2
- package/dist/esm/commands/fmt.js +11 -3
- package/dist/esm/config.js +46 -3
- package/dist/esm/entry-chain.d.ts +24 -0
- package/dist/esm/entry-chain.js +57 -0
- package/dist/esm/glyphs.d.ts +1 -1
- package/dist/esm/helpers/escapeControlCharacters.d.ts +10 -0
- package/dist/esm/helpers/escapeControlCharacters.js +12 -0
- package/dist/esm/resolver.js +5 -2
- package/dist/esm/runCli.js +141 -98
- package/dist/esm/steps.d.ts +38 -13
- package/dist/esm/steps.js +77 -10
- package/dist/esm/tiers.d.ts +5 -4
- package/dist/esm/vitest-git-isolation.js +3 -1
- package/docs/reporting.md +2 -0
- package/docs/scripts.md +10 -2
- package/docs/utilities.md +2 -2
- package/docs/vitest.md +12 -8
- package/package.json +2 -2
package/.readyup/manifest.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"bundledDependencies": {
|
|
6
6
|
"@williamthorsen/toolbelt.errors": "0.8.3",
|
|
7
7
|
"@williamthorsen/toolbelt.filesystem": "0.9.3",
|
|
8
|
+
"@williamthorsen/toolbelt.packaging": "0.6.3",
|
|
8
9
|
"@williamthorsen/toolbelt.terminal": "0.2.1",
|
|
9
10
|
"ansi-regex": "6.4.0",
|
|
10
11
|
"ansi-styles": "6.2.3",
|
|
@@ -29,11 +30,6 @@
|
|
|
29
30
|
"kind": "module",
|
|
30
31
|
"path": "../../nmr-core/dist/esm/cache-store.js"
|
|
31
32
|
},
|
|
32
|
-
{
|
|
33
|
-
"hash": "a5f44396",
|
|
34
|
-
"kind": "module",
|
|
35
|
-
"path": "../../nmr-core/dist/esm/findPackageRoot.js"
|
|
36
|
-
},
|
|
37
33
|
{
|
|
38
34
|
"hash": "54384dca",
|
|
39
35
|
"kind": "module",
|
|
@@ -55,7 +51,7 @@
|
|
|
55
51
|
"path": "../../nmr-core/dist/esm/helpers/workspace-patterns.js"
|
|
56
52
|
},
|
|
57
53
|
{
|
|
58
|
-
"hash": "
|
|
54
|
+
"hash": "191f7c24",
|
|
59
55
|
"kind": "module",
|
|
60
56
|
"path": "../../nmr-core/dist/esm/index.js"
|
|
61
57
|
},
|
|
@@ -64,11 +60,6 @@
|
|
|
64
60
|
"kind": "module",
|
|
65
61
|
"path": "../../nmr-core/dist/esm/parseArgs.js"
|
|
66
62
|
},
|
|
67
|
-
{
|
|
68
|
-
"hash": "a7efc38f",
|
|
69
|
-
"kind": "module",
|
|
70
|
-
"path": "../../nmr-core/dist/esm/readPackageVersion.js"
|
|
71
|
-
},
|
|
72
63
|
{
|
|
73
64
|
"hash": "14547f78",
|
|
74
65
|
"kind": "module",
|
|
@@ -95,7 +86,7 @@
|
|
|
95
86
|
"path": "kits/default.ts"
|
|
96
87
|
},
|
|
97
88
|
{
|
|
98
|
-
"hash": "
|
|
89
|
+
"hash": "22efcac6",
|
|
99
90
|
"kind": "inline",
|
|
100
91
|
"path": "../package.json",
|
|
101
92
|
"paths": [
|
|
@@ -103,7 +94,7 @@
|
|
|
103
94
|
]
|
|
104
95
|
},
|
|
105
96
|
{
|
|
106
|
-
"hash": "
|
|
97
|
+
"hash": "7e066d18",
|
|
107
98
|
"kind": "module",
|
|
108
99
|
"path": "../src/config.ts"
|
|
109
100
|
},
|
|
@@ -122,6 +113,11 @@
|
|
|
122
113
|
"kind": "module",
|
|
123
114
|
"path": "../src/helpers/config-path.ts"
|
|
124
115
|
},
|
|
116
|
+
{
|
|
117
|
+
"hash": "a663548a",
|
|
118
|
+
"kind": "module",
|
|
119
|
+
"path": "../src/helpers/escapeControlCharacters.ts"
|
|
120
|
+
},
|
|
125
121
|
{
|
|
126
122
|
"hash": "47249ca4",
|
|
127
123
|
"kind": "module",
|
|
@@ -153,17 +149,17 @@
|
|
|
153
149
|
"path": "../src/resolve-scripts.ts"
|
|
154
150
|
},
|
|
155
151
|
{
|
|
156
|
-
"hash": "
|
|
152
|
+
"hash": "15247f0e",
|
|
157
153
|
"kind": "module",
|
|
158
154
|
"path": "../src/resolver.ts"
|
|
159
155
|
},
|
|
160
156
|
{
|
|
161
|
-
"hash": "
|
|
157
|
+
"hash": "ed585ab6",
|
|
162
158
|
"kind": "module",
|
|
163
159
|
"path": "../src/steps.ts"
|
|
164
160
|
},
|
|
165
161
|
{
|
|
166
|
-
"hash": "
|
|
162
|
+
"hash": "d1e8e302",
|
|
167
163
|
"kind": "module",
|
|
168
164
|
"path": "../src/tiers.ts"
|
|
169
165
|
},
|
|
@@ -193,7 +189,7 @@
|
|
|
193
189
|
"readyupVersion": "0.40.0",
|
|
194
190
|
"source": "kits/default.ts",
|
|
195
191
|
"sourceHash": "aaeb1a84",
|
|
196
|
-
"targetHash": "
|
|
192
|
+
"targetHash": "a8b07f51"
|
|
197
193
|
}
|
|
198
194
|
]
|
|
199
195
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.45.0 — 2026-10-12
|
|
6
|
+
|
|
7
|
+
### 🎉 Features
|
|
8
|
+
|
|
9
|
+
- Adds `maintenance.auto=false` to the git config that the Vitest git-isolation setup file injects, so that a git command run by a test does not start a detached `git maintenance` process that can still be writing under `.git` when the test removes the repository. (#1033)
|
|
10
|
+
|
|
11
|
+
### 🐛 Bug fixes
|
|
12
|
+
|
|
13
|
+
- Fixes the issue that `nmr-fmt` failed a run in which every path matched by the pathspecs was one that it skips, such as a symlink, a submodule gitlink, or an unreadable file, and makes such a run exit 0 and name the skipped paths on stderr. (#1010)
|
|
14
|
+
- Fixes the issue that a `rootScripts` or `workspaceScripts` entry in `.config/nmr.config.ts` that names its own key, as a shell segment or as a step-list element, re-entered nmr without bound, by rejecting the entry when the config loads with an error that names the file, the field, the key, and the edit that resolves it. (#1011)
|
|
15
|
+
- Fixes the issue that a package's own `package.json` entry running `nmr -R <same command>` re-entered that package without bound instead of being discarded or rejected as a self-reference. (#1011)
|
|
16
|
+
- Fixes the issue that nmr treated a root entry such as `pnpm --recursive exec nmr build` as a re-invocation of its own command, because it ignored the `-r`, `--filter`, and `-w` flags that pnpm applies to the nmr that it launches. (#1011)
|
|
17
|
+
- Fixes the issue that `nmr` hung when a script entry re-entered its own command in the same directory without nmr recognizing the self-reference, as `nmr -F own-pkg build` in `own-pkg`'s own `build` or a cycle through two entries does, by failing the run with an error that names the command and the directory and lists the commands that the run had entered, outermost first. (#1012)
|
|
18
|
+
- Fixes the issue that the test-tier definitions in `vitest.md` gave a test that starts its own loopback server three defensible tiers: `tool` is now a process that the test starts or a program that the environment supplies, `localhost` a service already running on this machine, and an in-process server from the package's own dependency closure `unit`. (#1023)
|
|
19
|
+
- Fixes the issue that `nmr`'s warning about a `package.json` script that runs `nmr` through a shell sent an entry whose whole value is `nmr <name>` to `.config/nmr.config.ts`, when the accurate edit is to replace that value with the command of the `<name>` script in the same file. (#1030)
|
|
20
|
+
|
|
21
|
+
### ♻️ Refactoring
|
|
22
|
+
|
|
23
|
+
- Moves the work of `runInvocation` in `nmr` into the phase functions `establishInvocation`, `planRun`, and `executePlan`, which `runCli` calls in order, and changes `runGated` and `reportRecording` to take a named `RunPlan` in place of an options object that repeated its fields. (#1014)
|
|
24
|
+
|
|
25
|
+
### 🧪 Tests
|
|
26
|
+
|
|
27
|
+
- Replaces the `GIT_CEILING_DIRECTORIES` stub with a `GIT_DIR` stub that points to a missing path in the no-repository test of `check-cache-gate.tool.test.ts`, and sets `maintenance.auto` to `false` in the file's fixture repository, so that git neither finds a leftover of the removed `repo/.git` nor starts a detached maintenance process that writes under `.git`. (#1032)
|
|
28
|
+
|
|
5
29
|
## 0.44.0 — 2026-10-11
|
|
6
30
|
|
|
7
31
|
### 🎉 Features
|
package/README.md
CHANGED
|
@@ -5,28 +5,21 @@
|
|
|
5
5
|
Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
|
|
6
6
|
|
|
7
7
|
<!-- section:release-notes -->
|
|
8
|
-
## Release notes — v0.
|
|
8
|
+
## Release notes — v0.45.0 (2026-10-12)
|
|
9
9
|
|
|
10
10
|
### 🎉 Features
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
- Adds the `shouldRunInBackground` field to a composite element in `.config/nmr.config.ts`, which starts the step, runs the steps after it without waiting, prints the step's output whole after the step ends, and fails the composite if the step fails. (#1003)
|
|
14
|
-
- Adds a `self-hosted runner` checklist to nmr's readyup kit, which reads the repo's GitHub settings through `gh`, recommends setting `CI_RUNS_ON` when a private repo with an online self-hosted runner and compatible workflows has not set it, and warns about each failing condition once the variable is set. (#1006)
|
|
15
|
-
- Adds the root-only config key `selfHostedRunner.exempt`, a non-empty reason that stops the kit from recommending `CI_RUNS_ON` for a repo that qualifies for the self-hosted runners but stays off them. (#1006)
|
|
16
|
-
|
|
17
|
-
### 🪦 Removed
|
|
18
|
-
|
|
19
|
-
- 🚨 **Breaking:** Removes the zero-argument form of `getDefaultRootScripts` from the `@williamthorsen/nmr/scripts` export, which now requires the monorepo root so that it can read the declared workspaces. (#992)
|
|
12
|
+
- Adds `maintenance.auto=false` to the git config that the Vitest git-isolation setup file injects, so that a git command run by a test does not start a detached `git maintenance` process that can still be writing under `.git` when the test removes the repository. (#1033)
|
|
20
13
|
|
|
21
14
|
### 🐛 Bug fixes
|
|
22
15
|
|
|
23
|
-
- Fixes the issue that `
|
|
24
|
-
- Fixes the issue that `
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
16
|
+
- Fixes the issue that `nmr-fmt` failed a run in which every path matched by the pathspecs was one that it skips, such as a symlink, a submodule gitlink, or an unreadable file, and makes such a run exit 0 and name the skipped paths on stderr. (#1010)
|
|
17
|
+
- Fixes the issue that a `rootScripts` or `workspaceScripts` entry in `.config/nmr.config.ts` that names its own key, as a shell segment or as a step-list element, re-entered nmr without bound, by rejecting the entry when the config loads with an error that names the file, the field, the key, and the edit that resolves it. (#1011)
|
|
18
|
+
- Fixes the issue that a package's own `package.json` entry running `nmr -R <same command>` re-entered that package without bound instead of being discarded or rejected as a self-reference. (#1011)
|
|
19
|
+
- Fixes the issue that nmr treated a root entry such as `pnpm --recursive exec nmr build` as a re-invocation of its own command, because it ignored the `-r`, `--filter`, and `-w` flags that pnpm applies to the nmr that it launches. (#1011)
|
|
20
|
+
- Fixes the issue that `nmr` hung when a script entry re-entered its own command in the same directory without nmr recognizing the self-reference, as `nmr -F own-pkg build` in `own-pkg`'s own `build` or a cycle through two entries does, by failing the run with an error that names the command and the directory and lists the commands that the run had entered, outermost first. (#1012)
|
|
21
|
+
- Fixes the issue that the test-tier definitions in `vitest.md` gave a test that starts its own loopback server three defensible tiers: `tool` is now a process that the test starts or a program that the environment supplies, `localhost` a service already running on this machine, and an in-process server from the package's own dependency closure `unit`. (#1023)
|
|
22
|
+
- Fixes the issue that `nmr`'s warning about a `package.json` script that runs `nmr` through a shell sent an entry whose whole value is `nmr <name>` to `.config/nmr.config.ts`, when the accurate edit is to replace that value with the command of the `<name>` script in the same file. (#1030)
|
|
30
23
|
<!-- /section:release-notes -->
|
|
31
24
|
|
|
32
25
|
## Installation
|
|
@@ -124,7 +117,7 @@ Scripts resolve through three tiers. Higher tiers override lower ones:
|
|
|
124
117
|
2. **Repo-wide config**: Additions and overrides in `.config/nmr.config.ts`
|
|
125
118
|
3. **Per-package overrides**: Scripts in a package's `package.json`
|
|
126
119
|
|
|
127
|
-
[Scripts and configuration](docs/scripts.md#three-tier-override-system) shows a worked example, how to skip a script for one package, and what nmr does with a `package.json` entry that re-invokes its own command.
|
|
120
|
+
[Scripts and configuration](docs/scripts.md#three-tier-override-system) shows a worked example, how to skip a script for one package, and what nmr does with a `package.json` or `.config/nmr.config.ts` entry that re-invokes its own command.
|
|
128
121
|
|
|
129
122
|
## Pre and post hooks
|
|
130
123
|
|
|
@@ -13,5 +13,5 @@ delivery: ambient
|
|
|
13
13
|
- A check that already passed on the current working tree skips, printing `SKIP <scope>: <command>: passed …` and, when the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it. Read the replayed excerpt rather than running the check again: Never pass `--no-cache` to get a "real" run, which takes 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 when there are too many to fit. A skip without an 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. Output that you need in full is a recording to read rather than a command to run again. It runs nothing, and it is held to the tree rather than to the terminal: A recording made by a piped run prints for a reader at one. A composite prints its constituents' excerpts, not their transcripts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. When there is nothing to show, `--log` says so on one line, names which case it is, and exits non-zero.
|
|
15
15
|
- Every command that nmr runs reports one line -- `PASS <scope>: <command>: passed in 12.4s`, `FAIL … failed in 1.2s (exit 1)`, `SKIP … passed 4m ago on this tree`, or `NOOP … skipped, the override is empty` -- and a composite reports alongside every command into which it expands. Those are the words that a pipe and a CI log get; a terminal gets ✅, ❌, ⏩, and ⚪ in their place, and `--output-style rich` or `NMR_OUTPUT_STYLE=rich` forces the emoji on any stream. The lines print in every verbosity, so `nmr -q <command>` is the low-noise way to run rather than a silent one: It withholds the output of the commands that nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. nmr reads its own flags only before the command name and passes everything after the name to the commands that run: `nmr ci -q` hands `-q` to `nmr-fmt`, which rejects it. 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
|
-
- 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 that the test reaches -- `tool` (a program
|
|
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 that the test reaches -- `tool` (a process that the test starts, or a program that the environment supplies), `localhost` (a service already running on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --sources` reports both and warns that the check is missing.
|
|
17
17
|
- The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (the repo does not define a build script of its own); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; and `check-cache.md` for the check-result cache's key, the retention key to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type FormatMode = 'check' | 'write';
|
|
2
2
|
export interface FormatTargets {
|
|
3
|
+
/** Listed paths that Prettier cannot be handed, such as symlinks and submodule gitlinks. */
|
|
4
|
+
excludedFiles: string[];
|
|
3
5
|
/** Paths relative to the working directory, as `git ls-files` emits them. */
|
|
4
6
|
files: string[];
|
|
5
7
|
/** Absolute paths to every `.prettierignore` governing the repository, root-most first. */
|
|
@@ -42,8 +44,8 @@ export declare function runFmt(argv: string[], cwd?: string): Promise<number>;
|
|
|
42
44
|
* `pathspecs` narrow the selection and are passed to git verbatim: They have git pathspec semantics rather
|
|
43
45
|
* than shell glob semantics.
|
|
44
46
|
*
|
|
45
|
-
* A listed path that
|
|
46
|
-
* fails the resolution.
|
|
47
|
+
* A listed path that Prettier cannot be handed is set aside in `excludedFiles`, and one that this process is denied
|
|
48
|
+
* access to in `unreadableFiles`; any other stat failure fails the resolution.
|
|
47
49
|
*/
|
|
48
50
|
export declare function resolveFormatTargets(cwd: string, pathspecs?: string[]): ResolveTargetsResult;
|
|
49
51
|
/** @internal */
|
package/dist/esm/commands/fmt.js
CHANGED
|
@@ -33,15 +33,20 @@ export async function runFmt(argv, cwd = process.cwd()) {
|
|
|
33
33
|
reportError(`nmr-fmt: ${resolvedTargets.error}`);
|
|
34
34
|
return 1;
|
|
35
35
|
}
|
|
36
|
-
const { files, ignorePaths, repositoryRoot, unreadableFiles } = resolvedTargets.targets;
|
|
36
|
+
const { excludedFiles, files, ignorePaths, repositoryRoot, unreadableFiles } = resolvedTargets.targets;
|
|
37
37
|
for (const { file, code } of unreadableFiles) {
|
|
38
38
|
process.stderr.write(`nmr-fmt: skipped ${file}, which cannot be read (${code})\n`);
|
|
39
39
|
}
|
|
40
40
|
if (files.length === 0) {
|
|
41
|
-
if (parsedArgs.pathspecs.length
|
|
42
|
-
|
|
41
|
+
if (parsedArgs.pathspecs.length === 0)
|
|
42
|
+
return 0;
|
|
43
|
+
if (excludedFiles.length === 0 && unreadableFiles.length === 0) {
|
|
44
|
+
reportError(`nmr-fmt: the pathspecs did not match any file that git lists: ${parsedArgs.pathspecs.join(', ')}`);
|
|
43
45
|
return 1;
|
|
44
46
|
}
|
|
47
|
+
if (excludedFiles.length > 0) {
|
|
48
|
+
process.stderr.write(`nmr-fmt: nothing to format; skipped paths that are not regular files: ${excludedFiles.join(', ')}\n`);
|
|
49
|
+
}
|
|
45
50
|
return 0;
|
|
46
51
|
}
|
|
47
52
|
const cli = resolvePrettierCli(cwd);
|
|
@@ -84,6 +89,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
|
|
|
84
89
|
const ignoreFiles = runGit([...LIST_FILES_ARGS, '--', IGNORE_FILE_PATHSPEC], repositoryRoot);
|
|
85
90
|
if (!ignoreFiles.ok)
|
|
86
91
|
return ignoreFiles;
|
|
92
|
+
const excludedFiles = [];
|
|
87
93
|
const files = [];
|
|
88
94
|
const unreadableFiles = [];
|
|
89
95
|
const listedPaths = dedupe(splitNulSeparated(listedFiles.stdout));
|
|
@@ -91,6 +97,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
|
|
|
91
97
|
const status = classifyListedFile(cwd, file);
|
|
92
98
|
switch (status.kind) {
|
|
93
99
|
case 'excluded':
|
|
100
|
+
excludedFiles.push(file);
|
|
94
101
|
break;
|
|
95
102
|
case 'failed':
|
|
96
103
|
return { ok: false, error: status.error };
|
|
@@ -108,6 +115,7 @@ export function resolveFormatTargets(cwd, pathspecs = []) {
|
|
|
108
115
|
return {
|
|
109
116
|
ok: true,
|
|
110
117
|
targets: {
|
|
118
|
+
excludedFiles: excludedFiles.toSorted(),
|
|
111
119
|
files: files.toSorted(),
|
|
112
120
|
ignorePaths: dedupe([path.join(repositoryRoot, IGNORE_FILENAME), ...discoveredIgnorePaths]),
|
|
113
121
|
repositoryRoot,
|
package/dist/esm/config.js
CHANGED
|
@@ -9,11 +9,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { pathToFileURL } from 'node:url';
|
|
11
11
|
import { resolveConfigPath } from "./helpers/config-path.js";
|
|
12
|
+
import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
|
|
12
13
|
import { findClosestName } from "./helpers/findClosestName.js";
|
|
13
14
|
import { isHookName } from "./helpers/hook-name.js";
|
|
14
15
|
import { isObject, isStringRecord } from "./helpers/type-guards.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
|
|
17
|
+
import { buildRootRegistry, buildWorkspaceRegistry, describeScript, readPackageJsonScripts } from "./resolver.js";
|
|
18
|
+
import { findUnexpressibleToken, readSelfReference, readStepListSelfReference, } from "./steps.js";
|
|
17
19
|
import { UserError } from "./UserError.js";
|
|
18
20
|
import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
|
|
19
21
|
import { getWorkspacePackageDirs, isMonorepoRoot } from "./workspace.js";
|
|
@@ -35,6 +37,10 @@ const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommand
|
|
|
35
37
|
const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
|
|
36
38
|
const RECOGNIZED_SELF_HOSTED_RUNNER_KEYS = ['exempt'];
|
|
37
39
|
const RECOGNIZED_STEP_KEYS = ['run', 'shouldDeclineArguments', 'shouldRunInBackground'];
|
|
40
|
+
const SCRIPT_FIELDS = [
|
|
41
|
+
{ field: 'rootScripts', scope: 'root' },
|
|
42
|
+
{ field: 'workspaceScripts', scope: 'workspaceRegistry' },
|
|
43
|
+
];
|
|
38
44
|
const RETIRED_STEP_KEYS = new Map([['declinesArgs', 'shouldDeclineArguments']]);
|
|
39
45
|
function isScriptRecord(value) {
|
|
40
46
|
if (!isObject(value))
|
|
@@ -286,7 +292,9 @@ export async function loadConfig(baseDir) {
|
|
|
286
292
|
}
|
|
287
293
|
export async function loadRootConfig(monorepoRoot) {
|
|
288
294
|
const config = await loadConfig(monorepoRoot);
|
|
289
|
-
|
|
295
|
+
const configPath = resolveConfigPath(monorepoRoot);
|
|
296
|
+
assertTierKeys(config, CONFIG_TIERS.root, configPath);
|
|
297
|
+
assertNoSelfReferences(config, configPath, monorepoRoot);
|
|
290
298
|
return config;
|
|
291
299
|
}
|
|
292
300
|
export async function loadWorkspaceConfig(packageDir) {
|
|
@@ -302,6 +310,30 @@ function assertNoRetiredKeys(value, retiredKeys, configPath, prefix) {
|
|
|
302
310
|
throw new UserError(`Invalid nmr config at ${configPath}: \`${prefix}${key}\` was renamed to \`${prefix}${replacement}\`.`);
|
|
303
311
|
}
|
|
304
312
|
}
|
|
313
|
+
function assertNoSelfReferences(config, configPath, monorepoRoot) {
|
|
314
|
+
for (const { field, scope } of SCRIPT_FIELDS) {
|
|
315
|
+
const scripts = config[field] ?? {};
|
|
316
|
+
for (const [key, script] of Object.entries(scripts)) {
|
|
317
|
+
const selfReference = typeof script === 'string'
|
|
318
|
+
? readSelfReference({ commandName: key, scope, script })
|
|
319
|
+
: readStepListSelfReference({
|
|
320
|
+
commandName: key,
|
|
321
|
+
elements: script.map((element) => (typeof element === 'string' ? element : element.run)),
|
|
322
|
+
scope,
|
|
323
|
+
});
|
|
324
|
+
if (selfReference === undefined)
|
|
325
|
+
continue;
|
|
326
|
+
const defaults = field === 'rootScripts' ? getDefaultRootScripts(monorepoRoot) : getDefaultWorkspaceScripts();
|
|
327
|
+
const remedy = formatSelfReferenceRemedy({
|
|
328
|
+
isDefault: !isHookName(key) && Object.hasOwn(defaults, key),
|
|
329
|
+
key,
|
|
330
|
+
selfReference,
|
|
331
|
+
});
|
|
332
|
+
throw new UserError(`Invalid nmr config at ${configPath}: \`${field}.${key}\` re-invokes \`${key}\` ` +
|
|
333
|
+
`(\`${escapeControlCharacters(describeScript(script))}\`), which would re-enter nmr without bound. ${remedy}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
305
337
|
function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = '') {
|
|
306
338
|
const unrecognizedKeys = Object.keys(value).filter((key) => !recognizedKeys.includes(key));
|
|
307
339
|
if (unrecognizedKeys.length === 0) {
|
|
@@ -324,3 +356,14 @@ function formatKeyList(keys, prefix) {
|
|
|
324
356
|
.map((key) => `\`${prefix}${key}\``)
|
|
325
357
|
.join(', ');
|
|
326
358
|
}
|
|
359
|
+
function formatSelfReferenceRemedy(options) {
|
|
360
|
+
const { isDefault, key, selfReference } = options;
|
|
361
|
+
if (isDefault) {
|
|
362
|
+
return selfReference === 'sole'
|
|
363
|
+
? `Delete the entry: nmr's own \`${key}\` then runs.`
|
|
364
|
+
: `Delete the entry and move the steps that it adds to a \`${key}:pre\` or \`${key}:post\` script.`;
|
|
365
|
+
}
|
|
366
|
+
return selfReference === 'sole'
|
|
367
|
+
? 'Delete the entry.'
|
|
368
|
+
: `Delete the re-invocation: \`${key}\` runs the steps beside it.`;
|
|
369
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carries the entries that a run is inside, from the outermost inward, down to every process that it spawns. An
|
|
3
|
+
* entry that finds itself already on the chain would re-enter itself without bound.
|
|
4
|
+
*/
|
|
5
|
+
export declare const ENTRY_CHAIN_ENV_VAR = "NMR_ENTRY_CHAIN";
|
|
6
|
+
/** A command that an invocation resolved, and the directory whose registry it resolved the command from. */
|
|
7
|
+
export interface ChainEntry {
|
|
8
|
+
command: string;
|
|
9
|
+
dir: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Admits an invocation onto the chain that it inherited, returning the environment that its steps inherit in turn.
|
|
13
|
+
* Throws a `UserError` when an ancestor in the run is already running the same command from the same directory, or
|
|
14
|
+
* when the inherited chain cannot be read.
|
|
15
|
+
*
|
|
16
|
+
* Records the directory's real path, so that a symlinked checkout or a `/private` prefix seen by one process and not
|
|
17
|
+
* another cannot hide a repeat.
|
|
18
|
+
*/
|
|
19
|
+
export declare function enterChain(options: {
|
|
20
|
+
anchorDir: string;
|
|
21
|
+
command: string;
|
|
22
|
+
env: NodeJS.ProcessEnv;
|
|
23
|
+
monorepoRoot: string;
|
|
24
|
+
}): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isObject } from "./helpers/type-guards.js";
|
|
4
|
+
import { UserError } from "./UserError.js";
|
|
5
|
+
export const ENTRY_CHAIN_ENV_VAR = 'NMR_ENTRY_CHAIN';
|
|
6
|
+
export function enterChain(options) {
|
|
7
|
+
const { anchorDir, command, env, monorepoRoot } = options;
|
|
8
|
+
const chain = readEntryChain(env[ENTRY_CHAIN_ENV_VAR]);
|
|
9
|
+
if (chain === undefined) {
|
|
10
|
+
throw new UserError(`${ENTRY_CHAIN_ENV_VAR} is not a chain that nmr wrote; unset it. nmr passes it to the processes that it starts.`);
|
|
11
|
+
}
|
|
12
|
+
const entry = { command, dir: realpathSync(anchorDir) };
|
|
13
|
+
const extendedChain = [...chain, entry];
|
|
14
|
+
if (chain.some((ancestor) => ancestor.command === entry.command && ancestor.dir === entry.dir)) {
|
|
15
|
+
throw new UserError(formatReentryError(extendedChain, realpathSync(monorepoRoot)));
|
|
16
|
+
}
|
|
17
|
+
return { ...env, [ENTRY_CHAIN_ENV_VAR]: JSON.stringify(extendedChain) };
|
|
18
|
+
}
|
|
19
|
+
function describeDir(dir, monorepoRoot) {
|
|
20
|
+
const relativeDir = path.relative(monorepoRoot, dir);
|
|
21
|
+
if (relativeDir === '') {
|
|
22
|
+
return 'root';
|
|
23
|
+
}
|
|
24
|
+
return relativeDir.startsWith('..') || path.isAbsolute(relativeDir) ? dir : relativeDir;
|
|
25
|
+
}
|
|
26
|
+
function formatReentryError(chain, monorepoRoot) {
|
|
27
|
+
const repeat = chain.at(-1);
|
|
28
|
+
if (repeat === undefined) {
|
|
29
|
+
throw new Error('A re-entry error needs the entry that repeats.');
|
|
30
|
+
}
|
|
31
|
+
const repeatDir = describeDir(repeat.dir, monorepoRoot);
|
|
32
|
+
const lines = chain.map((entry) => {
|
|
33
|
+
const isRepeat = entry.command === repeat.command && entry.dir === repeat.dir;
|
|
34
|
+
return ` ${entry.command} in ${describeDir(entry.dir, monorepoRoot)}${isRepeat ? ' (repeat)' : ''}`;
|
|
35
|
+
});
|
|
36
|
+
return [
|
|
37
|
+
`nmr re-entered \`${repeat.command}\` in ${repeatDir}, which this run is already running there, ` +
|
|
38
|
+
'so the run would never end. The entries that this run has entered, outermost first:',
|
|
39
|
+
...lines,
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
function isChainEntry(value) {
|
|
43
|
+
return isObject(value) && typeof value['command'] === 'string' && typeof value['dir'] === 'string';
|
|
44
|
+
}
|
|
45
|
+
function readEntryChain(value) {
|
|
46
|
+
if (value === undefined || value === '') {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(value);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return Array.isArray(parsed) && parsed.every(isChainEntry) ? parsed : undefined;
|
|
57
|
+
}
|
package/dist/esm/glyphs.d.ts
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* `noop` is a neutral circle: Nothing ran because the repo asked for nothing to run, which is neither a
|
|
6
6
|
* failure nor a block.
|
|
7
7
|
*/
|
|
8
|
-
export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "
|
|
8
|
+
export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "package" | "clean" | "catalog" | "noop" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
|
|
9
9
|
export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a declaration's text as the file stores it, so that a value written across lines is quoted on one line.
|
|
3
|
+
*
|
|
4
|
+
* A diagnostic names a declaration that the reader has to edit, and a JSON string contains an escape sequence where
|
|
5
|
+
* a shell would read a control character. The rendered escape keeps the diagnostic on one line, and it is the text
|
|
6
|
+
* for which the reader will search the file.
|
|
7
|
+
*
|
|
8
|
+
* Apply it only where the text is quoted: A comparison against the declaration reads the unescaped text.
|
|
9
|
+
*/
|
|
10
|
+
export declare function escapeControlCharacters(text: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const NAMED_ESCAPES = new Map([
|
|
2
|
+
['\n', String.raw `\n`],
|
|
3
|
+
['\r', String.raw `\r`],
|
|
4
|
+
['\t', String.raw `\t`],
|
|
5
|
+
]);
|
|
6
|
+
export function escapeControlCharacters(text) {
|
|
7
|
+
let escapedText = text;
|
|
8
|
+
for (const [char, escape] of NAMED_ESCAPES) {
|
|
9
|
+
escapedText = escapedText.split(char).join(escape);
|
|
10
|
+
}
|
|
11
|
+
return escapedText;
|
|
12
|
+
}
|
package/dist/esm/resolver.js
CHANGED
|
@@ -108,12 +108,12 @@ export function findChainedSelfReference(packageDir, commandName) {
|
|
|
108
108
|
if (script === undefined) {
|
|
109
109
|
return undefined;
|
|
110
110
|
}
|
|
111
|
-
return readSelfReference({
|
|
111
|
+
return readSelfReference({ commandName, scope: readPackageScope(packageDir), script }) === 'chained'
|
|
112
112
|
? script
|
|
113
113
|
: undefined;
|
|
114
114
|
}
|
|
115
115
|
export function isSelfReferential(script, commandName, packageDir) {
|
|
116
|
-
return readSelfReference({
|
|
116
|
+
return readSelfReference({ commandName, scope: readPackageScope(packageDir), script }) !== undefined;
|
|
117
117
|
}
|
|
118
118
|
export function resolveScript(commandName, registry, packageDir, isWorkspaceRoot) {
|
|
119
119
|
if (packageDir) {
|
|
@@ -137,3 +137,6 @@ export function resolveScript(commandName, registry, packageDir, isWorkspaceRoot
|
|
|
137
137
|
}
|
|
138
138
|
return { origin: { tier: 'registry', key: commandName }, steps: expandScript(registryEntry, isWorkspaceRoot) };
|
|
139
139
|
}
|
|
140
|
+
function readPackageScope(packageDir) {
|
|
141
|
+
return isMonorepoRoot(packageDir) ? 'root' : 'package';
|
|
142
|
+
}
|