@williamthorsen/nmr 0.30.0 → 0.31.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.
Files changed (64) hide show
  1. package/.readyup/kits/default.js +27 -16
  2. package/.readyup/manifest.json +2 -2
  3. package/README.md +299 -62
  4. package/agents/guidance/rulebooks/nmr.md +4 -2
  5. package/bin/nmr-report-catalog.js +11 -0
  6. package/dist/esm/UserError.d.ts +3 -0
  7. package/dist/esm/UserError.js +6 -0
  8. package/dist/esm/check-cache.d.ts +36 -1
  9. package/dist/esm/check-cache.js +91 -23
  10. package/dist/esm/cli-build.js +2 -1
  11. package/dist/esm/cli-clean.js +2 -1
  12. package/dist/esm/cli-ensure-prepublish-hooks.js +2 -1
  13. package/dist/esm/cli-report-catalog.d.ts +1 -0
  14. package/dist/esm/cli-report-catalog.js +11 -0
  15. package/dist/esm/cli-report-overrides.js +2 -1
  16. package/dist/esm/cli-taze.js +2 -1
  17. package/dist/esm/cli.js +2 -1
  18. package/dist/esm/commands/report-catalog.d.ts +1 -0
  19. package/dist/esm/commands/report-catalog.js +34 -0
  20. package/dist/esm/commands/report-overrides.js +22 -7
  21. package/dist/esm/commands/taze.js +2 -1
  22. package/dist/esm/config.d.ts +1 -0
  23. package/dist/esm/config.js +66 -18
  24. package/dist/esm/default-scripts.d.ts +5 -1
  25. package/dist/esm/default-scripts.js +13 -10
  26. package/dist/esm/defineConfig.d.ts +1 -1
  27. package/dist/esm/help.js +3 -1
  28. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  29. package/dist/esm/helpers/clampToBytes.js +20 -0
  30. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  31. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  32. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  33. package/dist/esm/helpers/duration.js +16 -9
  34. package/dist/esm/helpers/package-json.d.ts +11 -4
  35. package/dist/esm/helpers/package-json.js +51 -22
  36. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  37. package/dist/esm/helpers/readStringValues.js +8 -0
  38. package/dist/esm/helpers/transcript.d.ts +5 -0
  39. package/dist/esm/helpers/transcript.js +59 -0
  40. package/dist/esm/recording.d.ts +65 -0
  41. package/dist/esm/recording.js +96 -0
  42. package/dist/esm/replay-assembly.d.ts +9 -0
  43. package/dist/esm/replay-assembly.js +26 -0
  44. package/dist/esm/report-format.d.ts +18 -0
  45. package/dist/esm/report-format.js +25 -0
  46. package/dist/esm/reportCliFailure.d.ts +2 -0
  47. package/dist/esm/reportCliFailure.js +11 -0
  48. package/dist/esm/resolve-scripts.d.ts +1 -1
  49. package/dist/esm/resolver.d.ts +9 -1
  50. package/dist/esm/resolver.js +26 -17
  51. package/dist/esm/runCli.js +339 -74
  52. package/dist/esm/runner.d.ts +9 -3
  53. package/dist/esm/runner.js +25 -4
  54. package/dist/esm/scripts.d.ts +1 -1
  55. package/dist/esm/steps.d.ts +9 -2
  56. package/dist/esm/steps.js +117 -4
  57. package/dist/esm/types.d.ts +11 -2
  58. package/dist/esm/verbosity.d.ts +15 -4
  59. package/dist/esm/verbosity.js +32 -12
  60. package/dist/esm/verdict.d.ts +29 -0
  61. package/dist/esm/verdict.js +150 -0
  62. package/dist/esm/workspace.d.ts +2 -0
  63. package/dist/esm/workspace.js +20 -3
  64. package/package.json +12 -5
@@ -1,4 +1,5 @@
1
1
  import type { Writable } from 'node:stream';
2
+ import type { OutputChannels } from './runner.js';
2
3
  import type { CheckCacheConfig, NmrConfig } from './types.js';
3
4
  export interface CheckCacheEntry {
4
5
  key: string;
@@ -10,6 +11,22 @@ export interface CheckCacheEntry {
10
11
  durationMs: number;
11
12
  recordedAt: string;
12
13
  buildDigests: Record<string, string>;
14
+ retention?: Retention;
15
+ }
16
+ export interface EntryRef {
17
+ anchorDir: string;
18
+ command: string;
19
+ monorepoRoot: string;
20
+ }
21
+ export interface ReplayLine {
22
+ command: string;
23
+ excerpt: string;
24
+ scope: string;
25
+ }
26
+ export interface Retention {
27
+ key: string;
28
+ replay: ReplayLine[];
29
+ runId: string;
13
30
  }
14
31
  export interface BuildOutputState {
15
32
  missing: string[];
@@ -28,7 +45,17 @@ export declare const CURRENT_RUNTIME: RuntimeIdentity;
28
45
  export declare const DEBUG_ENV_VAR = "NMR_DEBUG";
29
46
  export declare const DEFAULT_CACHEABLE_COMMANDS: string[];
30
47
  export declare const NO_CACHE_ENV_VAR = "NMR_NO_CACHE";
48
+ export declare const RUN_ID_ENV_VAR = "NMR_RUN_ID";
31
49
  export declare const TREE_SNAPSHOT_ENV_VAR = "NMR_TREE_SNAPSHOT";
50
+ export declare function certifyRetention(options: {
51
+ anchorDir: string;
52
+ command: string;
53
+ entry: CheckCacheEntry;
54
+ env: NodeJS.ProcessEnv;
55
+ monorepoRoot: string;
56
+ runId: string;
57
+ stderr: Writable;
58
+ }): Promise<void>;
32
59
  export declare function computeCacheKey(options: {
33
60
  anchorDir: string;
34
61
  command: string;
@@ -45,18 +72,26 @@ export declare function computeCacheKey(options: {
45
72
  ok: false;
46
73
  reason: string;
47
74
  };
75
+ export declare function computeRetentionKey(options: {
76
+ channels: OutputChannels;
77
+ env: NodeJS.ProcessEnv;
78
+ passKey: string;
79
+ }): string;
48
80
  export declare function encodeTreeSnapshot(snapshot: TreeSnapshot): string;
49
81
  export declare function findStaleBuildOutput(earlier: Record<string, string>, later: Record<string, string>): string | undefined;
50
82
  export declare function formatMisplacedNoCacheWarning(command: string): string;
51
- export declare function formatSkipLine(command: string, entry: CheckCacheEntry, now: number): string;
83
+ export declare function isCacheableCommand(checkCache: CheckCacheConfig | undefined, command: string): boolean;
52
84
  export declare function readBuildOutputState(monorepoRoot: string, config: NmrConfig): Promise<BuildOutputState>;
53
85
  export declare function readCheckCacheEntry(options: {
54
86
  anchorDir: string;
55
87
  command: string;
56
88
  monorepoRoot: string;
57
89
  }): Promise<CheckCacheEntry | undefined>;
90
+ export declare function readTranscript(ref: EntryRef): Promise<string | undefined>;
91
+ export declare function recordTranscript(ref: EntryRef, transcript: string | undefined): Promise<void>;
58
92
  export declare function removeCheckCache(scopeDir: string): Promise<void>;
59
93
  export declare function resolveCacheableCommands(checkCache: CheckCacheConfig | undefined): Set<string>;
94
+ export declare function resolveRunId(env: NodeJS.ProcessEnv): string;
60
95
  export declare function resolveTreeSnapshot(options: {
61
96
  monorepoRoot: string;
62
97
  env: NodeJS.ProcessEnv;
@@ -1,11 +1,12 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { readFileSync, realpathSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
- import { hashWorkingTree, readHeadSha, readJsonCacheEntry, removeCacheDir, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
5
+ import { hashWorkingTree, readCacheEntry, readHeadSha, readJsonCacheEntry, removeCacheDir, removeCacheEntry, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
+ import { describeError } from '@williamthorsen/toolbelt.errors';
6
7
  import { hasBuildOutput, readBuildDigest } from "./commands/build-output.js";
7
8
  import { loadWorkspaceConfig } from "./config.js";
8
- import { formatDuration, formatSaving } from "./helpers/duration.js";
9
+ import { isHookName } from "./helpers/hook-name.js";
9
10
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
10
11
  import { getDefaultWorkspaceScripts } from "./resolve-scripts.js";
11
12
  import { buildWorkspaceRegistry, resolveScript } from "./resolver.js";
@@ -39,6 +40,7 @@ export const DEFAULT_CACHEABLE_COMMANDS = [
39
40
  'typecheck',
40
41
  ];
41
42
  export const NO_CACHE_ENV_VAR = 'NMR_NO_CACHE';
43
+ export const RUN_ID_ENV_VAR = 'NMR_RUN_ID';
42
44
  export const TREE_SNAPSHOT_ENV_VAR = 'NMR_TREE_SNAPSHOT';
43
45
  const BUILT_IN_COMPILE = 'nmr-compile';
44
46
  const CACHE_TOOL = 'nmr-check';
@@ -48,7 +50,26 @@ const INSTALL_FINGERPRINT_FILES = [
48
50
  ];
49
51
  const KEY_FORMAT = 'nmr-check-cache-v1';
50
52
  const KEYED_ENV_VARS = ['LANG', 'LC_ALL', 'NODE_OPTIONS', 'TZ'];
53
+ const RETENTION_KEY_FORMAT = 'nmr-retention-v1';
54
+ const RETENTION_KEYED_ENV_VARS = ['CI', 'COLUMNS', 'FORCE_COLOR', 'NO_COLOR', 'TERM'];
51
55
  const UNSAFE_SLUG_CHARACTERS = /[^\w.-]+/g;
56
+ export async function certifyRetention(options) {
57
+ const { entry, runId } = options;
58
+ if (entry.retention === undefined || entry.retention.runId === runId) {
59
+ return;
60
+ }
61
+ try {
62
+ await writeCheckCacheEntry({
63
+ anchorDir: options.anchorDir,
64
+ command: options.command,
65
+ monorepoRoot: options.monorepoRoot,
66
+ entry: { ...entry, retention: { ...entry.retention, runId } },
67
+ });
68
+ }
69
+ catch (error) {
70
+ writeDebugNote(`could not certify ${options.command}: ${describeError(error)}`, options.env, options.stderr);
71
+ }
72
+ }
52
73
  export function computeCacheKey(options) {
53
74
  const fingerprint = resolveInstallFingerprint(options.monorepoRoot);
54
75
  if (!fingerprint.ok) {
@@ -68,16 +89,16 @@ export function computeCacheKey(options) {
68
89
  runtime.arch,
69
90
  fingerprint.fingerprint,
70
91
  ];
71
- for (const name of KEYED_ENV_VARS) {
72
- const value = options.env[name];
73
- parts.push(name, value === undefined ? 'unset' : 'set', value ?? '');
74
- }
75
- const hash = createHash('sha256');
76
- for (const part of parts) {
77
- hash.update(part);
78
- hash.update('\0');
79
- }
80
- return { ok: true, key: hash.digest('hex') };
92
+ return { ok: true, key: digestParts([...parts, ...composeEnvParts(KEYED_ENV_VARS, options.env)]) };
93
+ }
94
+ export function computeRetentionKey(options) {
95
+ const parts = [
96
+ RETENTION_KEY_FORMAT,
97
+ options.passKey,
98
+ describeChannel(options.channels.stdout),
99
+ describeChannel(options.channels.stderr),
100
+ ];
101
+ return digestParts([...parts, ...composeEnvParts(RETENTION_KEYED_ENV_VARS, options.env)]);
81
102
  }
82
103
  export function encodeTreeSnapshot(snapshot) {
83
104
  return `${snapshot.hash} ${snapshot.headSha}`;
@@ -90,11 +111,8 @@ export function formatMisplacedNoCacheWarning(command) {
90
111
  return (`⚠️ --no-cache after the command name is passed to \`${command}\`, not read by nmr. ` +
91
112
  `Did you mean \`nmr --no-cache ${command}\`?`);
92
113
  }
93
- export function formatSkipLine(command, entry, now) {
94
- const age = formatDuration(Math.max(0, now - Date.parse(entry.recordedAt)));
95
- const saving = formatSaving(entry.durationMs);
96
- const savingClause = saving === undefined ? '' : ` (${saving})`;
97
- return `⏭️ ${command}: passed ${age} ago on this tree${savingClause}.`;
114
+ export function isCacheableCommand(checkCache, command) {
115
+ return !isHookName(command) && checkCache?.enabled !== false && resolveCacheableCommands(checkCache).has(command);
98
116
  }
99
117
  export async function readBuildOutputState(monorepoRoot, config) {
100
118
  const state = { missing: [], digests: {} };
@@ -126,7 +144,23 @@ export async function readBuildOutputState(monorepoRoot, config) {
126
144
  return state;
127
145
  }
128
146
  export async function readCheckCacheEntry(options) {
129
- return readJsonCacheEntry(resolveEntryPath(options), isCheckCacheEntry);
147
+ const parsed = await readJsonCacheEntry(resolveEntryPath(options), isParsedCheckCacheEntry);
148
+ if (parsed === undefined) {
149
+ return undefined;
150
+ }
151
+ const { retention, ...pass } = parsed;
152
+ return isRetention(retention) ? { ...pass, retention } : pass;
153
+ }
154
+ export async function readTranscript(ref) {
155
+ return readCacheEntry(resolveEntryPath(ref, '.log'));
156
+ }
157
+ export async function recordTranscript(ref, transcript) {
158
+ const entryPath = resolveEntryPath(ref, '.log');
159
+ if (transcript === undefined) {
160
+ await removeCacheEntry(entryPath);
161
+ return;
162
+ }
163
+ await writeCacheEntry(entryPath, transcript);
130
164
  }
131
165
  export async function removeCheckCache(scopeDir) {
132
166
  await removeCacheDir({ tool: CACHE_TOOL, scopeDir });
@@ -139,6 +173,10 @@ export function resolveCacheableCommands(checkCache) {
139
173
  }
140
174
  return commands;
141
175
  }
176
+ export function resolveRunId(env) {
177
+ const inherited = env[RUN_ID_ENV_VAR];
178
+ return inherited === undefined || inherited === '' ? randomUUID() : inherited;
179
+ }
142
180
  export function resolveTreeSnapshot(options) {
143
181
  const inherited = decodeTreeSnapshot(options.env[TREE_SNAPSHOT_ENV_VAR]);
144
182
  if (inherited !== undefined && readHeadSha(options.monorepoRoot) === inherited.headSha) {
@@ -168,6 +206,12 @@ export function writeDebugNote(message, env, stderr) {
168
206
  stderr.write(`nmr check-cache: ${message}\n`);
169
207
  }
170
208
  }
209
+ function composeEnvParts(names, env) {
210
+ return names.flatMap((name) => {
211
+ const value = env[name];
212
+ return [name, value === undefined ? 'unset' : 'set', value ?? ''];
213
+ });
214
+ }
171
215
  function decodeTreeSnapshot(encoded) {
172
216
  if (encoded === undefined) {
173
217
  return undefined;
@@ -178,7 +222,18 @@ function decodeTreeSnapshot(encoded) {
178
222
  }
179
223
  return { hash, headSha };
180
224
  }
181
- function isCheckCacheEntry(value) {
225
+ function describeChannel(channel) {
226
+ return channel === 'pipe' ? 'pipe' : 'descriptor';
227
+ }
228
+ function digestParts(parts) {
229
+ const hash = createHash('sha256');
230
+ for (const part of parts) {
231
+ hash.update(part);
232
+ hash.update('\0');
233
+ }
234
+ return hash.digest('hex');
235
+ }
236
+ function isParsedCheckCacheEntry(value) {
182
237
  if (!isObject(value)) {
183
238
  return false;
184
239
  }
@@ -193,19 +248,32 @@ function isCheckCacheEntry(value) {
193
248
  }
194
249
  function isProbeSubject(packageDir, registry) {
195
250
  const build = resolveScript('build', registry, packageDir, false);
196
- if (build === undefined || build.source === 'package') {
251
+ if (build === undefined || build.origin.tier === 'package') {
197
252
  return false;
198
253
  }
199
254
  const compile = resolveScript('compile', registry, packageDir, false);
200
255
  return compile !== undefined && renderChain(compile.steps) === BUILT_IN_COMPILE;
201
256
  }
202
- function resolveEntryPath(options) {
257
+ function isReplayLine(value) {
258
+ return (isObject(value) &&
259
+ typeof value['command'] === 'string' &&
260
+ typeof value['excerpt'] === 'string' &&
261
+ typeof value['scope'] === 'string');
262
+ }
263
+ function isRetention(value) {
264
+ return (isObject(value) &&
265
+ typeof value['key'] === 'string' &&
266
+ typeof value['runId'] === 'string' &&
267
+ Array.isArray(value['replay']) &&
268
+ value['replay'].every((line) => isReplayLine(line)));
269
+ }
270
+ function resolveEntryPath(options, extension = '.json') {
203
271
  const anchorDir = path.resolve(options.anchorDir);
204
272
  const ref = {
205
273
  tool: CACHE_TOOL,
206
274
  scopeDir: options.monorepoRoot,
207
275
  slug: `${path.basename(anchorDir)}-${options.command.replaceAll(UNSAFE_SLUG_CHARACTERS, '-')}`,
208
- extension: '.json',
276
+ extension,
209
277
  discriminators: [anchorDir, options.command],
210
278
  };
211
279
  return resolveCacheEntryPath(ref);
@@ -1,4 +1,5 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { buildPackage } from "./commands/build.js";
3
4
  import { loadWorkspaceConfig } from "./config.js";
4
5
  try {
@@ -9,6 +10,6 @@ try {
9
10
  });
10
11
  }
11
12
  catch (error) {
12
- reportError(error instanceof Error ? error.message : String(error));
13
+ reportError(describeError(error));
13
14
  process.exitCode = 1;
14
15
  }
@@ -1,9 +1,10 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { runClean } from "./commands/clean.js";
3
4
  try {
4
5
  await runClean(process.cwd());
5
6
  }
6
7
  catch (error) {
7
- reportError(error instanceof Error ? error.message : String(error));
8
+ reportError(describeError(error));
8
9
  process.exitCode = 1;
9
10
  }
@@ -1,4 +1,5 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { DEFAULT_HOOK, ensurePrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
3
4
  import { findMonorepoRoot } from "./workspace.js";
4
5
  const flagSchema = {
@@ -38,6 +39,6 @@ try {
38
39
  }
39
40
  }
40
41
  catch (error) {
41
- reportError(error instanceof Error ? error.message : String(error));
42
+ reportError(describeError(error));
42
43
  process.exitCode = 1;
43
44
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import process from 'node:process';
2
+ import { reportError } from '@williamthorsen/nmr-core';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { reportCatalog } from "./commands/report-catalog.js";
5
+ try {
6
+ reportCatalog(process.cwd());
7
+ }
8
+ catch (error) {
9
+ reportError(describeError(error));
10
+ process.exitCode = 1;
11
+ }
@@ -1,4 +1,5 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { reportOverrides } from "./commands/report-overrides.js";
3
4
  import { findMonorepoRoot } from "./workspace.js";
4
5
  try {
@@ -6,6 +7,6 @@ try {
6
7
  reportOverrides(monorepoRoot);
7
8
  }
8
9
  catch (error) {
9
- reportError(error instanceof Error ? error.message : String(error));
10
+ reportError(describeError(error));
10
11
  process.exitCode = 1;
11
12
  }
@@ -1,10 +1,11 @@
1
1
  import process from 'node:process';
2
2
  import { reportError } from '@williamthorsen/nmr-core';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
3
4
  import { runTaze } from "./commands/taze.js";
4
5
  try {
5
6
  process.exitCode = runTaze(process.argv.slice(2));
6
7
  }
7
8
  catch (error) {
8
- reportError(error instanceof Error ? error.message : String(error));
9
+ reportError(describeError(error));
9
10
  process.exitCode = 1;
10
11
  }
package/dist/esm/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import process from 'node:process';
2
+ import { reportCliFailure } from "./reportCliFailure.js";
2
3
  import { runCli } from "./runCli.js";
3
4
  try {
4
5
  const { exitCode } = await runCli({
@@ -11,6 +12,6 @@ try {
11
12
  process.exitCode = exitCode;
12
13
  }
13
14
  catch (error) {
14
- process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
15
+ reportCliFailure(error, process.stderr);
15
16
  process.exitCode = 1;
16
17
  }
@@ -0,0 +1 @@
1
+ export declare function reportCatalog(cwd: string): void;
@@ -0,0 +1,34 @@
1
+ import { findContainingPackageDir } from "../context.js";
2
+ import { DEPENDENCY_FIELDS, readPackageJson } from "../helpers/package-json.js";
3
+ import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
4
+ const CATALOG_PROTOCOL = 'catalog:';
5
+ export function reportCatalog(cwd) {
6
+ const monorepoRoot = findMonorepoRoot(cwd);
7
+ const packageDir = findContainingPackageDir(cwd, getWorkspacePackageDirs(monorepoRoot));
8
+ if (packageDir === undefined) {
9
+ return;
10
+ }
11
+ const dependencies = findCataloguedDependencies(packageDir);
12
+ if (dependencies.length === 0) {
13
+ return;
14
+ }
15
+ const subject = dependencies.length === 1 ? '1 dependency comes' : `${dependencies.length} dependencies come`;
16
+ console.warn(`📚 WARN: ${subject} from a catalog, which a package-scoped upgrade does not read:`);
17
+ for (const { name, specifier } of dependencies) {
18
+ console.warn(`- ${name} → ${specifier}`);
19
+ }
20
+ console.warn(`Run \`nmr upgrade\` from ${monorepoRoot} to include them.`);
21
+ }
22
+ function findCataloguedDependencies(packageDir) {
23
+ const pkg = readPackageJson(packageDir);
24
+ const found = new Map();
25
+ for (const field of DEPENDENCY_FIELDS) {
26
+ const declared = Object.entries(pkg[field] ?? {});
27
+ for (const [name, specifier] of declared) {
28
+ if (specifier.startsWith(CATALOG_PROTOCOL) && !found.has(name)) {
29
+ found.set(name, specifier);
30
+ }
31
+ }
32
+ }
33
+ return [...found].map(([name, specifier]) => ({ name, specifier })).toSorted((a, b) => a.name.localeCompare(b.name));
34
+ }
@@ -1,12 +1,27 @@
1
1
  import { getPnpmOverrides, readPackageJson } from "../helpers/package-json.js";
2
+ import { UserError } from "../UserError.js";
3
+ import { readWorkspaceOverrides } from "../workspace.js";
2
4
  export function reportOverrides(monorepoRoot) {
3
- const pkg = readPackageJson(monorepoRoot);
4
- const overrides = getPnpmOverrides(pkg);
5
- if (!overrides || Object.keys(overrides).length === 0) {
6
- return;
5
+ const declared = listEntries(readWorkspaceOverrides(monorepoRoot));
6
+ if (declared.length > 0) {
7
+ console.warn('🔒 WARN: pnpm overrides are active! Check whether these are still needed:');
8
+ for (const [name, version] of declared) {
9
+ console.warn(`- ${name} → ${version}`);
10
+ }
7
11
  }
8
- console.warn('🔒 WARN: pnpm overrides are active! Check whether these are still needed:');
9
- for (const [name, version] of Object.entries(overrides)) {
10
- console.warn(`- ${name} → ${version}`);
12
+ rejectLegacyOverrides(monorepoRoot);
13
+ }
14
+ function listEntries(overrides) {
15
+ return Object.entries(overrides ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
16
+ }
17
+ function rejectLegacyOverrides(monorepoRoot) {
18
+ const legacy = listEntries(getPnpmOverrides(readPackageJson(monorepoRoot)));
19
+ if (legacy.length === 0) {
20
+ return;
11
21
  }
22
+ throw new UserError([
23
+ 'pnpm 11 reads no `pnpm.overrides` from package.json, so these pin nothing while an upgrade run with `--write` goes on rewriting them:',
24
+ ...legacy.map(([name, version]) => `- ${name} → ${String(version)}`),
25
+ 'Move them to the `overrides` block in pnpm-workspace.yaml, quoting each version, or run `pnpx codemod run pnpm-v10-to-v11`.',
26
+ ].join('\n'));
12
27
  }
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import process from 'node:process';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { reportError } from '@williamthorsen/nmr-core';
5
+ import { describeError } from '@williamthorsen/toolbelt.errors';
5
6
  const TAZE_CLI_SPECIFIER = 'taze/cli';
6
7
  export function runTaze(args, options = {}) {
7
8
  const stderr = options.stderr ?? process.stderr;
@@ -12,7 +13,7 @@ export function runTaze(args, options = {}) {
12
13
  cliPath = resolveCliPath();
13
14
  }
14
15
  catch (error) {
15
- const detail = error instanceof Error ? error.message : String(error);
16
+ const detail = describeError(error);
16
17
  reportError(`Could not resolve '${TAZE_CLI_SPECIFIER}' from nmr's dependencies. Reinstall the workspace to restore it. (${detail})`, stderr);
17
18
  return 1;
18
19
  }
@@ -1,4 +1,5 @@
1
1
  import type { NmrConfig } from './types.js';
2
+ export declare const CONFIG_RELATIVE_PATH: string;
2
3
  export declare function resolveConfigPath(baseDir: string): string;
3
4
  export declare function loadConfig(baseDir: string): Promise<NmrConfig>;
4
5
  export declare function loadRootConfig(monorepoRoot: string): Promise<NmrConfig>;
@@ -11,12 +11,15 @@ import path from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
13
13
  import { findUnexpressibleToken } from "./steps.js";
14
+ import { UserError } from "./UserError.js";
15
+ import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
14
16
  const CONFIG_FILENAME = 'nmr.config.ts';
15
17
  const CONFIG_DIR = '.config';
18
+ export const CONFIG_RELATIVE_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
16
19
  const CONFIG_TIERS = {
17
20
  root: {
18
21
  label: 'a monorepo-root config',
19
- honoredKeys: ['checkCache', 'devBin', 'rootScripts', 'workspaceScripts'],
22
+ honoredKeys: ['checkCache', 'devBin', 'output', 'rootScripts', 'workspaceScripts'],
20
23
  elsewhere: "the package's own config",
21
24
  },
22
25
  workspace: {
@@ -28,6 +31,8 @@ const CONFIG_TIERS = {
28
31
  const RECOGNIZED_KEYS = [...CONFIG_TIERS.root.honoredKeys, ...CONFIG_TIERS.workspace.honoredKeys];
29
32
  const RECOGNIZED_BUILD_KEYS = ['extraIgnorePatterns'];
30
33
  const RECOGNIZED_CHECK_CACHE_KEYS = ['enabled', 'excludeCommands', 'extraCommands'];
34
+ const RECOGNIZED_OUTPUT_KEYS = ['commandVerbosity', 'extraAgentEnvVars'];
35
+ const RECOGNIZED_STEP_KEYS = ['declinesArgs', 'run'];
31
36
  export function resolveConfigPath(baseDir) {
32
37
  return path.join(baseDir, CONFIG_DIR, CONFIG_FILENAME);
33
38
  }
@@ -35,33 +40,46 @@ function isScriptRecord(value) {
35
40
  if (!isObject(value))
36
41
  return false;
37
42
  for (const v of Object.values(value)) {
38
- if (typeof v !== 'string' && !Array.isArray(v))
39
- return false;
40
- if (Array.isArray(v) && v.some((item) => typeof item !== 'string'))
43
+ if (typeof v === 'string')
44
+ continue;
45
+ if (!Array.isArray(v) || !v.every(isScriptElement))
41
46
  return false;
42
47
  }
43
48
  return true;
44
49
  }
50
+ function isScriptElement(value) {
51
+ if (typeof value === 'string')
52
+ return true;
53
+ if (!isObject(value) || typeof value['run'] !== 'string')
54
+ return false;
55
+ const declinesArgs = value['declinesArgs'];
56
+ return declinesArgs === undefined || typeof declinesArgs === 'boolean';
57
+ }
45
58
  function validateScriptField(value, fieldName, configPath) {
46
59
  if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
47
60
  return undefined;
48
61
  }
49
62
  const scripts = value[fieldName];
50
63
  if (!isScriptRecord(scripts)) {
51
- throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | string[]>`);
64
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string | element[]>, ` +
65
+ 'where an element is a command string or `{ run: string, declinesArgs?: boolean }`');
52
66
  }
53
- assertExpressibleElements(scripts, fieldName, configPath);
67
+ assertValidElements(scripts, fieldName, configPath);
54
68
  return scripts;
55
69
  }
56
- function assertExpressibleElements(scripts, fieldName, configPath) {
70
+ function assertValidElements(scripts, fieldName, configPath) {
57
71
  for (const [command, script] of Object.entries(scripts)) {
58
72
  if (typeof script === 'string')
59
73
  continue;
60
- for (const element of script) {
74
+ for (const spec of script) {
75
+ if (typeof spec !== 'string') {
76
+ assertRecognizedKeys(spec, RECOGNIZED_STEP_KEYS, configPath, `${fieldName}.${command}.`);
77
+ }
78
+ const element = typeof spec === 'string' ? spec : spec.run;
61
79
  const token = findUnexpressibleToken(element);
62
80
  if (token === undefined)
63
81
  continue;
64
- throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}.${command}\` element \`${element}\` carries ` +
82
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}.${command}\` element \`${element}\` carries ` +
65
83
  `\`${token}\`, which is neither a command name nor an nmr flag. Name it as a script of its own and ` +
66
84
  `reference that name here.`);
67
85
  }
@@ -76,12 +94,12 @@ function validateBuildField(value, configPath) {
76
94
  return undefined;
77
95
  }
78
96
  if (!isObject(build)) {
79
- throw new Error(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
97
+ throw new UserError(`Invalid nmr config at ${configPath}: \`build\` must be an object`);
80
98
  }
81
99
  assertRecognizedKeys(build, RECOGNIZED_BUILD_KEYS, configPath, 'build.');
82
100
  const extraIgnorePatterns = build['extraIgnorePatterns'];
83
101
  if (extraIgnorePatterns !== undefined && !isStringArray(extraIgnorePatterns)) {
84
- throw new Error(`Invalid nmr config at ${configPath}: \`build.extraIgnorePatterns\` must be a string[]`);
102
+ throw new UserError(`Invalid nmr config at ${configPath}: \`build.extraIgnorePatterns\` must be a string[]`);
85
103
  }
86
104
  return extraIgnorePatterns === undefined ? {} : { extraIgnorePatterns };
87
105
  }
@@ -91,14 +109,14 @@ function validateCheckCacheField(value, configPath) {
91
109
  return undefined;
92
110
  }
93
111
  if (!isObject(checkCache)) {
94
- throw new Error(`Invalid nmr config at ${configPath}: \`checkCache\` must be an object`);
112
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache\` must be an object`);
95
113
  }
96
114
  assertRecognizedKeys(checkCache, RECOGNIZED_CHECK_CACHE_KEYS, configPath, 'checkCache.');
97
115
  const config = {};
98
116
  const enabled = checkCache['enabled'];
99
117
  if (enabled !== undefined) {
100
118
  if (typeof enabled !== 'boolean') {
101
- throw new TypeError(`Invalid nmr config at ${configPath}: \`checkCache.enabled\` must be a boolean`);
119
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.enabled\` must be a boolean`);
102
120
  }
103
121
  config.enabled = enabled;
104
122
  }
@@ -108,24 +126,51 @@ function validateCheckCacheField(value, configPath) {
108
126
  continue;
109
127
  }
110
128
  if (!isStringArray(commands)) {
111
- throw new Error(`Invalid nmr config at ${configPath}: \`checkCache.${field}\` must be a string[]`);
129
+ throw new UserError(`Invalid nmr config at ${configPath}: \`checkCache.${field}\` must be a string[]`);
112
130
  }
113
131
  config[field] = commands;
114
132
  }
115
133
  return config;
116
134
  }
135
+ function validateOutputField(value, configPath) {
136
+ const output = value['output'];
137
+ if (output === undefined) {
138
+ return undefined;
139
+ }
140
+ if (!isObject(output)) {
141
+ throw new UserError(`Invalid nmr config at ${configPath}: \`output\` must be an object`);
142
+ }
143
+ assertRecognizedKeys(output, RECOGNIZED_OUTPUT_KEYS, configPath, 'output.');
144
+ const config = {};
145
+ const commandVerbosity = output['commandVerbosity'];
146
+ if (commandVerbosity !== undefined) {
147
+ if (typeof commandVerbosity !== 'string' || !isCommandVerbosity(commandVerbosity)) {
148
+ const rendered = typeof commandVerbosity === 'string' ? commandVerbosity : JSON.stringify(commandVerbosity);
149
+ throw new UserError(`Invalid nmr config at ${configPath}: ${formatVerbosityRejection('`output.commandVerbosity`', rendered)}`);
150
+ }
151
+ config.commandVerbosity = commandVerbosity;
152
+ }
153
+ const extraAgentEnvVars = output['extraAgentEnvVars'];
154
+ if (extraAgentEnvVars !== undefined) {
155
+ if (!isStringArray(extraAgentEnvVars)) {
156
+ throw new UserError(`Invalid nmr config at ${configPath}: \`output.extraAgentEnvVars\` must be a string[]`);
157
+ }
158
+ config.extraAgentEnvVars = extraAgentEnvVars;
159
+ }
160
+ return config;
161
+ }
117
162
  function validateStringRecordField(value, fieldName, configPath) {
118
163
  if (!Object.hasOwn(value, fieldName) || value[fieldName] === undefined) {
119
164
  return undefined;
120
165
  }
121
166
  if (!isStringRecord(value[fieldName])) {
122
- throw new Error(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string>`);
167
+ throw new UserError(`Invalid nmr config at ${configPath}: \`${fieldName}\` must be a Record<string, string>`);
123
168
  }
124
169
  return value[fieldName];
125
170
  }
126
171
  function validateConfig(value, configPath) {
127
172
  if (!isObject(value)) {
128
- throw new Error(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
173
+ throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
129
174
  }
130
175
  assertRecognizedKeys(value, RECOGNIZED_KEYS, configPath);
131
176
  const config = {};
@@ -138,6 +183,9 @@ function validateConfig(value, configPath) {
138
183
  const devBin = validateStringRecordField(value, 'devBin', configPath);
139
184
  if (devBin)
140
185
  config.devBin = devBin;
186
+ const output = validateOutputField(value, configPath);
187
+ if (output)
188
+ config.output = output;
141
189
  const workspaceScripts = validateScriptField(value, 'workspaceScripts', configPath);
142
190
  if (workspaceScripts)
143
191
  config.workspaceScripts = workspaceScripts;
@@ -170,7 +218,7 @@ function assertRecognizedKeys(value, recognizedKeys, configPath, prefix = '') {
170
218
  if (unrecognized.length === 0) {
171
219
  return;
172
220
  }
173
- throw new Error(`Invalid nmr config at ${configPath}: unrecognized ${unrecognized.length === 1 ? 'key' : 'keys'} ` +
221
+ throw new UserError(`Invalid nmr config at ${configPath}: unrecognized ${unrecognized.length === 1 ? 'key' : 'keys'} ` +
174
222
  `${formatKeyList(unrecognized, prefix)}. Recognized: ${formatKeyList(recognizedKeys, prefix)}.`);
175
223
  }
176
224
  function assertTierKeys(config, tier, configPath) {
@@ -178,7 +226,7 @@ function assertTierKeys(config, tier, configPath) {
178
226
  if (unsupported.length === 0) {
179
227
  return;
180
228
  }
181
- throw new Error(`Invalid nmr config at ${configPath}: ${tier.label} honors ${tier.honoredKeys.toSorted().join(', ')} alone, ` +
229
+ throw new UserError(`Invalid nmr config at ${configPath}: ${tier.label} honors ${tier.honoredKeys.toSorted().join(', ')} alone, ` +
182
230
  `not ${unsupported.toSorted().join(', ')}. Move those keys to ${tier.elsewhere}.`);
183
231
  }
184
232
  function formatKeyList(keys, prefix) {