@williamthorsen/nmr 0.19.0 → 0.20.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.
Files changed (33) hide show
  1. package/AGENTS.md +5 -96
  2. package/README.md +131 -47
  3. package/dist/esm/cli-ensure-prepublish-hooks.js +1 -1
  4. package/dist/esm/cli-report-overrides.js +1 -1
  5. package/dist/esm/cli-sync-agent-files.js +1 -1
  6. package/dist/esm/commands/clean.js +2 -1
  7. package/dist/esm/commands/ensure-prepublish-hooks.js +1 -1
  8. package/dist/esm/context.d.ts +0 -2
  9. package/dist/esm/context.js +7 -60
  10. package/dist/esm/default-scripts.js +1 -2
  11. package/dist/esm/help.js +5 -3
  12. package/dist/esm/helpers/workspace-patterns.d.ts +1 -0
  13. package/dist/esm/helpers/workspace-patterns.js +29 -0
  14. package/dist/esm/index.d.ts +0 -1
  15. package/dist/esm/runCli.js +16 -16
  16. package/dist/esm/workspace.d.ts +2 -0
  17. package/dist/esm/workspace.js +38 -0
  18. package/package.json +7 -12
  19. package/bin/nmr-sync-pnpm-version.js +0 -11
  20. package/dist/esm/cli-sync-pnpm-version.d.ts +0 -1
  21. package/dist/esm/cli-sync-pnpm-version.js +0 -11
  22. package/dist/esm/commands/sync-pnpm-version.d.ts +0 -3
  23. package/dist/esm/commands/sync-pnpm-version.js +0 -40
  24. package/dist/esm/helpers/code-quality-pnpm-action.d.ts +0 -13
  25. package/dist/esm/helpers/code-quality-pnpm-action.js +0 -22
  26. package/dist/esm/tests/consistency.d.ts +0 -7
  27. package/dist/esm/tests/consistency.js +0 -57
  28. package/dist/esm/tests/helpers/get-runtime-version-from-asdf.d.ts +0 -1
  29. package/dist/esm/tests/helpers/get-runtime-version-from-asdf.js +0 -12
  30. package/dist/esm/tests/helpers/get-string-from-yaml-file.d.ts +0 -1
  31. package/dist/esm/tests/helpers/get-string-from-yaml-file.js +0 -12
  32. package/dist/esm/tests/helpers/get-value-at-path.d.ts +0 -1
  33. package/dist/esm/tests/helpers/get-value-at-path.js +0 -34
package/AGENTS.md CHANGED
@@ -2,100 +2,9 @@
2
2
  source: '@williamthorsen/nmr@0.0.0-source'
3
3
  ---
4
4
 
5
- # nmr: agent guidance
5
+ # nmr: agent guidance (generated; do not edit)
6
6
 
7
- This file is managed by `@williamthorsen/nmr`. Do not edit; run `nmr sync-agent-files` to refresh it after an nmr upgrade that changes this guidance.
8
-
9
- ## Discover scripts by running nmr
10
-
11
- Run `nmr` with no command (from the monorepo root or any workspace package) to list every available script, including composite expansions and resolved shell commands. Check this before guessing a script name from another repo: The registry is authoritative.
12
-
13
- ## Invocation rules
14
-
15
- - Use `nmr <command>` for anything nmr provides. Do not use `pnpm run <command>`.
16
- - You can invoke nmr from the monorepo root or any workspace package.
17
-
18
- ### How to make `nmr` resolvable
19
-
20
- `nmr` ships as a workspace bin. The bare `nmr` command works only when your shell can find `<root>/node_modules/.bin/nmr`. Choose one:
21
-
22
- - **direnv** (recommended for contributors). With [direnv](https://direnv.net/) installed, the repo's `.envrc` adds `node_modules/.bin` to your `PATH` automatically. From any subdirectory, bare `nmr` works.
23
- - **`pnpm exec nmr <command>`**: Works without setup. pnpm resolves the bin from the workspace root.
24
-
25
- Avoid `npx nmr`. Inside git worktrees, `npx` can resolve a different nmr binary from outside the working tree.
26
-
27
- ## Root vs. workspace context
28
-
29
- nmr walks up to find `pnpm-workspace.yaml`, then decides which registry to use based on whether your cwd is inside a workspace package. The same command name (e.g. `build`, `test`, `check:strict`) often exists in both registries with different behavior; the root version typically delegates across all workspaces. Use `-w` to force the root registry from inside a package dir, and `-F <pkg>` to run a single package's script from anywhere.
30
-
31
- ## Composite scripts
32
-
33
- A script value shown in `nmr` output as `[a, b, c]` is a composite: it runs `nmr a && nmr b && nmr c`, stopping at the first failure. Composites can reference other composites.
34
-
35
- ## `root:*` siblings
36
-
37
- Some root scripts (e.g. `lint`, `typecheck`, `test`) expand to `nmr root:X && pnpm --recursive exec nmr X`. The `root:X` variant runs only against root-level files; the plain name runs everywhere. Use `root:X` directly when you want to isolate a failure to the root code.
38
-
39
- ## Managed build
40
-
41
- The default `compile` script runs `nmr-compile`, a standalone bin that uses the TypeScript compiler API to emit a package's `src` to `dist/esm` as `.js` and `.d.ts` in one pass, rewriting relative `.ts`→`.js` specifiers and tsconfig `paths` aliases in both outputs, and skipping work when inputs are unchanged. `typescript` is a peer dependency (`>=5.7.0 <7`; TypeScript 7 ships no compiler API). There is no repo-local build script to maintain, and no separate typings step; `nmr build` runs `compile` alone. To find or debug the build, look to `nmr-compile`, not a `config/build.ts` in the consuming repo.
42
-
43
- ## Dependency upgrades
44
-
45
- `nmr upgrade` reports available dependency upgrades, honoring the version ceilings the repo declares in its root `taze.config.ts`. Scope follows context: from the root it covers the root `package.json` and every workspace in one pass; from inside a package, or under `nmr -F <package> upgrade`, it covers that package. `nmr root:upgrade` covers the root `package.json` alone. Run the root-context commands from the monorepo root.
46
-
47
- There are no `outdated` or `update` commands; `upgrade` replaced them. Reach for `pnpm outdated` or `pnpm update` directly if you need a report that ignores the ceilings.
48
-
49
- Arguments pass through to the underlying tool, including the range mode: `nmr upgrade major` proposes major upgrades, still inside the ceilings. Every form above is report-only until you add `--write`.
50
-
51
- **`-w` is not the same on both sides of the command.** `nmr upgrade -w` passes `--write` through and rewrites `package.json`; `nmr -w upgrade` is nmr's own `--workspace-root` and writes nothing. Use the long forms.
52
-
53
- To change what a repo holds back, edit its `taze.config.ts`. It imports `defineConfig` from `@williamthorsen/nmr/taze`, which supplies the shared upgrade policy — so that file should carry only the ceilings specific to that repo, and the repo needs no dependency on the upgrade tool itself.
54
-
55
- ## Override behaviors
56
-
57
- In `.config/nmr.config.ts` or a package's `package.json`, override values have special semantics:
58
-
59
- - `""` (empty string): Skip the script with a "Skipping" message; exit 0.
60
- - `":"`: No-op; exit 0. Prefer this over `""` if your repo enforces non-empty script values.
61
- - Any other string: Runs in place of the default.
62
-
63
- ## Pre and post hooks
64
-
65
- Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same 3-tier registry (built-in defaults → `.config/nmr.config.ts` → per-package `package.json`). Wrapping is uniform; nested invocations from composite expansion get their own hook treatment. Hook failure short-circuits the chain via shell `&&` semantics, propagating the failing exit code.
66
-
67
- Behaviors worth knowing:
68
-
69
- - **Silent when absent**: Missing hooks produce no error and no output.
70
- - **Skip overrides apply to hooks**: A hook value of `""` or `":"` is treated the same as not defining the hook. No console message.
71
- - **Skipping the main command skips its hooks**: When `X` is overridden to `""` or `":"`, neither `X:pre` nor `X:post` fires.
72
- - **Recursion guard**: Direct invocation of a hook (e.g., `nmr build:pre`) is treated as a leaf operation. It does NOT itself attempt to resolve `build:pre:pre` or `build:pre:post`.
73
- - **Passthrough args attach only to the main command**: `nmr X --flag value` runs hooks without `--flag value`.
74
-
75
- Worked examples:
76
-
77
- ```ts
78
- // .config/nmr.config.ts — extend `nmr build` with a pre-build compile step
79
- import { defineConfig } from '@williamthorsen/nmr';
80
-
81
- export default defineConfig({
82
- workspaceScripts: {
83
- 'build:pre': 'npx rdy compile',
84
- },
85
- });
86
- ```
87
-
88
- ```jsonc
89
- // packages/nmr/package.json — re-stamp .agents/nmr/AGENTS.md after every build
90
- {
91
- "scripts": {
92
- "build:post": "nmr-sync-agent-files",
93
- },
94
- }
95
- ```
96
-
97
- The second example calls the bin directly to sidestep the workspace-vs-root registry distinction.
98
-
99
- ## Agent-file sync
100
-
101
- The presence and version stamp of `.agents/nmr/AGENTS.md` is verified by `check:agent-files`, which is part of the default root `check:strict` composite. If it fails, run `nmr sync-agent-files`.
7
+ - Use `nmr <command>`, not `pnpm run <command>`, for anything nmr provides. Run bare `nmr` to list every command and the shell command it resolves to; a wrong guess reports only `Unknown command`, with no list.
8
+ - Scope follows cwd, and bare `nmr` lists both registries: from the repo root a command covers root files and every workspace; from inside a package, that package alone. `nmr -F <pkg> <command>` targets one package from anywhere; `nmr root:<command>` targets root files alone, which isolates a failure to root code.
9
+ - Never `npx nmr`: inside a git worktree it can resolve a different nmr from outside the tree. Fall back to `pnpm exec nmr`.
10
+ - 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`), and the `nmr-compile` build (there is no repo-local build script).
package/README.md CHANGED
@@ -1,27 +1,54 @@
1
1
  # @williamthorsen/nmr
2
2
 
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.
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.19.0 (2026-07-22)
6
+ ## Release notes — v0.20.0 (2026-07-28)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - 🚨 **Breaking:** Replace the dependency commands with nmr upgrade (#491)
10
+ - 🚨 **Breaking:** Migrate to code-quality workflow v7 and retire the version-restatement apparatus (#503)
11
11
 
12
- Adds `nmr upgrade` as an ergonomic wrapper for displaying and applying available dependency upgrades. Taze is used under the hood to set a soaking period (customizable, defaulting to 7 days) before recently published dependencies will be accepted.
12
+ Migrates the code-quality workflow from `@v6` to `@v7`, which no longer requires the Node version to be specified in two places, and removes the test that previously checked for drift. Also removed are the `nmr sync-pnpm-version` command and drift-related exported functions.
13
13
 
14
- Removes the four dependency commands it replaces: `outdated`, `outdated:latest`, `update`, and `update:latest`.
14
+ Taking this upgrade requires migrating the repo's code-quality workflow to `@v7` and deleting its version-alignment test in the same change.
15
+
16
+ - 🚨 **Breaking:** Resolve workspace packages with pnpm's pattern semantics (#509)
17
+
18
+ nmr now excludes packages matched by a `!`-prefixed pattern in `pnpm-workspace.yaml` from the packages it operates on. Deep globs such as `packages/**` now match nested packages, and running a command from inside a nested package targets that package rather than an enclosing ancestor. Exclusion patterns must be quoted to take effect. nmr now requires Node.js 24.16 or later.
19
+
20
+ - Report pnpm overrides when reviewing dependencies (#514)
21
+
22
+ From the monorepo root, `nmr upgrade` now reports the repo's active pnpm dependency overrides alongside the upgrade report, making clear when a pin is the reason an expected upgrade is absent. Repos that wired up this override report themselves with an install-time hook can now remove that hook.
23
+
24
+ - Trim the bundled agent guidance to a cheatsheet (#516)
25
+
26
+ Trims the agent guidance nmr installs into consuming repos from 100 lines to 10. Only the bare essentials are now injected into context. Agents are instructed to consult the package's README for details.
27
+
28
+ ### 🐛 Bug fixes
29
+
30
+ - Anchor script execution to the resolved registry's directory (#517)
31
+
32
+ Fixes an issue where `nmr --workspace-root`, run from inside a workspace package, either failed outright or silently acted on the wrong directory. Commands now behave the same regardless of the directory they are invoked from: root commands act on the monorepo root, and package commands on their own package. Two changes for existing users: `nmr --workspace-root clean` now sweeps every workspace package instead of cleaning only the current one, and relative paths passed to a script now resolve against the directory the script runs in.
15
33
  <!-- /section:release-notes -->
16
34
 
17
35
  ## Installation
18
36
 
19
- Requires Node.js 24 or later.
37
+ Requires Node.js 24.16 or later.
20
38
 
21
39
  ```bash
22
40
  pnpm add -D @williamthorsen/nmr
23
41
  ```
24
42
 
43
+ ### Making `nmr` resolvable
44
+
45
+ nmr installs as a workspace bin, so the bare `nmr` command works only when your shell can find `<root>/node_modules/.bin/nmr`. Choose one:
46
+
47
+ - **[direnv](https://direnv.net/)** (recommended for contributors). Add `PATH_add node_modules/.bin` to the repo's `.envrc`, and bare `nmr` works from any subdirectory.
48
+ - **`pnpm exec nmr <command>`**. Works with no setup: pnpm resolves the bin from the workspace root.
49
+
50
+ > **Note:** Avoid `npx nmr`. Inside a git worktree, `npx` can resolve a different nmr binary from outside the working tree, so the command succeeds while running the wrong code.
51
+
25
52
  ## Quick start
26
53
 
27
54
  nmr works out of the box with no configuration. It ships with built-in scripts for common monorepo tasks.
@@ -39,7 +66,7 @@ From the monorepo root:
39
66
  ```bash
40
67
  nmr test # Run root tests + recursive workspace tests
41
68
  nmr build # Build all packages
42
- nmr ci # Run check:strict && build (full CI pipeline)
69
+ nmr ci # Run build && check:strict && audit (full CI pipeline)
43
70
  ```
44
71
 
45
72
  nmr detects where you are and selects the right scripts automatically — see [context-aware resolution](#context-aware-resolution) below.
@@ -48,19 +75,26 @@ nmr detects where you are and selects the right scripts automatically — see [c
48
75
 
49
76
  nmr's key feature is that the same command runs different scripts depending on where you invoke it. It walks up from your current directory to find `pnpm-workspace.yaml`, then checks whether your CWD is inside a workspace package directory.
50
77
 
51
- | Where you run `nmr` | Registry used | `nmr test` runs |
52
- | -------------------------- | ----------------- | --------------------------------------------- |
53
- | Monorepo root | Root scripts | Root tests + `pnpm --recursive exec nmr test` |
54
- | Inside a workspace package | Workspace scripts | `pnpm exec vitest` (for that package only) |
55
- | Anywhere, with `-w` flag | Root scripts | Forces root registry regardless of location |
78
+ | Where you run `nmr` | Registry used | Working directory | `nmr test` runs |
79
+ | --------------------------------- | ----------------- | ----------------- | --------------------------------------------- |
80
+ | Monorepo root | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
81
+ | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest` (for that package only) |
82
+ | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | Root tests + `pnpm --recursive exec nmr test` |
56
83
 
57
- Use `-w` to escape package context:
84
+ Relative paths in a script resolve against that working directory, not the invocation directory.
85
+
86
+ Use `--workspace-root` to escape package context:
58
87
 
59
88
  ```bash
60
89
  # From inside packages/nmr-core, run the root check suite
61
- nmr -w check
90
+ nmr --workspace-root check
62
91
  ```
63
92
 
93
+ Two consequences:
94
+
95
+ - `nmr --workspace-root clean` sweeps every workspace package, as `nmr clean` does from the root.
96
+ - Passthrough paths resolve against the working directory: from `packages/nmr/src/`, `nmr --workspace-root fmt pnpm-workspace.yaml` formats the file at the monorepo root.
97
+
64
98
  ## Three-tier override system
65
99
 
66
100
  Scripts resolve through three tiers. Higher tiers override lower ones:
@@ -92,6 +126,8 @@ Script values can be `string` or `string[]`. Arrays expand to chained `nmr` sub-
92
126
  // expands to: nmr lint && nmr fmt
93
127
  ```
94
128
 
129
+ Passthrough arguments attach to the final step alone, because the expansion is a single shell chain: `nmr fix --dry-run` runs `nmr lint && nmr fmt --dry-run`.
130
+
95
131
  ## Configuration
96
132
 
97
133
  Create `.config/nmr.config.ts` in the monorepo root to add or override scripts:
@@ -137,6 +173,44 @@ For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites
137
173
 
138
174
  > **Note:** Path resolution uses a heuristic: any non-flag token containing `/` is treated as a relative path. This works well for typical dev-tool commands but may incorrectly resolve URL-like values or glob patterns. Flags using `--flag=value` syntax are not resolved; use the spaced form `--flag value` for paths that need resolution.
139
175
 
176
+ ## Pre and post hooks
177
+
178
+ Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same three tiers as any other script (built-in defaults, then `.config/nmr.config.ts`, then per-package `package.json`). Wrapping is uniform: nested invocations from composite expansion get their own hook treatment. Hook failure short-circuits the chain via shell `&&` semantics, propagating the failing exit code.
179
+
180
+ Behaviors worth knowing:
181
+
182
+ - **Silent when absent**: Missing hooks produce no error and no output.
183
+ - **Skip overrides apply to hooks**: A hook value of `""` or `":"` is treated the same as not defining the hook, with no console message.
184
+ - **Skipping the main command skips its hooks**: When `X` is overridden to `""` or `":"`, neither `X:pre` nor `X:post` fires.
185
+ - **Recursion guard**: Direct invocation of a hook (e.g. `nmr build:pre`) is treated as a leaf operation. It does not itself attempt to resolve `build:pre:pre` or `build:pre:post`.
186
+ - **Passthrough args attach only to the main command**: `nmr X --flag value` runs hooks without `--flag value`.
187
+
188
+ Extend `nmr build` with a pre-build step for every workspace package:
189
+
190
+ ```ts
191
+ // .config/nmr.config.ts
192
+ import { defineConfig } from '@williamthorsen/nmr';
193
+
194
+ export default defineConfig({
195
+ workspaceScripts: {
196
+ 'build:pre': 'npx rdy compile',
197
+ },
198
+ });
199
+ ```
200
+
201
+ Attach a post-build step to one package:
202
+
203
+ ```jsonc
204
+ // packages/nmr/package.json
205
+ {
206
+ "scripts": {
207
+ "build:post": "nmr-sync-agent-files",
208
+ },
209
+ }
210
+ ```
211
+
212
+ The second example calls the bin directly, which sidesteps the workspace-versus-root registry distinction.
213
+
140
214
  ## Dependency upgrades
141
215
 
142
216
  `nmr upgrade` reports the dependency upgrades available to your repo. What it covers depends on where you run it:
@@ -148,6 +222,8 @@ For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites
148
222
  | `nmr -F <package> upgrade` | that package, from anywhere |
149
223
  | `nmr root:upgrade` | the root `package.json` alone |
150
224
 
225
+ `nmr upgrade` from the root precedes its report with any active `pnpm.overrides` declared in the root `package.json`; `nmr root:upgrade` does not. An override pins a transitive dependency, so an upgrade masked by one never appears in the report.
226
+
151
227
  The upgrade tool ([taze](https://github.com/antfu-collective/taze)) arrives with nmr, so your repo declares no dependency on it. Everything after the command name is passed through, including the range mode:
152
228
 
153
229
  ```bash
@@ -156,8 +232,6 @@ nmr upgrade major # major upgrades, still inside the ceilings
156
232
  nmr upgrade --write # apply the proposals to package.json
157
233
  ```
158
234
 
159
- > **Note:** `-w` means different things on either side of the command. `nmr upgrade -w` passes `--write` to the upgrade tool; `nmr -w upgrade` is nmr's own `--workspace-root` flag and applies no write. Prefer the long forms to keep them apart. Run the root-context commands from the monorepo root: like nmr's other `root:` scripts, they take the current directory as their starting point.
160
-
161
235
  ### Configuring upgrades
162
236
 
163
237
  Declare version ceilings in a `taze.config.ts` at the monorepo root:
@@ -225,7 +299,7 @@ A package gets this variant automatically when it contains a `vitest.integration
225
299
  | Command | Runs |
226
300
  | ------- | --------------------------------- |
227
301
  | `build` | `pnpm --recursive exec nmr build` |
228
- | `ci` | `build`, `check:strict` |
302
+ | `ci` | `build`, `check:strict`, `audit` |
229
303
  | `clean` | `nmr-clean` |
230
304
 
231
305
  #### Check and quality
@@ -283,9 +357,9 @@ A package gets this variant automatically when it contains a `vitest.integration
283
357
 
284
358
  #### Dependencies
285
359
 
286
- | Command | Runs |
287
- | --------- | --------------------------------------- |
288
- | `upgrade` | `nmr-taze --include-locked --recursive` |
360
+ | Command | Runs |
361
+ | --------- | --------------------------------------------------------------- |
362
+ | `upgrade` | `nmr-report-overrides && nmr-taze --include-locked --recursive` |
289
363
 
290
364
  See [dependency upgrades](#dependency-upgrades) for the workflow and its configuration.
291
365
 
@@ -305,11 +379,10 @@ These scripts operate on root-level code only (not workspace packages):
305
379
 
306
380
  #### Utilities
307
381
 
308
- | Command | Runs |
309
- | ------------------- | ----------------------- |
310
- | `report-overrides` | `nmr-report-overrides` |
311
- | `sync-agent-files` | `nmr-sync-agent-files` |
312
- | `sync-pnpm-version` | `nmr-sync-pnpm-version` |
382
+ | Command | Runs |
383
+ | ------------------ | ---------------------- |
384
+ | `report-overrides` | `nmr-report-overrides` |
385
+ | `sync-agent-files` | `nmr-sync-agent-files` |
313
386
 
314
387
  ## CLI reference
315
388
 
@@ -319,11 +392,13 @@ These scripts operate on root-level code only (not workspace packages):
319
392
  nmr [flags] <command> [args...]
320
393
  ```
321
394
 
395
+ Position determines ownership: flags before the command name are nmr's own, and everything after the command name is forwarded untouched to the resolved command.
396
+
322
397
  | Flag | Description | Default |
323
398
  | ------------------------ | --------------------------------------------------- | ------- |
324
399
  | `-F, --filter <pattern>` | Run command in matching packages | — |
325
400
  | `-R, --recursive` | Run command in all packages | — |
326
- | `-w, --workspace-root` | Force root script registry | — |
401
+ | `-w, --workspace-root` | Use root scripts, running at the monorepo root | — |
327
402
  | `-q, --quiet` | Suppress info messages; show full output on failure | — |
328
403
  | `-?, --help` | Show available commands | — |
329
404
  | `-V, --version` | Show version number | — |
@@ -337,14 +412,14 @@ nmr build # Compile to .js and .d.ts in one pass
337
412
 
338
413
  # From the monorepo root
339
414
  nmr test # Root tests + recursive workspace tests
340
- nmr ci # check:strict + build
415
+ nmr ci # build + check:strict + audit
341
416
 
342
417
  # Target specific packages
343
- nmr -F core test # Test only the core package
344
- nmr -R lint # Lint all workspace packages
418
+ nmr --filter core test # Test only the core package
419
+ nmr --recursive lint # Lint all workspace packages
345
420
 
346
421
  # Force root context from anywhere
347
- nmr -w check # Run root check from a package dir
422
+ nmr --workspace-root check # Run root check from a package dir
348
423
  ```
349
424
 
350
425
  ## Additional subcommands
@@ -353,7 +428,7 @@ These commands are available as `nmr` subcommands and as standalone `nmr-`-prefi
353
428
 
354
429
  ### `report-overrides`
355
430
 
356
- Report any active `pnpm.overrides` in the root `package.json`. Useful as a `postinstall` hook to remind developers of active overrides that may need cleanup.
431
+ Report any active `pnpm.overrides` in the root `package.json`, reminding developers of overrides that may need cleanup. The root `upgrade` script runs it automatically, so no per-repo wiring is needed. Invoking it directly is useful for a one-off report.
357
432
 
358
433
  ```bash
359
434
  nmr report-overrides
@@ -378,13 +453,16 @@ To expose the synced guidance to Claude Code sessions, add this include to the c
378
453
  @nmr/AGENTS.md
379
454
  ```
380
455
 
381
- ### `sync-pnpm-version`
456
+ #### What belongs in the synced file
382
457
 
383
- Synchronize the pnpm version from the root `package.json` `packageManager` field into the GitHub `code-quality.yaml` workflow file.
458
+ The synced file is injected at launch in every consuming repo, so every line is paid for on every task whether or not it is relevant. It is a cheatsheet, not a manual.
384
459
 
385
- ```bash
386
- nmr sync-pnpm-version
387
- ```
460
+ A line earns a place in `AGENTS.md` only if both hold:
461
+
462
+ 1. It is absent from nmr's own output. Bare `nmr` already prints the flags, every command, and the shell command each resolves to; failing checks already name their own fix. Repeating any of that costs launch tokens to say something the agent will be told anyway, at the moment it matters.
463
+ 2. The obvious action goes wrong without it. Guidance that prevents a silent mistake earns its place; guidance that prevents a mistake the next error message would diagnose does not.
464
+
465
+ Everything else belongs in this README, reachable on demand at `node_modules/@williamthorsen/nmr/README.md`, with a pointer from `AGENTS.md` naming the topic so an agent knows to look. When adding a feature, document it here and extend that pointer rather than the cheatsheet.
388
466
 
389
467
  ## Standalone utilities
390
468
 
@@ -408,7 +486,7 @@ nmr-compile
408
486
 
409
487
  ### `nmr-taze`
410
488
 
411
- Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched. This is what the `upgrade` and `root:upgrade` scripts resolve to — see [dependency upgrades](#dependency-upgrades) for the workflow.
489
+ Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched. This is what `root:upgrade` resolves to, and what the root `upgrade` script ends with — see [dependency upgrades](#dependency-upgrades) for the workflow.
412
490
 
413
491
  Under pnpm's isolated `node_modules`, a transitive package's binary is absent from the consuming repo's `node_modules/.bin`, so a repo that depends on nmr cannot run `taze` directly. `nmr-taze` can, because nmr is a direct dependency, and it resolves the tool from the tree nmr controls.
414
492
 
@@ -436,25 +514,31 @@ After installing, a consuming repo's root `package.json` scripts shrink to lifec
436
514
 
437
515
  ```json
438
516
  {
439
- "prepare": "lefthook install",
440
- "postinstall": "nmr report-overrides"
517
+ "prepare": "lefthook install"
441
518
  }
442
519
  ```
443
520
 
444
521
  Per-package `package.json` files no longer need script entries. Run `nmr <command>` directly.
445
522
 
446
- ## Consistency tests
523
+ ## Workspace introspection
447
524
 
448
- Export structural consistency checks for use in your test suite:
525
+ Repo-wide tests and scripts often need to know where the monorepo root is, or which directories its workspace packages live in. The `@williamthorsen/nmr/workspace` subpath publishes the two pnpm-workspace lookups nmr uses internally:
449
526
 
450
527
  ```ts
451
- // __tests__/consistency.test.ts
452
- import { runConsistencyChecks } from '@williamthorsen/nmr/tests';
528
+ // __tests__/packages.test.ts
529
+ import { findMonorepoRoot, getWorkspacePackageDirs } from '@williamthorsen/nmr/workspace';
530
+
531
+ const monorepoRoot = findMonorepoRoot();
453
532
 
454
- runConsistencyChecks();
533
+ for (const packageDir of getWorkspacePackageDirs(monorepoRoot)) {
534
+ // assert something about every workspace package
535
+ }
455
536
  ```
456
537
 
457
- This verifies:
538
+ `findMonorepoRoot(startDir?)` walks up from `startDir`, defaulting to `process.cwd()`, until it reaches a directory containing `pnpm-workspace.yaml`. It throws if it runs out of parent directories without finding one.
539
+
540
+ `getWorkspacePackageDirs(monorepoRoot)` reads the workspace patterns from that repo's `pnpm-workspace.yaml` and resolves them to absolute package directories, sorted and free of duplicates. Patterns carry pnpm's own semantics: `packages/*`, deeper globs such as `packages/**`, exact paths such as `tools/cli`, and `!`-prefixed exclusions such as `!packages/legacy` or `!**/test/**`, which filter every directory the positive patterns matched regardless of where they appear in the list. Nothing under `node_modules` is ever returned.
541
+
542
+ One divergence from pnpm: a directory counts as a package only if it holds a `package.json`, not a `package.yaml` or `package.json5`.
458
543
 
459
- - pnpm version matches between `package.json` and GitHub workflow
460
- - Node.js version matches between `.tool-versions` and GitHub workflow
544
+ Quote exclusion patterns in the manifest — `- '!packages/legacy'`. An unquoted `!` opens a YAML tag rather than a string, so the entry never reaches nmr (or pnpm) as a pattern.
@@ -1,6 +1,6 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
2
  import { DEFAULT_HOOK, ensurePrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
3
- import { findMonorepoRoot } from "./context.js";
3
+ import { findMonorepoRoot } from "./workspace.js";
4
4
  const flagSchema = {
5
5
  fix: { long: '--fix', type: 'boolean' },
6
6
  dryRun: { long: '--dry-run', type: 'boolean' },
@@ -1,6 +1,6 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
2
  import { reportOverrides } from "./commands/report-overrides.js";
3
- import { findMonorepoRoot } from "./context.js";
3
+ import { findMonorepoRoot } from "./workspace.js";
4
4
  try {
5
5
  const monorepoRoot = findMonorepoRoot();
6
6
  reportOverrides(monorepoRoot);
@@ -1,6 +1,6 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
2
  import { check, sync } from "./commands/sync-agent-files.js";
3
- import { findMonorepoRoot } from "./context.js";
3
+ import { findMonorepoRoot } from "./workspace.js";
4
4
  const flagSchema = {
5
5
  check: { long: '--check', type: 'boolean' },
6
6
  };
@@ -1,9 +1,10 @@
1
1
  import { rm } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { loadConfig } from "../config.js";
4
- import { findContainingPackageDir, findMonorepoRoot, getWorkspacePackageDirs } from "../context.js";
4
+ import { findContainingPackageDir } from "../context.js";
5
5
  import { applyDevBin, buildWorkspaceRegistry, hasIntegrationTestConfig, resolveScript } from "../resolver.js";
6
6
  import { runCommand } from "../runner.js";
7
+ import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
7
8
  import { resolveBuildCachePath } from "./build.js";
8
9
  const CLEAN_ICON = '🧹';
9
10
  const CLEAN_COMMAND = 'clean';
@@ -1,8 +1,8 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getWorkspacePackageDirs } from "../context.js";
4
3
  import { readPackageJson } from "../helpers/package-json.js";
5
4
  import { isObject } from "../helpers/type-guards.js";
5
+ import { getWorkspacePackageDirs } from "../workspace.js";
6
6
  export const DEFAULT_HOOK = 'npm run build';
7
7
  export function ensurePrepublishHooks(monorepoRoot, options) {
8
8
  const hookCommand = options.command ?? DEFAULT_HOOK;
@@ -5,7 +5,5 @@ export interface ResolvedContext {
5
5
  packageDir?: string;
6
6
  config: NmrConfig;
7
7
  }
8
- export declare function findMonorepoRoot(startDir?: string): string;
9
- export declare function getWorkspacePackageDirs(monorepoRoot: string): string[];
10
8
  export declare function findContainingPackageDir(dir: string, workspacePackageDirs: string[]): string | undefined;
11
9
  export declare function resolveContext(cwd?: string): Promise<ResolvedContext>;
@@ -1,71 +1,18 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
1
  import path from 'node:path';
3
- import { parse } from 'yaml';
4
2
  import { loadConfig } from "./config.js";
5
- import { isObject } from "./helpers/type-guards.js";
6
- export function findMonorepoRoot(startDir) {
7
- let dir = path.resolve(startDir ?? process.cwd());
8
- for (;;) {
9
- if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
10
- return dir;
11
- }
12
- const parent = path.dirname(dir);
13
- if (parent === dir) {
14
- throw new Error('Could not find monorepo root: no pnpm-workspace.yaml found in any parent directory');
15
- }
16
- dir = parent;
17
- }
18
- }
19
- export function getWorkspacePackageDirs(monorepoRoot) {
20
- const workspaceFile = path.join(monorepoRoot, 'pnpm-workspace.yaml');
21
- const content = readFileSync(workspaceFile, 'utf8');
22
- const parsed = parse(content);
23
- const packages = getPackagesFromParsedYaml(parsed);
24
- if (!packages) {
25
- return [];
26
- }
27
- const dirs = [];
28
- for (const pattern of packages) {
29
- if (pattern.endsWith('/*')) {
30
- const prefix = pattern.slice(0, -2);
31
- const prefixDir = path.resolve(monorepoRoot, prefix);
32
- if (existsSync(prefixDir)) {
33
- for (const entry of readdirSync(prefixDir)) {
34
- const fullPath = path.join(prefixDir, entry);
35
- if (statSync(fullPath).isDirectory() && existsSync(path.join(fullPath, 'package.json'))) {
36
- dirs.push(fullPath);
37
- }
38
- }
39
- }
40
- }
41
- else if (!pattern.includes('*')) {
42
- const fullPath = path.resolve(monorepoRoot, pattern);
43
- if (existsSync(fullPath) && existsSync(path.join(fullPath, 'package.json'))) {
44
- dirs.push(fullPath);
45
- }
46
- }
47
- }
48
- return dirs;
49
- }
3
+ import { findMonorepoRoot, getWorkspacePackageDirs } from "./workspace.js";
50
4
  export function findContainingPackageDir(dir, workspacePackageDirs) {
51
5
  const resolved = path.resolve(dir);
6
+ let nearest;
52
7
  for (const pkgDir of workspacePackageDirs) {
53
8
  const resolvedPkgDir = path.resolve(pkgDir);
54
- if (resolved === resolvedPkgDir || resolved.startsWith(resolvedPkgDir + path.sep)) {
55
- return resolvedPkgDir;
9
+ if (resolved !== resolvedPkgDir && !resolved.startsWith(resolvedPkgDir + path.sep))
10
+ continue;
11
+ if (nearest === undefined || resolvedPkgDir.length > nearest.length) {
12
+ nearest = resolvedPkgDir;
56
13
  }
57
14
  }
58
- return undefined;
59
- }
60
- function getPackagesFromParsedYaml(parsed) {
61
- if (!isObject(parsed))
62
- return undefined;
63
- const packages = parsed.packages;
64
- if (!Array.isArray(packages))
65
- return undefined;
66
- if (!packages.every((p) => typeof p === 'string'))
67
- return undefined;
68
- return packages;
15
+ return nearest;
69
16
  }
70
17
  export async function resolveContext(cwd) {
71
18
  const resolvedCwd = path.resolve(cwd ?? process.cwd());
@@ -55,10 +55,9 @@ export const rootScripts = {
55
55
  'root:typecheck': 'tsgo --noEmit',
56
56
  'root:upgrade': 'nmr-taze --include-locked',
57
57
  'sync-agent-files': 'nmr-sync-agent-files',
58
- 'sync-pnpm-version': 'nmr-sync-pnpm-version',
59
58
  test: 'nmr root:test && pnpm --recursive exec nmr test',
60
59
  'test:coverage': 'nmr root:test && pnpm --recursive exec nmr test:coverage',
61
60
  'test:watch': 'vitest --watch',
62
61
  typecheck: 'nmr root:typecheck && pnpm --recursive exec nmr typecheck',
63
- upgrade: 'nmr-taze --include-locked --recursive',
62
+ upgrade: 'nmr-report-overrides && nmr-taze --include-locked --recursive',
64
63
  };
package/dist/esm/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isHookName } from "./helpers/hook-name.js";
2
- import { buildRootRegistry, buildWorkspaceRegistry, describeScript, isSelfReferential, readPackageJsonScripts, } from "./resolver.js";
2
+ import { buildRootRegistry, buildWorkspaceRegistry, describeScript, hasIntegrationTestConfig, isSelfReferential, readPackageJsonScripts, } from "./resolver.js";
3
3
  export function generateHelp(config, packageDir, useRoot) {
4
4
  const lines = [
5
5
  'Usage: nmr [flags] <command> [args...]',
@@ -7,15 +7,17 @@ export function generateHelp(config, packageDir, useRoot) {
7
7
  'Flags:',
8
8
  ' -F, --filter <pattern> Run command in matching packages',
9
9
  ' -R, --recursive Run command in all packages',
10
- ' -w, --workspace-root Run root command regardless of cwd',
10
+ ' -w, --workspace-root Use root scripts, running at the monorepo root',
11
11
  ' -q, --quiet Suppress output on success; show full output on failure',
12
12
  ' -?, --help Show this help',
13
+ ' -V, --version Show version number',
13
14
  '',
14
15
  'Workspace commands:',
15
16
  ];
16
17
  const overrides = packageDir === undefined ? {} : collectOverrides(packageDir);
17
18
  let hadOverride = false;
18
- const workspaceRegistry = filterHooks(buildWorkspaceRegistry(config, false));
19
+ const useIntTests = !useRoot && packageDir !== undefined && hasIntegrationTestConfig(packageDir);
20
+ const workspaceRegistry = filterHooks(buildWorkspaceRegistry(config, useIntTests));
19
21
  const workspaceMarked = !useRoot ? applyOverrides(workspaceRegistry, overrides) : new Set();
20
22
  if (workspaceMarked.size > 0)
21
23
  hadOverride = true;
@@ -0,0 +1 @@
1
+ export declare function resolvePackageDirs(monorepoRoot: string, patterns: string[]): string[];
@@ -0,0 +1,29 @@
1
+ import { globSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const MANIFEST = 'package.json';
4
+ const ALWAYS_EXCLUDED = ['**/node_modules/**'];
5
+ export function resolvePackageDirs(monorepoRoot, patterns) {
6
+ const included = [];
7
+ const excluded = [];
8
+ for (const pattern of patterns) {
9
+ if (pattern.trim() === '')
10
+ continue;
11
+ const isNegated = pattern.startsWith('!');
12
+ const target = isNegated ? excluded : included;
13
+ target.push(toManifestPattern(isNegated ? pattern.slice(1) : pattern));
14
+ }
15
+ if (included.length === 0) {
16
+ return [];
17
+ }
18
+ const options = {
19
+ cwd: monorepoRoot,
20
+ exclude: [...excluded, ...ALWAYS_EXCLUDED],
21
+ followSymlinks: true,
22
+ };
23
+ const matches = globSync(included, options);
24
+ const dirs = matches.map((match) => path.dirname(path.resolve(monorepoRoot, match)));
25
+ return [...new Set(dirs)].toSorted();
26
+ }
27
+ function toManifestPattern(pattern) {
28
+ return `${pattern.replace(/\/?$/, '')}/${MANIFEST}`;
29
+ }
@@ -1,3 +1,2 @@
1
1
  export type { NmrConfig } from './config.js';
2
2
  export { defineConfig } from './config.js';
3
- export type { ResolvedContext } from './context.js';
@@ -20,9 +20,9 @@ export async function runCli(options) {
20
20
  }
21
21
  const context = await resolveContext(cwd);
22
22
  const useRoot = parsed.workspaceRoot || context.isRoot;
23
- const packageDir = useRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
23
+ const anchorDir = useRoot ? context.monorepoRoot : (context.packageDir ?? context.monorepoRoot);
24
24
  if (parsed.help || !parsed.command) {
25
- stdout.write(`${generateHelp(context.config, packageDir, useRoot)}\n`);
25
+ stdout.write(`${generateHelp(context.config, anchorDir, useRoot)}\n`);
26
26
  return { exitCode: 0 };
27
27
  }
28
28
  const { command } = parsed;
@@ -41,8 +41,8 @@ export async function runCli(options) {
41
41
  }
42
42
  const registry = useRoot
43
43
  ? buildRootRegistry(context.config)
44
- : buildWorkspaceRegistry(context.config, hasIntegrationTestConfig(packageDir));
45
- const resolved = resolveScript(command, registry, packageDir, parsed.workspaceRoot);
44
+ : buildWorkspaceRegistry(context.config, hasIntegrationTestConfig(anchorDir));
45
+ const resolved = resolveScript(command, registry, anchorDir, parsed.workspaceRoot);
46
46
  if (!resolved) {
47
47
  if (env.NMR_RUN_IF_PRESENT === '1') {
48
48
  return { exitCode: 0 };
@@ -50,20 +50,20 @@ export async function runCli(options) {
50
50
  reportError(`Unknown command: ${command}`, stderr);
51
51
  return { exitCode: 1 };
52
52
  }
53
- const skipExitCode = handleSkipMessage(resolved.command, packageDir, parsed.quiet, stdout);
53
+ const skipExitCode = handleSkipMessage(resolved.command, anchorDir, parsed.quiet, stdout);
54
54
  if (skipExitCode !== undefined) {
55
55
  return { exitCode: skipExitCode };
56
56
  }
57
57
  if (resolved.source === 'package' && !parsed.quiet && registry[command] !== undefined) {
58
- stdout.write(`📦 ${path.basename(packageDir)}: Using override script: ${resolved.command}\n`);
58
+ stdout.write(`📦 ${path.basename(anchorDir)}: Using override script: ${resolved.command}\n`);
59
59
  }
60
60
  const substitutedCommand = applyDevBin(resolved.command, context.config.devBin, context.monorepoRoot);
61
61
  const mainCommand = substitutedCommand + passthrough;
62
62
  const isHookInvocation = isHookName(command);
63
63
  const fullCommand = isHookInvocation
64
64
  ? mainCommand
65
- : wrapWithHooks(command, mainCommand, registry, packageDir, parsed.workspaceRoot);
66
- const exitCode = runCommand(fullCommand, cwd, runOptions);
65
+ : wrapWithHooks(command, mainCommand, registry, anchorDir, parsed.workspaceRoot);
66
+ const exitCode = runCommand(fullCommand, anchorDir, runOptions);
67
67
  return { exitCode };
68
68
  }
69
69
  function parseArgs(args) {
@@ -121,35 +121,35 @@ function parseArgs(args) {
121
121
  }
122
122
  return { ok: true, parsed };
123
123
  }
124
- function handleSkipMessage(resolvedCommand, packageDir, quiet, stdout) {
124
+ function handleSkipMessage(resolvedCommand, anchorDir, quiet, stdout) {
125
125
  if (resolvedCommand === '') {
126
126
  if (!quiet) {
127
- stdout.write(`⛔ ${path.basename(packageDir)}: Override script is defined but empty. Skipping.\n`);
127
+ stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is defined but empty. Skipping.\n`);
128
128
  }
129
129
  return 0;
130
130
  }
131
131
  if (resolvedCommand === ':') {
132
132
  if (!quiet) {
133
- stdout.write(`⛔ ${path.basename(packageDir)}: Override script is a no-op. Skipping.\n`);
133
+ stdout.write(`⛔ ${path.basename(anchorDir)}: Override script is a no-op. Skipping.\n`);
134
134
  }
135
135
  return 0;
136
136
  }
137
137
  return undefined;
138
138
  }
139
- function wrapWithHooks(command, mainCommand, registry, packageDir, workspaceRoot) {
139
+ function wrapWithHooks(command, mainCommand, registry, anchorDir, workspaceRoot) {
140
140
  const segments = [];
141
141
  const flag = workspaceRoot ? '-w ' : '';
142
- if (hasRunnableHook(`${command}:pre`, registry, packageDir, workspaceRoot)) {
142
+ if (hasRunnableHook(`${command}:pre`, registry, anchorDir, workspaceRoot)) {
143
143
  segments.push(`nmr ${flag}${command}:pre`);
144
144
  }
145
145
  segments.push(mainCommand);
146
- if (hasRunnableHook(`${command}:post`, registry, packageDir, workspaceRoot)) {
146
+ if (hasRunnableHook(`${command}:post`, registry, anchorDir, workspaceRoot)) {
147
147
  segments.push(`nmr ${flag}${command}:post`);
148
148
  }
149
149
  return segments.join(' && ');
150
150
  }
151
- function hasRunnableHook(hookName, registry, packageDir, workspaceRoot) {
152
- const resolved = resolveScript(hookName, registry, packageDir, workspaceRoot);
151
+ function hasRunnableHook(hookName, registry, anchorDir, workspaceRoot) {
152
+ const resolved = resolveScript(hookName, registry, anchorDir, workspaceRoot);
153
153
  if (!resolved)
154
154
  return false;
155
155
  return resolved.command !== '' && resolved.command !== ':';
@@ -0,0 +1,2 @@
1
+ export declare function findMonorepoRoot(startDir?: string): string;
2
+ export declare function getWorkspacePackageDirs(monorepoRoot: string): string[];
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { isObject } from "./helpers/type-guards.js";
5
+ import { resolvePackageDirs } from "./helpers/workspace-patterns.js";
6
+ export function findMonorepoRoot(startDir) {
7
+ let dir = path.resolve(startDir ?? process.cwd());
8
+ for (;;) {
9
+ if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
10
+ return dir;
11
+ }
12
+ const parent = path.dirname(dir);
13
+ if (parent === dir) {
14
+ throw new Error('Could not find monorepo root: no pnpm-workspace.yaml found in any parent directory');
15
+ }
16
+ dir = parent;
17
+ }
18
+ }
19
+ export function getWorkspacePackageDirs(monorepoRoot) {
20
+ const workspaceFile = path.join(monorepoRoot, 'pnpm-workspace.yaml');
21
+ const content = readFileSync(workspaceFile, 'utf8');
22
+ const parsed = parse(content);
23
+ const packages = getPackagesFromParsedYaml(parsed);
24
+ if (!packages) {
25
+ return [];
26
+ }
27
+ return resolvePackageDirs(monorepoRoot, packages);
28
+ }
29
+ function getPackagesFromParsedYaml(parsed) {
30
+ if (!isObject(parsed))
31
+ return undefined;
32
+ const packages = parsed.packages;
33
+ if (!Array.isArray(packages))
34
+ return undefined;
35
+ if (!packages.every((p) => typeof p === 'string'))
36
+ return undefined;
37
+ return packages;
38
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "private": false,
5
- "description": "Context-aware script runner for PNPM monorepos",
5
+ "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
7
7
  "monorepo",
8
8
  "pnpm",
@@ -34,9 +34,9 @@
34
34
  "types": "./dist/esm/taze.d.ts",
35
35
  "default": "./dist/esm/taze.js"
36
36
  },
37
- "./tests": {
38
- "types": "./dist/esm/tests/consistency.d.ts",
39
- "default": "./dist/esm/tests/consistency.js"
37
+ "./workspace": {
38
+ "types": "./dist/esm/workspace.d.ts",
39
+ "default": "./dist/esm/workspace.js"
40
40
  }
41
41
  },
42
42
  "bin": {
@@ -46,7 +46,6 @@
46
46
  "nmr-compile": "bin/nmr-compile.js",
47
47
  "nmr-report-overrides": "bin/nmr-report-overrides.js",
48
48
  "nmr-sync-agent-files": "bin/nmr-sync-agent-files.js",
49
- "nmr-sync-pnpm-version": "bin/nmr-sync-pnpm-version.js",
50
49
  "nmr-taze": "bin/nmr-taze.js"
51
50
  },
52
51
  "files": [
@@ -59,17 +58,13 @@
59
58
  "jiti": "2.7.0",
60
59
  "taze": "19.15.1",
61
60
  "yaml": "2.9.0",
62
- "zod": "4.4.3",
63
- "@williamthorsen/nmr-core": "0.7.1"
64
- },
65
- "devDependencies": {
66
- "@williamthorsen/toolbelt.strings": "3.1.6"
61
+ "@williamthorsen/nmr-core": "0.7.2"
67
62
  },
68
63
  "peerDependencies": {
69
64
  "typescript": ">=5.7.0 <7"
70
65
  },
71
66
  "engines": {
72
- "node": ">=24"
67
+ "node": ">=24.16.0"
73
68
  },
74
69
  "publishConfig": {
75
70
  "access": "public"
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env node
2
- try {
3
- await import('../dist/esm/cli-sync-pnpm-version.js');
4
- } catch (error) {
5
- if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr-sync-pnpm-version: build output not found — run `pnpm run build` first\n');
7
- } else {
8
- process.stderr.write(`nmr-sync-pnpm-version: failed to load: ${error.message}\n`);
9
- }
10
- process.exit(1);
11
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,11 +0,0 @@
1
- import { reportError } from '@williamthorsen/nmr-core';
2
- import { syncPnpmVersion } from "./commands/sync-pnpm-version.js";
3
- import { findMonorepoRoot } from "./context.js";
4
- try {
5
- const monorepoRoot = findMonorepoRoot();
6
- syncPnpmVersion(monorepoRoot);
7
- }
8
- catch (error) {
9
- reportError(error instanceof Error ? error.message : String(error));
10
- process.exitCode = 1;
11
- }
@@ -1,3 +0,0 @@
1
- declare function extractPnpmVersion(packageManager: string | undefined): string | null;
2
- export declare function syncPnpmVersion(monorepoRoot: string): void;
3
- export { extractPnpmVersion };
@@ -1,40 +0,0 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { parseDocument } from 'yaml';
4
- import { CodeQualityPnpmWorkflowSchema, getPnpmVersionNodes } from "../helpers/code-quality-pnpm-action.js";
5
- import { readPackageJson } from "../helpers/package-json.js";
6
- const WORKFLOW_RELATIVE_PATH = '.github/workflows/code-quality.yaml';
7
- function extractPnpmVersion(packageManager) {
8
- if (!packageManager) {
9
- return null;
10
- }
11
- const match = /^pnpm@(.+)$/.exec(packageManager);
12
- return match?.[1] ?? null;
13
- }
14
- export function syncPnpmVersion(monorepoRoot) {
15
- console.info('Synchronizing pnpm version in GitHub workflow...');
16
- const pkg = readPackageJson(monorepoRoot);
17
- const pnpmVersion = extractPnpmVersion(pkg.packageManager);
18
- if (!pnpmVersion) {
19
- throw new Error(`Could not extract pnpm version from package.json packageManager field\npackageManager field: ${pkg.packageManager ?? '(not set)'}`);
20
- }
21
- console.info(`Package.json pnpm version: ${pnpmVersion}`);
22
- const workflowPath = path.join(monorepoRoot, WORKFLOW_RELATIVE_PATH);
23
- const doc = parseDocument(readFileSync(workflowPath, 'utf8'));
24
- const [parseError] = doc.errors;
25
- if (parseError) {
26
- throw new Error(`Failed to parse workflow file: ${workflowPath}\n${parseError.message}`);
27
- }
28
- CodeQualityPnpmWorkflowSchema.parse(doc.toJS());
29
- const outdatedNodes = getPnpmVersionNodes(doc).filter((node) => String(node.value) !== pnpmVersion);
30
- if (outdatedNodes.length === 0) {
31
- console.info('Workflow pnpm version is already up to date');
32
- return;
33
- }
34
- for (const node of outdatedNodes) {
35
- node.value = pnpmVersion;
36
- }
37
- writeFileSync(workflowPath, doc.toString(), 'utf8');
38
- console.info(`✓ Updated ${outdatedNodes.length} pnpm-version occurrence(s) → ${pnpmVersion}`);
39
- }
40
- export { extractPnpmVersion };
@@ -1,13 +0,0 @@
1
- import { type Document, type Scalar } from 'yaml';
2
- import { z } from 'zod';
3
- export declare const CodeQualityPnpmWorkflowSchema: z.ZodObject<{
4
- jobs: z.ZodObject<{
5
- 'code-quality': z.ZodObject<{
6
- with: z.ZodObject<{
7
- 'pnpm-version': z.ZodString;
8
- }, z.core.$loose>;
9
- }, z.core.$loose>;
10
- }, z.core.$loose>;
11
- }, z.core.$loose>;
12
- export type CodeQualityPnpmWorkflow = z.infer<typeof CodeQualityPnpmWorkflowSchema>;
13
- export declare function getPnpmVersionNodes(doc: Document): Scalar[];
@@ -1,22 +0,0 @@
1
- import { isScalar, visit } from 'yaml';
2
- import { z } from 'zod';
3
- export const CodeQualityPnpmWorkflowSchema = z.looseObject({
4
- jobs: z.looseObject({
5
- 'code-quality': z.looseObject({
6
- with: z.looseObject({
7
- 'pnpm-version': z.string(),
8
- }),
9
- }),
10
- }),
11
- });
12
- export function getPnpmVersionNodes(doc) {
13
- const nodes = [];
14
- visit(doc, {
15
- Pair(_, pair) {
16
- if (isScalar(pair.key) && pair.key.value === 'pnpm-version' && isScalar(pair.value)) {
17
- nodes.push(pair.value);
18
- }
19
- },
20
- });
21
- return nodes;
22
- }
@@ -1,7 +0,0 @@
1
- export { findMonorepoRoot } from '../context.js';
2
- export declare function checkPnpmVersionConsistency(monorepoRoot: string): void;
3
- export declare function checkNodeVersionConsistency(monorepoRoot: string): void;
4
- export declare function getPnpmVersionFromAction(monorepoRoot: string): Promise<string>;
5
- export declare function getPnpmVersionFromPackageJson(monorepoRoot: string): string;
6
- export declare function getNodeVersionFromAction(monorepoRoot: string): Promise<string>;
7
- export declare function runConsistencyChecks(): void;
@@ -1,57 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { describe, expect, it } from 'vitest';
4
- import { findMonorepoRoot } from "../context.js";
5
- export { findMonorepoRoot } from "../context.js";
6
- import { getRuntimeVersionFromAsdf } from "./helpers/get-runtime-version-from-asdf.js";
7
- import { getStringFromYamlFile } from "./helpers/get-string-from-yaml-file.js";
8
- import { getValueAtPathOrThrow } from "./helpers/get-value-at-path.js";
9
- const GITHUB_ACTION_FILE_PATH = '.github/workflows/code-quality.yaml';
10
- export function checkPnpmVersionConsistency(monorepoRoot) {
11
- describe('pnpm version consistency', () => {
12
- it('pnpm version is the same in GitHub action and package.json', async () => {
13
- const actionVersion = await getPnpmVersionFromAction(monorepoRoot);
14
- const packageJsonVersion = getPnpmVersionFromPackageJson(monorepoRoot);
15
- expect(actionVersion).toBe(packageJsonVersion);
16
- });
17
- });
18
- }
19
- export function checkNodeVersionConsistency(monorepoRoot) {
20
- describe('Node.js version consistency', () => {
21
- it('version is the same in GitHub action and .tool-versions', async () => {
22
- const toolVersion = await getRuntimeVersionFromAsdf('nodejs', monorepoRoot);
23
- const actionVersion = await getNodeVersionFromAction(monorepoRoot);
24
- expect(toolVersion).toBe(actionVersion);
25
- });
26
- });
27
- }
28
- export async function getPnpmVersionFromAction(monorepoRoot) {
29
- const actionPath = path.join(monorepoRoot, GITHUB_ACTION_FILE_PATH);
30
- return getStringFromYamlFile(actionPath, 'jobs.code-quality.with.pnpm-version', 'pnpm version');
31
- }
32
- export function getPnpmVersionFromPackageJson(monorepoRoot) {
33
- const pkgPath = path.join(monorepoRoot, 'package.json');
34
- const raw = fs.readFileSync(pkgPath, 'utf8');
35
- const pkg = JSON.parse(raw);
36
- const pm = getValueAtPathOrThrow(pkg, 'packageManager');
37
- if (typeof pm !== 'string') {
38
- throw new TypeError('"packageManager" field missing or not a string in package.json.');
39
- }
40
- const [name, version] = pm.split('@', 2);
41
- if (name !== 'pnpm') {
42
- throw new Error('packageManager is not pnpm.');
43
- }
44
- if (!version) {
45
- throw new Error('pnpm version missing in package.json.');
46
- }
47
- return version;
48
- }
49
- export async function getNodeVersionFromAction(monorepoRoot) {
50
- const actionPath = path.join(monorepoRoot, GITHUB_ACTION_FILE_PATH);
51
- return getStringFromYamlFile(actionPath, 'jobs.code-quality.with.node-version', 'Node.js version');
52
- }
53
- export function runConsistencyChecks() {
54
- const monorepoRoot = findMonorepoRoot();
55
- checkPnpmVersionConsistency(monorepoRoot);
56
- checkNodeVersionConsistency(monorepoRoot);
57
- }
@@ -1 +0,0 @@
1
- export declare function getRuntimeVersionFromAsdf(runtime: string, monorepoRoot: string): Promise<string>;
@@ -1,12 +0,0 @@
1
- import assert from 'node:assert';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- export async function getRuntimeVersionFromAsdf(runtime, monorepoRoot) {
5
- const toolVersionsPath = path.join(monorepoRoot, '.tool-versions');
6
- const toolVersions = await fs.promises.readFile(toolVersionsPath, { encoding: 'utf8' });
7
- const versionLine = toolVersions.split('\n').find((line) => line.trim().startsWith(runtime));
8
- assert.ok(versionLine, `${runtime} not found in .tool-versions`);
9
- const [, version] = versionLine.trim().split(/\s+/, 2);
10
- assert.ok(version, `${runtime} version missing in .tool-versions`);
11
- return version;
12
- }
@@ -1 +0,0 @@
1
- export declare function getStringFromYamlFile(filePath: string, keyPath: string, label: string): Promise<string>;
@@ -1,12 +0,0 @@
1
- import assert from 'node:assert';
2
- import fs from 'node:fs';
3
- import { parse } from 'yaml';
4
- import { getValueAtPathOrThrow } from "./get-value-at-path.js";
5
- export async function getStringFromYamlFile(filePath, keyPath, label) {
6
- const raw = await fs.promises.readFile(filePath, { encoding: 'utf8' });
7
- const parsed = parse(raw);
8
- assert.ok(parsed, `YAML content not found in ${filePath}`);
9
- const value = getValueAtPathOrThrow(parsed, keyPath);
10
- assert.ok(typeof value === 'string' && value.length > 0, `${label} not found at ${keyPath}`);
11
- return value;
12
- }
@@ -1 +0,0 @@
1
- export declare function getValueAtPathOrThrow(obj: unknown, objPath: string): unknown;
@@ -1,34 +0,0 @@
1
- function isObject(value) {
2
- return typeof value === 'object' && value !== null && !Array.isArray(value);
3
- }
4
- export function getValueAtPathOrThrow(obj, objPath) {
5
- if (!isObject(obj)) {
6
- throw new Error('Expected an object as root value.');
7
- }
8
- const keys = objPath.split('.');
9
- let current = obj;
10
- for (const key of keys) {
11
- if (Array.isArray(current)) {
12
- const index = Number(key);
13
- if (Number.isNaN(index)) {
14
- throw new TypeError(`Expected array index at segment "${key}" in path "${objPath}"`);
15
- }
16
- if (index >= 0 && index < current.length) {
17
- current = current[index];
18
- }
19
- else {
20
- throw new Error(`Array index out of bounds: "${key}" in path "${objPath}"`);
21
- }
22
- }
23
- else if (isObject(current)) {
24
- if (!(key in current)) {
25
- throw new Error(`Missing key "${key}" in path "${objPath}"`);
26
- }
27
- current = current[key];
28
- }
29
- else {
30
- throw new Error(`Unexpected non-object/non-array at segment "${key}" in path "${objPath}"`);
31
- }
32
- }
33
- return current;
34
- }