@williamthorsen/nmr 0.40.0 → 0.41.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.
@@ -1,4 +1,21 @@
1
1
  [
2
+ {
3
+ "version": "0.41.0",
4
+ "date": "2026-10-02",
5
+ "sections": [
6
+ {
7
+ "title": "🎉 Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Adds a hint to `nmr` that, when a command fails, names any of nmr's own flags written after the command name and shows the corrected invocation, such as `nmr -q check`. (#956)",
12
+ "hash": "21e1759f8eb8257d81bc5bf1e533dbd496d99017",
13
+ "entry": 1
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ },
2
19
  {
3
20
  "version": "0.40.0",
4
21
  "date": "2026-10-01",
@@ -617,7 +617,7 @@ function formatPaths(paths) {
617
617
  ${paths.map((path2) => ` ${path2}`).join("\n")}`;
618
618
  }
619
619
  function getMinVersion() {
620
- const pickedFields = { "version": "0.40.0" };
620
+ const pickedFields = { "version": "0.41.0" };
621
621
  if (typeof pickedFields["version"] !== "string") {
622
622
  throw new TypeError("nmr/package.json: 'version' must be a string");
623
623
  }
@@ -16,7 +16,7 @@
16
16
  "path": "kits/default.ts"
17
17
  },
18
18
  {
19
- "hash": "9c1005ba",
19
+ "hash": "00e9a998",
20
20
  "kind": "inline",
21
21
  "path": "../package.json",
22
22
  "paths": [
@@ -49,7 +49,7 @@
49
49
  "readyupVersion": "0.38.0",
50
50
  "source": "kits/default.ts",
51
51
  "sourceHash": "5f7bd316",
52
- "targetHash": "da11ca1f"
52
+ "targetHash": "69936c4f"
53
53
  }
54
54
  ]
55
55
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.41.0 — 2026-10-02
6
+
7
+ ### 🎉 Features
8
+
9
+ - Adds a hint to `nmr` that, when a command fails, names any of nmr's own flags written after the command name and shows the corrected invocation, such as `nmr -q check`. (#956)
10
+
5
11
  ## 0.40.0 — 2026-10-01
6
12
 
7
13
  ### 🎉 Features
package/README.md CHANGED
@@ -5,18 +5,11 @@
5
5
  Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v0.40.0 (2026-10-01)
8
+ ## Release notes — v0.41.0 (2026-10-02)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Makes `checkTestFileConventions` and the readyup kit's tier and placement checks skip untracked paths that git ignores, so that local build output such as a stale `.netlify/` directory no longer fails the check. (#898)
13
- - Excludes untracked paths that git ignores from collection in `defineVitestConfig` and `defineRootVitestConfig`, so that Vitest does not run a test file that the conventions check skips. (#898)
14
- - Rewords the `nmr --log` refusal for a run that the check-result cache does not cover to `the check-result cache does not apply here (NMR_DEBUG=1 reports why)`, and renames the matching `docs/check-cache.md` heading to "When the cache does not apply", which changes its anchor. (#923)
15
-
16
- ### 🐛 Bug fixes
17
-
18
- - Rewords the messages printed by `nmr`, its bin wrappers, and its readyup kit, which used figures such as "sits under" and "holds", em-dashes, and "no" as a determiner, so that a diagnosis such as "-F/--filter did not match any workspace" states its condition directly. (#932)
19
- - Fixes the issue that the `nmr` rulebook recommended `-q` without stating that nmr reads its own flags only before the command name, which led agents to run `nmr ci -q` and fail when `nmr-fmt` rejected the flag. (#948)
12
+ - Adds a hint to `nmr` that, when a command fails, names any of nmr's own flags written after the command name and shows the corrected invocation, such as `nmr -q check`. (#956)
20
13
  <!-- /section:release-notes -->
21
14
 
22
15
  ## Installation
@@ -66,7 +59,7 @@ nmr --recursive lint # Lint all workspace packages
66
59
 
67
60
  `--recursive`, and a `--filter` that selects more than one package, do not give any package terminal input. An interactive command such as `test:watch` needs a filter that selects a single package.
68
61
 
69
- Position determines ownership: Flags before the command name are nmr's own, and everything after the command name is forwarded untouched to the resolved command.
62
+ Position determines ownership: Flags before the command name are nmr's own, and everything after the command name is forwarded untouched to the resolved command. When a command fails and one of nmr's own flags follows its name, nmr names the flag and prints the corrected invocation.
70
63
 
71
64
  `nmr --help` lists every flag and command, with the shell command to which each command resolves. Per-package `package.json` files do not need any script entries.
72
65
 
@@ -16,9 +16,9 @@ import { isTerminalStream, OUTPUT_STYLE_ENV_VAR, OUTPUT_STYLE_FLAG, resolveOutpu
16
16
  import { renderRecording, renderRefusal, resolveRecording } from "./recording.js";
17
17
  import { assembleReplay } from "./replay-assembly.js";
18
18
  import { readReportFormatEnv, REPORT_FORMAT_ENV_VAR, resolveReportFormat } from "./report-format.js";
19
- import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, findChainedSelfReference, resolveScript, } from "./resolver.js";
19
+ import { applyDevBinToSteps, buildRootRegistry, buildWorkspaceRegistry, expandScript, findChainedSelfReference, readPackageJsonScripts, resolveScript, } from "./resolver.js";
20
20
  import { resolveChannel, runSteps } from "./runner.js";
21
- import { composeNmrStep, dropRecursiveSteps, findNmrCrossing, renderChain } from "./steps.js";
21
+ import { composeNmrStep, dropRecursiveSteps, findNmrCrossing, quoteToken, renderChain } from "./steps.js";
22
22
  import { UserError } from "./UserError.js";
23
23
  import { COMMAND_VERBOSITY_ENV_VAR, readVerbosityEnv, resolveVerbosity, } from "./verbosity.js";
24
24
  import { writeVerdict } from "./verdict.js";
@@ -34,7 +34,7 @@ const NAMED_ESCAPES = new Map([
34
34
  ]);
35
35
  export const RUN_IF_PRESENT_ENV_VAR = 'NMR_RUN_IF_PRESENT';
36
36
  export async function runCli(options) {
37
- const { args, cwd, env, stdout, stderr } = options;
37
+ const { args, env, stdout, stderr } = options;
38
38
  const parseResult = parseArgs(args);
39
39
  if (!parseResult.ok) {
40
40
  reportError(parseResult.error, stderr);
@@ -52,6 +52,18 @@ export async function runCli(options) {
52
52
  return { exitCode: 1 };
53
53
  }
54
54
  const { styles } = presentationRead;
55
+ const { exitCode, isUnknownCommand = false } = await runInvocation({ ...options, parsedArgs, presentationRead });
56
+ if (exitCode !== 0 && !isUnknownCommand && isTopLevelInvocation(env)) {
57
+ const hint = formatMisplacedFlagHint(args, parsedArgs.passthrough, styles.stderr);
58
+ if (hint !== undefined) {
59
+ stderr.write(`${hint}\n`);
60
+ }
61
+ }
62
+ return { exitCode };
63
+ }
64
+ async function runInvocation(options) {
65
+ const { cwd, env, parsedArgs, presentationRead, stderr, stdout } = options;
66
+ const { styles } = presentationRead;
55
67
  if (parsedArgs.shouldShowVersion) {
56
68
  stdout.write(`${VERSION}\n`);
57
69
  return { exitCode: 0 };
@@ -103,7 +115,8 @@ export async function runCli(options) {
103
115
  };
104
116
  const delegation = composeDelegation({ childEnv, command, parsedArgs });
105
117
  if (delegation !== undefined) {
106
- return runDelegation({ context, delegation, parsedArgs, runOptions, stderr });
118
+ const result = await runDelegation({ context, delegation, parsedArgs, runOptions, stderr });
119
+ return { ...result, isUnknownCommand: result.exitCode !== 0 && !isDefinedInAnyScope(command, context) };
107
120
  }
108
121
  const registry = shouldUseRoot ? buildRootRegistry(context.config) : buildWorkspaceRegistry(context.config);
109
122
  assertNoSelfReference({
@@ -120,7 +133,7 @@ export async function runCli(options) {
120
133
  return { exitCode: 0 };
121
134
  }
122
135
  reportError(`Unknown command: ${command}`, stderr);
123
- return { exitCode: 1 };
136
+ return { exitCode: 1, isUnknownCommand: true };
124
137
  }
125
138
  const { isEmptiedByWorkspace, steps: runnableSteps } = readRunnableSteps(resolvedScript, context.workspacePackageDirs);
126
139
  const resolvedCommand = renderChain(runnableSteps);
@@ -200,6 +213,7 @@ export async function runCli(options) {
200
213
  reportVerdict({ command, scope, ...outcome }, stdout, format, styles.stdout);
201
214
  return { exitCode };
202
215
  }
216
+ const FILTER_FLAGS = new Set(['-F', '--filter']);
203
217
  const BOOLEAN_FLAGS = new Map([
204
218
  ['-?', 'shouldShowHelp'],
205
219
  ['--help', 'shouldShowHelp'],
@@ -587,7 +601,7 @@ function parseArgs(args) {
587
601
  const arg = args[index];
588
602
  if (arg === undefined)
589
603
  break;
590
- if (arg === '-F' || arg === '--filter') {
604
+ if (FILTER_FLAGS.has(arg)) {
591
605
  index++;
592
606
  const filterValue = args[index];
593
607
  if (!filterValue) {
@@ -862,6 +876,69 @@ async function runGated(options) {
862
876
  outcome: exitCode === 0 ? { outcome: 'passed', durationMs } : { outcome: 'failed', durationMs, exitCode },
863
877
  };
864
878
  }
879
+ function formatMisplacedFlagHint(args, passthrough, style) {
880
+ const { flagNames, movedTokens, remainingTokens } = splitMisplacedFlags(passthrough);
881
+ if (flagNames.length === 0) {
882
+ return undefined;
883
+ }
884
+ const commandIndex = args.length - passthrough.length - 1;
885
+ const correctedTokens = [
886
+ 'nmr',
887
+ ...args.slice(0, commandIndex),
888
+ ...movedTokens,
889
+ ...args.slice(commandIndex, commandIndex + 1),
890
+ ...remainingTokens,
891
+ ];
892
+ const subject = flagNames.length === 1 ? 'is an nmr flag' : 'are nmr flags';
893
+ return (`${STATUS_GLYPHS[style].warning.text} ${renderQuotedList(flagNames)} ${subject}; ` +
894
+ `nmr reads its flags only before the command: \`${correctedTokens.map(quoteToken).join(' ')}\``);
895
+ }
896
+ function isDefinedInAnyScope(command, context) {
897
+ const registries = [buildRootRegistry(context.config), buildWorkspaceRegistry(context.config)];
898
+ if (registries.some((registry) => Object.hasOwn(registry, command))) {
899
+ return true;
900
+ }
901
+ return [context.monorepoRoot, ...context.workspacePackageDirs].some((dir) => Object.hasOwn(readPackageJsonScripts(dir) ?? {}, command));
902
+ }
903
+ function isTopLevelInvocation(env) {
904
+ const runId = env[RUN_ID_ENV_VAR];
905
+ return runId === undefined || runId === '';
906
+ }
907
+ function readMisplacedFlagLength(args, index) {
908
+ const arg = args[index] ?? '';
909
+ if (FILTER_FLAGS.has(arg)) {
910
+ return (args[index + 1] ?? '') === '' ? undefined : 2;
911
+ }
912
+ const booleanFlag = BOOLEAN_FLAGS.get(arg);
913
+ if (booleanFlag !== undefined) {
914
+ return booleanFlag === 'shouldShowHelp' || booleanFlag === 'shouldShowVersion' ? undefined : 1;
915
+ }
916
+ const styleArgument = readOutputStyleArgument(args, index);
917
+ return styleArgument?.ok === true ? styleArgument.consumedCount : undefined;
918
+ }
919
+ function splitMisplacedFlags(passthrough) {
920
+ const flagNames = new Set();
921
+ const movedTokens = [];
922
+ const remainingTokens = [];
923
+ let index = 0;
924
+ while (index < passthrough.length) {
925
+ const arg = passthrough[index] ?? '';
926
+ if (arg === '--') {
927
+ remainingTokens.push(...passthrough.slice(index));
928
+ break;
929
+ }
930
+ const flagLength = readMisplacedFlagLength(passthrough, index);
931
+ if (flagLength === undefined) {
932
+ remainingTokens.push(arg);
933
+ index++;
934
+ continue;
935
+ }
936
+ flagNames.add(arg.startsWith(`${OUTPUT_STYLE_FLAG}=`) ? OUTPUT_STYLE_FLAG : arg);
937
+ movedTokens.push(...passthrough.slice(index, index + flagLength));
938
+ index += flagLength;
939
+ }
940
+ return { flagNames: [...flagNames], movedTokens, remainingTokens };
941
+ }
865
942
  function quoteForShell(arg) {
866
943
  return "'" + arg.replace(/'/g, String.raw `'\''`) + "'";
867
944
  }
@@ -75,6 +75,8 @@ export declare function findNmrCrossing(steps: readonly Step[]): string | undefi
75
75
  * literally would be quoted whole by the rendering, turning the element into a command that nobody wrote.
76
76
  */
77
77
  export declare function findUnexpressibleToken(element: string): string | undefined;
78
+ /** Quotes a token that the shell would not read literally, and leaves every other token bare. */
79
+ export declare function quoteToken(token: string): string;
78
80
  /**
79
81
  * Returns the command that a structural step's nmr process runs, whether the step hands that command to other
80
82
  * scopes rather than running it in its own scope, and whether it anchors the command at the monorepo root.
package/dist/esm/steps.js CHANGED
@@ -33,6 +33,12 @@ export function findNmrCrossing(steps) {
33
33
  export function findUnexpressibleToken(element) {
34
34
  return tokenize(element).find((token) => !SHELL_SAFE_TOKEN.test(token));
35
35
  }
36
+ export function quoteToken(token) {
37
+ if (SHELL_SAFE_TOKEN.test(token)) {
38
+ return token;
39
+ }
40
+ return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
41
+ }
36
42
  export function readNmrStep(step) {
37
43
  if (step.kind !== 'structural') {
38
44
  return undefined;
@@ -66,12 +72,6 @@ function isRedirectionOperator(char, precedingText, nextChar) {
66
72
  }
67
73
  return (char === '&' && nextChar === '>') || /[<>]\s*$/.test(precedingText);
68
74
  }
69
- function quoteToken(token) {
70
- if (SHELL_SAFE_TOKEN.test(token)) {
71
- return token;
72
- }
73
- return "'" + token.replaceAll("'", String.raw `'\''`) + "'";
74
- }
75
75
  function readNmrTail(segment) {
76
76
  const [head, ...rest] = dropLeadingAssignments(tokenizeSegment(segment));
77
77
  if (head === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -80,7 +80,7 @@
80
80
  "@williamthorsen/nmr-core": "0.13.1",
81
81
  "@williamthorsen/toolbelt.errors": "0.8.1",
82
82
  "glob": "13.0.6",
83
- "prettier-plugin-sh": "0.19.0",
83
+ "prettier-plugin-sh": "0.20.2",
84
84
  "taze": "21.1.0"
85
85
  },
86
86
  "devDependencies": {