@williamthorsen/nmr 0.14.1 → 0.15.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,29 @@
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.15.0 (2026-06-27)
7
+
8
+ ### 🎉 Features
9
+
10
+ - Centralize the per-package build as an nmr-compile bin (#419)
11
+
12
+ Introduces `nmr-compile`, a single command shipped with `@williamthorsen/nmr` that compiles each workspace package and now backs the default build. Consuming repos can delete their own per-package build script and pick up future build fixes just by upgrading nmr. Repeated builds with unchanged source now reliably skip recompiling instead of occasionally rebuilding for no reason, and import aliases now resolve correctly in symlinked checkouts.
13
+
14
+ ### 🐛 Bug fixes
15
+
16
+ - Write the build cache only after a successful compile (#421)
17
+
18
+ Fixes an issue where a package compile that failed partway — from a crash, disk error, or transient build failure — could leave stale or incomplete output that the next build skipped over as unchanged. The next build now retries the failed compile, so recovering no longer requires a manual `nmr clean` or a source-file edit.
19
+
20
+ - Stop sync-agent-files --check failing on version-only bumps (#424)
21
+
22
+ Fixes an issue where `nmr sync-agent-files --check` failed after an nmr upgrade even when the managed agent guidance was identical. The check now passes whenever the guidance content is current, even if the version has changed.
23
+
24
+ - Remove inapplicable bootstrap fallback from agent guidance (#425)
25
+
26
+ Fixes an issue where the agent guidance bundled with nmr told coding agents to run a recovery step that exists only in the nmr repo itself.
27
+ <!-- /section:release-notes -->
6
28
 
7
29
  ## Installation
8
30
 
@@ -137,7 +159,7 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
137
159
  | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
138
160
  | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
139
161
  | `clean` | `pnpm exec rimraf dist/*` |
140
- | `compile` | `tsx ../../config/build.ts` |
162
+ | `compile` | `nmr-compile` |
141
163
  | `fix` | `lint`, `fmt` |
142
164
  | `fix:check` | `fmt:check`, `lint:check` |
143
165
  | `fmt` | `prettier --list-different --write .` |
@@ -321,7 +343,7 @@ The default root `check:strict` composite includes `check:agent-files`, which ru
321
343
  To expose the synced guidance to Claude Code sessions, add this include to the consuming repo's `.agents/PROJECT.md`:
322
344
 
323
345
  ```markdown
324
- @.agents/nmr/AGENTS.md
346
+ @nmr/AGENTS.md
325
347
  ```
326
348
 
327
349
  ### `sync-pnpm-version`
@@ -334,6 +356,14 @@ nmr sync-pnpm-version
334
356
 
335
357
  ## Standalone utilities
336
358
 
359
+ ### `nmr-compile`
360
+
361
+ 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.
362
+
363
+ ```bash
364
+ nmr-compile
365
+ ```
366
+
337
367
  ### `ensure-prepublish-hooks`
338
368
 
339
369
  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
- 100ea1821185a3e234d52dd9a44609bf7ead7593a0e877d8d1c331754f7883a4
1
+ d2f01850cfe4f42bf00cb8b8eaf3d3fab438916c419957842d927d22fb9d67cf
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import { buildPackage } from "./commands/build.js";
2
+ try {
3
+ await buildPackage(process.cwd());
4
+ } catch (error) {
5
+ console.error(error instanceof Error ? error.message : String(error));
6
+ process.exit(1);
7
+ }
@@ -1,31 +1,38 @@
1
+ import { parseArgs, translateParseError } 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));
9
8
  try {
10
- const parsed = parseArgs(process.argv);
11
- if ("error" in parsed) {
12
- console.error(parsed.error);
9
+ runCommand(flags.check);
10
+ } catch (error) {
11
+ console.error(error instanceof Error ? error.message : String(error));
12
+ process.exit(1);
13
+ }
14
+ function parseArgsOrExit(argv) {
15
+ try {
16
+ return parseArgs(argv, flagSchema);
17
+ } catch (error) {
18
+ console.error(`Error: ${translateParseError(error)}`);
13
19
  process.exit(1);
14
20
  }
21
+ }
22
+ function runCommand(checkOnly) {
15
23
  const monorepoRoot = findMonorepoRoot();
16
- if (parsed.mode === "sync") {
17
- const { written, stamp } = sync(monorepoRoot);
18
- console.info(`\u2713 Wrote ${written} (${stamp})`);
24
+ if (!checkOnly) {
25
+ const { path: destinationPath, packageSpecifier, changed } = sync(monorepoRoot);
26
+ console.info(
27
+ changed ? `\u2713 Wrote ${destinationPath} (${packageSpecifier})` : `\u2713 ${destinationPath} already up to date`
28
+ );
19
29
  process.exit(0);
20
30
  }
21
31
  const result = check(monorepoRoot);
22
32
  if (result.ok) {
23
- console.info(`\u2713 .agents/nmr/AGENTS.md is in sync (${result.stamp})`);
33
+ console.info("\u2713 .agents/nmr/AGENTS.md is in sync");
24
34
  process.exit(0);
25
35
  }
26
36
  console.error(result.reason);
27
37
  process.exit(1);
28
- } catch (error) {
29
- console.error(error instanceof Error ? error.message : String(error));
30
- process.exit(1);
31
38
  }
@@ -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,
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
- import yaml from "js-yaml";
3
+ import { parse } from "yaml";
4
4
  import { loadConfig } from "./config.js";
5
5
  import { isObject } from "./helpers/type-guards.js";
6
6
  function findMonorepoRoot(startDir) {
@@ -19,7 +19,7 @@ function findMonorepoRoot(startDir) {
19
19
  function getWorkspacePackageDirs(monorepoRoot) {
20
20
  const workspaceFile = path.join(monorepoRoot, "pnpm-workspace.yaml");
21
21
  const content = readFileSync(workspaceFile, "utf8");
22
- const parsed = yaml.load(content);
22
+ const parsed = parse(content);
23
23
  const packages = getPackagesFromParsedYaml(parsed);
24
24
  if (!packages) {
25
25
  return [];
@@ -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 .",
@@ -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
  };
@@ -1,10 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import fs from "node:fs";
3
- import yaml from "js-yaml";
3
+ import { parse } from "yaml";
4
4
  import { getValueAtPathOrThrow } from "./get-value-at-path.js";
5
5
  async function getStringFromYamlFile(filePath, keyPath, label) {
6
6
  const raw = await fs.promises.readFile(filePath, { encoding: "utf8" });
7
- const parsed = yaml.load(raw);
7
+ const parsed = parse(raw);
8
8
  assert.ok(parsed, `YAML content not found in ${filePath}`);
9
9
  const value = getValueAtPathOrThrow(parsed, keyPath);
10
10
  assert.ok(typeof value === "string" && value.length > 0, `${label} not found at ${keyPath}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.14.1",
3
+ "version": "0.15.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
- "js-yaml": "4.1.1",
46
+ "yaml": "2.9.0",
44
47
  "zod": "4.4.3",
45
- "@williamthorsen/nmr-core": "0.3.2"
48
+ "@williamthorsen/nmr-core": "0.4.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 yaml from "js-yaml";
3
- function readYamlFile(filepath) {
4
- try {
5
- const content = readFileSync(filepath, "utf8");
6
- return yaml.load(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
- };