@williamthorsen/nmr 0.54.0 → 0.55.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 (69) hide show
  1. package/.meta/changelog.json +65 -0
  2. package/.readyup/kits/default.js +25 -10
  3. package/.readyup/manifest.json +26 -20
  4. package/CHANGELOG.md +22 -0
  5. package/README.md +10 -3
  6. package/bin/ensure-prepublish-hooks.js +6 -2
  7. package/bin/nmr-actionlint.js +6 -2
  8. package/bin/nmr-clean.js +6 -2
  9. package/bin/nmr-compile.js +6 -2
  10. package/bin/nmr-fmt.js +6 -2
  11. package/bin/nmr-report-catalog.js +6 -2
  12. package/bin/nmr-report-overrides.js +6 -2
  13. package/bin/nmr-shellcheck.js +15 -0
  14. package/bin/nmr-taze.js +6 -2
  15. package/bin/nmr-vitest.js +6 -2
  16. package/bin/nmr.js +6 -2
  17. package/dist/esm/check-cache.js +1 -0
  18. package/dist/esm/child-process.js +1 -1
  19. package/dist/esm/cli-actionlint.js +2 -3
  20. package/dist/esm/cli-build.js +2 -3
  21. package/dist/esm/cli-clean.js +2 -3
  22. package/dist/esm/cli-ensure-prepublish-hooks.js +2 -3
  23. package/dist/esm/cli-fmt.js +8 -1
  24. package/dist/esm/cli-report-catalog.js +2 -3
  25. package/dist/esm/cli-report-overrides.js +2 -3
  26. package/dist/esm/cli-shellcheck.d.ts +1 -0
  27. package/dist/esm/cli-shellcheck.js +11 -0
  28. package/dist/esm/cli-taze.js +2 -3
  29. package/dist/esm/cli-vitest.js +8 -1
  30. package/dist/esm/cli.js +2 -2
  31. package/dist/esm/commands/actionlint.js +2 -1
  32. package/dist/esm/commands/build.js +9 -6
  33. package/dist/esm/commands/clean.js +2 -2
  34. package/dist/esm/commands/compiler.js +3 -3
  35. package/dist/esm/commands/ensure-prepublish-hooks.js +10 -3
  36. package/dist/esm/commands/node-pin.js +1 -1
  37. package/dist/esm/commands/report-overrides.js +1 -2
  38. package/dist/esm/commands/rewrite-specifiers.js +3 -2
  39. package/dist/esm/commands/shellcheck.d.ts +25 -0
  40. package/dist/esm/commands/shellcheck.js +41 -0
  41. package/dist/esm/commands/tsconfig-chain.js +4 -3
  42. package/dist/esm/commands/workflow-tools.js +10 -8
  43. package/dist/esm/config-check-cache.js +1 -1
  44. package/dist/esm/config-validation.js +1 -1
  45. package/dist/esm/config.js +1 -1
  46. package/dist/esm/crossing-diagnostics.js +1 -2
  47. package/dist/esm/default-scripts.js +1 -0
  48. package/dist/esm/delegation-target.d.ts +5 -4
  49. package/dist/esm/delegation-target.js +15 -0
  50. package/dist/esm/entry-chain.js +1 -1
  51. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  52. package/dist/esm/helpers/duration.js +1 -1
  53. package/dist/esm/helpers/package-json.js +1 -1
  54. package/dist/esm/machine-lock.js +1 -1
  55. package/dist/esm/pass-manifest.js +2 -3
  56. package/dist/esm/remote-host-session.js +2 -2
  57. package/dist/esm/resolveBinStyles.js +1 -2
  58. package/dist/esm/user-config.js +1 -1
  59. package/dist/esm/vitest-source-resolution.js +1 -1
  60. package/dist/esm/workspace.js +1 -1
  61. package/docs/check-cache.md +1 -1
  62. package/docs/delegate.md +7 -5
  63. package/docs/scripts.md +4 -0
  64. package/docs/utilities.md +9 -1
  65. package/package.json +6 -5
  66. package/dist/esm/UserError.d.ts +0 -8
  67. package/dist/esm/UserError.js +0 -6
  68. package/dist/esm/reportCliFailure.d.ts +0 -9
  69. package/dist/esm/reportCliFailure.js +0 -11
@@ -1,5 +1,4 @@
1
- import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
- import { describeError } from '@williamthorsen/toolbelt.errors';
1
+ import { parseArgsOrExit, reportFailure } from '@williamthorsen/nmr-core';
3
2
  import { DEFAULT_HOOK, ensurePrepublishHooks, reportPrepublishHooks } from "./commands/ensure-prepublish-hooks.js";
4
3
  import { resolveBinStyles } from "./resolveBinStyles.js";
5
4
  import { findMonorepoRoot } from "./workspace.js";
@@ -20,6 +19,6 @@ try {
20
19
  }
21
20
  }
22
21
  catch (error) {
23
- reportError(describeError(error));
22
+ reportFailure('ensure-prepublish-hooks', error);
24
23
  process.exitCode = 1;
25
24
  }
@@ -1,3 +1,10 @@
1
1
  import process from 'node:process';
2
+ import { reportFailure } from '@williamthorsen/nmr-core';
2
3
  import { runFmt } from "./commands/fmt.js";
3
- process.exitCode = await runFmt(process.argv.slice(2), process.cwd());
4
+ try {
5
+ process.exitCode = await runFmt(process.argv.slice(2), process.cwd());
6
+ }
7
+ catch (error) {
8
+ reportFailure('nmr-fmt', error);
9
+ process.exitCode = 1;
10
+ }
@@ -1,12 +1,11 @@
1
1
  import process from 'node:process';
2
- import { reportError } from '@williamthorsen/nmr-core';
3
- import { describeError } from '@williamthorsen/toolbelt.errors';
2
+ import { reportFailure } from '@williamthorsen/nmr-core';
4
3
  import { reportCatalog } from "./commands/report-catalog.js";
5
4
  import { resolveBinStyles } from "./resolveBinStyles.js";
6
5
  try {
7
6
  reportCatalog(process.cwd(), resolveBinStyles().stderr);
8
7
  }
9
8
  catch (error) {
10
- reportError(describeError(error));
9
+ reportFailure('nmr-report-catalog', error);
11
10
  process.exitCode = 1;
12
11
  }
@@ -1,5 +1,4 @@
1
- import { reportError } from '@williamthorsen/nmr-core';
2
- import { describeError } from '@williamthorsen/toolbelt.errors';
1
+ import { reportFailure } from '@williamthorsen/nmr-core';
3
2
  import { reportOverrides } from "./commands/report-overrides.js";
4
3
  import { resolveBinStyles } from "./resolveBinStyles.js";
5
4
  import { findMonorepoRoot } from "./workspace.js";
@@ -8,6 +7,6 @@ try {
8
7
  reportOverrides(monorepoRoot, resolveBinStyles().stderr);
9
8
  }
10
9
  catch (error) {
11
- reportError(describeError(error));
10
+ reportFailure('nmr-report-overrides', error);
12
11
  process.exitCode = 1;
13
12
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import process from 'node:process';
2
+ import { reportFailure } from '@williamthorsen/nmr-core';
3
+ import { runShellcheck } from "./commands/shellcheck.js";
4
+ import { findMonorepoRoot } from "./workspace.js";
5
+ try {
6
+ process.exitCode = await runShellcheck(findMonorepoRoot(), process.argv.slice(2));
7
+ }
8
+ catch (error) {
9
+ reportFailure('nmr-shellcheck', error);
10
+ process.exitCode = 1;
11
+ }
@@ -1,12 +1,11 @@
1
1
  import process from 'node:process';
2
- import { reportError } from '@williamthorsen/nmr-core';
3
- import { describeError } from '@williamthorsen/toolbelt.errors';
2
+ import { reportFailure } from '@williamthorsen/nmr-core';
4
3
  import { runTaze } from "./commands/taze.js";
5
4
  import { resolveBinStyles } from "./resolveBinStyles.js";
6
5
  try {
7
6
  process.exitCode = await runTaze(process.argv.slice(2), { stderrStyle: resolveBinStyles().stderr });
8
7
  }
9
8
  catch (error) {
10
- reportError(describeError(error));
9
+ reportFailure('nmr-taze', error);
11
10
  process.exitCode = 1;
12
11
  }
@@ -1,3 +1,10 @@
1
1
  import process from 'node:process';
2
+ import { reportFailure } from '@williamthorsen/nmr-core';
2
3
  import { runNmrVitest } from "./commands/run-vitest.js";
3
- process.exitCode = runNmrVitest(process.argv.slice(2), process.cwd());
4
+ try {
5
+ process.exitCode = runNmrVitest(process.argv.slice(2), process.cwd());
6
+ }
7
+ catch (error) {
8
+ reportFailure('nmr-vitest', error);
9
+ process.exitCode = 1;
10
+ }
package/dist/esm/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import process from 'node:process';
2
- import { reportCliFailure } from "./reportCliFailure.js";
2
+ import { reportFailure } from '@williamthorsen/nmr-core';
3
3
  import { runCli } from "./runCli.js";
4
4
  try {
5
5
  const { exitCode } = await runCli({
@@ -12,6 +12,6 @@ try {
12
12
  process.exitCode = exitCode;
13
13
  }
14
14
  catch (error) {
15
- reportCliFailure(error, process.stderr);
15
+ reportFailure('nmr', error);
16
16
  process.exitCode = 1;
17
17
  }
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { UserError } from '@williamthorsen/nmr-core';
4
5
  import { describeSignalExitCode } from "../child-process.js";
5
6
  import { ensureTool } from "./workflow-tools.js";
6
7
  const WORKFLOWS_DIR = path.join('.github', 'workflows');
@@ -15,7 +16,7 @@ export async function runActionlint(monorepoRoot, args, options = {}) {
15
16
  const shellcheckPath = await ensure('shellcheck', cacheRoot);
16
17
  const outcome = (options.spawn ?? spawnInherited)(actionlintPath, buildActionlintArgs(shellcheckPath, args), monorepoRoot);
17
18
  if (outcome.error !== undefined) {
18
- throw new Error(`Cannot run actionlint: ${outcome.error.message}`, { cause: outcome.error });
19
+ throw new UserError(`Cannot run actionlint: ${outcome.error.message}`, { cause: outcome.error });
19
20
  }
20
21
  if (outcome.signal !== null) {
21
22
  return describeSignalExitCode(outcome.signal);
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readFile, rename, rm } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { formatGlyphLine, readCacheEntry, startTimer, STATUS_GLYPHS, writeCacheEntry, } from '@williamthorsen/nmr-core';
5
+ import { formatGlyphLine, readCacheEntry, startTimer, STATUS_GLYPHS, UserError, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
+ import { describeError } from '@williamthorsen/toolbelt.errors';
6
7
  import { glob } from 'glob';
7
8
  import { NMR_GLYPHS } from "../glyphs.js";
8
9
  import { resolveConfigPath } from "../helpers/config-path.js";
@@ -91,7 +92,7 @@ async function emitPackage(packageDir, entryPoints, outdir) {
91
92
  }
92
93
  function assertEmitSucceeded(result) {
93
94
  if (result.emitSkipped) {
94
- throw new Error(`nmr-compile: emit failed.\n${formatDiagnostics(result.diagnostics)}`);
95
+ throw new UserError(`nmr-compile: emit failed.\n${formatDiagnostics(result.diagnostics)}`);
95
96
  }
96
97
  }
97
98
  function createCachingCompilerHost(compilerOptions) {
@@ -126,7 +127,7 @@ function resolveEmitDir(packageDir, outdir) {
126
127
  if (relativePathFromPackage === '' ||
127
128
  relativePathFromPackage.startsWith('..') ||
128
129
  path.isAbsolute(relativePathFromPackage)) {
129
- throw new Error(`nmr-compile: refusing to build into '${outdir}', which does not resolve inside the package. ` +
130
+ throw new UserError(`nmr-compile: refusing to build into '${outdir}', which does not resolve inside the package. ` +
130
131
  'The build replaces its output directory on each emit, so the directory must be below the package root.');
131
132
  }
132
133
  return resolvedDir;
@@ -143,7 +144,9 @@ async function swapIntoPlace(emitDir, scratchDirs) {
143
144
  if (hadPreviousOutput && !existsSync(emitDir)) {
144
145
  await rename(scratchDirs.previousDir, emitDir);
145
146
  }
146
- throw error;
147
+ throw new UserError(`nmr-compile: failed to move the built output into ${emitDir}: ${describeError(error)}`, {
148
+ cause: error,
149
+ });
147
150
  }
148
151
  await discardScratchDirs(scratchDirs);
149
152
  }
@@ -152,11 +155,11 @@ function synthesizeCompilerOptions(packageDir, outdir) {
152
155
  const configPath = path.join(packageDir, 'tsconfig.json');
153
156
  const configFile = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName));
154
157
  if (configFile.error) {
155
- throw new Error(`nmr-compile: failed to read ${configPath}.\n${formatDiagnostics([configFile.error])}`);
158
+ throw new UserError(`nmr-compile: failed to read ${configPath}.\n${formatDiagnostics([configFile.error])}`);
156
159
  }
157
160
  const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageDir);
158
161
  if (parsedConfig.errors.length > 0) {
159
- throw new Error(`nmr-compile: failed to parse ${configPath}.\n${formatDiagnostics(parsedConfig.errors)}`);
162
+ throw new UserError(`nmr-compile: failed to parse ${configPath}.\n${formatDiagnostics(parsedConfig.errors)}`);
160
163
  }
161
164
  const resolvedOutDir = path.resolve(packageDir, outdir);
162
165
  return {
@@ -1,6 +1,6 @@
1
1
  import { rm } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { formatGlyphLine } from '@williamthorsen/nmr-core';
3
+ import { formatGlyphLine, UserError } from '@williamthorsen/nmr-core';
4
4
  import { removeCheckCache } from "../check-cache-entries.js";
5
5
  import { loadRootConfig } from "../config.js";
6
6
  import { findContainingPackageDir } from "../context.js";
@@ -71,7 +71,7 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs, style) {
71
71
  channels: { stderr: resolveChannel(process.stderr, false), stdout: resolveChannel(process.stdout, false) },
72
72
  });
73
73
  if (exitCode !== 0) {
74
- throw new Error(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
74
+ throw new UserError(`nmr-clean: \`${command}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
75
75
  }
76
76
  cleanedCount++;
77
77
  }
@@ -1,4 +1,4 @@
1
- import { hasErrnoCode } from '@williamthorsen/nmr-core';
1
+ import { hasErrnoCode, UserError } from '@williamthorsen/nmr-core';
2
2
  const MINIMUM_TYPESCRIPT_MAJOR = 5;
3
3
  const MINIMUM_TYPESCRIPT_MINOR = 7;
4
4
  const UNSUPPORTED_TYPESCRIPT_MAJOR = 7;
@@ -14,7 +14,7 @@ export function assertSupportedTypeScript() {
14
14
  const minor = minorPart === undefined ? 0 : Number(minorPart);
15
15
  const tooOld = major < MINIMUM_TYPESCRIPT_MAJOR || (major === MINIMUM_TYPESCRIPT_MAJOR && minor < MINIMUM_TYPESCRIPT_MINOR);
16
16
  if (tooOld || major >= UNSUPPORTED_TYPESCRIPT_MAJOR) {
17
- throw new Error(`nmr-compile requires TypeScript ${SUPPORTED_TYPESCRIPT_RANGE}, but found ` +
17
+ throw new UserError(`nmr-compile requires TypeScript ${SUPPORTED_TYPESCRIPT_RANGE}, but found ` +
18
18
  `${typeof version === 'string' ? version : 'a version without the compiler API'}. ${INSTALL_TYPESCRIPT_GUIDANCE}`);
19
19
  }
20
20
  }
@@ -38,7 +38,7 @@ export async function loadCompiler(loadTypeScript = importTypeScript) {
38
38
  }
39
39
  catch (error) {
40
40
  if (hasErrnoCode(error, 'ERR_MODULE_NOT_FOUND') && error.message.includes("'typescript'")) {
41
- throw new Error(`nmr-compile requires TypeScript ${SUPPORTED_TYPESCRIPT_RANGE}, but 'typescript' could not be resolved. ` +
41
+ throw new UserError(`nmr-compile requires TypeScript ${SUPPORTED_TYPESCRIPT_RANGE}, but 'typescript' could not be resolved. ` +
42
42
  INSTALL_TYPESCRIPT_GUIDANCE, { cause: error });
43
43
  }
44
44
  throw error;
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
3
+ import { STATUS_GLYPHS, UserError } from '@williamthorsen/nmr-core';
4
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
5
  import { readPackageJson } from "../helpers/package-json.js";
5
6
  import { reportClosing } from "../helpers/reportClosing.js";
6
7
  import { isObject } from "../helpers/type-guards.js";
@@ -75,9 +76,15 @@ export function reportPrepublishHooks(result, hookCommand, style) {
75
76
  function addPrepublishOnly(packageDir, command) {
76
77
  const filePath = path.join(packageDir, 'package.json');
77
78
  const rawText = readFileSync(filePath, 'utf8');
78
- const parsedManifest = JSON.parse(rawText);
79
+ let parsedManifest;
80
+ try {
81
+ parsedManifest = JSON.parse(rawText);
82
+ }
83
+ catch (error) {
84
+ throw new UserError(`Invalid package.json in ${packageDir}: ${describeError(error)}`, { cause: error });
85
+ }
79
86
  if (!isObject(parsedManifest)) {
80
- throw new TypeError(`Invalid package.json in ${packageDir}: expected an object`);
87
+ throw new UserError(`Invalid package.json in ${packageDir}: expected an object`);
81
88
  }
82
89
  const scripts = isObject(parsedManifest['scripts']) ? parsedManifest['scripts'] : {};
83
90
  scripts['prepublishOnly'] = command;
@@ -7,7 +7,7 @@ import { NMR_GLYPHS } from "../glyphs.js";
7
7
  import { isObject } from "../helpers/type-guards.js";
8
8
  export const NODE_RELEASE_INDEX_URL = 'https://nodejs.org/dist/index.json';
9
9
  const TOOL_VERSIONS_FILE = '.tool-versions';
10
- const SOAK_MS = 2 * 24 * 60 * 60 * 1_000;
10
+ const SOAK_MS = 2 * 24 * 60 * 60 * 1000;
11
11
  const FETCH_TIMEOUT_MS = 10_000;
12
12
  const NODEJS_ENTRY_PATTERN = /^[ \t]*nodejs[ \t]+([^\s#]+)/m;
13
13
  const PLAIN_VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
@@ -1,8 +1,7 @@
1
- import { formatGlyphLine } from '@williamthorsen/nmr-core';
1
+ import { formatGlyphLine, UserError } from '@williamthorsen/nmr-core';
2
2
  import { NMR_GLYPHS } from "../glyphs.js";
3
3
  import { getPnpmOverrides, readPackageJson } from "../helpers/package-json.js";
4
4
  import { reportClosing } from "../helpers/reportClosing.js";
5
- import { UserError } from "../UserError.js";
6
5
  import { readWorkspaceOverrides } from "../workspace.js";
7
6
  export function reportOverrides(monorepoRoot, style) {
8
7
  const declaredOverrides = listEntries(readWorkspaceOverrides(monorepoRoot));
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { UserError } from '@williamthorsen/nmr-core';
2
3
  import { getCompiler } from "./compiler.js";
3
4
  const TS_EXTENSIONS = ['.ts', '.tsx'];
4
5
  const JS_EXTENSION = '.js';
@@ -88,7 +89,7 @@ function resolveSpecifierReplacement(specifier, sourceContainingFile, compilerOp
88
89
  const ts = getCompiler();
89
90
  const resolvedModule = ts.resolveModuleName(specifier, sourceContainingFile, compilerOptions, ts.sys).resolvedModule;
90
91
  if (!resolvedModule) {
91
- throw new Error(`nmr-compile: could not resolve aliased import '${specifier}' from ${sourceContainingFile}. ` +
92
+ throw new UserError(`nmr-compile: could not resolve aliased import '${specifier}' from ${sourceContainingFile}. ` +
92
93
  `Verify the tsconfig 'paths' mapping and that the target file exists.`);
93
94
  }
94
95
  if (!isWithin(sourceRoot, resolvedModule.resolvedFileName)) {
@@ -97,7 +98,7 @@ function resolveSpecifierReplacement(specifier, sourceContainingFile, compilerOp
97
98
  if (bareResolvedModule) {
98
99
  return undefined;
99
100
  }
100
- throw new Error(`nmr-compile: aliased import '${specifier}' from ${sourceContainingFile} resolves to ` +
101
+ throw new UserError(`nmr-compile: aliased import '${specifier}' from ${sourceContainingFile} resolves to ` +
101
102
  `${resolvedModule.resolvedFileName}, outside the package source root ${sourceRoot}, and does not resolve ` +
102
103
  `the way Node will at runtime, which ignores tsconfig 'paths', 'baseUrl', and 'rootDirs'. The ` +
103
104
  `emitted specifier would fail at runtime; re-anchor the alias inside the package.`);
@@ -0,0 +1,25 @@
1
+ import { type WorkflowTool } from './workflow-tools.js';
2
+ /** How a spawned shellcheck ended. */
3
+ export interface ShellcheckOutcome {
4
+ error?: Error | undefined;
5
+ signal: NodeJS.Signals | null;
6
+ status: number | null;
7
+ }
8
+ export interface RunShellcheckOptions {
9
+ /** Directory under which the pinned tools are cached; defaults to `node_modules/.cache/nmr-tools` at the root. */
10
+ cacheRoot?: string;
11
+ ensure?: (tool: WorkflowTool, cacheRoot: string) => Promise<string>;
12
+ /** Lists the tracked `*.sh` files under the root, relative to it. */
13
+ listFiles?: (monorepoRoot: string) => string[];
14
+ spawn?: (file: string, args: string[], cwd: string) => ShellcheckOutcome;
15
+ }
16
+ /**
17
+ * Lints the monorepo's tracked shell scripts with the pinned shellcheck and returns its exit status. Trailing
18
+ * arguments replace the file list. A repo that does not track any `*.sh` file passes without fetching the tool.
19
+ */
20
+ export declare function runShellcheck(monorepoRoot: string, args: readonly string[], options?: RunShellcheckOptions): Promise<number>;
21
+ /**
22
+ * Lists the `*.sh` files that git tracks under the root, relative to it. git's `*` also matches `/`, so the pathspec
23
+ * reaches every directory.
24
+ */
25
+ export declare function listTrackedScripts(monorepoRoot: string): string[];
@@ -0,0 +1,41 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { UserError } from '@williamthorsen/nmr-core';
5
+ import { describeSignalExitCode } from "../child-process.js";
6
+ import { ensureTool } from "./workflow-tools.js";
7
+ export async function runShellcheck(monorepoRoot, args, options = {}) {
8
+ const shellcheckArgs = args.length > 0 ? [...args] : listPresentScripts(monorepoRoot, options.listFiles);
9
+ if (shellcheckArgs.length === 0) {
10
+ console.info('No shell scripts to lint: the working tree does not contain any tracked *.sh file.');
11
+ return 0;
12
+ }
13
+ const cacheRoot = options.cacheRoot ?? path.join(monorepoRoot, 'node_modules', '.cache', 'nmr-tools');
14
+ const ensure = options.ensure ?? ((tool, root) => ensureTool(tool, { cacheRoot: root }));
15
+ const shellcheckPath = await ensure('shellcheck', cacheRoot);
16
+ const outcome = (options.spawn ?? spawnInherited)(shellcheckPath, shellcheckArgs, monorepoRoot);
17
+ if (outcome.error !== undefined) {
18
+ throw new UserError(`Cannot run shellcheck: ${outcome.error.message}`, { cause: outcome.error });
19
+ }
20
+ if (outcome.signal !== null) {
21
+ return describeSignalExitCode(outcome.signal);
22
+ }
23
+ return outcome.status ?? 1;
24
+ }
25
+ export function listTrackedScripts(monorepoRoot) {
26
+ const result = spawnSync('git', ['ls-files', '-z', '--', '*.sh'], { cwd: monorepoRoot, encoding: 'utf8' });
27
+ if (result.error !== undefined) {
28
+ throw new UserError(`Cannot list tracked shell scripts: ${result.error.message}`, { cause: result.error });
29
+ }
30
+ if (result.status !== 0) {
31
+ throw new UserError(`Cannot list tracked shell scripts: ${result.stderr.trim()}`);
32
+ }
33
+ return result.stdout.split('\0').filter((file) => file !== '');
34
+ }
35
+ function listPresentScripts(monorepoRoot, listFiles = listTrackedScripts) {
36
+ return listFiles(monorepoRoot).filter((file) => fs.existsSync(path.join(monorepoRoot, file)));
37
+ }
38
+ function spawnInherited(file, args, cwd) {
39
+ const result = spawnSync(file, args, { cwd, stdio: 'inherit' });
40
+ return { error: result.error, signal: result.signal, status: result.status };
41
+ }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { UserError } from '@williamthorsen/nmr-core';
2
3
  import { formatDiagnostics, getCompiler } from "./compiler.js";
3
4
  import { isRelativeSpecifier } from "./rewrite-specifiers.js";
4
5
  export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json') {
@@ -14,7 +15,7 @@ export function resolveTsconfigChain(packageDir, configFileName = 'tsconfig.json
14
15
  resolvedChain.push(normalizedPath);
15
16
  const configFile = ts.readConfigFile(normalizedPath, (fileName) => ts.sys.readFile(fileName));
16
17
  if (configFile.error) {
17
- throw new Error(`nmr-compile: failed to read ${normalizedPath}.\n${formatDiagnostics([configFile.error])}`);
18
+ throw new UserError(`nmr-compile: failed to read ${normalizedPath}.\n${formatDiagnostics([configFile.error])}`);
18
19
  }
19
20
  for (const entry of normalizeExtendsField(configFile.config)) {
20
21
  walk(resolveExtendsTarget(entry, normalizedPath));
@@ -47,12 +48,12 @@ function resolveExtendsTarget(extendsEntry, fromConfigPath) {
47
48
  if (ts.sys.fileExists(withJsonExtension)) {
48
49
  return withJsonExtension;
49
50
  }
50
- throw new Error(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not exist.`);
51
+ throw new UserError(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not exist.`);
51
52
  }
52
53
  const resolvedPath = resolvePackageSpecifier(extendsEntry, fromConfigPath) ??
53
54
  resolvePackageSpecifier(`${extendsEntry}/tsconfig.json`, fromConfigPath);
54
55
  if (resolvedPath === undefined) {
55
- throw new Error(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not resolve to a file.`);
56
+ throw new UserError(`nmr-compile: ${fromConfigPath} extends '${extendsEntry}', which does not resolve to a file.`);
56
57
  }
57
58
  return resolvedPath;
58
59
  }
@@ -3,8 +3,8 @@ import { createHash } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
+ import { UserError } from '@williamthorsen/nmr-core';
6
7
  import { describeError } from '@williamthorsen/toolbelt.errors';
7
- import { UserError } from "../UserError.js";
8
8
  const SUPPORTED_PLATFORMS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'];
9
9
  const DOWNLOAD_TIMEOUT_MS = 60_000;
10
10
  const ACTIONLINT_RELEASE = 'https://github.com/rhysd/actionlint/releases/download/v1.7.12';
@@ -71,7 +71,7 @@ export async function ensureTool(tool, options) {
71
71
  const archive = await download(asset.url, options.fetch ?? globalThis.fetch);
72
72
  const actualSha256 = createHash('sha256').update(archive).digest('hex');
73
73
  if (actualSha256 !== asset.sha256) {
74
- throw new Error(`Checksum mismatch for ${asset.url}: expected sha256 ${asset.sha256}, got ${actualSha256}`);
74
+ throw new UserError(`Checksum mismatch for ${asset.url}: expected sha256 ${asset.sha256}, got ${actualSha256}`);
75
75
  }
76
76
  const archivePath = path.join(scratchDir, 'archive.tar.gz');
77
77
  fs.writeFileSync(archivePath, archive);
@@ -103,7 +103,7 @@ function createCacheRoot(cacheRoot) {
103
103
  fs.mkdirSync(cacheRoot, { recursive: true });
104
104
  }
105
105
  catch (error) {
106
- throw new Error(`Cannot create the tool cache at ${cacheRoot}`, { cause: error });
106
+ throw new UserError(`Cannot create the tool cache at ${cacheRoot}: ${describeError(error)}`, { cause: error });
107
107
  }
108
108
  }
109
109
  async function download(url, fetchImpl) {
@@ -112,20 +112,20 @@ async function download(url, fetchImpl) {
112
112
  response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
113
113
  }
114
114
  catch (error) {
115
- throw new Error(`Cannot download ${url}: ${describeError(error)}`, { cause: error });
115
+ throw new UserError(`Cannot download ${url}: ${describeError(error)}`, { cause: error });
116
116
  }
117
117
  if (!response.ok) {
118
- throw new Error(`Cannot download ${url}: HTTP ${response.status}`);
118
+ throw new UserError(`Cannot download ${url}: HTTP ${response.status}`);
119
119
  }
120
120
  return Buffer.from(await response.arrayBuffer());
121
121
  }
122
122
  function extractMember(archivePath, extractDir, member) {
123
123
  const result = spawnSync('tar', ['-xzf', archivePath, '-C', extractDir, member], { encoding: 'utf8' });
124
124
  if (result.error !== undefined) {
125
- throw new Error(`Cannot run tar to extract ${member}: ${result.error.message}`, { cause: result.error });
125
+ throw new UserError(`Cannot run tar to extract ${member}: ${result.error.message}`, { cause: result.error });
126
126
  }
127
127
  if (result.status !== 0) {
128
- throw new Error(`tar could not extract ${member} from ${archivePath}: ${result.stderr.trim()}`);
128
+ throw new UserError(`tar could not extract ${member} from ${archivePath}: ${result.stderr.trim()}`);
129
129
  }
130
130
  }
131
131
  function publishStage(stageDir, targetDir, binaryPath) {
@@ -134,7 +134,9 @@ function publishStage(stageDir, targetDir, binaryPath) {
134
134
  }
135
135
  catch (error) {
136
136
  if (!fs.existsSync(binaryPath)) {
137
- throw error;
137
+ throw new UserError(`Cannot move the downloaded tool into ${targetDir}: ${describeError(error)}`, {
138
+ cause: error,
139
+ });
138
140
  }
139
141
  }
140
142
  }
@@ -1,7 +1,7 @@
1
+ import { UserError } from '@williamthorsen/nmr-core';
1
2
  import { findClosestName } from "./helpers/findClosestName.js";
2
3
  import { isHookName } from "./helpers/hook-name.js";
3
4
  import { buildRootRegistry, buildWorkspaceRegistry, readPackageJsonScripts } from "./resolver.js";
4
- import { UserError } from "./UserError.js";
5
5
  import { getWorkspacePackageDirs, isMonorepoRoot } from "./workspace.js";
6
6
  export function assertResolvableCheckCacheCommands(config, configPath, baseDir) {
7
7
  const { checkCache } = config;
@@ -1,7 +1,7 @@
1
+ import { UserError } from '@williamthorsen/nmr-core';
1
2
  import { assertResolvableCheckCacheCommands } from "./config-check-cache.js";
2
3
  import { isObject, isStringRecord } from "./helpers/type-guards.js";
3
4
  import { findUnexpressibleToken } from "./steps.js";
4
- import { UserError } from "./UserError.js";
5
5
  import { formatVerbosityRejection, isCommandVerbosity } from "./verbosity.js";
6
6
  export const CONFIG_TIERS = {
7
7
  root: {
@@ -8,6 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import { existsSync } from 'node:fs';
10
10
  import { pathToFileURL } from 'node:url';
11
+ import { UserError } from '@williamthorsen/nmr-core';
11
12
  import { CONFIG_TIERS, validateConfig } from "./config-validation.js";
12
13
  import { resolveConfigPath } from "./helpers/config-path.js";
13
14
  import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
@@ -16,7 +17,6 @@ import { isObject } from "./helpers/type-guards.js";
16
17
  import { getDefaultRootScripts, getDefaultWorkspaceScripts } from "./resolve-scripts.js";
17
18
  import { describeScript } from "./resolver.js";
18
19
  import { readSelfReference, readStepListSelfReference } from "./steps.js";
19
- import { UserError } from "./UserError.js";
20
20
  const SCRIPT_FIELDS = [
21
21
  { field: 'rootScripts', scope: 'root' },
22
22
  { field: 'workspaceScripts', scope: 'workspaceRegistry' },
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { STATUS_GLYPHS } from '@williamthorsen/nmr-core';
2
+ import { STATUS_GLYPHS, UserError } from '@williamthorsen/nmr-core';
3
3
  import { resolveConfigPath } from "./helpers/config-path.js";
4
4
  import { escapeControlCharacters } from "./helpers/escapeControlCharacters.js";
5
5
  import { isHookName } from "./helpers/hook-name.js";
@@ -7,7 +7,6 @@ import { resolvePackageJsonPath } from "./helpers/package-json.js";
7
7
  import { hasRunnableHook } from "./hooks.js";
8
8
  import { expandScript, findChainedSelfReference, readPackageJsonScripts, } from "./resolver.js";
9
9
  import { findNmrCrossing, readSoleNmrCommand, renderChain } from "./steps.js";
10
- import { UserError } from "./UserError.js";
11
10
  const CROSSING_CONSEQUENCE = "so nmr handles the nested run's output as a tool's.";
12
11
  export function assertNoSelfReference(options) {
13
12
  const { anchorDir, command, isReading, isWorkspaceRoot, monorepoRoot, registry } = options;
@@ -46,6 +46,7 @@ export function buildRootScripts(workspaceGlobs) {
46
46
  'fmt:check': 'nmr-fmt --check',
47
47
  lint: 'eslint --fix .',
48
48
  'lint:check': 'eslint .',
49
+ 'lint:shell': 'nmr-shellcheck',
49
50
  'lint:strict': 'strict-lint',
50
51
  'lint:workflows': 'nmr-actionlint',
51
52
  prepush: [{ run: 'audit', shouldDeclineArguments: true }, 'ci'],
@@ -27,10 +27,11 @@ export type DelegationTarget = {
27
27
  * 2. `--delegate-to` runs it on the hosts that it names, and `--delegate` on `NMR_DELEGATE`'s, or else the user
28
28
  * config's.
29
29
  * 3. A repo config declaring `delegate: false` runs it here.
30
- * 4. `NMR_DELEGATE` naming hosts runs it on them.
31
- * 5. `NMR_DELEGATE` set without a host runs it here, which is how a step keeps to where its parent runs.
32
- * 6. The user config's `when` matching runs it on the config's hosts, unless `CI` is set or the invocation fans out.
33
- * 7. Otherwise, it runs here.
30
+ * 4. A package install hook, which `npm_lifecycle_event` names, runs it here.
31
+ * 5. `NMR_DELEGATE` naming hosts runs it on them.
32
+ * 6. `NMR_DELEGATE` set without a host runs it here, which is how a step keeps to where its parent runs.
33
+ * 7. The user config's `when` matching runs it on the config's hosts, unless `CI` is set or the invocation fans out.
34
+ * 8. Otherwise, it runs here.
34
35
  *
35
36
  * A command that rewrites the tree, or that runs until it is stopped, cannot be delegated, and neither can `--log`,
36
37
  * which reads this machine's recordings. Asked for by a flag, such an invocation is refused; asked for by the
@@ -16,6 +16,9 @@ export async function resolveDelegationTarget(options) {
16
16
  if (isRepoOptedOut) {
17
17
  return { kind: 'local' };
18
18
  }
19
+ if (isInstallHook(env)) {
20
+ return { kind: 'local' };
21
+ }
19
22
  if (envHosts !== undefined) {
20
23
  return isDelegable ? { kind: 'remote', hosts: envHosts, source: 'env' } : { kind: 'local' };
21
24
  }
@@ -33,6 +36,15 @@ export async function resolveDelegationTarget(options) {
33
36
  return { kind: 'remote', hosts: delegate.hosts, source: 'config', configPath: loaded.path, when: delegate.when };
34
37
  }
35
38
  const DEFAULT_USER_CONFIG_PATH = '~/.config/nmr/config.ts';
39
+ const INSTALL_HOOKS = new Set([
40
+ 'dependencies',
41
+ 'install',
42
+ 'postinstall',
43
+ 'postprepare',
44
+ 'preinstall',
45
+ 'prepare',
46
+ 'preprepare',
47
+ ]);
36
48
  const NON_DELEGABLE_BASE_COMMANDS = ['build', 'clean', 'compile', 'fix', 'fmt', 'lint', 'test:watch', 'upgrade'];
37
49
  const NON_DELEGABLE_COMMANDS = new Set([
38
50
  ...NON_DELEGABLE_BASE_COMMANDS,
@@ -41,6 +53,9 @@ const NON_DELEGABLE_COMMANDS = new Set([
41
53
  function hasValue(value) {
42
54
  return value !== undefined && value !== '';
43
55
  }
56
+ function isInstallHook(env) {
57
+ return INSTALL_HOOKS.has(env['npm_lifecycle_event'] ?? '');
58
+ }
44
59
  function parseHostList(value) {
45
60
  const names = value.split(',').map((host) => host.trim());
46
61
  const [first, ...rest] = new Set(names.filter((host) => host !== ''));
@@ -1,7 +1,7 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { UserError } from '@williamthorsen/nmr-core';
3
4
  import { isObject } from "./helpers/type-guards.js";
4
- import { UserError } from "./UserError.js";
5
5
  export const ENTRY_CHAIN_ENV_VAR = 'NMR_ENTRY_CHAIN';
6
6
  export function enterChain(options) {
7
7
  const { anchorDir, command, env, monorepoRoot } = options;
@@ -1,7 +1,7 @@
1
1
  import { clampToBytes } from "./clampToBytes.js";
2
2
  import { cleanTranscript } from "./transcript.js";
3
3
  const MAX_BLOCK_LINES = 8;
4
- export const MAX_EXCERPT_BYTES = 2_048;
4
+ export const MAX_EXCERPT_BYTES = 2048;
5
5
  const NON_RULE_CHARACTER = /[^-=_~*+.|:#]/u;
6
6
  export function deriveExcerpt(transcript) {
7
7
  const lines = cleanTranscript(transcript).split('\n');
@@ -17,7 +17,7 @@ export function formatSaving(durationMs) {
17
17
  return `saved ~${formatDuration(durationMs)}`;
18
18
  }
19
19
  const MILLISECONDS_PER_MINUTE = 60_000;
20
- const MILLISECONDS_PER_SECOND = 1_000;
20
+ const MILLISECONDS_PER_SECOND = 1000;
21
21
  const MINUTES_PER_HOUR = 60;
22
22
  const SECONDS_PER_MINUTE = 60;
23
23
  function joinUnits(major, minor, minorUnit) {
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { UserError } from '@williamthorsen/nmr-core';
3
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
- import { UserError } from "../UserError.js";
5
5
  import { isMonorepoRoot } from "../workspace.js";
6
6
  import { CONFIG_RELATIVE_PATH } from "./config-path.js";
7
7
  import { readStringValues } from "./readStringValues.js";