@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 +16 -16
- package/README.md +33 -3
- package/bin/nmr-compile.js +11 -0
- package/dist/esm/.cache +1 -1
- package/dist/esm/cli-build.d.ts +1 -0
- package/dist/esm/cli-build.js +7 -0
- package/dist/esm/cli-sync-agent-files.js +23 -16
- package/dist/esm/commands/build.d.ts +15 -0
- package/dist/esm/commands/build.js +122 -0
- package/dist/esm/commands/sync-agent-files.d.ts +4 -5
- package/dist/esm/commands/sync-agent-files.js +37 -51
- package/dist/esm/commands/sync-pnpm-version.js +16 -11
- package/dist/esm/context.js +2 -2
- package/dist/esm/default-scripts.js +1 -1
- package/dist/esm/helpers/code-quality-pnpm-action.d.ts +2 -1
- package/dist/esm/helpers/code-quality-pnpm-action.js +12 -3
- package/dist/esm/tests/helpers/get-string-from-yaml-file.js +2 -2
- package/package.json +9 -3
- package/dist/esm/helpers/yaml-utils.d.ts +0 -1
- package/dist/esm/helpers/yaml-utils.js +0 -14
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
48
|
-
- `":"
|
|
49
|
-
- Any other string
|
|
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
|
|
58
|
-
- **Skip overrides apply to hooks
|
|
59
|
-
- **Skipping the main command skips its hooks
|
|
60
|
-
- **Recursion guard
|
|
61
|
-
- **Passthrough args attach only to the main command
|
|
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
|
|
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` | `
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
d2f01850cfe4f42bf00cb8b8eaf3d3fab438916c419957842d927d22fb9d67cf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 (
|
|
17
|
-
const {
|
|
18
|
-
console.info(
|
|
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(
|
|
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
|
|
1
|
+
export declare function check(destinationDir: string): CheckResult;
|
|
2
2
|
export interface SyncResult {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
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:
|
|
23
|
+
reason: `${DESTINATION_RELATIVE_PATH} content is out of date. Run \`nmr sync-agent-files\`.`
|
|
64
24
|
};
|
|
65
25
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,
|
package/dist/esm/context.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import
|
|
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 =
|
|
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: "
|
|
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
|
|
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
|
|
12
|
-
|
|
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
|
-
|
|
25
|
+
getPnpmVersionNodes
|
|
17
26
|
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import
|
|
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 =
|
|
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.
|
|
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
|
-
"
|
|
46
|
+
"yaml": "2.9.0",
|
|
44
47
|
"zod": "4.4.3",
|
|
45
|
-
"@williamthorsen/nmr-core": "0.
|
|
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
|
-
};
|