@williamthorsen/nmr 2.0.1 → 2.0.2

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 (92) hide show
  1. package/.meta/changelog.json +90 -0
  2. package/.readyup/kits/default.js +41 -5
  3. package/.readyup/manifest.json +14 -9
  4. package/CHANGELOG.md +24 -0
  5. package/README.md +9 -3
  6. package/dist/esm/{check-cache-build-output.d.ts → check-cache/check-cache-build-output.d.ts} +1 -1
  7. package/dist/esm/{check-cache-build-output.js → check-cache/check-cache-build-output.js} +6 -6
  8. package/dist/esm/{check-cache-delegation.d.ts → check-cache/check-cache-delegation.d.ts} +3 -3
  9. package/dist/esm/{check-cache-delegation.js → check-cache/check-cache-delegation.js} +2 -2
  10. package/dist/esm/{check-cache-entries.js → check-cache/check-cache-entries.js} +1 -1
  11. package/dist/esm/{check-cache-gate.d.ts → check-cache/check-cache-gate.d.ts} +5 -4
  12. package/dist/esm/{check-cache-gate.js → check-cache/check-cache-gate.js} +10 -10
  13. package/dist/esm/{check-cache-key.d.ts → check-cache/check-cache-key.d.ts} +1 -1
  14. package/dist/esm/{check-cache-record.d.ts → check-cache/check-cache-record.d.ts} +6 -5
  15. package/dist/esm/{check-cache-record.js → check-cache/check-cache-record.js} +7 -6
  16. package/dist/esm/{check-cache.d.ts → check-cache/check-cache.d.ts} +10 -2
  17. package/dist/esm/{check-cache.js → check-cache/check-cache.js} +7 -4
  18. package/dist/esm/{config-check-cache.d.ts → check-cache/config-check-cache.d.ts} +1 -1
  19. package/dist/esm/{config-check-cache.js → check-cache/config-check-cache.js} +4 -4
  20. package/dist/esm/{pass-manifest.d.ts → check-cache/pass-manifest.d.ts} +1 -1
  21. package/dist/esm/{pass-manifest.js → check-cache/pass-manifest.js} +1 -1
  22. package/dist/esm/{recording.js → check-cache/recording.js} +2 -2
  23. package/dist/esm/{replay-assembly.d.ts → check-cache/replay-assembly.d.ts} +1 -1
  24. package/dist/esm/{replay-assembly.js → check-cache/replay-assembly.js} +2 -2
  25. package/dist/esm/child-env.d.ts +1 -1
  26. package/dist/esm/child-env.js +2 -2
  27. package/dist/esm/commands/actionlint.d.ts +2 -7
  28. package/dist/esm/commands/actionlint.js +5 -11
  29. package/dist/esm/commands/clean.js +1 -1
  30. package/dist/esm/commands/fmt-cache.js +3 -2
  31. package/dist/esm/commands/fmt.d.ts +6 -13
  32. package/dist/esm/commands/fmt.js +47 -30
  33. package/dist/esm/commands/node-pin.d.ts +15 -5
  34. package/dist/esm/commands/node-pin.js +43 -10
  35. package/dist/esm/commands/package-manifest.d.ts +28 -0
  36. package/dist/esm/commands/package-manifest.js +32 -0
  37. package/dist/esm/commands/run-vitest.js +21 -20
  38. package/dist/esm/commands/shellcheck.d.ts +2 -7
  39. package/dist/esm/commands/shellcheck.js +3 -11
  40. package/dist/esm/commands/spawn-inherited.d.ts +21 -0
  41. package/dist/esm/commands/spawn-inherited.js +11 -0
  42. package/dist/esm/commands/taze.d.ts +6 -6
  43. package/dist/esm/commands/taze.js +3 -7
  44. package/dist/esm/config-validation.js +1 -1
  45. package/dist/esm/config.js +3 -12
  46. package/dist/esm/{delegate-host.d.ts → delegation/delegate-host.d.ts} +3 -3
  47. package/dist/esm/{delegate-host.js → delegation/delegate-host.js} +9 -8
  48. package/dist/esm/{delegation-target.js → delegation/delegation-target.js} +1 -1
  49. package/dist/esm/{remote-probe.js → delegation/remote-probe.js} +2 -2
  50. package/dist/esm/{remote-run.js → delegation/remote-run.js} +5 -5
  51. package/dist/esm/{remote-session.js → delegation/remote-session.js} +1 -1
  52. package/dist/esm/{user-config.js → delegation/user-config.js} +4 -13
  53. package/dist/esm/env-switch.d.ts +25 -0
  54. package/dist/esm/env-switch.js +26 -0
  55. package/dist/esm/fan-out.d.ts +4 -1
  56. package/dist/esm/fan-out.js +1 -3
  57. package/dist/esm/help.js +1 -1
  58. package/dist/esm/helpers/filter-selection.d.ts +2 -0
  59. package/dist/esm/helpers/importConfigDefault.d.ts +7 -0
  60. package/dist/esm/helpers/importConfigDefault.js +45 -0
  61. package/dist/esm/invocation.d.ts +7 -3
  62. package/dist/esm/machine-lock.js +2 -1
  63. package/dist/esm/route.d.ts +6 -0
  64. package/dist/esm/route.js +15 -2
  65. package/dist/esm/run-chain.d.ts +1 -1
  66. package/dist/esm/run-chain.js +1 -1
  67. package/dist/esm/runCli.d.ts +9 -0
  68. package/dist/esm/runCli.js +24 -9
  69. package/dist/esm/runner.d.ts +2 -0
  70. package/dist/esm/runner.js +1 -1
  71. package/dist/esm/verdict.d.ts +1 -1
  72. package/docs/check-cache.md +7 -7
  73. package/docs/delegate.md +2 -2
  74. package/docs/machine-lock.md +4 -4
  75. package/docs/scripts.md +4 -0
  76. package/docs/upgrades.md +9 -1
  77. package/docs/utilities.md +4 -2
  78. package/package.json +1 -1
  79. /package/dist/esm/{check-cache-entries.d.ts → check-cache/check-cache-entries.d.ts} +0 -0
  80. /package/dist/esm/{check-cache-key.js → check-cache/check-cache-key.js} +0 -0
  81. /package/dist/esm/{recording.d.ts → check-cache/recording.d.ts} +0 -0
  82. /package/dist/esm/{delegation-target.d.ts → delegation/delegation-target.d.ts} +0 -0
  83. /package/dist/esm/{remote-host-session.d.ts → delegation/remote-host-session.d.ts} +0 -0
  84. /package/dist/esm/{remote-host-session.js → delegation/remote-host-session.js} +0 -0
  85. /package/dist/esm/{remote-probe-host.d.ts → delegation/remote-probe-host.d.ts} +0 -0
  86. /package/dist/esm/{remote-probe-host.js → delegation/remote-probe-host.js} +0 -0
  87. /package/dist/esm/{remote-probe.d.ts → delegation/remote-probe.d.ts} +0 -0
  88. /package/dist/esm/{remote-run.d.ts → delegation/remote-run.d.ts} +0 -0
  89. /package/dist/esm/{remote-session.d.ts → delegation/remote-session.d.ts} +0 -0
  90. /package/dist/esm/{remote-snapshot.d.ts → delegation/remote-snapshot.d.ts} +0 -0
  91. /package/dist/esm/{remote-snapshot.js → delegation/remote-snapshot.js} +0 -0
  92. /package/dist/esm/{user-config.d.ts → delegation/user-config.d.ts} +0 -0
@@ -1,7 +1,7 @@
1
1
  import { formatGlyphLine, STATUS_GLYPHS } from '@williamthorsen/nmr-core';
2
+ import { NMR_GLYPHS } from "../glyphs.js";
3
+ import { formatDuration } from "../helpers/duration.js";
2
4
  import { readCheckCacheEntry, readTranscript } from "./check-cache-entries.js";
3
- import { NMR_GLYPHS } from "./glyphs.js";
4
- import { formatDuration } from "./helpers/duration.js";
5
5
  export function renderRecording(options) {
6
6
  const { entry } = options.recording;
7
7
  const age = formatDuration(Math.max(0, Date.now() - Date.parse(entry.recordedAt)));
@@ -1,5 +1,5 @@
1
+ import { type Step } from '../steps.js';
1
2
  import { type ReplayLine } from './check-cache-entries.js';
2
- import { type Step } from './steps.js';
3
3
  /**
4
4
  * Assembles what a composite's skip replays: the excerpts recorded by its constituents, in the order in which
5
5
  * its own steps name them.
@@ -1,6 +1,6 @@
1
+ import { readNmrStep } from "../steps.js";
2
+ import { getWorkspacePackageDirs } from "../workspace.js";
1
3
  import { readCheckCacheEntry } from "./check-cache-entries.js";
2
- import { readNmrStep } from "./steps.js";
3
- import { getWorkspacePackageDirs } from "./workspace.js";
4
4
  export async function assembleReplay(options) {
5
5
  const { anchorDir, monorepoRoot, runId, steps, treeHash } = options;
6
6
  const candidates = steps.flatMap((step) => {
@@ -1,5 +1,5 @@
1
1
  import type { StreamStyles } from '@williamthorsen/nmr-core';
2
- import { type TreeSnapshot } from './check-cache.js';
2
+ import { type TreeSnapshot } from './check-cache/check-cache.js';
3
3
  import type { Invocation } from './invocation.js';
4
4
  import { type MachineLock } from './machine-lock.js';
5
5
  import { type ReportFormat } from './report-format.js';
@@ -1,8 +1,8 @@
1
- import { DEBUG_ENV_VAR, encodeTreeSnapshot, NO_CACHE_ENV_VAR, RUN_ID_ENV_VAR, TREE_SNAPSHOT_ENV_VAR, } from "./check-cache.js";
1
+ import { DEBUG_ENV_VAR, encodeTreeSnapshot, NO_CACHE_ENV_VAR, RUN_ID_ENV_VAR, TREE_SNAPSHOT_ENV_VAR, } from "./check-cache/check-cache.js";
2
+ import { DELEGATE_ENV_VAR } from "./delegation/remote-run.js";
2
3
  import { acquireMachineLock, formatMisplacedNoLockWarning, HEAVY_COMMANDS, LOCK_TOKEN_ENV_VAR, NO_LOCK_ENV_VAR, } from "./machine-lock.js";
3
4
  import { formatFlagSpelling } from "./nmr-flags.js";
4
5
  import { OUTPUT_STYLE_ENV_VAR } from "./output-style.js";
5
- import { DELEGATE_ENV_VAR } from "./remote-run.js";
6
6
  import { REPORT_FORMAT_ENV_VAR } from "./report-format.js";
7
7
  import { COMMAND_VERBOSITY_ENV_VAR } from "./verbosity.js";
8
8
  export function addLockEnv(env, lock) {
@@ -1,15 +1,10 @@
1
+ import { type SpawnInherited } from './spawn-inherited.js';
1
2
  import { type WorkflowTool } from './workflow-tools.js';
2
- /** How a spawned actionlint ended. */
3
- export interface ActionlintOutcome {
4
- error?: Error | undefined;
5
- signal: NodeJS.Signals | null;
6
- status: number | null;
7
- }
8
3
  export interface RunActionlintOptions {
9
4
  /** Directories under which the pinned tools are cached, in lookup order; defaults to `resolveToolCacheRoots`. */
10
5
  cacheRoots?: readonly [string, ...string[]];
11
6
  ensure?: (tool: WorkflowTool, cacheRoots: readonly [string, ...string[]]) => Promise<string>;
12
- spawn?: (file: string, args: string[], cwd: string) => ActionlintOutcome;
7
+ spawn?: SpawnInherited;
13
8
  }
14
9
  /**
15
10
  * Lints the monorepo's GitHub Actions workflows with the pinned actionlint, running its shellcheck pass with the
@@ -1,8 +1,7 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { UserError } from '@williamthorsen/nmr-core';
5
- import { describeSignalExitCode } from "../child-process.js";
4
+ import { resolveInheritedExitCode, spawnInherited } from "./spawn-inherited.js";
6
5
  import { ensureTool, resolveToolCacheRoots } from "./workflow-tools.js";
7
6
  const WORKFLOWS_DIR = path.join('.github', 'workflows');
8
7
  export async function runActionlint(monorepoRoot, args, options = {}) {
@@ -14,19 +13,14 @@ export async function runActionlint(monorepoRoot, args, options = {}) {
14
13
  const ensure = options.ensure ?? ((tool, roots) => ensureTool(tool, { cacheRoots: roots }));
15
14
  const actionlintPath = await ensure('actionlint', cacheRoots);
16
15
  const shellcheckPath = await ensure('shellcheck', cacheRoots);
17
- const outcome = (options.spawn ?? spawnInherited)(actionlintPath, buildActionlintArgs(shellcheckPath, args), monorepoRoot);
16
+ const outcome = (options.spawn ?? spawnInherited)(actionlintPath, buildActionlintArgs(shellcheckPath, args), {
17
+ cwd: monorepoRoot,
18
+ });
18
19
  if (outcome.error !== undefined) {
19
20
  throw new UserError(`Cannot run actionlint: ${outcome.error.message}`, { cause: outcome.error });
20
21
  }
21
- if (outcome.signal !== null) {
22
- return describeSignalExitCode(outcome.signal);
23
- }
24
- return outcome.status ?? 1;
22
+ return resolveInheritedExitCode(outcome);
25
23
  }
26
24
  export function buildActionlintArgs(shellcheckPath, args) {
27
25
  return [`-shellcheck=${shellcheckPath}`, '-pyflakes=', ...args];
28
26
  }
29
- function spawnInherited(file, args, cwd) {
30
- const result = spawnSync(file, args, { cwd, stdio: 'inherit' });
31
- return { error: result.error, signal: result.signal, status: result.status };
32
- }
@@ -4,7 +4,7 @@ import process from 'node:process';
4
4
  import { UserError } from '@williamthorsen/nmr-core';
5
5
  import { defineCommand, runCli } from '@williamthorsen/nmr-core/cli';
6
6
  import { NMR_CLEAN_BIN } from "../bins.js";
7
- import { removeCheckCache } from "../check-cache-entries.js";
7
+ import { removeCheckCache } from "../check-cache/check-cache-entries.js";
8
8
  import { loadRootConfig } from "../config.js";
9
9
  import { findContainingPackageDir } from "../context.js";
10
10
  import { reportClosing } from "../helpers/reportClosing.js";
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
5
- import { NO_CACHE_ENV_VAR } from "../check-cache.js";
5
+ import { NO_CACHE_ENV_VAR } from "../check-cache/check-cache.js";
6
+ import { isEnvSwitchOn } from "../env-switch.js";
6
7
  export const FMT_CACHE_DIRECTORY = path.join('node_modules', '.cache', 'prettier', 'nmr-fmt');
7
8
  const LOCKFILE_NAMES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lock', 'bun.lockb'];
8
9
  const NO_LOCKFILE_MARKER = 'nolock';
@@ -10,7 +11,7 @@ const HASH_LENGTH = 16;
10
11
  const CACHE_FILE_PATTERN = /^([0-9a-f]{16})\.([0-9a-f]{16}|nolock)\.json$/;
11
12
  export function prepareFmtCache(options) {
12
13
  const { cwd, env, repositoryRoot, shouldBypassCache } = options;
13
- if (shouldBypassCache || env[NO_CACHE_ENV_VAR] === '1')
14
+ if (shouldBypassCache || isEnvSwitchOn(env, NO_CACHE_ENV_VAR))
14
15
  return { kind: 'uncached' };
15
16
  try {
16
17
  if (statSync(path.join(repositoryRoot, 'node_modules'), { throwIfNoEntry: false })?.isDirectory() !== true) {
@@ -1,24 +1,17 @@
1
+ import { type SpawnInherited } from './spawn-inherited.js';
1
2
  export type FormatMode = 'check' | 'write';
2
3
  /**
3
4
  * Runs `nmr-fmt` against `argv` and returns the exit code to report: It formats or checks the files that git
4
5
  * reports for `cwd`. A repository without a Prettier config of its own is formatted with the house config. The run
5
- * uses Prettier's content-keyed cache unless `--no-cache` or `NMR_NO_CACHE=1` bypasses it.
6
+ * uses Prettier's content-keyed cache unless `--no-cache` or `NMR_NO_CACHE=1` bypasses it, and an `NMR_NO_CACHE`
7
+ * that is neither on nor off is rejected as a usage error.
6
8
  *
7
9
  * Trailing arguments are git pathspecs, not Prettier flags; an unrecognized option is rejected rather
8
10
  * than passed along, since git would read it as a pathspec matching nothing and the run would report
9
11
  * success over an empty selection. Every argument after `--` is a pathspec, even one that starts
10
12
  * with `-`.
11
13
  */
12
- export declare function runFmt(argv: string[], cwd?: string): Promise<number>;
13
- /**
14
- * How a spawned Prettier process ended.
15
- *
16
- * @internal
17
- */
18
- export interface PrettierOutcome {
19
- error?: Error | undefined;
20
- status: number | null;
21
- }
14
+ export declare function runFmt(argv: string[], cwd?: string, env?: NodeJS.ProcessEnv): Promise<number>;
22
15
  /** @internal */
23
16
  export interface RunPrettierOptions {
24
17
  cliPath: string;
@@ -32,8 +25,8 @@ export interface RunPrettierOptions {
32
25
  cwd: string;
33
26
  /** Overridable so that the multi-batch path is reachable without a fixture that hits the real ceiling. */
34
27
  budgetBytes?: number;
35
- /** Runs the Prettier CLI as `file` with `args` in `cwd`. */
36
- spawn?: (file: string, args: string[], cwd: string) => PrettierOutcome;
28
+ /** Runs the Prettier CLI on the caller's stdio. */
29
+ spawn?: SpawnInherited;
37
30
  }
38
31
  /**
39
32
  * Runs Prettier over the selection, returning the first non-zero exit code. Every batch runs even after
@@ -1,6 +1,4 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { spawnSync } from 'node:child_process';
3
- import { readFileSync } from 'node:fs';
4
2
  import { createRequire } from 'node:module';
5
3
  import path from 'node:path';
6
4
  import process from 'node:process';
@@ -9,8 +7,12 @@ import { isRecord, reportError } from '@williamthorsen/nmr-core';
9
7
  import { defineCommand, runCli, UsageError } from '@williamthorsen/nmr-core/cli';
10
8
  import { describeError } from '@williamthorsen/toolbelt.errors';
11
9
  import { NMR_FMT_BIN } from "../bins.js";
10
+ import { NO_CACHE_ENV_VAR } from "../check-cache/check-cache.js";
11
+ import { readEnvSwitch } from "../env-switch.js";
12
12
  import { prepareFmtCache } from "./fmt-cache.js";
13
13
  import { resolveFormatTargets } from "./format-targets.js";
14
+ import { BROKEN_MANIFEST_FIX, findPackageManifest, resolveDeclaredCli, } from "./package-manifest.js";
15
+ import { resolveInheritedExitCode, spawnInherited } from "./spawn-inherited.js";
14
16
  const PRETTIER_PACKAGE = 'prettier';
15
17
  const DEFAULT_CONFIG_PATH = fileURLToPath(new URL(`../prettier-default.config${path.extname(fileURLToPath(import.meta.url))}`, import.meta.url));
16
18
  const ARGUMENT_BUDGET_BYTES = 100_000;
@@ -18,7 +20,7 @@ const MODE_ARGS = {
18
20
  check: ['--check'],
19
21
  write: ['--list-different', '--write'],
20
22
  };
21
- export async function runFmt(argv, cwd = process.cwd()) {
23
+ export async function runFmt(argv, cwd = process.cwd(), env = process.env) {
22
24
  const command = defineCommand({
23
25
  summary: 'Format or check the files that git lists',
24
26
  flags: {
@@ -31,12 +33,20 @@ export async function runFmt(argv, cwd = process.cwd()) {
31
33
  if (flags.check === flags.write) {
32
34
  throw new UsageError(flags.check ? 'Pass --check or --write, not both.' : 'Pass --check or --write.');
33
35
  }
34
- return formatFiles({ mode: flags.check ? 'check' : 'write', pathspecs: operands.pathspec, shouldBypassCache: flags.noCache }, cwd);
36
+ const noCache = readEnvSwitch(env, NO_CACHE_ENV_VAR);
37
+ if (!noCache.ok) {
38
+ throw new UsageError(noCache.error);
39
+ }
40
+ return formatFiles({
41
+ mode: flags.check ? 'check' : 'write',
42
+ pathspecs: operands.pathspec,
43
+ shouldBypassCache: flags.noCache || noCache.isOn,
44
+ }, cwd, env);
35
45
  },
36
46
  });
37
47
  return runCli(argv, command, { name: NMR_FMT_BIN, stdout: process.stdout, stderr: process.stderr, baseDir: cwd });
38
48
  }
39
- async function formatFiles(request, cwd) {
49
+ async function formatFiles(request, cwd, env) {
40
50
  const resolvedTargets = resolveFormatTargets(cwd, request.pathspecs);
41
51
  if (!resolvedTargets.ok) {
42
52
  reportError(NMR_FMT_BIN, resolvedTargets.error);
@@ -70,7 +80,7 @@ async function formatFiles(request, cwd) {
70
80
  }
71
81
  const cache = prepareFmtCache({
72
82
  cwd,
73
- env: process.env,
83
+ env,
74
84
  repositoryRoot,
75
85
  shouldBypassCache: request.shouldBypassCache,
76
86
  });
@@ -92,17 +102,36 @@ function resolvePrettierCli(cwd) {
92
102
  ok: false,
93
103
  error: `\`${PRETTIER_PACKAGE}\` could not be resolved from this repository or from nmr's own installation, which declares it as a dependency. Reinstall \`@williamthorsen/nmr\`.`,
94
104
  };
95
- const manifestPath = resolveFrom(path.join(cwd, 'package.json'), `${PRETTIER_PACKAGE}/package.json`) ??
96
- resolveFrom(import.meta.url, `${PRETTIER_PACKAGE}/package.json`);
97
- if (manifestPath === undefined)
98
- return missingResult;
99
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
100
- if (!isRecord(manifest))
101
- return missingResult;
102
- const bin = isRecord(manifest['bin']) ? manifest['bin'][PRETTIER_PACKAGE] : manifest['bin'];
103
- if (typeof bin !== 'string')
105
+ const repositoryLookup = findPackageManifest(path.join(cwd, 'package.json'), PRETTIER_PACKAGE);
106
+ const copy = repositoryLookup.kind === 'absent'
107
+ ? {
108
+ lookup: findPackageManifest(import.meta.url, PRETTIER_PACKAGE),
109
+ origin: "nmr's installation",
110
+ reinstallStep: 'Reinstall `@williamthorsen/nmr`.',
111
+ }
112
+ : {
113
+ lookup: repositoryLookup,
114
+ origin: 'this repository',
115
+ reinstallStep: `Reinstall this repository's \`${PRETTIER_PACKAGE}\` dependency.`,
116
+ };
117
+ const { lookup } = copy;
118
+ if (lookup.kind === 'absent')
104
119
  return missingResult;
105
- return { ok: true, cliPath: path.resolve(path.dirname(manifestPath), bin), manifestPath };
120
+ if (lookup.kind === 'broken') {
121
+ return {
122
+ ok: false,
123
+ error: `\`${PRETTIER_PACKAGE}\` could not be loaded from ${copy.origin}: ${lookup.cause}. ${BROKEN_MANIFEST_FIX}`,
124
+ };
125
+ }
126
+ const { manifest, manifestPath } = lookup;
127
+ const cliPath = resolveDeclaredCli(manifest, manifestPath, PRETTIER_PACKAGE);
128
+ if (cliPath === undefined) {
129
+ return {
130
+ ok: false,
131
+ error: `\`${PRETTIER_PACKAGE}\` at ${path.dirname(manifestPath)} does not declare a CLI. ${copy.reinstallStep}`,
132
+ };
133
+ }
134
+ return { ok: true, cliPath, manifestPath };
106
135
  }
107
136
  async function findRepositoryConfig(manifestPath, cwd) {
108
137
  try {
@@ -136,12 +165,12 @@ export function runPrettier(options) {
136
165
  ];
137
166
  let firstFailureStatus = 0;
138
167
  for (const batch of batchWithinBudget(files, budgetBytes)) {
139
- const result = spawn(process.execPath, [...prettierArgs, ...batch], cwd);
168
+ const result = spawn(process.execPath, [...prettierArgs, ...batch], { cwd });
140
169
  if (result.error) {
141
170
  reportError(NMR_FMT_BIN, `could not run \`${PRETTIER_PACKAGE}\` (${result.error.message}).`);
142
171
  return 1;
143
172
  }
144
- const status = result.status ?? 1;
173
+ const status = resolveInheritedExitCode(result);
145
174
  if (status !== 0 && firstFailureStatus === 0) {
146
175
  firstFailureStatus = status;
147
176
  }
@@ -166,15 +195,3 @@ function batchWithinBudget(files, budgetBytes) {
166
195
  batches.push(batch);
167
196
  return batches;
168
197
  }
169
- function resolveFrom(parent, specifier) {
170
- try {
171
- return createRequire(parent).resolve(specifier);
172
- }
173
- catch {
174
- return undefined;
175
- }
176
- }
177
- function spawnInherited(file, args, cwd) {
178
- const { error, status } = spawnSync(file, args, { cwd, stdio: 'inherit' });
179
- return { error, status };
180
- }
@@ -1,10 +1,20 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
+ /**
4
+ * Prefix of node-build's definition files on the branch from which asdf-nodejs updates node-build before an install.
5
+ * A version is installable through asdf once a file named for it exists here.
6
+ */
7
+ export declare const NODE_BUILD_DEFINITION_URL = "https://raw.githubusercontent.com/nodenv/node-build/main/share/node-build/";
3
8
  /** Node's release index, which lists every release with its version and its UTC release day. */
4
9
  export declare const NODE_RELEASE_INDEX_URL = "https://nodejs.org/dist/index.json";
5
10
  export interface CheckNodePinOptions {
6
11
  /** Returns the parsed release index. Injected by tests; defaults to fetching `NODE_RELEASE_INDEX_URL`. */
7
12
  fetchReleases?: () => Promise<unknown>;
13
+ /**
14
+ * Reports whether node-build defines a version. Injected by tests; defaults to requesting the version's file under
15
+ * `NODE_BUILD_DEFINITION_URL`.
16
+ */
17
+ isNodeBuildDefined?: (version: string) => Promise<boolean>;
8
18
  /** Suppresses the report, but not the warnings. */
9
19
  isSilent: boolean;
10
20
  /** The current time, against which the soak is measured. Injected by tests. */
@@ -16,15 +26,15 @@ export interface CheckNodePinOptions {
16
26
  }
17
27
  /**
18
28
  * Reports the newest release in the major line of the `nodejs` pin in `dir`'s `.tool-versions` that is newer than
19
- * the pin and at least 24 hours old, and rewrites the pin to it when `shouldWrite` is set.
29
+ * the pin, at least 24 hours old, and defined by node-build, and rewrites the pin to it when `shouldWrite` is set.
30
+ * Reports the newest newer release that node-build does not define yet as held back.
20
31
  *
21
32
  * Reports nothing when the file or its `nodejs` entry is absent, or when the pin is current. Every failure is a
22
33
  * warning, so that the check never fails the upgrade that runs it.
23
34
  */
24
35
  export declare function checkNodePin(dir: string, options: CheckNodePinOptions): Promise<void>;
25
36
  /**
26
- * Returns the newest plain `X.Y.Z` release in `pin`'s major line that is newer than `pin` and whose UTC release day
27
- * began at least two days before `now`, or `undefined` when none is. Ignores an entry that lacks a plain version or
28
- * a `YYYY-MM-DD` date.
37
+ * Returns every plain `X.Y.Z` release in `pin`'s major line that is newer than `pin` and whose UTC release day began
38
+ * at least two days before `now`, newest first. Ignores an entry that lacks a plain version or a `YYYY-MM-DD` date.
29
39
  */
30
- export declare function selectNodeUpgrade(pin: string, releases: readonly unknown[], now: Date): string | undefined;
40
+ export declare function selectNodeUpgradeCandidates(pin: string, releases: readonly unknown[], now: Date): string[];
@@ -4,6 +4,7 @@ import process from 'node:process';
4
4
  import { formatGlyphLine, isRecord } from '@williamthorsen/nmr-core';
5
5
  import { describeError } from '@williamthorsen/toolbelt.errors';
6
6
  import { NMR_GLYPHS } from "../glyphs.js";
7
+ export const NODE_BUILD_DEFINITION_URL = 'https://raw.githubusercontent.com/nodenv/node-build/main/share/node-build/';
7
8
  export const NODE_RELEASE_INDEX_URL = 'https://nodejs.org/dist/index.json';
8
9
  const TOOL_VERSIONS_FILE = '.tool-versions';
9
10
  const SOAK_MS = 2 * 24 * 60 * 60 * 1000;
@@ -51,13 +52,30 @@ export async function checkNodePin(dir, options) {
51
52
  warn(`${NODE_RELEASE_INDEX_URL} did not return a list of releases`);
52
53
  return;
53
54
  }
54
- const target = selectNodeUpgrade(pin.version, releases, options.now ?? new Date());
55
- if (target === undefined)
55
+ const candidates = selectNodeUpgradeCandidates(pin.version, releases, options.now ?? new Date());
56
+ let target;
57
+ try {
58
+ target = await findNewestDefined(candidates, options.isNodeBuildDefined ?? requestNodeBuildDefinition);
59
+ }
60
+ catch (error) {
61
+ warn(`${NODE_BUILD_DEFINITION_URL} is unreachable (${describeError(error)})`);
62
+ return;
63
+ }
64
+ const heldBack = candidates[0] === target ? undefined : candidates[0];
65
+ function reportHeldBack() {
66
+ if (heldBack !== undefined && !options.isSilent) {
67
+ report(`nodejs ${heldBack} is held back: node-build does not define it yet.`);
68
+ }
69
+ }
70
+ if (target === undefined) {
71
+ reportHeldBack();
56
72
  return;
73
+ }
57
74
  if (!options.shouldWrite) {
58
75
  if (!options.isSilent) {
59
76
  report(`nodejs ${pin.version} → ${target} is available. Run with --write to update ${TOOL_VERSIONS_FILE}.`);
60
77
  }
78
+ reportHeldBack();
61
79
  return;
62
80
  }
63
81
  const updated = content.slice(0, pin.start) + target + content.slice(pin.start + pin.version.length);
@@ -70,11 +88,11 @@ export async function checkNodePin(dir, options) {
70
88
  }
71
89
  if (!options.isSilent)
72
90
  report(`Updated nodejs ${pin.version} → ${target} in ${TOOL_VERSIONS_FILE}.`);
91
+ reportHeldBack();
73
92
  }
74
- export function selectNodeUpgrade(pin, releases, now) {
93
+ export function selectNodeUpgradeCandidates(pin, releases, now) {
75
94
  const pinParts = parseVersion(pin);
76
- let best;
77
- let bestVersion;
95
+ const candidates = [];
78
96
  for (const release of releases) {
79
97
  if (!isRecord(release))
80
98
  continue;
@@ -89,12 +107,9 @@ export function selectNodeUpgrade(pin, releases, now) {
89
107
  continue;
90
108
  if (Date.parse(`${date}T00:00:00Z`) + SOAK_MS > now.getTime())
91
109
  continue;
92
- if (best !== undefined && compareVersions(parts, best) <= 0)
93
- continue;
94
- best = parts;
95
- bestVersion = bareVersion;
110
+ candidates.push({ parts, version: bareVersion });
96
111
  }
97
- return bestVersion;
112
+ return candidates.toSorted((a, b) => compareVersions(b.parts, a.parts)).map((candidate) => candidate.version);
98
113
  }
99
114
  function compareVersions(a, b) {
100
115
  for (let index = 0; index < 3; index++) {
@@ -111,6 +126,13 @@ async function fetchNodeReleaseIndex() {
111
126
  const body = await response.json();
112
127
  return body;
113
128
  }
129
+ async function findNewestDefined(candidates, isDefined) {
130
+ for (const candidate of candidates) {
131
+ if (await isDefined(candidate))
132
+ return candidate;
133
+ }
134
+ return undefined;
135
+ }
114
136
  function findNodePin(content) {
115
137
  const match = NODEJS_ENTRY_PATTERN.exec(content);
116
138
  const version = match?.[1];
@@ -121,6 +143,17 @@ function findNodePin(content) {
121
143
  function isMissingFileError(error) {
122
144
  return isRecord(error) && error['code'] === 'ENOENT';
123
145
  }
146
+ async function requestNodeBuildDefinition(version) {
147
+ const response = await fetch(`${NODE_BUILD_DEFINITION_URL}${version}`, {
148
+ method: 'HEAD',
149
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
150
+ });
151
+ if (response.status === 404)
152
+ return false;
153
+ if (response.status !== 200)
154
+ throw new Error(`HTTP ${response.status}`);
155
+ return true;
156
+ }
124
157
  function parseVersion(version) {
125
158
  return version.split('.').map(Number);
126
159
  }
@@ -0,0 +1,28 @@
1
+ /** The fix for a broken lookup, whose cause can name the manifest of the resolving package as well as the tool's. */
2
+ export declare const BROKEN_MANIFEST_FIX = "Repair that manifest, or reinstall the package that contains it.";
3
+ /**
4
+ * Looks up the manifest of `packageName` as `require` from `parent` resolves it, a file path or a file URL.
5
+ *
6
+ * Only `MODULE_NOT_FOUND` means that the package is absent: Node rejects an installed package whose manifest is
7
+ * malformed or unreadable with another code, and the message of that error, which `cause` repeats, names the
8
+ * manifest at fault. A failure to read or parse the manifest after resolution prefixes `cause` with its path.
9
+ * `cause` does not end with a period, so that a caller can end the sentence that it composes around it.
10
+ */
11
+ export declare function findPackageManifest(parent: string, packageName: string): ManifestLookup;
12
+ /** What a lookup of a package's manifest found: the parsed manifest, no installed package, or a broken one. */
13
+ export type ManifestLookup = {
14
+ kind: 'found';
15
+ manifestPath: string;
16
+ manifest: unknown;
17
+ } | {
18
+ kind: 'absent';
19
+ } | {
20
+ kind: 'broken';
21
+ cause: string;
22
+ };
23
+ /**
24
+ * Returns the absolute path of the CLI that the manifest at `manifestPath` declares for `packageName`, or `undefined`
25
+ * when it does not declare one. `bin` may be a bare path, which names the package's lone CLI, or a map keyed by
26
+ * command name.
27
+ */
28
+ export declare function resolveDeclaredCli(manifest: unknown, manifestPath: string, packageName: string): string | undefined;
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { hasErrnoCode, isRecord } from '@williamthorsen/nmr-core';
5
+ import { describeError } from '@williamthorsen/toolbelt.errors';
6
+ export const BROKEN_MANIFEST_FIX = 'Repair that manifest, or reinstall the package that contains it.';
7
+ export function findPackageManifest(parent, packageName) {
8
+ let manifestPath;
9
+ try {
10
+ manifestPath = createRequire(parent).resolve(`${packageName}/package.json`);
11
+ }
12
+ catch (error) {
13
+ if (hasErrnoCode(error, 'MODULE_NOT_FOUND'))
14
+ return { kind: 'absent' };
15
+ return { kind: 'broken', cause: describeCause(error) };
16
+ }
17
+ try {
18
+ const source = readFileSync(manifestPath, 'utf8').replace(/^\u{FEFF}/u, '');
19
+ return { kind: 'found', manifestPath, manifest: JSON.parse(source) };
20
+ }
21
+ catch (error) {
22
+ return { kind: 'broken', cause: `${manifestPath}: ${describeCause(error)}` };
23
+ }
24
+ }
25
+ export function resolveDeclaredCli(manifest, manifestPath, packageName) {
26
+ const declaredBin = isRecord(manifest) ? manifest['bin'] : undefined;
27
+ const bin = isRecord(declaredBin) ? declaredBin[packageName] : declaredBin;
28
+ return typeof bin === 'string' ? path.resolve(path.dirname(manifestPath), bin) : undefined;
29
+ }
30
+ function describeCause(error) {
31
+ return describeError(error).replace(/\.$/, '');
32
+ }
@@ -1,13 +1,12 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { createRequire } from 'node:module';
1
+ import { existsSync } from 'node:fs';
4
2
  import path from 'node:path';
5
3
  import process from 'node:process';
6
- import { isRecord, reportError } from '@williamthorsen/nmr-core';
4
+ import { reportError } from '@williamthorsen/nmr-core';
7
5
  import { findMonorepoRoot as findMonorepoRootOrNothing } from '@williamthorsen/nmr-core/workspace';
8
6
  import { NMR_VITEST_BIN } from "../bins.js";
9
- import { describeSignalExitCode } from "../child-process.js";
10
7
  import { omitOutcomeFile } from "../stated-outcome.js";
8
+ import { BROKEN_MANIFEST_FIX, findPackageManifest, resolveDeclaredCli } from "./package-manifest.js";
9
+ import { resolveInheritedExitCode, spawnInherited } from "./spawn-inherited.js";
11
10
  const VITEST_PACKAGE = 'vitest';
12
11
  const SUPPORTED_VITEST_RANGE = '>=4.0.0 <6';
13
12
  const CONFIG_EXTENSIONS = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'];
@@ -17,18 +16,15 @@ export function runNmrVitest(argv, cwd = process.cwd()) {
17
16
  reportError(NMR_VITEST_BIN, cli.error);
18
17
  return 1;
19
18
  }
20
- const result = spawnSync(process.execPath, [cli.cliPath, ...chooseVitestConfigArgs(argv, cwd)], {
19
+ const result = spawnInherited(process.execPath, [cli.cliPath, ...chooseVitestConfigArgs(argv, cwd)], {
21
20
  cwd,
22
21
  env: omitOutcomeFile(process.env),
23
- stdio: 'inherit',
24
22
  });
25
23
  if (result.error) {
26
24
  reportError(NMR_VITEST_BIN, `could not run \`${VITEST_PACKAGE}\` (${result.error.message}).`);
27
25
  return 1;
28
26
  }
29
- if (result.signal !== null)
30
- return describeSignalExitCode(result.signal);
31
- return result.status ?? 1;
27
+ return resolveInheritedExitCode(result);
32
28
  }
33
29
  export function chooseVitestConfigArgs(argv, cwd) {
34
30
  if (namesConfig(argv))
@@ -65,17 +61,22 @@ function resolveVitestCli(cwd) {
65
61
  ok: false,
66
62
  error: `requires Vitest ${SUPPORTED_VITEST_RANGE}, but \`${VITEST_PACKAGE}\` could not be resolved from ${cwd}. Install a version in that range as the \`${VITEST_PACKAGE}\` peer dependency.`,
67
63
  };
68
- let manifestPath;
69
- try {
70
- manifestPath = createRequire(path.join(cwd, 'package.json')).resolve(`${VITEST_PACKAGE}/package.json`);
71
- }
72
- catch {
64
+ const lookup = findPackageManifest(path.join(cwd, 'package.json'), VITEST_PACKAGE);
65
+ if (lookup.kind === 'absent')
73
66
  return missingResult;
67
+ if (lookup.kind === 'broken') {
68
+ return {
69
+ ok: false,
70
+ error: `\`${VITEST_PACKAGE}\` could not be loaded from ${cwd}: ${lookup.cause}. ${BROKEN_MANIFEST_FIX}`,
71
+ };
74
72
  }
75
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
76
- const bin = isRecord(manifest) && isRecord(manifest['bin']) ? manifest['bin'][VITEST_PACKAGE] : undefined;
77
- if (typeof bin !== 'string') {
78
- return { ok: false, error: `\`${VITEST_PACKAGE}\` at ${path.dirname(manifestPath)} does not declare a CLI.` };
73
+ const { manifest, manifestPath } = lookup;
74
+ const cliPath = resolveDeclaredCli(manifest, manifestPath, VITEST_PACKAGE);
75
+ if (cliPath === undefined) {
76
+ return {
77
+ ok: false,
78
+ error: `\`${VITEST_PACKAGE}\` at ${path.dirname(manifestPath)} does not declare a CLI. Reinstall the \`${VITEST_PACKAGE}\` peer dependency.`,
79
+ };
79
80
  }
80
- return { ok: true, cliPath: path.resolve(path.dirname(manifestPath), bin) };
81
+ return { ok: true, cliPath };
81
82
  }
@@ -1,17 +1,12 @@
1
+ import { type SpawnInherited } from './spawn-inherited.js';
1
2
  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
3
  export interface RunShellcheckOptions {
9
4
  /** Directories under which the pinned tools are cached, in lookup order; defaults to `resolveToolCacheRoots`. */
10
5
  cacheRoots?: readonly [string, ...string[]];
11
6
  ensure?: (tool: WorkflowTool, cacheRoots: readonly [string, ...string[]]) => Promise<string>;
12
7
  /** Lists the tracked `*.sh` files under the root, relative to it. */
13
8
  listFiles?: (monorepoRoot: string) => string[];
14
- spawn?: (file: string, args: string[], cwd: string) => ShellcheckOutcome;
9
+ spawn?: SpawnInherited;
15
10
  }
16
11
  /**
17
12
  * Lints the monorepo's tracked shell scripts with the pinned shellcheck and returns its exit status. Trailing
@@ -1,8 +1,7 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { runGit, UserError } from '@williamthorsen/nmr-core';
5
- import { describeSignalExitCode } from "../child-process.js";
4
+ import { resolveInheritedExitCode, spawnInherited } from "./spawn-inherited.js";
6
5
  import { ensureTool, resolveToolCacheRoots } from "./workflow-tools.js";
7
6
  export async function runShellcheck(monorepoRoot, args, options = {}) {
8
7
  const shellcheckArgs = args.length > 0 ? [...args] : listPresentScripts(monorepoRoot, options.listFiles);
@@ -13,14 +12,11 @@ export async function runShellcheck(monorepoRoot, args, options = {}) {
13
12
  const cacheRoots = options.cacheRoots ?? resolveToolCacheRoots(monorepoRoot);
14
13
  const ensure = options.ensure ?? ((tool, roots) => ensureTool(tool, { cacheRoots: roots }));
15
14
  const shellcheckPath = await ensure('shellcheck', cacheRoots);
16
- const outcome = (options.spawn ?? spawnInherited)(shellcheckPath, shellcheckArgs, monorepoRoot);
15
+ const outcome = (options.spawn ?? spawnInherited)(shellcheckPath, shellcheckArgs, { cwd: monorepoRoot });
17
16
  if (outcome.error !== undefined) {
18
17
  throw new UserError(`Cannot run shellcheck: ${outcome.error.message}`, { cause: outcome.error });
19
18
  }
20
- if (outcome.signal !== null) {
21
- return describeSignalExitCode(outcome.signal);
22
- }
23
- return outcome.status ?? 1;
19
+ return resolveInheritedExitCode(outcome);
24
20
  }
25
21
  export function listTrackedScripts(monorepoRoot) {
26
22
  const result = runGit(['ls-files', '-z', '--', '*.sh'], monorepoRoot);
@@ -32,7 +28,3 @@ export function listTrackedScripts(monorepoRoot) {
32
28
  function listPresentScripts(monorepoRoot, listFiles = listTrackedScripts) {
33
29
  return listFiles(monorepoRoot).filter((file) => fs.existsSync(path.join(monorepoRoot, file)));
34
30
  }
35
- function spawnInherited(file, args, cwd) {
36
- const result = spawnSync(file, args, { cwd, stdio: 'inherit' });
37
- return { error: result.error, signal: result.signal, status: result.status };
38
- }