@williamthorsen/nmr 1.0.0 → 2.0.1

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.
Files changed (44) hide show
  1. package/.meta/changelog.json +104 -0
  2. package/.readyup/kits/default.js +12 -28
  3. package/.readyup/manifest.json +19 -24
  4. package/CHANGELOG.md +33 -0
  5. package/README.md +9 -3
  6. package/dist/esm/check-cache-gate.d.ts +0 -3
  7. package/dist/esm/check-cache-gate.js +1 -5
  8. package/dist/esm/cli-build.js +3 -10
  9. package/dist/esm/cli-clean.js +1 -2
  10. package/dist/esm/cli-report-catalog.js +2 -3
  11. package/dist/esm/cli-report-overrides.js +3 -5
  12. package/dist/esm/commands/build-output.d.ts +7 -32
  13. package/dist/esm/commands/build-output.js +8 -13
  14. package/dist/esm/commands/build-report.d.ts +6 -0
  15. package/dist/esm/commands/build-report.js +21 -0
  16. package/dist/esm/commands/build.d.ts +27 -4
  17. package/dist/esm/commands/build.js +24 -86
  18. package/dist/esm/commands/clean-report.d.ts +6 -0
  19. package/dist/esm/commands/clean-report.js +20 -0
  20. package/dist/esm/commands/clean.d.ts +39 -7
  21. package/dist/esm/commands/clean.js +57 -34
  22. package/dist/esm/commands/compile.d.ts +5 -0
  23. package/dist/esm/commands/compile.js +32 -0
  24. package/dist/esm/commands/fmt.d.ts +11 -0
  25. package/dist/esm/commands/fmt.js +6 -2
  26. package/dist/esm/commands/publish-output.d.ts +40 -0
  27. package/dist/esm/commands/publish-output.js +66 -0
  28. package/dist/esm/commands/report-catalog.d.ts +5 -0
  29. package/dist/esm/commands/report-catalog.js +19 -0
  30. package/dist/esm/commands/report-overrides.d.ts +5 -0
  31. package/dist/esm/commands/report-overrides.js +20 -1
  32. package/dist/esm/config-validation.js +1 -14
  33. package/dist/esm/config.d.ts +1 -1
  34. package/dist/esm/crossing-diagnostics.d.ts +1 -1
  35. package/dist/esm/resolver.d.ts +0 -12
  36. package/dist/esm/resolver.js +0 -35
  37. package/dist/esm/run-chain.d.ts +1 -1
  38. package/dist/esm/run-chain.js +1 -5
  39. package/dist/esm/types.d.ts +0 -1
  40. package/docs/check-cache.md +0 -1
  41. package/docs/scripts.md +17 -12
  42. package/docs/upgrades.md +1 -1
  43. package/docs/utilities.md +2 -2
  44. package/package.json +8 -8
@@ -0,0 +1,66 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { rename, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { UserError } from '@williamthorsen/nmr-core';
5
+ import { describeError } from '@williamthorsen/toolbelt.errors';
6
+ import { getCompiler } from "./compiler.js";
7
+ import { isWithin } from "./rewrite-specifiers.js";
8
+ export async function discardScratchDirs(emitDir) {
9
+ try {
10
+ await removeScratchDirs(emitDir);
11
+ }
12
+ catch {
13
+ }
14
+ }
15
+ export async function publishOutput(emitDir, stagedFiles) {
16
+ if (stagedFiles.size === 0) {
17
+ await rm(emitDir, { force: true, recursive: true });
18
+ return 0;
19
+ }
20
+ writeStagedOutput(stagedFiles, emitDir);
21
+ await swapIntoPlace(emitDir);
22
+ return stagedFiles.size;
23
+ }
24
+ export async function removeScratchDirs(emitDir) {
25
+ const { previousDir, stagingDir } = resolveScratchDirs(emitDir);
26
+ await rm(previousDir, { force: true, recursive: true });
27
+ await rm(stagingDir, { force: true, recursive: true });
28
+ }
29
+ export function resolveScratchDirs(emitDir) {
30
+ const parent = path.dirname(emitDir);
31
+ const name = path.basename(emitDir);
32
+ return {
33
+ previousDir: path.join(parent, `.${name}.previous`),
34
+ stagingDir: path.join(parent, `.${name}.staging`),
35
+ };
36
+ }
37
+ async function swapIntoPlace(emitDir) {
38
+ const { previousDir, stagingDir } = resolveScratchDirs(emitDir);
39
+ const hadPreviousOutput = existsSync(emitDir);
40
+ if (hadPreviousOutput) {
41
+ await rename(emitDir, previousDir);
42
+ }
43
+ try {
44
+ await rename(stagingDir, emitDir);
45
+ }
46
+ catch (error) {
47
+ if (hadPreviousOutput && !existsSync(emitDir)) {
48
+ await rename(previousDir, emitDir);
49
+ }
50
+ throw new UserError(`failed to move the built output into ${emitDir}: ${describeError(error)}`, {
51
+ cause: error,
52
+ });
53
+ }
54
+ await discardScratchDirs(emitDir);
55
+ }
56
+ function writeStagedOutput(stagedFiles, emitDir) {
57
+ const ts = getCompiler();
58
+ const { stagingDir } = resolveScratchDirs(emitDir);
59
+ for (const [fileName, file] of stagedFiles) {
60
+ if (!isWithin(emitDir, fileName)) {
61
+ throw new Error(`the compiler emitted ${fileName}, which is outside the output directory ${emitDir}. ` +
62
+ "Verify the resolved tsconfig's 'outDir' and 'declarationDir'.");
63
+ }
64
+ ts.sys.writeFile(path.join(stagingDir, path.relative(emitDir, fileName)), file.text, file.writeByteOrderMark);
65
+ }
66
+ }
@@ -8,3 +8,8 @@ import { type OutputStyle } from '@williamthorsen/nmr-core';
8
8
  * pass runs, to keep the reader from taking that result to mean that every dependency is up to date.
9
9
  */
10
10
  export declare function reportCatalog(cwd: string, style: OutputStyle): void;
11
+ /**
12
+ * Runs `nmr-report-catalog` against `argv` from `cwd` and returns the exit code to report. The command takes neither
13
+ * an option nor an operand, so anything but `--help` is a usage error and reports nothing.
14
+ */
15
+ export declare function runReportCatalog(argv: string[], cwd: string): Promise<number>;
@@ -1,8 +1,12 @@
1
+ import process from 'node:process';
1
2
  import { formatGlyphLine } from '@williamthorsen/nmr-core';
3
+ import { defineCommand, runCli } from '@williamthorsen/nmr-core/cli';
4
+ import { NMR_REPORT_CATALOG_BIN } from "../bins.js";
2
5
  import { findContainingPackageDir } from "../context.js";
3
6
  import { NMR_GLYPHS } from "../glyphs.js";
4
7
  import { DEPENDENCY_FIELDS, readPackageJson } from "../helpers/package-json.js";
5
8
  import { reportClosing } from "../helpers/reportClosing.js";
9
+ import { resolveBinStyles } from "../resolveBinStyles.js";
6
10
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
7
11
  const CATALOG_PROTOCOL = 'catalog:';
8
12
  export function reportCatalog(cwd, style) {
@@ -21,6 +25,21 @@ export function reportCatalog(cwd, style) {
21
25
  }
22
26
  reportClosing(formatGlyphLine(NMR_GLYPHS, style, 'catalog', describeCatalog(dependencies.length, monorepoRoot)), console.warn);
23
27
  }
28
+ export async function runReportCatalog(argv, cwd) {
29
+ const command = defineCommand({
30
+ summary: 'Report the catalogued dependencies that a package-scoped upgrade does not read',
31
+ run: () => {
32
+ reportCatalog(cwd, resolveBinStyles().stderr);
33
+ return 0;
34
+ },
35
+ });
36
+ return runCli(argv, command, {
37
+ name: NMR_REPORT_CATALOG_BIN,
38
+ stdout: process.stdout,
39
+ stderr: process.stderr,
40
+ baseDir: cwd,
41
+ });
42
+ }
24
43
  function describeCatalog(count, monorepoRoot) {
25
44
  const subject = count === 1 ? '1 catalogued dependency went' : `${count} catalogued dependencies went`;
26
45
  const object = count === 1 ? 'it' : 'them';
@@ -4,3 +4,8 @@ import { type OutputStyle } from '@williamthorsen/nmr-core';
4
4
  * `pnpm.overrides` block left behind in the root `package.json`.
5
5
  */
6
6
  export declare function reportOverrides(monorepoRoot: string, style: OutputStyle): void;
7
+ /**
8
+ * Runs `nmr-report-overrides` against `argv` for the monorepo containing `cwd` and returns the exit code to report.
9
+ * The command takes neither an option nor an operand, so anything but `--help` is a usage error and reports nothing.
10
+ */
11
+ export declare function runReportOverrides(argv: string[], cwd: string): Promise<number>;
@@ -1,8 +1,12 @@
1
+ import process from 'node:process';
1
2
  import { formatGlyphLine, UserError } from '@williamthorsen/nmr-core';
3
+ import { defineCommand, runCli } from '@williamthorsen/nmr-core/cli';
4
+ import { NMR_REPORT_OVERRIDES_BIN } from "../bins.js";
2
5
  import { NMR_GLYPHS } from "../glyphs.js";
3
6
  import { getPnpmOverrides, readPackageJson } from "../helpers/package-json.js";
4
7
  import { reportClosing } from "../helpers/reportClosing.js";
5
- import { readWorkspaceOverrides } from "../workspace.js";
8
+ import { resolveBinStyles } from "../resolveBinStyles.js";
9
+ import { findMonorepoRoot, readWorkspaceOverrides } from "../workspace.js";
6
10
  export function reportOverrides(monorepoRoot, style) {
7
11
  const declaredOverrides = listEntries(readWorkspaceOverrides(monorepoRoot));
8
12
  if (declaredOverrides.length > 0) {
@@ -14,6 +18,21 @@ export function reportOverrides(monorepoRoot, style) {
14
18
  }
15
19
  rejectLegacyOverrides(monorepoRoot);
16
20
  }
21
+ export async function runReportOverrides(argv, cwd) {
22
+ const command = defineCommand({
23
+ summary: 'Report the pnpm overrides that the monorepo declares',
24
+ run: () => {
25
+ reportOverrides(findMonorepoRoot(cwd), resolveBinStyles().stderr);
26
+ return 0;
27
+ },
28
+ });
29
+ return runCli(argv, command, {
30
+ name: NMR_REPORT_OVERRIDES_BIN,
31
+ stdout: process.stdout,
32
+ stderr: process.stderr,
33
+ baseDir: cwd,
34
+ });
35
+ }
17
36
  function describeOverrides(count) {
18
37
  return count === 1
19
38
  ? '1 override is active. Check whether it is still needed.'
@@ -1,12 +1,11 @@
1
1
  import { isRecord, UserError } from '@williamthorsen/nmr-core';
2
2
  import { assertResolvableCheckCacheCommands } from "./config-check-cache.js";
3
- import { isStringRecord } from "./helpers/type-guards.js";
4
3
  import { findUnexpressibleToken } from "./steps.js";
5
4
  import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
6
5
  export const CONFIG_TIERS = {
7
6
  root: {
8
7
  label: 'a monorepo-root config',
9
- honoredKeys: ['checkCache', 'delegate', 'devBin', 'output', 'rootScripts', 'selfHostedRunner', 'workspaceScripts'],
8
+ honoredKeys: ['checkCache', 'delegate', 'output', 'rootScripts', 'selfHostedRunner', 'workspaceScripts'],
10
9
  elsewhere: "the package's own config",
11
10
  },
12
11
  workspace: {
@@ -44,9 +43,6 @@ export function validateConfig(value, configPath, baseDir) {
44
43
  config.checkCache = checkCache;
45
44
  if (value['delegate'] !== undefined)
46
45
  config.delegate = validateDelegateField(value['delegate'], configPath);
47
- const devBin = validateStringRecordField(value, 'devBin', configPath);
48
- if (devBin)
49
- config.devBin = devBin;
50
46
  const output = validateOutputField(value, configPath);
51
47
  if (output)
52
48
  config.output = output;
@@ -225,12 +221,3 @@ function validateScriptField(value, fieldName, configPath) {
225
221
  assertValidElements(scripts, fieldName, configPath);
226
222
  return scripts;
227
223
  }
228
- function validateStringRecordField(value, fieldName, configPath) {
229
- if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
230
- return undefined;
231
- }
232
- if (!isStringRecord(value[fieldName])) {
233
- throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string>`);
234
- }
235
- return value[fieldName];
236
- }
@@ -6,7 +6,7 @@ import type { NmrConfig } from './types.js';
6
6
  */
7
7
  export declare function loadConfig(baseDir: string): Promise<NmrConfig>;
8
8
  /**
9
- * Loads the monorepo-root `.config/nmr.config.ts`, the tier that feeds script resolution and `devBin`.
9
+ * Loads the monorepo-root `.config/nmr.config.ts`, the tier that feeds script resolution.
10
10
  * Returns an empty config if the file doesn't exist.
11
11
  *
12
12
  * Throws on a recognized key that this tier does not honor. `build` governs one package's compile, which reads the
@@ -24,7 +24,7 @@ export declare function assertNoSelfReference(options: {
24
24
  *
25
25
  * Ahead of the gate, so that a command that usually skips still reports its crossing. A `--log` reports
26
26
  * none, running nothing that could cross. Reads the resolved steps rather than the full chain: the line names
27
- * a declaration to edit, and a hook, a passthrough, and a `devBin` substitution are none.
27
+ * a declaration to edit, and neither a hook nor a passthrough is one.
28
28
  */
29
29
  export declare function reportNmrCrossing(options: {
30
30
  config: NmrConfig;
@@ -1,18 +1,6 @@
1
1
  import { type ScriptRegistry, type ScriptValue } from './resolve-scripts.js';
2
2
  import { type Step } from './steps.js';
3
3
  import type { NmrConfig } from './types.js';
4
- /**
5
- * Replaces the first token of a command with a `devBin` substitute.
6
- * Relative paths in the replacement are resolved from `monorepoRoot`.
7
- */
8
- export declare function applyDevBin(command: string, devBin: Record<string, string> | undefined, monorepoRoot: string): string;
9
- /**
10
- * Applies a `devBin` substitution to each opaque step, leaving nmr's own compositions alone.
11
- *
12
- * A structural step names a command that nmr resolves for itself, so substituting its first token would replace
13
- * the `nmr` that runs the composite rather than the leaf tool that `devBin` documents replacing.
14
- */
15
- export declare function applyDevBinToSteps(steps: readonly Step[], devBin: Record<string, string> | undefined, monorepoRoot: string): readonly Step[];
16
4
  /**
17
5
  * Where a resolved script was read from.
18
6
  *
@@ -1,44 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import path from 'node:path';
3
2
  import { hasErrnoCode, isRecord } from '@williamthorsen/nmr-core';
4
3
  import { parsePackageJson, readScriptRecord, resolvePackageJsonPath } from "./helpers/package-json.js";
5
4
  import { getDefaultRootScripts, getDefaultWorkspaceScripts, } from "./resolve-scripts.js";
6
5
  import { composeNmrStep, readSelfReference } from "./steps.js";
7
6
  import { isMonorepoRoot } from "./workspace.js";
8
- export function applyDevBin(command, devBin, monorepoRoot) {
9
- if (!devBin) {
10
- return command;
11
- }
12
- const spaceIndex = command.indexOf(' ');
13
- const firstToken = spaceIndex === -1 ? command : command.slice(0, spaceIndex);
14
- const restOfCommand = spaceIndex === -1 ? '' : command.slice(spaceIndex);
15
- const replacement = devBin[firstToken];
16
- if (replacement === undefined) {
17
- return command;
18
- }
19
- const resolvedReplacement = resolveReplacementPaths(replacement, monorepoRoot);
20
- return resolvedReplacement + restOfCommand;
21
- }
22
- export function applyDevBinToSteps(steps, devBin, monorepoRoot) {
23
- if (!devBin) {
24
- return steps;
25
- }
26
- return steps.map((step) => step.kind === 'opaque' ? { kind: 'opaque', command: applyDevBin(step.command, devBin, monorepoRoot) } : step);
27
- }
28
- function resolveReplacementPaths(replacement, monorepoRoot) {
29
- const tokens = replacement.split(' ');
30
- return tokens
31
- .map((token, index) => {
32
- if (index === 0)
33
- return token;
34
- if (token.startsWith('-'))
35
- return token;
36
- if (!token.includes('/'))
37
- return token;
38
- return path.resolve(monorepoRoot, token);
39
- })
40
- .join(' ');
41
- }
42
7
  export function expandScript(script, isWorkspaceRoot) {
43
8
  if (typeof script === 'string') {
44
9
  return [{ kind: 'opaque', command: script }];
@@ -2,7 +2,7 @@ import { type OutputStyle } from '@williamthorsen/nmr-core';
2
2
  import type { TreeSnapshot } from './check-cache.js';
3
3
  import { type Invocation, type PhaseResult, type RunPlan } from './invocation.js';
4
4
  import type { ScriptRegistry } from './resolve-scripts.js';
5
- import { type ResolvedScript } from './resolver.js';
5
+ import type { ResolvedScript } from './resolver.js';
6
6
  /**
7
7
  * Assembles the chain that a resolved script runs at this scope, the hooks wrapped around it included, and the key
8
8
  * under which a pass by that chain is recorded. Settles a no-op override and an argument that no step can take.
@@ -9,7 +9,6 @@ import { quoteForShell } from "./helpers/quoteForShell.js";
9
9
  import { wrapWithHooks } from "./hooks.js";
10
10
  import { USAGE_EXIT_CODE } from "./invocation.js";
11
11
  import { reportVerdict } from "./presentation.js";
12
- import { applyDevBinToSteps } from "./resolver.js";
13
12
  import { dropRecursiveSteps, renderChain } from "./steps.js";
14
13
  export function assembleChain(options) {
15
14
  const { invocation, registry, resolvedScript, snapshot } = options;
@@ -21,9 +20,7 @@ export function assembleChain(options) {
21
20
  reportVerdict({ command, scope: invocation.scope, outcome: 'no-op', reason: noOpReason }, stdout, invocation.format, styles.stdout);
22
21
  return { settled: true, result: { exitCode: 0 } };
23
22
  }
24
- const substitutedSteps = applyDevBinToSteps(runnableSteps, context.config.devBin, context.monorepoRoot);
25
- const substitutedCommand = renderChain(substitutedSteps);
26
- const bindResult = bindPassthrough(substitutedSteps, parsedArgs.passthrough, command);
23
+ const bindResult = bindPassthrough(runnableSteps, parsedArgs.passthrough, command);
27
24
  if (!bindResult.ok) {
28
25
  reportError(NMR_BIN, bindResult.error, stderr);
29
26
  return { settled: true, result: { exitCode: USAGE_EXIT_CODE } };
@@ -53,7 +50,6 @@ export function assembleChain(options) {
53
50
  monorepoRoot: context.monorepoRoot,
54
51
  snapshot,
55
52
  stderr,
56
- substitution: substitutedCommand === resolvedCommand ? undefined : substitutedCommand,
57
53
  });
58
54
  return { settled: false, value: { commandString: fullCommand, key, ownSteps: mainSteps, steps: fullSteps } };
59
55
  }
@@ -59,7 +59,6 @@ export interface NmrConfig {
59
59
  * config says. A delegation flag still sends a run elsewhere.
60
60
  */
61
61
  delegate?: false;
62
- devBin?: Record<string, string>;
63
62
  output?: OutputConfig;
64
63
  workspaceScripts?: Record<string, ScriptValue>;
65
64
  rootScripts?: Record<string, ScriptValue>;
@@ -63,7 +63,6 @@ The gate never wrongly skips. When it cannot be sure, it does nothing and the co
63
63
  - When the monorepo root is not the git toplevel.
64
64
  - When a changed path cannot be read, contains a newline or bytes that are not UTF-8, or is neither a file nor a symlink (an untracked nested repository, for instance).
65
65
  - When the repository does not have a pnpm install to fingerprint.
66
- - Under a `devBin` substitution, which runs a binary built from a location that the tree id does not describe.
67
66
  - For any invocation carrying arguments after the command name, and for every step below it: the arguments narrow what runs, and a step served from a recorded pass is a step that did not.
68
67
 
69
68
  `NMR_DEBUG=1` reports why a run did not skip and why the cache did not apply.
package/docs/scripts.md CHANGED
@@ -79,7 +79,7 @@ The line names the declaration to which it is an edit (the file, the field, and
79
79
 
80
80
  One kind of `package.json` entry is resolved in its own file: an entry whose whole value is `nmr <name>`, without flags, arguments, assignments, or a launcher, where `<name>` is a script in the same `package.json` that runs a command other than nmr and does not have a `:pre` or `:post` hook. Replace the entry's value with that command, and delete the script: `"build:pre": "nmr compile:ports"` beside `"compile:ports": "node scripts/compile-ports.ts"` becomes `"build:pre": "node scripts/compile-ports.ts"`. Keep the script when the registry also defines `<name>`, because the script then overrides nmr's own command. The script must come from the same file: Inlining a command that resolves from the registry would copy nmr's own command text into the package, where nmr's later changes do not reach it.
81
81
 
82
- nmr recognizes `nmr` in command position: at the start of a step, after an unquoted `&&`, `||`, `;`, `|`, or a newline, past any leading `NAME=value` assignments, and behind `npx`, `bunx`, or an `exec`, `dlx`, or `x` subcommand of `npm`, `pnpm`, `yarn`, or `bun`. This line reports a boundary rather than enforcing a rule: It covers a step that nmr runs as written, and only the reporting of the nested run is degraded. The line is also partial in three ways: a value-taking flag standing immediately before the program name hides it (`npx -p foo nmr`), a launcher outside that set goes unreported, and a `devBin` substitution that introduces `nmr` itself is not reported, the line being an edit to a declaration rather than to a substitution.
82
+ nmr recognizes `nmr` in command position: at the start of a step, after an unquoted `&&`, `||`, `;`, `|`, or a newline, past any leading `NAME=value` assignments, and behind `npx`, `bunx`, or an `exec`, `dlx`, or `x` subcommand of `npm`, `pnpm`, `yarn`, or `bun`. This line reports a boundary rather than enforcing a rule: It covers a step that nmr runs as written, and only the reporting of the nested run is degraded. The line is also partial in two ways: a value-taking flag standing immediately before the program name hides it (`npx -p foo nmr`), and a launcher outside that set goes unreported.
83
83
 
84
84
  A [self-referential entry](#three-tier-override-system), in `package.json` or in `.config/nmr.config.ts`, is the shape that nmr cannot run as written, and it is rejected rather than reported. The same recognition finds it, so the same residuals apply: What goes unrecognized there re-enters nmr once, and the run then fails with an error naming the command and the directory that it re-entered.
85
85
 
@@ -125,7 +125,6 @@ A minimal `.config/nmr.config.ts` is in the [README](../README.md#configuration)
125
125
  | `delegate` | `false` | Keeps every check on the machine that has the tree, whatever `NMR_DELEGATE` or the [user config](delegate.md#the-user-config) says; a delegation flag still sends a run elsewhere (root config only) |
126
126
  | `workspaceScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the workspace registry (tier 2) |
127
127
  | `rootScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the root registry (tier 2) |
128
- | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
129
128
  | `output` | `{ commandVerbosity?: CommandVerbosity; extraAgentEnvVars?: string[] }` | How loudly runs report command output; see [configuring](reporting.md#configuring) |
130
129
  | `selfHostedRunner` | `{ exempt: string }` | Why a qualifying repo keeps CI off the self-hosted runners; stops [nmr's readyup kit](../README.md#conformance-checks) from recommending `CI_RUNS_ON` (root config only). The reason is structural: a circular dependency, a platform or capacity the runners lack, or network reach. A missing tool is installed by a workflow step instead |
131
130
 
@@ -135,7 +134,7 @@ Each field belongs to exactly one tier, and nmr loads from a config only the fie
135
134
 
136
135
  ### Package-level configuration
137
136
 
138
- A package may have its own `.config/nmr.config.ts`, which `nmr-compile` reads from the package that it is compiling. This tier honors `build` alone; declaring `workspaceScripts`, `rootScripts`, or `devBin` there fails with a message naming them, rather than being quietly ignored: Those belong in the monorepo-root config, and a package that appeared to set them would otherwise build on settings that nothing applied. The monorepo-root config has the mirror restriction: `build` there fails the same way, since only a package's own config affects the compile.
137
+ A package may have its own `.config/nmr.config.ts`, which `nmr-compile` reads from the package that it is compiling. This tier honors `build` alone; declaring `workspaceScripts`, `rootScripts`, or any other root-config field there fails with a message naming them, rather than being quietly ignored: Those belong in the monorepo-root config, and a package that appeared to set them would otherwise build on settings that nothing applied. The monorepo-root config has the mirror restriction: `build` there fails the same way, since only a package's own config affects the compile.
139
138
 
140
139
  ```ts
141
140
  // packages/my-package/.config/nmr.config.ts
@@ -150,23 +149,29 @@ export default defineConfig({
150
149
 
151
150
  > **Building nmr from source:** A config file that has to load while nmr itself is being built (before its `dist` exists) must use the type-only form, `import type { NmrConfig } from '@williamthorsen/nmr/config'` with `satisfies NmrConfig`, which does not produce a runtime import. `defineConfig` is unavailable at that moment, because it resolves through nmr's build output. Every other config, including every consumer's, imports nmr from an installed tarball that contains `dist`, and can use `defineConfig` freely.
152
151
 
153
- ### `devBin`: source-repo binary substitution
152
+ ### Running a workspace bin from source
154
153
 
155
- During development of a CLI tool inside the monorepo, the published binary may not reflect the latest source changes. `devBin` maps a binary name to a replacement command that runs from source:
154
+ A script that runs a bin built in this monorepo runs its build output, which can lag the source or be missing before the first build. To run the source instead, override the script whose value runs the bin, and call `node` on the bin's source file: Node runs TypeScript directly.
155
+
156
+ A resolved script runs in the directory of its scope: A root script runs from the monorepo root, so a path relative to the root works there, and a workspace script runs in each package's directory, so the config builds an absolute path from `import.meta.dirname`, the config's own `.config` directory.
156
157
 
157
158
  ```ts
159
+ // .config/nmr.config.ts
160
+ import { defineConfig } from '@williamthorsen/nmr/config';
161
+
162
+ const myCli = `${import.meta.dirname}/../packages/my-cli/src/cli.ts`;
163
+
158
164
  export default defineConfig({
159
- devBin: {
160
- 'my-cli': 'tsx packages/my-cli/src/cli.ts',
165
+ rootScripts: {
166
+ 'build:post': 'node packages/my-cli/src/cli.ts compile',
167
+ },
168
+ workspaceScripts: {
169
+ lint: `node "${myCli}" lint`,
161
170
  },
162
171
  });
163
172
  ```
164
173
 
165
- When nmr resolves a command whose first token matches a `devBin` key, it replaces that token with the mapped command. Arguments are preserved. Relative paths in the replacement are resolved from the monorepo root.
166
-
167
- For example, if a workspace script resolves to `my-cli --verbose`, nmr rewrites it to `tsx /absolute/path/to/packages/my-cli/src/cli.ts --verbose`.
168
-
169
- > **Note:** Path resolution uses a heuristic: Any non-flag token containing `/` is treated as a relative path. This works well for typical dev-tool commands but may incorrectly resolve URL-like values or glob patterns. Flags using `--flag=value` syntax are not resolved; use the spaced form `--flag value` for paths that need resolution.
174
+ A package's own `package.json` script outranks the config (tier 3), so a package script that runs the bin is changed in that `package.json`.
170
175
 
171
176
  ## Default commands
172
177
 
package/docs/upgrades.md CHANGED
@@ -130,7 +130,7 @@ These commands are available as `nmr` subcommands and as standalone `nmr-`-prefi
130
130
 
131
131
  ### `report-catalog`
132
132
 
133
- Report the dependencies that the current package takes from a [pnpm catalog](https://pnpm.io/catalogs), which a package-scoped `upgrade` cannot read (see [dependency upgrades](#dependency-upgrades) for why). Each line names the dependency, its `catalog:` specifier, and the monorepo root from which a covering pass runs. The workspace `upgrade` script runs it automatically, so a repo does not need to wire it up. The subcommand belongs to the workspace registry and runs from a package directory. The `nmr-report-catalog` bin reports nothing when run from the monorepo root, because a root-scoped pass reads the catalog itself.
133
+ Report the dependencies that the current package takes from a [pnpm catalog](https://pnpm.io/catalogs), which a package-scoped `upgrade` cannot read (see [dependency upgrades](#dependency-upgrades) for why). Each line names the dependency, its `catalog:` specifier, and the monorepo root from which a covering pass runs. The workspace `upgrade` script runs it automatically, so a repo does not need to wire it up. The subcommand belongs to the workspace registry and runs from a package directory. The `nmr-report-catalog` bin reports nothing when run from the monorepo root, because a root-scoped pass reads the catalog itself. It takes no arguments: `--help` prints its usage, and any other argument is a usage error, which exits 2 without reporting.
134
134
 
135
135
  ```bash
136
136
  nmr report-catalog
package/docs/utilities.md CHANGED
@@ -12,7 +12,7 @@ nmr-actionlint
12
12
 
13
13
  ## `nmr-clean`
14
14
 
15
- Remove a package's build output (`dist`) and its `nmr-compile` cache entries, the key and the recorded emit duration, so that the next build cannot skip on leftover state. Run from a package directory, it cleans that package; run from the monorepo root, it sweeps every workspace package in a single pass, running each package's resolved `clean`, so a package that overrides `clean`, in `.config/nmr.config.ts` or in its own `package.json`, gets its own command rather than the sweep. Removal is idempotent: Cleaning an unbuilt package is a silent no-op. This is the default `clean` script at both levels.
15
+ Remove a package's build output (`dist`) and its `nmr-compile` cache entries, the key and the recorded emit duration, so that the next build cannot skip on leftover state. Run from a package directory, it cleans that package; run from the monorepo root, it sweeps every workspace package in a single pass, running each package's resolved `clean`, so a package that overrides `clean`, in `.config/nmr.config.ts` or in its own `package.json`, gets its own command rather than the sweep. Removal is idempotent: Cleaning an unbuilt package is a silent no-op. This is the default `clean` script at both levels. It takes no arguments: `--help` prints its usage, and any other argument is a usage error, which exits 2 without removing anything.
16
16
 
17
17
  ```bash
18
18
  nmr-clean
@@ -20,7 +20,7 @@ nmr-clean
20
20
 
21
21
  ## `nmr-compile`
22
22
 
23
- Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` from one program and `.d.ts` from a second that reuses it. The package's own `removeComments` governs the `.js` emit; the declarations emit forces it off, so `.d.ts` files keep their doc comments either way. Because the compiler parses each source file, every relative import form (static, re-export, dynamic `import()`, and bare side-effect) is rewritten from `.ts` or `.tsx` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when every input is unchanged and the previous output is still on disk (the key is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means (`nmr clean`, `rm -rf dist`, `git clean`) therefore forces a rebuild rather than a skip. This is the default `compile` script; run it from a package directory.
23
+ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` from one program and `.d.ts` from a second that reuses it. The package's own `removeComments` governs the `.js` emit; the declarations emit forces it off, so `.d.ts` files keep their doc comments either way. Because the compiler parses each source file, every relative import form (static, re-export, dynamic `import()`, and bare side-effect) is rewritten from `.ts` or `.tsx` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when every input is unchanged and the previous output is still on disk (the key is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means (`nmr clean`, `rm -rf dist`, `git clean`) therefore forces a rebuild rather than a skip. This is the default `compile` script; run it from a package directory. It takes no arguments: `--help` prints its usage, and any other argument is a usage error, which exits 2 without compiling.
24
24
 
25
25
  **What it compiles.** Entry points are `src/**/*.{ts,tsx}` less the directories that contain test scaffolding rather than published code: `__fixtures__/`, `__mocks__/`, `__tests__/`, and `test-utils/`. Add to that list per package with [`build.extraIgnorePatterns`](scripts.md#package-level-configuration). Ignoring a directory drops it as an _entry point_, not from the emit: The compiler still emits whatever the surviving entry points import, so a helper that production code uses is still compiled and the compiler never emits a dangling specifier in its importer. This list is deliberately not the Vitest [coverage exclusions](vitest.md#what-the-config-excludes): Helpers live in `test-utils/` precisely so that they stay covered.
26
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "1.0.0",
3
+ "version": "2.0.1",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -80,9 +80,9 @@
80
80
  "!docs/*.v*.md"
81
81
  ],
82
82
  "dependencies": {
83
- "@williamthorsen/nmr-core": "0.16.0",
84
- "@williamthorsen/toolbelt.errors": "0.9.0",
85
- "@williamthorsen/toolbelt.filesystem": "0.10.0",
83
+ "@williamthorsen/nmr-core": "0.16.1",
84
+ "@williamthorsen/toolbelt.errors": "0.9.1",
85
+ "@williamthorsen/toolbelt.filesystem": "0.10.1",
86
86
  "glob": "14.0.4",
87
87
  "prettier": "3.9.10",
88
88
  "prettier-plugin-sh": "0.20.2",
@@ -90,13 +90,13 @@
90
90
  "unconfig": "7.5.0"
91
91
  },
92
92
  "devDependencies": {
93
- "@williamthorsen/toolbelt.testing": "0.8.0",
94
- "@williamthorsen/toolbelt.vitest": "0.13.2",
93
+ "@williamthorsen/toolbelt.testing": "0.8.1",
94
+ "@williamthorsen/toolbelt.vitest": "0.13.3",
95
95
  "esbuild": "0.28.2",
96
- "readyup": "0.45.0",
96
+ "readyup": "0.45.1",
97
97
  "tinyglobby": "0.2.17",
98
98
  "typescript": "6.0.3",
99
- "vite": "8.3.6",
99
+ "vite": "8.3.7",
100
100
  "vitest": "5.1.0",
101
101
  "yaml": "2.9.1"
102
102
  },