@williamthorsen/nmr 0.50.0 โ†’ 0.51.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.
@@ -1,4 +1,59 @@
1
1
  [
2
+ {
3
+ "version": "0.51.0",
4
+ "date": "2026-10-24",
5
+ "sections": [
6
+ {
7
+ "title": "๐ŸŽ‰ Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Widens the optional `vitest` peer range of `@williamthorsen/nmr` from `>=4.0.0 <5` to `>=4.0.0 <6`, adding support for Vitest 5, under which a bare `vitest` started from a package without its own config needs `--config` naming the root's `vitest.config.*`. (#1100)",
12
+ "hash": "7a8049951fd41ef398dd2b0fdb88de6086223906",
13
+ "entry": 1
14
+ },
15
+ {
16
+ "description": "Adds the `nmr-vitest` command and runs every package-scope `test*` script through it; when the package directory does not contain a `vitest.config.*` or `vite.config.*`, `nmr-vitest` passes `--config` naming the `vitest.config.*` at the monorepo root, and otherwise passes its arguments to Vitest unchanged. (#1100)",
17
+ "hash": "7a8049951fd41ef398dd2b0fdb88de6086223906",
18
+ "entry": 2
19
+ }
20
+ ]
21
+ },
22
+ {
23
+ "title": "๐Ÿงช Tests",
24
+ "audience": "dev",
25
+ "items": [
26
+ {
27
+ "description": "Captures the output that passing tests leaked into the run, asserting on each error, hint, and `TAG_RESOLVE_FAILED` YAML warning emitted by the code under test, and pipes the stdio of the child processes run by tool tests so that their output does not reach the worker's streams. (#1097)",
28
+ "hash": "ed6e4187d7f30fa27eb59b495c5f51cf07eacdbc",
29
+ "entry": 2
30
+ }
31
+ ]
32
+ }
33
+ ]
34
+ },
35
+ {
36
+ "version": "0.50.1",
37
+ "date": "2026-10-22",
38
+ "sections": [
39
+ {
40
+ "title": "๐Ÿ› Bug fixes",
41
+ "audience": "all",
42
+ "items": [
43
+ {
44
+ "description": "Fixes the issue that the `self-hosted runner` checklist in the `nmr` readyup kit joined every readiness failure into one semicolon-separated line, which hid that a public repo rules out the runner whatever else holds; a public repo now reports its visibility alone, and a private repo lists each failure on its own line. (#1091)",
45
+ "hash": "615f6f30ff56d0ea777d47752013de0bc479368f",
46
+ "entry": 1
47
+ },
48
+ {
49
+ "description": "Fixes the issue that the checklist's missing-runner message stated a recent `williamthorsen/hq` allocation as the cause, when it is only a possible one. (#1091)",
50
+ "hash": "615f6f30ff56d0ea777d47752013de0bc479368f",
51
+ "entry": 2
52
+ }
53
+ ]
54
+ }
55
+ ]
56
+ },
2
57
  {
3
58
  "version": "0.50.0",
4
59
  "date": "2026-10-21",
@@ -1,6 +1,6 @@
1
1
  /** @noformat -- @generated. Do not edit. Compiled by rdy. */
2
2
  /* eslint-disable */
3
- export const __readyupVersion = "0.42.0";
3
+ export const __readyupVersion = "0.43.0";
4
4
  import { createRequire as __rdyCreateRequire } from 'node:module';
5
5
  const require = __rdyCreateRequire(import.meta.url);
6
6
 
@@ -7382,12 +7382,12 @@ import { isRecord, listTrackedFiles } from "readyup/check-utils";
7382
7382
  import { globSync } from "node:fs";
7383
7383
  import { basename, join, sep } from "node:path";
7384
7384
 
7385
- // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.3/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
7385
+ // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.4/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/isError.js
7386
7386
  function isError(error) {
7387
7387
  return error instanceof Error || Object.prototype.toString.call(error) === "[object Error]";
7388
7388
  }
7389
7389
 
7390
- // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.3/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
7390
+ // ../../node_modules/.pnpm/@williamthorsen+toolbelt.errors@0.8.4/node_modules/@williamthorsen/toolbelt.errors/dist/esm/4-release/describeError.js
7391
7391
  function describeError(error) {
7392
7392
  try {
7393
7393
  if (isError(error) && typeof error.message === "string" && error.message !== "") {
@@ -7660,12 +7660,12 @@ var workspaceScripts = {
7660
7660
  "lint:check": "eslint .",
7661
7661
  "lint:strict": "strict-lint",
7662
7662
  "report-catalog": "nmr-report-catalog",
7663
- test: `pnpm exec vitest ${GATE_PROJECTS}`,
7664
- "test:all": "pnpm exec vitest",
7665
- "test:coverage": `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
7666
- "test:tool": "pnpm exec vitest --project tool",
7667
- "test:unit": "pnpm exec vitest --project unit",
7668
- "test:watch": `pnpm exec vitest ${GATE_PROJECTS} --watch`,
7663
+ test: `nmr-vitest ${GATE_PROJECTS}`,
7664
+ "test:all": "nmr-vitest",
7665
+ "test:coverage": `nmr-vitest ${GATE_PROJECTS} --coverage`,
7666
+ "test:tool": "nmr-vitest --project tool",
7667
+ "test:unit": "nmr-vitest --project unit",
7668
+ "test:watch": `nmr-vitest ${GATE_PROJECTS} --watch`,
7669
7669
  typecheck: "tsgo --noEmit",
7670
7670
  // The command is a string because neither half names an nmr command: Both are binaries.
7671
7671
  upgrade: "nmr-report-catalog && nmr-taze",
@@ -8871,7 +8871,11 @@ async function describeRecommendationSkip(cwd = process.cwd(), readFacts) {
8871
8871
  if ("skip" in status) return status.skip;
8872
8872
  if (status.selection !== "unset") return `CI_RUNS_ON is already set to ${status.facts.ciRunsOn ?? ""}`;
8873
8873
  if (status.exempt !== void 0) return `the repo is exempt: ${status.exempt}`;
8874
- if (status.failures.length > 0) return `not ready: ${status.failures.join("; ")}`;
8874
+ if (!status.facts.isPrivate) return status.failures.join("\n");
8875
+ if (status.failures.length > 0) {
8876
+ return `not ready:
8877
+ ${status.failures.map((failure) => ` ${failure}`).join("\n")}`;
8878
+ }
8875
8879
  return false;
8876
8880
  }
8877
8881
  async function describeSelfHostedRunnerSkip(cwd = process.cwd(), readFacts) {
@@ -8879,15 +8883,15 @@ async function describeSelfHostedRunnerSkip(cwd = process.cwd(), readFacts) {
8879
8883
  return "skip" in status && status.skip;
8880
8884
  }
8881
8885
  function findSelfHostedRunnerFailures(facts, cwd) {
8882
- const failures = [];
8883
8886
  if (!facts.isPrivate) {
8884
- failures.push(
8885
- `${facts.nameWithOwner} is public: A self-hosted runner must never run code from a fork's pull request`
8886
- );
8887
+ return [
8888
+ `${facts.nameWithOwner} is public, so a self-hosted runner must not run its CI: A fork's pull request would run code on the runner`
8889
+ ];
8887
8890
  }
8891
+ const failures = [];
8888
8892
  if (facts.onlineSelfHostedRunners === 0) {
8889
8893
  failures.push(
8890
- `${facts.nameWithOwner} does not have an online runner labelled self-hosted. When williamthorsen/hq allocates the repo slots in apps/runners/config/repos.yaml, the runners have not registered yet`
8894
+ `${facts.nameWithOwner} does not have an online runner labelled self-hosted. If williamthorsen/hq has just allocated the repo's slots in apps/runners/config/repos.yaml, the runners may not have registered yet`
8891
8895
  );
8892
8896
  }
8893
8897
  const workflows = readWorkflows(cwd);
@@ -8962,7 +8966,7 @@ async function gatherSelfHostedRunnerStatus(cwd, readFacts) {
8962
8966
  try {
8963
8967
  exempt = (await loadRootConfig(cwd)).selfHostedRunner?.exempt;
8964
8968
  } catch (error) {
8965
- failures.push(`the nmr config cannot be loaded: ${describeError(error)}`);
8969
+ if (facts.isPrivate) failures.push(`the nmr config cannot be loaded: ${describeError(error)}`);
8966
8970
  }
8967
8971
  return {
8968
8972
  facts,
@@ -9184,7 +9188,7 @@ function hasSupportedStrictLintVersion() {
9184
9188
  });
9185
9189
  }
9186
9190
  function getMinVersion() {
9187
- const pickedFields = { "version": "0.50.0" };
9191
+ const pickedFields = { "version": "0.51.0" };
9188
9192
  if (typeof pickedFields["version"] !== "string") {
9189
9193
  throw new TypeError("nmr/package.json: 'version' must be a string");
9190
9194
  }
@@ -9358,7 +9362,7 @@ var vitestChecks = [
9358
9362
  severity: "recommend",
9359
9363
  quiet: true,
9360
9364
  check: () => noReExportOnlyVitestConfigs(),
9361
- fix: "Delete these files. Vitest resolves config by walking up from the run root, so a per-package re-export is redundant"
9365
+ fix: "Delete these files. `nmr-vitest` passes the root config to every package that does not have a config of its own, so a per-package re-export is redundant"
9362
9366
  }
9363
9367
  ];
9364
9368
  function everyTestFileNamesItsTier(cwd = process.cwd()) {
@@ -3,9 +3,9 @@
3
3
  "kits": [
4
4
  {
5
5
  "bundledDependencies": {
6
- "@williamthorsen/toolbelt.errors": "0.8.3",
7
- "@williamthorsen/toolbelt.filesystem": "0.9.3",
8
- "@williamthorsen/toolbelt.packaging": "0.6.3",
6
+ "@williamthorsen/toolbelt.errors": "0.8.4",
7
+ "@williamthorsen/toolbelt.filesystem": "0.9.4",
8
+ "@williamthorsen/toolbelt.packaging": "0.6.4",
9
9
  "@williamthorsen/toolbelt.terminal": "0.2.1",
10
10
  "ansi-regex": "6.4.0",
11
11
  "ansi-styles": "6.2.3",
@@ -24,7 +24,7 @@
24
24
  "checklist": "nmr",
25
25
  "checks": [
26
26
  "@williamthorsen/nmr in devDependencies",
27
- "@williamthorsen/nmr >= 0.50.0",
27
+ "@williamthorsen/nmr >= 0.51.0",
28
28
  "pnpm-workspace.yaml exists",
29
29
  "catalogued dependencies use catalog:",
30
30
  "package.json has packageManager field",
@@ -176,7 +176,7 @@
176
176
  "path": "lib/root-scripts.ts"
177
177
  },
178
178
  {
179
- "hash": "3d661ab8",
179
+ "hash": "97f29052",
180
180
  "kind": "module",
181
181
  "path": "lib/self-hosted-runner.ts"
182
182
  },
@@ -191,7 +191,7 @@
191
191
  "path": "lib/shared-configs.ts"
192
192
  },
193
193
  {
194
- "hash": "b3bdf8ee",
194
+ "hash": "66043902",
195
195
  "kind": "module",
196
196
  "path": "lib/vitest.ts"
197
197
  },
@@ -201,7 +201,7 @@
201
201
  "path": "lib/workspace-build.ts"
202
202
  },
203
203
  {
204
- "hash": "4f47ba73",
204
+ "hash": "006bcdd5",
205
205
  "kind": "inline",
206
206
  "path": "../package.json",
207
207
  "paths": [
@@ -224,7 +224,7 @@
224
224
  "path": "../src/config.ts"
225
225
  },
226
226
  {
227
- "hash": "d2448f72",
227
+ "hash": "72ad1e0e",
228
228
  "kind": "module",
229
229
  "path": "../src/default-scripts.ts"
230
230
  },
@@ -316,10 +316,10 @@
316
316
  ],
317
317
  "name": "default",
318
318
  "path": "kits/default.js",
319
- "readyupVersion": "0.42.0",
319
+ "readyupVersion": "0.43.0",
320
320
  "source": "kits/default.ts",
321
321
  "sourceHash": "9fd61bf4",
322
- "targetHash": "81b3206d"
322
+ "targetHash": "54da2b6a"
323
323
  }
324
324
  ]
325
325
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.51.0 โ€” 2026-10-24
6
+
7
+ ### ๐ŸŽ‰ Features
8
+
9
+ - Widens the optional `vitest` peer range of `@williamthorsen/nmr` from `>=4.0.0 <5` to `>=4.0.0 <6`, adding support for Vitest 5, under which a bare `vitest` started from a package without its own config needs `--config` naming the root's `vitest.config.*`. (#1100)
10
+ - Adds the `nmr-vitest` command and runs every package-scope `test*` script through it; when the package directory does not contain a `vitest.config.*` or `vite.config.*`, `nmr-vitest` passes `--config` naming the `vitest.config.*` at the monorepo root, and otherwise passes its arguments to Vitest unchanged. (#1100)
11
+
12
+ ### ๐Ÿงช Tests
13
+
14
+ - Captures the output that passing tests leaked into the run, asserting on each error, hint, and `TAG_RESOLVE_FAILED` YAML warning emitted by the code under test, and pipes the stdio of the child processes run by tool tests so that their output does not reach the worker's streams. (#1097)
15
+
16
+ ## 0.50.1 โ€” 2026-10-22
17
+
18
+ ### ๐Ÿ› Bug fixes
19
+
20
+ - Fixes the issue that the `self-hosted runner` checklist in the `nmr` readyup kit joined every readiness failure into one semicolon-separated line, which hid that a public repo rules out the runner whatever else holds; a public repo now reports its visibility alone, and a private repo lists each failure on its own line. (#1091)
21
+ - Fixes the issue that the checklist's missing-runner message stated a recent `williamthorsen/hq` allocation as the cause, when it is only a possible one. (#1091)
22
+
5
23
  ## 0.50.0 โ€” 2026-10-21
6
24
 
7
25
  ### ๐ŸŽ‰ Features
package/README.md CHANGED
@@ -5,16 +5,12 @@
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.50.0 (2026-10-21)
8
+ ## Release notes โ€” v0.51.0 (2026-10-24)
9
9
 
10
10
  ### ๐ŸŽ‰ Features
11
11
 
12
- - Makes `nmr` run a command on this machine, after printing `nmr: running <command> here rather than on <hosts>` and what `ssh` reported, when the user config's `when` sends the command to other hosts and `ssh` cannot connect to any of them; until now, the delegation failed with exit 255 and a line that named `~/.config/nmr/config.ts`. (#1090)
13
- - Makes `nmr` delegate to a host that `ssh` connected to, rather than to the first listed host, when several delegation hosts are configured and the lock probe finds every one of them neither free nor busy. (#1090)
14
-
15
- ### ๐Ÿ› Bug fixes
16
-
17
- - Fixes the issue that delegation ignored the environment's `GIT_SSH_COMMAND`: `nmr` replaced it with plain `ssh` for the push and ran plain `ssh` for every other call to the host, which kept a machine that reaches its hosts through an ssh wrapper or proxy, such as the Claude Code sandbox, from delegating. (#1088)
12
+ - Widens the optional `vitest` peer range of `@williamthorsen/nmr` from `>=4.0.0 <5` to `>=4.0.0 <6`, adding support for Vitest 5, under which a bare `vitest` started from a package without its own config needs `--config` naming the root's `vitest.config.*`. (#1100)
13
+ - Adds the `nmr-vitest` command and runs every package-scope `test*` script through it; when the package directory does not contain a `vitest.config.*` or `vite.config.*`, `nmr-vitest` passes `--config` naming the `vitest.config.*` at the monorepo root, and otherwise passes its arguments to Vitest unchanged. (#1100)
18
14
  <!-- /section:release-notes -->
19
15
 
20
16
  ## Installation
@@ -84,11 +80,11 @@ nmr detects the directory from which it is invoked and selects the right scripts
84
80
 
85
81
  nmr's key feature is that the same command runs different scripts depending on where it is invoked. It walks up from the current directory to find `pnpm-workspace.yaml`, then checks whether the current directory is inside a workspace package directory.
86
82
 
87
- | Where `nmr` is invoked | Registry used | Working directory | `nmr test` runs |
88
- | --------------------------------- | ----------------- | ----------------- | -------------------------------------------------------------------- |
89
- | Monorepo root | Root scripts | Monorepo root | `root:test`, then `-R test` |
90
- | Inside a workspace package | Workspace scripts | The package root | `pnpm exec vitest --project unit --project tool` (that package only) |
91
- | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | `root:test`, then `-R test` |
83
+ | Where `nmr` is invoked | Registry used | Working directory | `nmr test` runs |
84
+ | --------------------------------- | ----------------- | ----------------- | -------------------------------------------------------------- |
85
+ | Monorepo root | Root scripts | Monorepo root | `root:test`, then `-R test` |
86
+ | Inside a workspace package | Workspace scripts | The package root | `nmr-vitest --project unit --project tool` (that package only) |
87
+ | Anywhere, with `--workspace-root` | Root scripts | Monorepo root | `root:test`, then `-R test` |
92
88
 
93
89
  Relative paths in a script resolve against that working directory, not the invocation directory.
94
90
 
@@ -321,7 +317,7 @@ rdy list --from npm:@williamthorsen/nmr # what nmr publishes
321
317
  - [Machine lock](docs/machine-lock.md): Which commands wait for one another, where the lock lives, and bypassing it
322
318
  - [Delegating to another machine](docs/delegate.md): The user config, `--delegate`, `--delegate-to`, `NMR_DELEGATE`, where a command runs, choosing among several hosts, what the host runs, and setting it up
323
319
  - [Dependency upgrades](docs/upgrades.md): `nmr upgrade`, the upgrade policy, `report-catalog`, `report-overrides`, and `nmr-taze`
324
- - [Standalone utilities](docs/utilities.md): `nmr-clean`, `nmr-compile`, `nmr-fmt`, and `ensure-prepublish-hooks`
320
+ - [Standalone utilities](docs/utilities.md): `nmr-clean`, `nmr-compile`, `nmr-fmt`, `nmr-vitest`, and `ensure-prepublish-hooks`
325
321
  - [Shared Vitest config](docs/vitest.md): Test tiers, test selections, the conventions check, and customization
326
322
  - [Shared Prettier config](docs/prettier.md): What it formats and how to customize it
327
323
  - [Workspace introspection](docs/workspace.md): The two workspace lookups
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/cli-vitest.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('nmr-vitest: build output not found; run `pnpm run bootstrap` first\n');
7
+ } else {
8
+ process.stderr.write(`nmr-vitest: failed to load: ${error.message}\n`);
9
+ }
10
+ process.exit(1);
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import process from 'node:process';
2
+ import { runNmrVitest } from "./commands/run-vitest.js";
3
+ process.exitCode = runNmrVitest(process.argv.slice(2), process.cwd());
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Runs the Vitest that `cwd` resolves, naming the monorepo root's config when the working directory does not have
3
+ * one of its own, and returns the exit code to report.
4
+ */
5
+ export declare function runNmrVitest(argv: string[], cwd?: string): number;
6
+ /**
7
+ * Returns `argv` with `--config` naming the monorepo root's `vitest.config.*` prepended, or `argv` unchanged when
8
+ * it already names a config, when `cwd` contains a Vitest or Vite config that Vitest loads itself, or when there is
9
+ * no monorepo root config to name.
10
+ *
11
+ * Vitest 5 does not search parent directories for a config, so a package without its own would otherwise run
12
+ * without the shared one.
13
+ */
14
+ export declare function chooseVitestConfigArgs(argv: string[], cwd: string): string[];
@@ -0,0 +1,79 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { reportError } from '@williamthorsen/nmr-core';
7
+ import { findMonorepoRoot as findMonorepoRootOrNothing } from '@williamthorsen/nmr-core/workspace';
8
+ import { describeSignalExitCode } from "../child-process.js";
9
+ import { isObject } from "../helpers/type-guards.js";
10
+ const VITEST_PACKAGE = 'vitest';
11
+ const SUPPORTED_VITEST_RANGE = '>=4.0.0 <6';
12
+ const CONFIG_EXTENSIONS = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'];
13
+ export function runNmrVitest(argv, cwd = process.cwd()) {
14
+ const cli = resolveVitestCli(cwd);
15
+ if (!cli.ok) {
16
+ reportError(`nmr-vitest: ${cli.error}`);
17
+ return 1;
18
+ }
19
+ const result = spawnSync(process.execPath, [cli.cliPath, ...chooseVitestConfigArgs(argv, cwd)], {
20
+ cwd,
21
+ stdio: 'inherit',
22
+ });
23
+ if (result.error) {
24
+ reportError(`nmr-vitest: could not run \`${VITEST_PACKAGE}\` (${result.error.message}).`);
25
+ return 1;
26
+ }
27
+ if (result.signal !== null)
28
+ return describeSignalExitCode(result.signal);
29
+ return result.status ?? 1;
30
+ }
31
+ export function chooseVitestConfigArgs(argv, cwd) {
32
+ if (namesConfig(argv))
33
+ return argv;
34
+ if (findConfigFile(cwd, ['vitest.config', 'vite.config']) !== undefined)
35
+ return argv;
36
+ const monorepoRoot = findMonorepoRootOrNothing(cwd);
37
+ if (monorepoRoot === undefined)
38
+ return argv;
39
+ const rootConfig = findConfigFile(monorepoRoot, ['vitest.config']);
40
+ return rootConfig === undefined ? argv : ['--config', rootConfig, ...argv];
41
+ }
42
+ function findConfigFile(dir, stems) {
43
+ for (const stem of stems) {
44
+ for (const extension of CONFIG_EXTENSIONS) {
45
+ const candidate = path.join(dir, `${stem}.${extension}`);
46
+ if (existsSync(candidate))
47
+ return candidate;
48
+ }
49
+ }
50
+ return undefined;
51
+ }
52
+ function namesConfig(argv) {
53
+ for (const arg of argv) {
54
+ if (arg === '--')
55
+ return false;
56
+ if (arg === '--config' || arg === '-c' || arg.startsWith('--config='))
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+ function resolveVitestCli(cwd) {
62
+ const missingResult = {
63
+ ok: false,
64
+ error: `requires Vitest ${SUPPORTED_VITEST_RANGE}, but \`${VITEST_PACKAGE}\` could not be resolved from ${cwd}. Install a version in that range as the \`${VITEST_PACKAGE}\` peer dependency.`,
65
+ };
66
+ let manifestPath;
67
+ try {
68
+ manifestPath = createRequire(path.join(cwd, 'package.json')).resolve(`${VITEST_PACKAGE}/package.json`);
69
+ }
70
+ catch {
71
+ return missingResult;
72
+ }
73
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
74
+ const bin = isObject(manifest) && isObject(manifest['bin']) ? manifest['bin'][VITEST_PACKAGE] : undefined;
75
+ if (typeof bin !== 'string') {
76
+ return { ok: false, error: `\`${VITEST_PACKAGE}\` at ${path.dirname(manifestPath)} does not declare a CLI.` };
77
+ }
78
+ return { ok: true, cliPath: path.resolve(path.dirname(manifestPath), bin) };
79
+ }
@@ -18,12 +18,12 @@ export const workspaceScripts = {
18
18
  'lint:check': 'eslint .',
19
19
  'lint:strict': 'strict-lint',
20
20
  'report-catalog': 'nmr-report-catalog',
21
- test: `pnpm exec vitest ${GATE_PROJECTS}`,
22
- 'test:all': 'pnpm exec vitest',
23
- 'test:coverage': `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
24
- 'test:tool': 'pnpm exec vitest --project tool',
25
- 'test:unit': 'pnpm exec vitest --project unit',
26
- 'test:watch': `pnpm exec vitest ${GATE_PROJECTS} --watch`,
21
+ test: `nmr-vitest ${GATE_PROJECTS}`,
22
+ 'test:all': 'nmr-vitest',
23
+ 'test:coverage': `nmr-vitest ${GATE_PROJECTS} --coverage`,
24
+ 'test:tool': 'nmr-vitest --project tool',
25
+ 'test:unit': 'nmr-vitest --project unit',
26
+ 'test:watch': `nmr-vitest ${GATE_PROJECTS} --watch`,
27
27
  typecheck: 'tsgo --noEmit',
28
28
  upgrade: 'nmr-report-catalog && nmr-taze',
29
29
  'view-coverage': 'open coverage/index.html',
@@ -21,7 +21,8 @@ export declare function checkTestFileConventions(options?: TestFileConventionsOp
21
21
  * remedy. Outside Vitest it does nothing.
22
22
  *
23
23
  * A second `@vitest/runner` copy is never handed the running state, and its `describe` fails with a bare `TypeError`.
24
- * The default `probe` comes from `vitest/suite`, which re-exports the same copy as the `describe` imported here.
24
+ * The default `probe` is `TestRunner.getCurrentSuite`, imported from the same `vitest` as `describe`, so both come from
25
+ * one runner copy.
25
26
  *
26
27
  * @internal - Exported only to enable testing
27
28
  */
package/dist/esm/tests.js CHANGED
@@ -1,5 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { getCurrentSuite } from 'vitest/suite';
1
+ import { describe, expect, it, TestRunner } from 'vitest';
3
2
  import { findMisplacedTestFiles, findUntieredTestFiles, TIER_NAMES } from "./tiers.js";
4
3
  import { findMonorepoRoot } from "./workspace.js";
5
4
  const MISPLACED_REMEDY = 'Every test file must be under a `__tests__` directory, the only place from which the shared Vitest config collects. Move each file below into one, or name its directory in `excludedBasenames`.';
@@ -18,7 +17,7 @@ export function checkTestFileConventions(options = {}) {
18
17
  });
19
18
  });
20
19
  }
21
- export function assertImportedRunnerIsActive(probe = getCurrentSuite) {
20
+ export function assertImportedRunnerIsActive(probe = TestRunner.getCurrentSuite) {
22
21
  if (process.env['VITEST'] === undefined)
23
22
  return;
24
23
  try {
@@ -124,7 +124,7 @@ The excerpt is a line; nmr also keeps the run's whole transcript. `nmr --log <co
124
124
  ```console
125
125
  $ nmr --log test
126
126
  ๐Ÿ’พ nmr: test โ€” recorded 2026-08-12T15:04:05.412Z (12m ago), ran in 12.4s
127
- $ pnpm exec vitest --project unit --project tool
127
+ $ nmr-vitest --project unit --project tool
128
128
 
129
129
  โœ“ src/__tests__/resolver.unit.test.ts (18 tests) 12ms
130
130
 
package/docs/utilities.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Standalone utilities
2
2
 
3
- The binaries behind nmr's default `clean`, `compile`, and `fmt` commands, and a check for the `prepublishOnly` hooks of publishable packages.
3
+ The binaries behind nmr's default `clean`, `compile`, `fmt`, and package `test` commands, and a check for the `prepublishOnly` hooks of publishable packages.
4
4
 
5
5
  ## `nmr-clean`
6
6
 
@@ -63,6 +63,16 @@ Pass `--no-cache`, or set `NMR_NO_CACHE=1`, to run uncached. `nmr --no-cache fmt
63
63
  nmr-fmt --check --no-cache
64
64
  ```
65
65
 
66
+ ## `nmr-vitest`
67
+
68
+ Run Vitest from a package directory with the monorepo's shared config. Vitest 5 does not search parent directories for a config, so `nmr-vitest` names it: When the working directory does not contain a `vitest.config.*` or `vite.config.*` of its own, it passes `--config` naming the `vitest.config.*` at the monorepo root, the directory that contains `pnpm-workspace.yaml`. It does not search the directories in between. A package with a config of its own, a monorepo root without a `vitest.config.*`, and a directory outside any monorepo all run Vitest without the flag. An explicit `--config` or `-c` ahead of any `--` passes through unchanged, and so does every other argument. This is the command behind every package-scope `test*` script.
69
+
70
+ `vitest` is an optional peer dependency (`>=4.0.0 <6`), resolved from the working directory alone and never from nmr's installation, because a second copy of the runner is not handed the state of the one that loads the tests. Run without it, `nmr-vitest` fails with an error naming the peer and the range. Its exit status is Vitest's, or 128 plus the signal number when a signal ends Vitest.
71
+
72
+ ```bash
73
+ nmr-vitest --project unit --project tool
74
+ ```
75
+
66
76
  ## `ensure-prepublish-hooks`
67
77
 
68
78
  Verify that all publishable workspace packages have a `prepublishOnly` script. Exits non-zero if any are missing.
package/docs/vitest.md CHANGED
@@ -4,7 +4,7 @@ The Vitest config that `@williamthorsen/nmr/vitest` publishes: its test tiers an
4
4
 
5
5
  The [README](../README.md#shared-vitest-config) shows the one-line config file.
6
6
 
7
- `vitest` is a peer dependency (`>=4.0.0 <5`), declared optional; the consuming repo provides it, and repos that never import this subpath are unaffected. `vite` is an optional peer as well (`>=8.0.0 <9`), required only by the [`tsconfigPaths` setting](#resolving-through-tsconfig-paths); a repo that declares a `vite` of its own below that range sees an unmet-peer warning whether or not it sets the flag.
7
+ `vitest` is a peer dependency (`>=4.0.0 <6`), declared optional; the consuming repo provides it, and repos that never import this subpath are unaffected. `vite` is an optional peer as well (`>=8.0.0 <9`), required only by the [`tsconfigPaths` setting](#resolving-through-tsconfig-paths); a repo that declares a `vite` of its own below that range sees an unmet-peer warning whether or not it sets the flag.
8
8
 
9
9
  ## Test tiers
10
10
 
@@ -58,6 +58,8 @@ Every package resolves the same six test commands. Nothing is detected on disk:
58
58
 
59
59
  To narrow to a single project, go through `test:all`: `nmr test:all --project remote`. Narrowing through `test` does not work, because a second `--project` widens the selection rather than restricting it.
60
60
 
61
+ The package scripts run [`nmr-vitest`](utilities.md#nmr-vitest), which names the root config when a package does not have one of its own. Vitest 5 does not search parent directories for a config, so under it a bare `vitest`, or an editor's test runner, started from such a package needs `--config` naming the root's `vitest.config.*`; Vitest 4 finds it unaided. A repo that overrides a package `test*` script with its own `vitest` invocation passes the flag there too.
62
+
61
63
  A run that does not collect any test files passes. That is what lets `nmr test:tool` fan out across a monorepo in which most packages do not have any tool-tier tests, and it means a package without any tests at all does not need a `"test": ""` override.
62
64
 
63
65
  The root registry defines the same six names, so a command means the same thing from the monorepo root as from inside a package. Each fans out to root-level files and every workspace package.
@@ -100,7 +102,7 @@ checkTestFileConventions({ excludedBasenames: ['cypress', 'generated'] });
100
102
 
101
103
  **Paths that git ignores are out of scope for both.** The sweep skips every untracked path that git ignores under the swept root, and the shared configs exclude the same paths from collection, so local build output such as a stale `.netlify/` directory is neither reported nor run. Git decides what is ignored: A tracked file stays in scope even when it matches an ignore pattern. Outside a git repository, or when git cannot run, nothing counts as ignored and only the named directories are pruned. A generated directory that the repo's `.gitignore` already covers therefore does not need an entry in either list.
102
104
 
103
- Expect the first run to fail in a repo that has never gated this. Vitest 4 excludes only `node_modules` and `.git` by default, so a `__tests__` tree under a generated or vendored directory that git tracks is collected and runs today; naming that directory in both lists is the fix, rather than widening what nmr prunes for everyone.
105
+ Expect the first run to fail in a repo that has never gated this. Vitest excludes only `node_modules` and `.git` by default, so a `__tests__` tree under a generated or vendored directory that git tracks is collected and runs today; naming that directory in both lists is the fix, rather than widening what nmr prunes for everyone.
104
106
 
105
107
  In a repo that has not declared the check, [nmr's readyup kit](../README.md#conformance-checks) reports both halves and warns that the check is missing. Once a test file under `__tests__` imports it, the kit skips both reports and names that file as the reason. Only the check reads the repo's `excludedBasenames`, and a kit report beside it could only repeat its findings or name a directory that the repo has pruned.
106
108
 
@@ -157,7 +159,7 @@ It folds across layers like the other two, the last layer to declare it winning.
157
159
 
158
160
  **It is an opt-in because its silent direction is the reverse of the two defaults'.** Leaving it off fails loudly, with an unresolved import naming the specifier. Turning it on is the silent direction, for a repo that declares `paths` for `tsc` alone: A specifier that resolved through a package's `exports` starts resolving through the alias instead, and nothing in the run reports the switch. A repo that does not declare `paths` is unaffected either way.
159
161
 
160
- **It requires Vite 8**, the version that introduced `resolve.tsconfigPaths`. nmr declares `vite` as an optional peer at `>=8.0.0 <9`, so a repo that declares a stale `vite` of its own sees a warning at install time. That check does not apply to a repo whose only Vite comes in through Vitest's own dependency, and Vitest 4 accepts Vite 6 and 7: On those, the emitted key does nothing and the aliased import fails exactly as it would with the flag off.
162
+ **It requires Vite 8**, the version that introduced `resolve.tsconfigPaths`. nmr declares `vite` as an optional peer at `>=8.0.0 <9`, so a repo that declares a stale `vite` of its own sees a warning at install time. That check does not apply to a repo that does not declare `vite` itself, whose Vite is the one that Vitest brings in: a dependency under Vitest 4 and an auto-installed peer under Vitest 5. Both majors accept Vite 6 and 7: On those, the emitted key does nothing and the aliased import fails exactly as it would with the flag off.
161
163
 
162
164
  ## Customizing by scope
163
165
 
@@ -215,7 +217,7 @@ Both factories fold any number of layers left to right: A later layer wins on a
215
217
 
216
218
  Order matters for `setupFiles`, because a shared setup file establishes the environment in which the package's own then runs. A later layer's `project` block likewise wins over an earlier layer's `tiers` target: The nearer config is the more deliberate.
217
219
 
218
- **A package's own `vite.config.ts` also counts as that one config.** Vitest searches for a config by ascending from the run root and stopping at the first directory containing any of `vitest.config.*` or `vite.config.*`, so a package with only a Vite config ends the search there and never reaches the root config. Within a directory `vitest.config.*` is tried first, which makes the fix a Vitest config beside the Vite one, calling `defineVitestConfig()` as any other package config does. Without it the package does not declare any projects, and every tier-selecting command fails with `No projects matched the filter`, `nmr ci` among them. [nmr's readyup kit](../README.md#conformance-checks) reports a package in that state.
220
+ **A package's own `vite.config.ts` also counts as that one config.** Vitest loads a `vitest.config.*` or `vite.config.*` that it finds in the run root, and [`nmr-vitest`](utilities.md#nmr-vitest) names the root config only for a package that does not contain either, so a package with only a Vite config never reaches the root config. Within a directory `vitest.config.*` is tried first, which makes the fix a Vitest config beside the Vite one, calling `defineVitestConfig()` as any other package config does. Without it the package does not declare any projects, and every tier-selecting command fails with `No projects matched the filter`, `nmr ci` among them. [nmr's readyup kit](../README.md#conformance-checks) reports a package in that state.
219
221
 
220
222
  **A path in a shared layer must be absolute.** Vitest resolves `setupFiles` against each project's own root, not against the module that declared the path, so a bare `'./vitest.setup.ts'` in a shared module names a different file in every package that consumes it -- and a package that happens to own a file by that name loads the wrong one instead of failing. The co-located form stays correct in a config file that declares the path directly.
221
223
 
@@ -249,7 +251,7 @@ The collection exclusions do not name fixtures, and that asymmetry is intentiona
249
251
 
250
252
  ## Root-scoped tests
251
253
 
252
- A monorepo's own root-level tests need a second config, because the package config is found by walking up from a package directory:
254
+ A monorepo's own root-level tests need a second config, because [`nmr-vitest`](utilities.md#nmr-vitest) passes the root's `vitest.config.*` to every package that does not have a config of its own, and that config covers packages:
253
255
 
254
256
  ```ts
255
257
  // vitest.root.config.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -63,7 +63,8 @@
63
63
  "nmr-fmt": "bin/nmr-fmt.js",
64
64
  "nmr-report-catalog": "bin/nmr-report-catalog.js",
65
65
  "nmr-report-overrides": "bin/nmr-report-overrides.js",
66
- "nmr-taze": "bin/nmr-taze.js"
66
+ "nmr-taze": "bin/nmr-taze.js",
67
+ "nmr-vitest": "bin/nmr-vitest.js"
67
68
  },
68
69
  "files": [
69
70
  ".meta/changelog.json",
@@ -77,9 +78,9 @@
77
78
  "!docs/*.v*.md"
78
79
  ],
79
80
  "dependencies": {
80
- "@williamthorsen/nmr-core": "0.13.5",
81
- "@williamthorsen/toolbelt.errors": "0.8.3",
82
- "@williamthorsen/toolbelt.filesystem": "0.9.3",
81
+ "@williamthorsen/nmr-core": "0.13.6",
82
+ "@williamthorsen/toolbelt.errors": "0.8.4",
83
+ "@williamthorsen/toolbelt.filesystem": "0.9.4",
83
84
  "glob": "13.0.6",
84
85
  "prettier": "3.9.10",
85
86
  "prettier-plugin-sh": "0.20.2",
@@ -87,20 +88,20 @@
87
88
  "unconfig": "7.5.0"
88
89
  },
89
90
  "devDependencies": {
90
- "@williamthorsen/toolbelt.testing": "0.6.3",
91
- "@williamthorsen/toolbelt.vitest": "0.10.3",
91
+ "@williamthorsen/toolbelt.testing": "0.7.0",
92
+ "@williamthorsen/toolbelt.vitest": "0.12.0",
92
93
  "esbuild": "0.28.2",
93
- "readyup": "0.42.0",
94
+ "readyup": "0.43.0",
94
95
  "tinyglobby": "0.2.17",
95
96
  "typescript": "6.0.3",
96
97
  "vite": "8.3.4",
97
- "vitest": "4.1.11",
98
+ "vitest": "5.0.5",
98
99
  "yaml": "2.9.1"
99
100
  },
100
101
  "peerDependencies": {
101
102
  "typescript": ">=5.7.0 <7",
102
103
  "vite": ">=8.0.0 <9",
103
- "vitest": ">=4.0.0 <5"
104
+ "vitest": ">=4.0.0 <6"
104
105
  },
105
106
  "peerDependenciesMeta": {
106
107
  "typescript": {