@williamthorsen/nmr 0.14.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -4,11 +4,11 @@ source: '@williamthorsen/nmr@0.0.0-source'
4
4
 
5
5
  # nmr: agent guidance
6
6
 
7
- This file is managed by `@williamthorsen/nmr`. Do not edit — re-run `nmr sync-agent-files` after an nmr upgrade to refresh it.
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
8
 
9
9
  ## Discover scripts by running nmr
10
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.
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
12
 
13
13
  ## Invocation rules
14
14
 
@@ -20,17 +20,13 @@ Run `nmr` with no command (from the monorepo root or any workspace package) to l
20
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
21
 
22
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.
23
+ - **`pnpm exec nmr <command>`**: Works without setup. pnpm resolves the bin from the workspace root.
24
24
 
25
25
  Avoid `npx nmr`. Inside git worktrees, `npx` can resolve a different nmr binary from outside the working tree.
26
26
 
27
- ### Bootstrap fallback
28
-
29
- If `nmr` itself fails to run (fresh clone, missing build output), run `pnpm run bootstrap` from the repo root first.
30
-
31
27
  ## Root vs. workspace context
32
28
 
33
- 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.
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.
34
30
 
35
31
  ## Composite scripts
36
32
 
@@ -40,13 +36,17 @@ A script value shown in `nmr` output as `[a, b, c]` is a composite: it runs `nmr
40
36
 
41
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.
42
38
 
39
+ ## Managed build
40
+
41
+ The default `compile` script runs `nmr-compile`, a standalone bin that esbuild-compiles a package's `src` to `dist/esm`, rewriting `~/` (package-root) import aliases and `.ts`→`.js` specifiers, and skipping work when inputs are unchanged. There is no repo-local build script to maintain; `nmr build` runs `compile` then `generate-typings`. To find or debug the build, look to `nmr-compile`, not a `config/build.ts` in the consuming repo.
42
+
43
43
  ## Override behaviors
44
44
 
45
45
  In `.config/nmr.config.ts` or a package's `package.json`, override values have special semantics:
46
46
 
47
- - `""` (empty string) — skip the script with a "Skipping" message; exit 0.
48
- - `":"` — no-op; exit 0. Prefer this over `""` if your repo enforces non-empty script values.
49
- - Any other string — runs in place of the default.
47
+ - `""` (empty string): Skip the script with a "Skipping" message; exit 0.
48
+ - `":"`: No-op; exit 0. Prefer this over `""` if your repo enforces non-empty script values.
49
+ - Any other string: Runs in place of the default.
50
50
 
51
51
  ## Pre and post hooks
52
52
 
@@ -54,11 +54,11 @@ Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X &&
54
54
 
55
55
  Behaviors worth knowing:
56
56
 
57
- - **Silent when absent** — missing hooks produce no error and no output.
58
- - **Skip overrides apply to hooks** — a hook value of `""` or `":"` is treated the same as not defining the hook. No console message.
59
- - **Skipping the main command skips its hooks** — when `X` is overridden to `""` or `":"`, neither `X:pre` nor `X:post` fires.
60
- - **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`.
61
- - **Passthrough args attach only to the main command** — `nmr X --flag value` runs hooks without `--flag value`.
57
+ - **Silent when absent**: Missing hooks produce no error and no output.
58
+ - **Skip overrides apply to hooks**: A hook value of `""` or `":"` is treated the same as not defining the hook. No console message.
59
+ - **Skipping the main command skips its hooks**: When `X` is overridden to `""` or `":"`, neither `X:pre` nor `X:post` fires.
60
+ - **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`.
61
+ - **Passthrough args attach only to the main command**: `nmr X --flag value` runs hooks without `--flag value`.
62
62
 
63
63
  Worked examples:
64
64
 
package/README.md CHANGED
@@ -2,7 +2,21 @@
2
2
 
3
3
  Context-aware script runner for PNPM monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
4
4
 
5
- <!-- section:release-notes --><!-- /section:release-notes -->
5
+ <!-- section:release-notes -->
6
+ ## Release notes — v0.16.0 (2026-06-30)
7
+
8
+ ### 🎉 Features
9
+
10
+ - 🚨 **Breaking:** Auto-activate integration test variant from config presence (#448)
11
+
12
+ A package can now separate its integration tests from its standalone suite simply by including a `vitest.integration.config.ts` (alongside a `vitest.standalone.config.ts`). The `--int-test` flag that previously enabled this is removed — that config-file pairing is now the only way to activate the separation. In such a package, `test` and `test:coverage` run only the standalone suite and skip integration tests, while a new `test:all` runs both suites together. The separation now holds even when tests run across every package at once, so a full-workspace `test` run still keeps integration tests out of the default suite. Packages that previously hand-copied these test scripts no longer need to.
13
+
14
+ ### 🐛 Bug fixes
15
+
16
+ - Prevent CLI output truncation when piped before exit (#446)
17
+
18
+ Fixes an issue where large output from the `nmr` and `v11y` commands could be truncated when captured through a pipe — for example, by a CI job.
19
+ <!-- /section:release-notes -->
6
20
 
7
21
  ## Installation
8
22
 
@@ -137,7 +151,7 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
137
151
  | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
138
152
  | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
139
153
  | `clean` | `pnpm exec rimraf dist/*` |
140
- | `compile` | `tsx ../../config/build.ts` |
154
+ | `compile` | `nmr-compile` |
141
155
  | `fix` | `lint`, `fmt` |
142
156
  | `fix:check` | `fmt:check`, `lint:check` |
143
157
  | `fmt` | `prettier --list-different --write .` |
@@ -154,11 +168,12 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
154
168
 
155
169
  #### Integration test variant
156
170
 
157
- Packages with a `vitest.integration.config.ts` file get different test commands. Use the `--int-test` flag to select this variant.
171
+ A package gets this variant automatically when it contains a `vitest.integration.config.ts` file: `test` and `test:coverage` run the standalone (non-integration) suite, and integration tests run only when invoked explicitly. Activation is detected per package, so it applies under the recursive `nmr ci` fan-out. Opting in requires **both** `vitest.integration.config.ts` and `vitest.standalone.config.ts`, since the standalone scripts reference the latter.
158
172
 
159
173
  | Command | Runs |
160
174
  | ------------------ | ------------------------------------------------------------------ |
161
175
  | `test` | `pnpm exec vitest --config=vitest.standalone.config.ts` |
176
+ | `test:all` | `pnpm exec vitest` |
162
177
  | `test:coverage` | `pnpm exec vitest --config=vitest.standalone.config.ts --coverage` |
163
178
  | `test:integration` | `pnpm exec vitest --config=vitest.integration.config.ts` |
164
179
  | `test:watch` | `pnpm exec vitest --config=vitest.standalone.config.ts --watch` |
@@ -272,7 +287,6 @@ nmr [flags] <command> [args...]
272
287
  | `-q, --quiet` | Suppress info messages; show full output on failure | — |
273
288
  | `-?, --help` | Show available commands | — |
274
289
  | `-V, --version` | Show version number | — |
275
- | `--int-test` | Use integration test scripts | — |
276
290
 
277
291
  ### Examples
278
292
 
@@ -334,6 +348,14 @@ nmr sync-pnpm-version
334
348
 
335
349
  ## Standalone utilities
336
350
 
351
+ ### `nmr-compile`
352
+
353
+ Compile a single package's `src` tree to `dist/esm` with esbuild. Rewrites `~/` (package-root) import aliases and `.ts`→`.js` specifiers to match the emitted output, and skips the build when no input has changed (a content-and-path hash is cached in `dist/esm/.cache`). This is the default `compile` script — run it from a package directory.
354
+
355
+ ```bash
356
+ nmr-compile
357
+ ```
358
+
337
359
  ### `ensure-prepublish-hooks`
338
360
 
339
361
  Verify that all publishable workspace packages have a `prepublishOnly` script. Exits non-zero if any are missing.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/cli-build.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('nmr-compile: build output not found — run `pnpm run build` first\n');
7
+ } else {
8
+ process.stderr.write(`nmr-compile: failed to load: ${error.message}\n`);
9
+ }
10
+ process.exit(1);
11
+ }
package/dist/esm/.cache CHANGED
@@ -1 +1 @@
1
- 762ecbce6ca8e70fc4e9734eb2fab7d61a05d38cda5a77fe8d1ce2c2ad45aa85
1
+ 47af840104236371ef384e3368ae463ca3d05d525fcffd96134d4c9c11257796
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { buildPackage } from "./commands/build.js";
2
+ try {
3
+ await buildPackage(process.cwd());
4
+ } catch (error) {
5
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
6
+ `);
7
+ process.exitCode = 1;
8
+ }
@@ -1,31 +1,12 @@
1
+ import { parseArgsOrExit, reportError } from "@williamthorsen/nmr-core";
1
2
  import { DEFAULT_HOOK, ensurePrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
2
3
  import { findMonorepoRoot } from "./context.js";
3
- let fix = false;
4
- let dryRun = false;
5
- let command;
6
- const args = process.argv.slice(2);
7
- for (let i = 0; i < args.length; i++) {
8
- const arg = args[i];
9
- switch (arg) {
10
- case "--fix":
11
- fix = true;
12
- break;
13
- case "--dry-run":
14
- dryRun = true;
15
- break;
16
- case "--command":
17
- i++;
18
- command = args[i];
19
- if (!command) {
20
- console.error("--command requires a value");
21
- process.exit(1);
22
- }
23
- break;
24
- default:
25
- console.error(`Unknown argument: ${arg}`);
26
- process.exit(1);
27
- }
28
- }
4
+ const flagSchema = {
5
+ fix: { long: "--fix", type: "boolean" },
6
+ dryRun: { long: "--dry-run", type: "boolean" },
7
+ command: { long: "--command", type: "string" }
8
+ };
9
+ const { fix, dryRun, command } = parseArgsOrExit(process.argv.slice(2), flagSchema).flags;
29
10
  try {
30
11
  const monorepoRoot = findMonorepoRoot();
31
12
  const hookCommand = command ?? DEFAULT_HOOK;
@@ -52,11 +33,12 @@ try {
52
33
  }
53
34
  if (result.hasFailures) {
54
35
  const missing = result.packages.filter((p) => p.action === "missing").length;
55
- console.error(`
56
- ${missing} package(s) missing prepublishOnly. Use --fix to add it.`);
57
- process.exit(1);
36
+ process.stderr.write(`
37
+ ${missing} package(s) missing prepublishOnly. Use --fix to add it.
38
+ `);
39
+ process.exitCode = 1;
58
40
  }
59
41
  } catch (error) {
60
- console.error(error instanceof Error ? error.message : String(error));
61
- process.exit(1);
42
+ reportError(error instanceof Error ? error.message : String(error));
43
+ process.exitCode = 1;
62
44
  }
@@ -1,9 +1,10 @@
1
+ import { reportError } from "@williamthorsen/nmr-core";
1
2
  import { reportOverrides } from "./commands/report-overrides.js";
2
3
  import { findMonorepoRoot } from "./context.js";
3
4
  try {
4
5
  const monorepoRoot = findMonorepoRoot();
5
6
  reportOverrides(monorepoRoot);
6
7
  } catch (error) {
7
- console.error(error instanceof Error ? error.message : String(error));
8
- process.exit(1);
8
+ reportError(error instanceof Error ? error.message : String(error));
9
+ process.exitCode = 1;
9
10
  }
@@ -1,31 +1,31 @@
1
+ import { parseArgsOrExit, reportError } from "@williamthorsen/nmr-core";
1
2
  import { check, sync } from "./commands/sync-agent-files.js";
2
3
  import { findMonorepoRoot } from "./context.js";
3
- function parseArgs(argv) {
4
- const args = argv.slice(2);
5
- if (args.length === 0) return { mode: "sync" };
6
- if (args.length === 1 && args[0] === "--check") return { mode: "check" };
7
- return { error: `Usage: nmr sync-agent-files [--check]` };
8
- }
4
+ const flagSchema = {
5
+ check: { long: "--check", type: "boolean" }
6
+ };
7
+ const { flags } = parseArgsOrExit(process.argv.slice(2), flagSchema);
9
8
  try {
10
- const parsed = parseArgs(process.argv);
11
- if ("error" in parsed) {
12
- console.error(parsed.error);
13
- process.exit(1);
14
- }
9
+ runCommand(flags.check);
10
+ } catch (error) {
11
+ reportError(error instanceof Error ? error.message : String(error));
12
+ process.exit(1);
13
+ }
14
+ function runCommand(checkOnly) {
15
15
  const monorepoRoot = findMonorepoRoot();
16
- if (parsed.mode === "sync") {
17
- const { written, stamp } = sync(monorepoRoot);
18
- console.info(`\u2713 Wrote ${written} (${stamp})`);
16
+ if (!checkOnly) {
17
+ const { path: destinationPath, packageSpecifier, changed } = sync(monorepoRoot);
18
+ console.info(
19
+ changed ? `\u2713 Wrote ${destinationPath} (${packageSpecifier})` : `\u2713 ${destinationPath} already up to date`
20
+ );
19
21
  process.exit(0);
20
22
  }
21
23
  const result = check(monorepoRoot);
22
24
  if (result.ok) {
23
- console.info(`\u2713 .agents/nmr/AGENTS.md is in sync (${result.stamp})`);
25
+ console.info("\u2713 .agents/nmr/AGENTS.md is in sync");
24
26
  process.exit(0);
25
27
  }
26
- console.error(result.reason);
27
- process.exit(1);
28
- } catch (error) {
29
- console.error(error instanceof Error ? error.message : String(error));
28
+ process.stderr.write(`${result.reason}
29
+ `);
30
30
  process.exit(1);
31
31
  }
@@ -1,9 +1,10 @@
1
+ import { reportError } from "@williamthorsen/nmr-core";
1
2
  import { syncPnpmVersion } from "./commands/sync-pnpm-version.js";
2
3
  import { findMonorepoRoot } from "./context.js";
3
4
  try {
4
5
  const monorepoRoot = findMonorepoRoot();
5
6
  syncPnpmVersion(monorepoRoot);
6
7
  } catch (error) {
7
- console.error(error instanceof Error ? error.message : String(error));
8
- process.exit(1);
8
+ reportError(error instanceof Error ? error.message : String(error));
9
+ process.exitCode = 1;
9
10
  }
package/dist/esm/cli.js CHANGED
@@ -8,9 +8,9 @@ try {
8
8
  stdout: process.stdout,
9
9
  stderr: process.stderr
10
10
  });
11
- process.exit(exitCode);
11
+ process.exitCode = exitCode;
12
12
  } catch (error) {
13
13
  process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}
14
14
  `);
15
- process.exit(1);
15
+ process.exitCode = 1;
16
16
  }
@@ -0,0 +1,15 @@
1
+ import { type Format, type Platform } from 'esbuild';
2
+ export interface BuildOptions {
3
+ aliases?: Record<string, string>;
4
+ entryGlobs?: string[];
5
+ ignore?: string[];
6
+ outdir?: string;
7
+ cacheFile?: string;
8
+ format?: Format;
9
+ platform?: Platform;
10
+ target?: string;
11
+ }
12
+ export declare function buildPackage(packageDir: string, options?: BuildOptions): Promise<void>;
13
+ export declare function computeBuildHash(packageDir: string, files: string[], outputConfig: object): Promise<string>;
14
+ export declare function resolveAliasImports(code: string, fileDir: string, aliasMap: Record<string, string>, packageDir: string): string;
15
+ export declare function rewriteTsImportExtensions(code: string): string;
@@ -0,0 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { build } from "esbuild";
6
+ import { glob } from "glob";
7
+ const PACKAGE_ICON = "\u{1F4E6}";
8
+ const SKIPPED_ICON = "\u23ED\uFE0F";
9
+ const DEFAULT_ALIASES = { "~/": "." };
10
+ const DEFAULT_ENTRY_GLOBS = ["src/**/*.ts"];
11
+ const DEFAULT_IGNORE = ["**/__tests__/**"];
12
+ const DEFAULT_OUTDIR = "dist/esm/";
13
+ const DEFAULT_CACHE_FILE = "dist/esm/.cache";
14
+ const DEFAULT_FORMAT = "esm";
15
+ const DEFAULT_PLATFORM = "node";
16
+ const DEFAULT_TARGET = "es2022";
17
+ async function buildPackage(packageDir, options = {}) {
18
+ const aliases = options.aliases ?? DEFAULT_ALIASES;
19
+ const cacheFile = options.cacheFile ?? DEFAULT_CACHE_FILE;
20
+ const outputConfig = {
21
+ format: options.format ?? DEFAULT_FORMAT,
22
+ platform: options.platform ?? DEFAULT_PLATFORM,
23
+ target: [options.target ?? DEFAULT_TARGET]
24
+ };
25
+ const entryPoints = await glob(options.entryGlobs ?? DEFAULT_ENTRY_GLOBS, {
26
+ cwd: packageDir,
27
+ ignore: options.ignore ?? DEFAULT_IGNORE
28
+ });
29
+ const dependencies = ["package.json"];
30
+ const { changed, currentHash } = await detectBuildChanges(
31
+ packageDir,
32
+ [...entryPoints, ...dependencies],
33
+ outputConfig,
34
+ cacheFile
35
+ );
36
+ if (!changed) {
37
+ return;
38
+ }
39
+ await build({
40
+ absWorkingDir: packageDir,
41
+ entryPoints,
42
+ outdir: options.outdir ?? DEFAULT_OUTDIR,
43
+ bundle: false,
44
+ sourcemap: false,
45
+ plugins: [rewriteTsExtensions(packageDir, aliases)],
46
+ ...outputConfig
47
+ });
48
+ await writeBuildCache(packageDir, cacheFile, currentHash);
49
+ }
50
+ async function computeBuildHash(packageDir, files, outputConfig) {
51
+ const hash = createHash("sha256");
52
+ for (const file of [...files].sort()) {
53
+ hash.update(file);
54
+ hash.update("\0");
55
+ hash.update(await readFile(path.join(packageDir, file)));
56
+ }
57
+ hash.update(JSON.stringify(outputConfig));
58
+ return hash.digest("hex");
59
+ }
60
+ function resolveAliasImports(code, fileDir, aliasMap, packageDir) {
61
+ let result = code;
62
+ for (const [alias, targetDir] of Object.entries(aliasMap)) {
63
+ const escaped = alias.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
64
+ const regex = new RegExp(String.raw`(?<=from\s+['"])${escaped}([^'"]+)(?=['"])`, "g");
65
+ result = result.replace(regex, (_, subpath) => {
66
+ const absolute = path.resolve(packageDir, targetDir, subpath);
67
+ const relative = path.relative(fileDir, absolute);
68
+ return relative.startsWith(".") ? relative : `./${relative}`;
69
+ });
70
+ }
71
+ return result;
72
+ }
73
+ function rewriteTsImportExtensions(code) {
74
+ return code.replaceAll(/(?<=from\s+['"])(\.{1,2}\/[^'"]+)\.ts(?=['"])/g, "$1.js");
75
+ }
76
+ async function detectBuildChanges(packageDir, files, outputConfig, cacheFile) {
77
+ const packageName = path.basename(packageDir);
78
+ const cachePath = path.join(packageDir, cacheFile);
79
+ const previousHash = existsSync(cachePath) ? readFileSync(cachePath, "utf8") : void 0;
80
+ const currentHash = await computeBuildHash(packageDir, files, outputConfig);
81
+ if (previousHash === currentHash) {
82
+ console.info(`${SKIPPED_ICON} ${packageName}: No changes detected. Skipping build.`);
83
+ return { changed: false, currentHash };
84
+ }
85
+ console.info(`${PACKAGE_ICON} ${packageName}: Changes detected.`);
86
+ return { changed: true, currentHash };
87
+ }
88
+ async function writeBuildCache(packageDir, cacheFile, hash) {
89
+ const cachePath = path.join(packageDir, cacheFile);
90
+ await mkdir(path.dirname(cachePath), { recursive: true });
91
+ await writeFile(cachePath, hash);
92
+ }
93
+ function rewriteTsExtensions(packageDir, aliasMap) {
94
+ const aliasBase = realpathSync(packageDir);
95
+ return {
96
+ name: "rewrite-ts-extensions",
97
+ setup(pluginBuild) {
98
+ pluginBuild.onLoad({ filter: /\.ts$/ }, async (args) => {
99
+ const fileDir = path.dirname(args.path);
100
+ let code = await readFile(args.path, "utf8");
101
+ let shebang = "";
102
+ if (code.startsWith("#!")) {
103
+ const newlineIndex = code.indexOf("\n");
104
+ if (newlineIndex === -1) {
105
+ return { contents: code, loader: "ts" };
106
+ }
107
+ shebang = code.slice(0, newlineIndex + 1);
108
+ code = code.slice(newlineIndex + 1);
109
+ }
110
+ code = resolveAliasImports(code, fileDir, aliasMap, aliasBase);
111
+ code = rewriteTsImportExtensions(code);
112
+ return { contents: `${shebang}${code}`, loader: "ts" };
113
+ });
114
+ }
115
+ };
116
+ }
117
+ export {
118
+ buildPackage,
119
+ computeBuildHash,
120
+ resolveAliasImports,
121
+ rewriteTsImportExtensions
122
+ };
@@ -1,14 +1,13 @@
1
- export declare function parseSourceStamp(content: string): string | null;
1
+ export declare function check(destinationDir: string): CheckResult;
2
2
  export interface SyncResult {
3
- written: string;
4
- stamp: string;
3
+ path: string;
4
+ packageSpecifier: string;
5
+ changed: boolean;
5
6
  }
6
7
  export declare function sync(destinationDir: string): SyncResult;
7
8
  export type CheckResult = {
8
9
  ok: true;
9
- stamp: string;
10
10
  } | {
11
11
  ok: false;
12
12
  reason: string;
13
13
  };
14
- export declare function check(destinationDir: string): CheckResult;
@@ -6,73 +6,59 @@ const VERSION = readPackageVersion(import.meta.url);
6
6
  const PACKAGE_NAME = "@williamthorsen/nmr";
7
7
  const DESTINATION_RELATIVE_PATH = ".agents/nmr/AGENTS.md";
8
8
  const SOURCE_FILENAME = "AGENTS.md";
9
- function getSourcePath() {
10
- let dir = path.dirname(fileURLToPath(import.meta.url));
11
- while (dir !== path.dirname(dir)) {
12
- const candidate = path.join(dir, SOURCE_FILENAME);
13
- if (existsSync(candidate)) return candidate;
14
- dir = path.dirname(dir);
15
- }
16
- throw new Error(`Could not locate ${SOURCE_FILENAME} in any parent of ${fileURLToPath(import.meta.url)}`);
17
- }
18
- function getDestinationPath(destinationDir) {
19
- return path.join(destinationDir, DESTINATION_RELATIVE_PATH);
20
- }
21
- function currentSourceStamp() {
22
- return `${PACKAGE_NAME}@${VERSION}`;
23
- }
24
9
  const FRONTMATTER_REGEX = /^---\n((?:.*\n)*?)---\n/;
25
- function stripFrontmatter(content) {
26
- return content.replace(FRONTMATTER_REGEX, "");
27
- }
28
- function parseSourceStamp(content) {
29
- const frontmatterMatch = FRONTMATTER_REGEX.exec(content);
30
- if (!frontmatterMatch) return null;
31
- const frontmatterBody = frontmatterMatch[1] ?? "";
32
- const sourceMatch = /^source:\s*['"]([^'"]+)['"]\s*$/m.exec(frontmatterBody);
33
- return sourceMatch?.[1] ?? null;
34
- }
35
- function sync(destinationDir) {
36
- const sourcePath = getSourcePath();
37
- const sourceContent = readFileSync(sourcePath, "utf8");
38
- const body = stripFrontmatter(sourceContent);
39
- const stamp = currentSourceStamp();
40
- const output = `---
41
- source: '${stamp}'
42
- ---
43
- ${body}`;
44
- const destinationPath = getDestinationPath(destinationDir);
45
- mkdirSync(path.dirname(destinationPath), { recursive: true });
46
- writeFileSync(destinationPath, output, "utf8");
47
- return { written: destinationPath, stamp };
48
- }
49
10
  function check(destinationDir) {
50
11
  const destinationPath = getDestinationPath(destinationDir);
51
- const expected = currentSourceStamp();
52
12
  if (!existsSync(destinationPath)) {
53
13
  return {
54
14
  ok: false,
55
15
  reason: `${DESTINATION_RELATIVE_PATH} is missing. Run \`nmr sync-agent-files\`.`
56
16
  };
57
17
  }
58
- const content = readFileSync(destinationPath, "utf8");
59
- const found = parseSourceStamp(content);
60
- if (found === null) {
18
+ const expectedBody = stripFrontmatter(readFileSync(getSourcePath(), "utf8"));
19
+ const foundBody = stripFrontmatter(readFileSync(destinationPath, "utf8"));
20
+ if (foundBody !== expectedBody) {
61
21
  return {
62
22
  ok: false,
63
- reason: `Cannot parse version stamp in ${DESTINATION_RELATIVE_PATH}. Run \`nmr sync-agent-files\`.`
23
+ reason: `${DESTINATION_RELATIVE_PATH} content is out of date. Run \`nmr sync-agent-files\`.`
64
24
  };
65
25
  }
66
- if (found !== expected) {
67
- return {
68
- ok: false,
69
- reason: `${DESTINATION_RELATIVE_PATH} is out of sync (file: ${found}, installed: ${expected}). Run \`nmr sync-agent-files\`.`
70
- };
26
+ return { ok: true };
27
+ }
28
+ function sync(destinationDir) {
29
+ const body = stripFrontmatter(readFileSync(getSourcePath(), "utf8"));
30
+ const packageSpecifier = buildPackageSpecifier();
31
+ const destinationPath = getDestinationPath(destinationDir);
32
+ if (existsSync(destinationPath) && stripFrontmatter(readFileSync(destinationPath, "utf8")) === body) {
33
+ return { path: destinationPath, packageSpecifier, changed: false };
71
34
  }
72
- return { ok: true, stamp: expected };
35
+ const output = `---
36
+ source: '${packageSpecifier}'
37
+ ---
38
+ ${body}`;
39
+ mkdirSync(path.dirname(destinationPath), { recursive: true });
40
+ writeFileSync(destinationPath, output, "utf8");
41
+ return { path: destinationPath, packageSpecifier, changed: true };
42
+ }
43
+ function buildPackageSpecifier() {
44
+ return `${PACKAGE_NAME}@${VERSION}`;
45
+ }
46
+ function getDestinationPath(destinationDir) {
47
+ return path.join(destinationDir, DESTINATION_RELATIVE_PATH);
48
+ }
49
+ function getSourcePath() {
50
+ let dir = path.dirname(fileURLToPath(import.meta.url));
51
+ while (dir !== path.dirname(dir)) {
52
+ const candidate = path.join(dir, SOURCE_FILENAME);
53
+ if (existsSync(candidate)) return candidate;
54
+ dir = path.dirname(dir);
55
+ }
56
+ throw new Error(`Could not locate ${SOURCE_FILENAME} in any parent of ${fileURLToPath(import.meta.url)}`);
57
+ }
58
+ function stripFrontmatter(content) {
59
+ return content.replace(FRONTMATTER_REGEX, "");
73
60
  }
74
61
  export {
75
62
  check,
76
- parseSourceStamp,
77
63
  sync
78
64
  };
@@ -1,8 +1,8 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { CodeQualityPnpmWorkflowSchema, getPnpmVersion } from "../helpers/code-quality-pnpm-action.js";
3
+ import { parseDocument } from "yaml";
4
+ import { CodeQualityPnpmWorkflowSchema, getPnpmVersionNodes } from "../helpers/code-quality-pnpm-action.js";
4
5
  import { readPackageJson } from "../helpers/package-json.js";
5
- import { readYamlFile } from "../helpers/yaml-utils.js";
6
6
  const WORKFLOW_RELATIVE_PATH = ".github/workflows/code-quality.yaml";
7
7
  function extractPnpmVersion(packageManager) {
8
8
  if (!packageManager) {
@@ -23,18 +23,23 @@ packageManager field: ${pkg.packageManager ?? "(not set)"}`
23
23
  }
24
24
  console.info(`Package.json pnpm version: ${pnpmVersion}`);
25
25
  const workflowPath = path.join(monorepoRoot, WORKFLOW_RELATIVE_PATH);
26
- const workflowData = readYamlFile(workflowPath);
27
- const workflow = CodeQualityPnpmWorkflowSchema.parse(workflowData);
28
- const currentWorkflowVersion = getPnpmVersion(workflow);
29
- console.info(`Current workflow pnpm version: ${currentWorkflowVersion}`);
30
- if (currentWorkflowVersion === pnpmVersion) {
26
+ const doc = parseDocument(readFileSync(workflowPath, "utf8"));
27
+ const [parseError] = doc.errors;
28
+ if (parseError) {
29
+ throw new Error(`Failed to parse workflow file: ${workflowPath}
30
+ ${parseError.message}`);
31
+ }
32
+ CodeQualityPnpmWorkflowSchema.parse(doc.toJS());
33
+ const outdatedNodes = getPnpmVersionNodes(doc).filter((node) => String(node.value) !== pnpmVersion);
34
+ if (outdatedNodes.length === 0) {
31
35
  console.info("Workflow pnpm version is already up to date");
32
36
  return;
33
37
  }
34
- const originalContent = readFileSync(workflowPath, "utf8");
35
- const updatedContent = originalContent.replace(/(\s+pnpm-version:\s+)(['"]?)[\d.]+\2/, `$1$2${pnpmVersion}$2`);
36
- writeFileSync(workflowPath, updatedContent, "utf8");
37
- console.info(`\u2713 Updated workflow pnpm version: ${currentWorkflowVersion} \u2192 ${pnpmVersion}`);
38
+ for (const node of outdatedNodes) {
39
+ node.value = pnpmVersion;
40
+ }
41
+ writeFileSync(workflowPath, doc.toString(), "utf8");
42
+ console.info(`\u2713 Updated ${outdatedNodes.length} pnpm-version occurrence(s) \u2192 ${pnpmVersion}`);
38
43
  }
39
44
  export {
40
45
  extractPnpmVersion,
@@ -3,7 +3,7 @@ const commonWorkspaceScripts = {
3
3
  check: ["typecheck", "fmt:check", "lint:check", "test"],
4
4
  "check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage"],
5
5
  clean: "pnpm exec rimraf dist/*",
6
- compile: "tsx ../../config/build.ts",
6
+ compile: "nmr-compile",
7
7
  fix: ["lint", "fmt"],
8
8
  "fix:check": ["fmt:check", "lint:check"],
9
9
  fmt: "prettier --list-different --write .",
@@ -17,6 +17,7 @@ const commonWorkspaceScripts = {
17
17
  };
18
18
  const integrationTestScripts = {
19
19
  test: "pnpm exec vitest --config=vitest.standalone.config.ts",
20
+ "test:all": "pnpm exec vitest",
20
21
  "test:coverage": "pnpm exec vitest --config=vitest.standalone.config.ts --coverage",
21
22
  "test:integration": "pnpm exec vitest --config=vitest.integration.config.ts",
22
23
  "test:watch": "pnpm exec vitest --config=vitest.standalone.config.ts --watch"
package/dist/esm/help.js CHANGED
@@ -16,7 +16,6 @@ function generateHelp(config, packageDir, useRoot) {
16
16
  " -w, --workspace-root Run root command regardless of cwd",
17
17
  " -q, --quiet Suppress output on success; show full output on failure",
18
18
  " -?, --help Show this help",
19
- " --int-test Use integration test scripts",
20
19
  "",
21
20
  "Workspace commands:"
22
21
  ];
@@ -1,3 +1,4 @@
1
+ import { type Document, type Scalar } from 'yaml';
1
2
  import { z } from 'zod';
2
3
  export declare const CodeQualityPnpmWorkflowSchema: z.ZodObject<{
3
4
  jobs: z.ZodObject<{
@@ -9,4 +10,4 @@ export declare const CodeQualityPnpmWorkflowSchema: z.ZodObject<{
9
10
  }, z.core.$loose>;
10
11
  }, z.core.$loose>;
11
12
  export type CodeQualityPnpmWorkflow = z.infer<typeof CodeQualityPnpmWorkflowSchema>;
12
- export declare function getPnpmVersion(workflow: CodeQualityPnpmWorkflow): string;
13
+ export declare function getPnpmVersionNodes(doc: Document): Scalar[];
@@ -1,3 +1,4 @@
1
+ import { isScalar, visit } from "yaml";
1
2
  import { z } from "zod";
2
3
  const CodeQualityPnpmWorkflowSchema = z.looseObject({
3
4
  jobs: z.looseObject({
@@ -8,10 +9,18 @@ const CodeQualityPnpmWorkflowSchema = z.looseObject({
8
9
  })
9
10
  })
10
11
  });
11
- function getPnpmVersion(workflow) {
12
- return workflow.jobs["code-quality"].with["pnpm-version"];
12
+ 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;
13
22
  }
14
23
  export {
15
24
  CodeQualityPnpmWorkflowSchema,
16
- getPnpmVersion
25
+ getPnpmVersionNodes
17
26
  };
@@ -8,6 +8,7 @@ export interface ResolvedScript {
8
8
  export declare function expandScript(script: ScriptValue, workspaceRoot: boolean): string;
9
9
  export declare function describeScript(script: ScriptValue): string;
10
10
  export declare function readPackageJsonScripts(packageDir: string): Record<string, string> | undefined;
11
+ export declare function hasIntegrationTestConfig(packageDir: string): boolean;
11
12
  export declare function buildWorkspaceRegistry(config: NmrConfig, useIntTests: boolean): ScriptRegistry;
12
13
  export declare function buildRootRegistry(config: NmrConfig): ScriptRegistry;
13
14
  export declare function isSelfReferential(script: string, commandName: string): boolean;
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isObject } from "./helpers/type-guards.js";
4
4
  import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
@@ -54,6 +54,9 @@ function readPackageJsonScripts(packageDir) {
54
54
  throw error;
55
55
  }
56
56
  }
57
+ function hasIntegrationTestConfig(packageDir) {
58
+ return existsSync(path.join(packageDir, "vitest.integration.config.ts"));
59
+ }
57
60
  function buildWorkspaceRegistry(config, useIntTests) {
58
61
  return {
59
62
  ...getDefaultWorkspaceScripts(useIntTests),
@@ -92,6 +95,7 @@ export {
92
95
  buildWorkspaceRegistry,
93
96
  describeScript,
94
97
  expandScript,
98
+ hasIntegrationTestConfig,
95
99
  isSelfReferential,
96
100
  readPackageJsonScripts,
97
101
  resolveScript
@@ -1,17 +1,22 @@
1
1
  import path from "node:path";
2
- import { readPackageVersion } from "@williamthorsen/nmr-core";
2
+ import { readPackageVersion, reportError } from "@williamthorsen/nmr-core";
3
3
  import { resolveContext } from "./context.js";
4
4
  import { generateHelp } from "./help.js";
5
5
  import { isHookName } from "./helpers/hook-name.js";
6
- import { applyDevBin, buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
6
+ import {
7
+ applyDevBin,
8
+ buildRootRegistry,
9
+ buildWorkspaceRegistry,
10
+ hasIntegrationTestConfig,
11
+ resolveScript
12
+ } from "./resolver.js";
7
13
  import { runCommand } from "./runner.js";
8
14
  const VERSION = readPackageVersion(import.meta.url);
9
15
  async function runCli(options) {
10
16
  const { args, cwd, env, stdout, stderr } = options;
11
17
  const parseResult = parseArgs(args);
12
18
  if (!parseResult.ok) {
13
- stderr.write(`${parseResult.error}
14
- `);
19
+ reportError(parseResult.error, stderr);
15
20
  return { exitCode: 1 };
16
21
  }
17
22
  const { parsed } = parseResult;
@@ -42,14 +47,13 @@ async function runCli(options) {
42
47
  const exitCode2 = runCommand(delegateCmd, context.monorepoRoot, { ...runOptions, env: childEnv });
43
48
  return { exitCode: exitCode2 };
44
49
  }
45
- const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config, parsed.intTest);
50
+ const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config, hasIntegrationTestConfig(packageDir));
46
51
  const resolved = resolveScript(command, registry, packageDir, parsed.workspaceRoot);
47
52
  if (!resolved) {
48
53
  if (env.NMR_RUN_IF_PRESENT === "1") {
49
54
  return { exitCode: 0 };
50
55
  }
51
- stderr.write(`Unknown command: ${command}
52
- `);
56
+ reportError(`Unknown command: ${command}`, stderr);
53
57
  return { exitCode: 1 };
54
58
  }
55
59
  const skipExitCode = handleSkipMessage(resolved.command, packageDir, parsed.quiet, stdout);
@@ -74,7 +78,6 @@ function parseArgs(args) {
74
78
  workspaceRoot: false,
75
79
  help: false,
76
80
  version: false,
77
- intTest: false,
78
81
  passthrough: []
79
82
  };
80
83
  let i = 0;
@@ -85,7 +88,7 @@ function parseArgs(args) {
85
88
  i++;
86
89
  const filterValue = args[i];
87
90
  if (filterValue === void 0) {
88
- return { ok: false, error: "Error: -F/--filter requires a pattern argument" };
91
+ return { ok: false, error: "-F/--filter requires a pattern argument" };
89
92
  }
90
93
  parsed.filter = filterValue;
91
94
  i++;
@@ -116,11 +119,6 @@ function parseArgs(args) {
116
119
  i++;
117
120
  continue;
118
121
  }
119
- if (arg === "--int-test") {
120
- parsed.intTest = true;
121
- i++;
122
- continue;
123
- }
124
122
  parsed.command = arg;
125
123
  parsed.passthrough = args.slice(i + 1);
126
124
  break;
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import process from "node:process";
3
+ import { reportError } from "@williamthorsen/nmr-core";
3
4
  function runCommand(command, cwd, options) {
4
5
  const quiet = options?.quiet === true;
5
6
  const stdout = options?.stdout ?? process.stdout;
@@ -14,8 +15,7 @@ function runCommand(command, cwd, options) {
14
15
  env
15
16
  });
16
17
  if (result.error) {
17
- stderr.write(`${result.error.message}
18
- `);
18
+ reportError(result.error.message, stderr);
19
19
  return 1;
20
20
  }
21
21
  if (quiet) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.14.2",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for PNPM monorepos",
6
6
  "keywords": [
@@ -29,6 +29,7 @@
29
29
  "bin": {
30
30
  "ensure-prepublish-hooks": "bin/ensure-prepublish-hooks.js",
31
31
  "nmr": "bin/nmr.js",
32
+ "nmr-compile": "bin/nmr-compile.js",
32
33
  "nmr-report-overrides": "bin/nmr-report-overrides.js",
33
34
  "nmr-sync-agent-files": "bin/nmr-sync-agent-files.js",
34
35
  "nmr-sync-pnpm-version": "bin/nmr-sync-pnpm-version.js"
@@ -39,10 +40,15 @@
39
40
  "dist"
40
41
  ],
41
42
  "dependencies": {
43
+ "esbuild": "0.28.1",
44
+ "glob": "13.0.6",
42
45
  "jiti": "2.7.0",
43
46
  "yaml": "2.9.0",
44
47
  "zod": "4.4.3",
45
- "@williamthorsen/nmr-core": "0.3.2"
48
+ "@williamthorsen/nmr-core": "0.5.0"
49
+ },
50
+ "devDependencies": {
51
+ "@williamthorsen/toolbelt.strings": "3.1.4"
46
52
  },
47
53
  "publishConfig": {
48
54
  "access": "public"
@@ -1 +0,0 @@
1
- export declare function readYamlFile(filepath: string): unknown;
@@ -1,14 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { parse } from "yaml";
3
- function readYamlFile(filepath) {
4
- try {
5
- const content = readFileSync(filepath, "utf8");
6
- return parse(content);
7
- } catch (error) {
8
- throw new Error(`Failed to read YAML file: ${filepath}
9
- ${error instanceof Error ? error.message : String(error)}`);
10
- }
11
- }
12
- export {
13
- readYamlFile
14
- };