@williamthorsen/nmr 0.12.1 → 0.13.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
@@ -36,6 +36,42 @@ In `.config/nmr.config.ts` or a package's `package.json`, override values have s
36
36
  - `":"` — no-op; exit 0. Prefer this over `""` if your repo enforces non-empty script values.
37
37
  - Any other string — runs in place of the default.
38
38
 
39
+ ## Pre and post hooks
40
+
41
+ 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.
42
+
43
+ Behaviors worth knowing:
44
+
45
+ - **Silent when absent** — missing hooks produce no error and no output.
46
+ - **Skip overrides apply to hooks** — a hook value of `""` or `":"` is treated the same as not defining the hook. No console message.
47
+ - **Skipping the main command skips its hooks** — when `X` is overridden to `""` or `":"`, neither `X:pre` nor `X:post` fires.
48
+ - **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`.
49
+ - **Passthrough args attach only to the main command** — `nmr X --flag value` runs hooks without `--flag value`.
50
+
51
+ Worked examples:
52
+
53
+ ```ts
54
+ // .config/nmr.config.ts — extend `nmr build` with a pre-build compile step
55
+ import { defineConfig } from '@williamthorsen/nmr';
56
+
57
+ export default defineConfig({
58
+ workspaceScripts: {
59
+ 'build:pre': 'npx rdy compile',
60
+ },
61
+ });
62
+ ```
63
+
64
+ ```jsonc
65
+ // packages/nmr/package.json — re-stamp .agents/nmr/AGENTS.md after every build
66
+ {
67
+ "scripts": {
68
+ "build:post": "nmr-sync-agent-files",
69
+ },
70
+ }
71
+ ```
72
+
73
+ The second example calls the bin directly to sidestep the workspace-vs-root registry distinction.
74
+
39
75
  ## Agent-file sync
40
76
 
41
77
  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
@@ -2,7 +2,25 @@
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.13.0 (2026-05-04)
7
+
8
+ ### 🎉 Features
9
+
10
+ - Add :pre and :post hook conventions to nmr commands (#339)
11
+
12
+ Adds `:pre` and `:post` hook conventions to nmr's command runner. Consumers can declare `X:pre` or `X:post` scripts that nmr runs automatically before and after script `X`. The hook commands are optional and ignored if missing. Hooks fire even when the main command is overridden, and direct invocations like `nmr build:pre` are treated as leaf operations rather than recursively cascading into `build:pre:pre` lookups.
13
+
14
+ - Filter `nmr --help` to nmr commands (#348)
15
+
16
+ Restricts `nmr --help` to nmr commands only. Hook scripts (names ending in `:pre` or `:post`) are now hidden from help, and generic `package.json` lifecycle scripts (`prepare`, `postinstall`, `bootstrap`) no longer appear. When a `package.json` script overrides a built-in nmr command, the override is shown inline alongside the command name with a `*` marker, and a single `* Overridden by package.json` footnote is appended whenever any marker is rendered. Help also now reflects the active resolution context: invoked from a subpackage you see workspace-level overrides; invoked from the repo root (or with `-w`) you see root-level overrides.
17
+
18
+ ### 🐛 Bug fixes
19
+
20
+ - Honor -w flag in composite-script step subprocesses (#346)
21
+
22
+ Fixes an issue where invoking `nmr -w <command>` from inside a workspace package, with `<command>` resolving to a composite (multi-step) script defined at the root level, failed with `Unknown command` for any step that was reachable only via the root script registry. The `-w` flag is now propagated to every step's subprocess invocation, so composite scripts run end-to-end regardless of which directory they are invoked from.
23
+ <!-- /section:release-notes -->
6
24
 
7
25
  ## Installation
8
26
 
package/dist/esm/.cache CHANGED
@@ -1 +1 @@
1
- ecd2594bf00be365cf1c1d58937d98631236e5a8841aade1fcaf6bb190cbf84b
1
+ 01752582ad3da5f245f0d28ce77644b2b67d6be30ddb469279c38399fbd7e03b
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";
@@ -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.13.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for PNPM monorepos",
6
6
  "keywords": [
@@ -41,10 +41,13 @@
41
41
  "dependencies": {
42
42
  "jiti": "2.6.1",
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
- };