@williamthorsen/nmr 0.12.1 → 0.14.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,7 +4,7 @@ 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 `pnpm exec nmr sync-agent-files` after an nmr upgrade to refresh it.
7
+ This file is managed by `@williamthorsen/nmr`. Do not edit — re-run `nmr sync-agent-files` after an nmr upgrade to refresh it.
8
8
 
9
9
  ## Discover scripts by running nmr
10
10
 
@@ -13,8 +13,20 @@ Run `nmr` with no command (from the monorepo root or any workspace package) to l
13
13
  ## Invocation rules
14
14
 
15
15
  - Use `nmr <command>` for anything nmr provides. Do not use `pnpm run <command>`.
16
- - Use `pnpm exec nmr`, not `npx nmr`. Inside git worktrees, `npx` can resolve a different nmr binary from outside the working tree.
17
- - If `nmr` itself fails to run (fresh clone, missing build output), run `pnpm run bootstrap` from the repo root first.
16
+ - You can invoke nmr from the monorepo root or any workspace package.
17
+
18
+ ### How to make `nmr` resolvable
19
+
20
+ `nmr` ships as a workspace bin. The bare `nmr` command works only when your shell can find `<root>/node_modules/.bin/nmr`. Choose one:
21
+
22
+ - **direnv** (recommended for contributors). With [direnv](https://direnv.net/) installed, the repo's `.envrc` adds `node_modules/.bin` to your `PATH` automatically. From any subdirectory, bare `nmr` works.
23
+ - **`pnpm exec nmr <command>`** — works without setup. pnpm resolves the bin from the workspace root.
24
+
25
+ Avoid `npx nmr`. Inside git worktrees, `npx` can resolve a different nmr binary from outside the working tree.
26
+
27
+ ### Bootstrap fallback
28
+
29
+ If `nmr` itself fails to run (fresh clone, missing build output), run `pnpm run bootstrap` from the repo root first.
18
30
 
19
31
  ## Root vs. workspace context
20
32
 
@@ -36,6 +48,42 @@ In `.config/nmr.config.ts` or a package's `package.json`, override values have s
36
48
  - `":"` — no-op; exit 0. Prefer this over `""` if your repo enforces non-empty script values.
37
49
  - Any other string — runs in place of the default.
38
50
 
51
+ ## Pre and post hooks
52
+
53
+ Every `nmr X` invocation auto-wraps as the equivalent of `nmr X:pre && nmr X && nmr X:post`. Hooks are first-class scripts that resolve through the same 3-tier registry (built-in defaults → `.config/nmr.config.ts` → per-package `package.json`). Wrapping is uniform; nested invocations from composite expansion get their own hook treatment. Hook failure short-circuits the chain via shell `&&` semantics, propagating the failing exit code.
54
+
55
+ Behaviors worth knowing:
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`.
62
+
63
+ Worked examples:
64
+
65
+ ```ts
66
+ // .config/nmr.config.ts — extend `nmr build` with a pre-build compile step
67
+ import { defineConfig } from '@williamthorsen/nmr';
68
+
69
+ export default defineConfig({
70
+ workspaceScripts: {
71
+ 'build:pre': 'npx rdy compile',
72
+ },
73
+ });
74
+ ```
75
+
76
+ ```jsonc
77
+ // packages/nmr/package.json — re-stamp .agents/nmr/AGENTS.md after every build
78
+ {
79
+ "scripts": {
80
+ "build:post": "nmr-sync-agent-files",
81
+ },
82
+ }
83
+ ```
84
+
85
+ The second example calls the bin directly to sidestep the workspace-vs-root registry distinction.
86
+
39
87
  ## Agent-file sync
40
88
 
41
89
  The presence and version stamp of `.agents/nmr/AGENTS.md` is verified by `check:agent-files`, which is part of the default root `check:strict` composite. If it fails, run `nmr sync-agent-files`.
package/README.md CHANGED
@@ -220,11 +220,11 @@ Packages with a `vitest.integration.config.ts` file get different test commands.
220
220
 
221
221
  #### Audit
222
222
 
223
- | Command | Runs |
224
- | ------------ | ----------------------------- |
225
- | `audit` | `audit:prod`, `audit:dev` |
226
- | `audit:dev` | `pnpm exec audit-deps --dev` |
227
- | `audit:prod` | `pnpm exec audit-deps --prod` |
223
+ | Command | Runs |
224
+ | ------------ | ------------------------- |
225
+ | `audit` | `audit:prod`, `audit:dev` |
226
+ | `audit:dev` | `pnpm exec v11y --dev` |
227
+ | `audit:prod` | `pnpm exec v11y --prod` |
228
228
 
229
229
  #### Dependencies
230
230
 
package/dist/esm/.cache CHANGED
@@ -1 +1 @@
1
- ecd2594bf00be365cf1c1d58937d98631236e5a8841aade1fcaf6bb190cbf84b
1
+ 2d3864d638817ab1f6e49b7d20cb118260518782ce8c8c372346c837f72edadc
package/dist/esm/cli.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
+ import { readPackageVersion } from "@williamthorsen/nmr-core";
3
4
  import { resolveContext } from "./context.js";
4
5
  import { generateHelp } from "./help.js";
6
+ import { isHookName } from "./helpers/hook-name.js";
5
7
  import { applyDevBin, buildRootRegistry, buildWorkspaceRegistry, resolveScript } from "./resolver.js";
6
8
  import { runCommand } from "./runner.js";
7
- import { VERSION } from "./version.js";
9
+ const VERSION = readPackageVersion(import.meta.url);
8
10
  function shellQuote(arg) {
9
11
  return "'" + arg.replace(/'/g, String.raw`'\''`) + "'";
10
12
  }
@@ -77,8 +79,10 @@ async function main() {
77
79
  process.exit(0);
78
80
  }
79
81
  const context = await resolveContext();
82
+ const useRoot = parsed.workspaceRoot || context.isRoot;
83
+ const packageDir = useRoot ? context.monorepoRoot : context.packageDir ?? context.monorepoRoot;
80
84
  if (parsed.help || !parsed.command) {
81
- console.info(generateHelp(context.config));
85
+ console.info(generateHelp(context.config, packageDir, useRoot));
82
86
  process.exit(0);
83
87
  }
84
88
  const { command } = parsed;
@@ -95,10 +99,8 @@ async function main() {
95
99
  const code2 = runCommand(delegateCmd, context.monorepoRoot, runOptions);
96
100
  process.exit(code2);
97
101
  }
98
- const useRoot = parsed.workspaceRoot || context.isRoot;
99
102
  const registry = useRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config, parsed.intTest);
100
- const packageDir = context.packageDir ?? context.monorepoRoot;
101
- const resolved = resolveScript(command, registry, packageDir);
103
+ const resolved = resolveScript(command, registry, packageDir, parsed.workspaceRoot);
102
104
  if (!resolved) {
103
105
  if (process.env.NMR_RUN_IF_PRESENT === "1") {
104
106
  process.exit(0);
@@ -106,27 +108,52 @@ async function main() {
106
108
  console.error(`Unknown command: ${command}`);
107
109
  process.exit(1);
108
110
  }
109
- const packageName = path.basename(packageDir);
110
- if (resolved.command === "") {
111
- if (!parsed.quiet) {
112
- console.info(`\u26D4 ${packageName}: Override script is defined but empty. Skipping.`);
113
- }
114
- process.exit(0);
115
- }
116
- if (resolved.command === ":") {
117
- if (!parsed.quiet) {
118
- console.info(`\u26D4 ${packageName}: Override script is a no-op. Skipping.`);
119
- }
120
- process.exit(0);
111
+ const skipExitCode = handleSkipMessage(resolved.command, packageDir, parsed.quiet);
112
+ if (skipExitCode !== void 0) {
113
+ process.exit(skipExitCode);
121
114
  }
122
115
  if (resolved.source === "package" && !parsed.quiet && registry[command] !== void 0) {
123
- console.info(`\u{1F4E6} ${packageName}: Using override script: ${resolved.command}`);
116
+ console.info(`\u{1F4E6} ${path.basename(packageDir)}: Using override script: ${resolved.command}`);
124
117
  }
125
118
  const substitutedCommand = applyDevBin(resolved.command, context.config.devBin, context.monorepoRoot);
126
- const fullCommand = substitutedCommand + passthrough;
119
+ const mainCommand = substitutedCommand + passthrough;
120
+ const isHookInvocation = isHookName(command);
121
+ const fullCommand = isHookInvocation ? mainCommand : wrapWithHooks(command, mainCommand, registry, packageDir, parsed.workspaceRoot);
127
122
  const code = runCommand(fullCommand, void 0, runOptions);
128
123
  process.exit(code);
129
124
  }
125
+ function handleSkipMessage(resolvedCommand, packageDir, quiet) {
126
+ if (resolvedCommand === "") {
127
+ if (!quiet) {
128
+ console.info(`\u26D4 ${path.basename(packageDir)}: Override script is defined but empty. Skipping.`);
129
+ }
130
+ return 0;
131
+ }
132
+ if (resolvedCommand === ":") {
133
+ if (!quiet) {
134
+ console.info(`\u26D4 ${path.basename(packageDir)}: Override script is a no-op. Skipping.`);
135
+ }
136
+ return 0;
137
+ }
138
+ return void 0;
139
+ }
140
+ function wrapWithHooks(command, mainCommand, registry, packageDir, workspaceRoot) {
141
+ const segments = [];
142
+ const flag = workspaceRoot ? "-w " : "";
143
+ if (hasRunnableHook(`${command}:pre`, registry, packageDir, workspaceRoot)) {
144
+ segments.push(`nmr ${flag}${command}:pre`);
145
+ }
146
+ segments.push(mainCommand);
147
+ if (hasRunnableHook(`${command}:post`, registry, packageDir, workspaceRoot)) {
148
+ segments.push(`nmr ${flag}${command}:post`);
149
+ }
150
+ return segments.join(" && ");
151
+ }
152
+ function hasRunnableHook(hookName, registry, packageDir, workspaceRoot) {
153
+ const resolved = resolveScript(hookName, registry, packageDir, workspaceRoot);
154
+ if (!resolved) return false;
155
+ return resolved.command !== "" && resolved.command !== ":";
156
+ }
130
157
  try {
131
158
  await main();
132
159
  } catch (error) {
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { VERSION } from "../version.js";
4
+ import { readPackageVersion } from "@williamthorsen/nmr-core";
5
+ const VERSION = readPackageVersion(import.meta.url);
5
6
  const PACKAGE_NAME = "@williamthorsen/nmr";
6
7
  const DESTINATION_RELATIVE_PATH = ".agents/nmr/AGENTS.md";
7
8
  const SOURCE_FILENAME = "AGENTS.md";
@@ -28,8 +28,8 @@ const standardTestScripts = {
28
28
  };
29
29
  const rootScripts = {
30
30
  audit: ["audit:prod", "audit:dev"],
31
- "audit:dev": "pnpm exec audit-deps --dev",
32
- "audit:prod": "pnpm exec audit-deps --prod",
31
+ "audit:dev": "pnpm exec v11y --dev",
32
+ "audit:prod": "pnpm exec v11y --prod",
33
33
  build: "pnpm --recursive exec nmr build",
34
34
  check: ["typecheck", "fmt:check", "lint:check", "test"],
35
35
  "check:agent-files": "nmr-sync-agent-files --check",
@@ -1,2 +1,2 @@
1
1
  import type { NmrConfig } from './config.js';
2
- export declare function generateHelp(config: NmrConfig): string;
2
+ export declare function generateHelp(config: NmrConfig, packageDir: string | undefined, useRoot: boolean): string;
package/dist/esm/help.js CHANGED
@@ -1,6 +1,12 @@
1
- import { buildRootRegistry, buildWorkspaceRegistry } from "./resolver.js";
2
- import { describeScript } from "./resolver.js";
3
- function generateHelp(config) {
1
+ import { isHookName } from "./helpers/hook-name.js";
2
+ import {
3
+ buildRootRegistry,
4
+ buildWorkspaceRegistry,
5
+ describeScript,
6
+ isSelfReferential,
7
+ readPackageJsonScripts
8
+ } from "./resolver.js";
9
+ function generateHelp(config, packageDir, useRoot) {
4
10
  const lines = [
5
11
  "Usage: nmr [flags] <command> [args...]",
6
12
  "",
@@ -14,16 +20,58 @@ function generateHelp(config) {
14
20
  "",
15
21
  "Workspace commands:"
16
22
  ];
17
- formatRegistry(buildWorkspaceRegistry(config, false), lines);
23
+ const overrides = packageDir === void 0 ? {} : collectOverrides(packageDir);
24
+ let hadOverride = false;
25
+ const workspaceRegistry = filterHooks(buildWorkspaceRegistry(config, false));
26
+ const workspaceMarked = !useRoot ? applyOverrides(workspaceRegistry, overrides) : /* @__PURE__ */ new Set();
27
+ if (workspaceMarked.size > 0) hadOverride = true;
28
+ formatRegistry(workspaceRegistry, workspaceMarked, lines);
18
29
  lines.push("", "Root commands:");
19
- formatRegistry(buildRootRegistry(config), lines);
30
+ const rootRegistry = filterHooks(buildRootRegistry(config));
31
+ const rootMarked = useRoot ? applyOverrides(rootRegistry, overrides) : /* @__PURE__ */ new Set();
32
+ if (rootMarked.size > 0) hadOverride = true;
33
+ formatRegistry(rootRegistry, rootMarked, lines);
34
+ if (hadOverride) {
35
+ lines.push("", "* Overridden by package.json");
36
+ }
20
37
  return lines.join("\n");
21
38
  }
22
- function formatRegistry(registry, lines) {
23
- const maxKeyLen = Math.max(...Object.keys(registry).map((k) => k.length));
24
- const pad = Math.max(maxKeyLen + 2, 20);
39
+ function collectOverrides(packageDir) {
40
+ const scripts = readPackageJsonScripts(packageDir);
41
+ if (!scripts) return {};
42
+ const filtered = {};
43
+ for (const [name, value] of Object.entries(scripts)) {
44
+ if (isHookName(name)) continue;
45
+ if (isSelfReferential(value, name)) continue;
46
+ filtered[name] = value;
47
+ }
48
+ return filtered;
49
+ }
50
+ function applyOverrides(registry, overrides) {
51
+ const marked = /* @__PURE__ */ new Set();
52
+ for (const [name, value] of Object.entries(overrides)) {
53
+ if (name in registry) {
54
+ registry[name] = value;
55
+ marked.add(name);
56
+ }
57
+ }
58
+ return marked;
59
+ }
60
+ function filterHooks(registry) {
61
+ const filtered = {};
62
+ for (const [key, value] of Object.entries(registry)) {
63
+ if (!isHookName(key)) filtered[key] = value;
64
+ }
65
+ return filtered;
66
+ }
67
+ function formatRegistry(registry, marked, lines) {
68
+ const keys = Object.keys(registry);
69
+ if (keys.length === 0) return;
70
+ const maxKeyLen = Math.max(...keys.map((k) => k.length));
71
+ const pad = Math.max(maxKeyLen + 1 + 2, 20);
25
72
  for (const [key, value] of Object.entries(registry)) {
26
- lines.push(` ${key.padEnd(pad)} ${describeScript(value)}`);
73
+ const marker = marked.has(key) ? "*" : " ";
74
+ lines.push(` ${(key + marker).padEnd(pad)} ${describeScript(value)}`);
27
75
  }
28
76
  }
29
77
  export {
@@ -0,0 +1 @@
1
+ export declare function isHookName(key: string): boolean;
@@ -0,0 +1,6 @@
1
+ function isHookName(key) {
2
+ return key.length > 4 && key.endsWith(":pre") || key.length > 5 && key.endsWith(":post");
3
+ }
4
+ export {
5
+ isHookName
6
+ };
@@ -5,8 +5,10 @@ export interface ResolvedScript {
5
5
  command: string;
6
6
  source: 'default' | 'package';
7
7
  }
8
- export declare function expandScript(script: ScriptValue): string;
8
+ export declare function expandScript(script: ScriptValue, workspaceRoot: boolean): string;
9
9
  export declare function describeScript(script: ScriptValue): string;
10
+ export declare function readPackageJsonScripts(packageDir: string): Record<string, string> | undefined;
10
11
  export declare function buildWorkspaceRegistry(config: NmrConfig, useIntTests: boolean): ScriptRegistry;
11
12
  export declare function buildRootRegistry(config: NmrConfig): ScriptRegistry;
12
- export declare function resolveScript(commandName: string, registry: ScriptRegistry, packageDir?: string): ResolvedScript | undefined;
13
+ export declare function isSelfReferential(script: string, commandName: string): boolean;
14
+ export declare function resolveScript(commandName: string, registry: ScriptRegistry, packageDir: string | undefined, workspaceRoot: boolean): ResolvedScript | undefined;
@@ -25,11 +25,12 @@ function resolveReplacementPaths(replacement, monorepoRoot) {
25
25
  return path.resolve(monorepoRoot, token);
26
26
  }).join(" ");
27
27
  }
28
- function expandScript(script) {
28
+ function expandScript(script, workspaceRoot) {
29
29
  if (typeof script === "string") {
30
30
  return script;
31
31
  }
32
- return script.map((s) => `nmr ${s}`).join(" && ");
32
+ const flag = workspaceRoot ? "-w " : "";
33
+ return script.map((s) => `nmr ${flag}${s}`).join(" && ");
33
34
  }
34
35
  function describeScript(script) {
35
36
  return typeof script === "string" ? script : `[${script.join(", ")}]`;
@@ -69,7 +70,7 @@ function isSelfReferential(script, commandName) {
69
70
  const prefix = `nmr ${commandName}`;
70
71
  return script === prefix || script.startsWith(`${prefix} `);
71
72
  }
72
- function resolveScript(commandName, registry, packageDir) {
73
+ function resolveScript(commandName, registry, packageDir, workspaceRoot) {
73
74
  if (packageDir) {
74
75
  const pkgScripts = readPackageJsonScripts(packageDir);
75
76
  if (pkgScripts && commandName in pkgScripts) {
@@ -83,7 +84,7 @@ function resolveScript(commandName, registry, packageDir) {
83
84
  if (registryEntry === void 0) {
84
85
  return void 0;
85
86
  }
86
- return { command: expandScript(registryEntry), source: "default" };
87
+ return { command: expandScript(registryEntry, workspaceRoot), source: "default" };
87
88
  }
88
89
  export {
89
90
  applyDevBin,
@@ -91,5 +92,7 @@ export {
91
92
  buildWorkspaceRegistry,
92
93
  describeScript,
93
94
  expandScript,
95
+ isSelfReferential,
96
+ readPackageJsonScripts,
94
97
  resolveScript
95
98
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for PNPM monorepos",
6
6
  "keywords": [
@@ -39,12 +39,15 @@
39
39
  "dist"
40
40
  ],
41
41
  "dependencies": {
42
- "jiti": "2.6.1",
42
+ "jiti": "2.7.0",
43
43
  "js-yaml": "4.1.1",
44
- "zod": "4.4.1"
44
+ "zod": "4.4.3",
45
+ "@williamthorsen/nmr-core": "0.3.1"
45
46
  },
46
47
  "publishConfig": {
47
48
  "access": "public"
48
49
  },
49
- "scripts": {}
50
+ "scripts": {
51
+ "build:post": "nmr-sync-agent-files"
52
+ }
50
53
  }
@@ -1 +0,0 @@
1
- export declare const VERSION = "0.12.1";
@@ -1,4 +0,0 @@
1
- const VERSION = "0.12.1";
2
- export {
3
- VERSION
4
- };