@williamthorsen/nmr 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,24 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
- import { readFile, rename, rm } from 'node:fs/promises';
3
+ import { readFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { formatGlyphLine, readCacheEntry, startTimer, STATUS_GLYPHS, UserError, writeCacheEntry, } from '@williamthorsen/nmr-core';
6
- import { describeError } from '@williamthorsen/toolbelt.errors';
7
- import { glob } from 'glob';
8
- import { NMR_GLYPHS } from "../glyphs.js";
5
+ import { readCacheEntry, startTimer, UserError, writeCacheEntry } from '@williamthorsen/nmr-core';
9
6
  import { resolveConfigPath } from "../helpers/config-path.js";
10
- import { formatSaving } from "../helpers/duration.js";
11
7
  import { writeStatedOutcome } from "../stated-outcome.js";
12
- import { DEFAULT_ENTRY_GLOBS, DEFAULT_IGNORE_PATTERNS, DEFAULT_OUTDIR, hasExpectedBuildOutput, readEmitDuration, resolveBuildCachePath, resolveScratchDirs, resolveToolchainFingerprint, writeEmitDuration, } from "./build-output.js";
8
+ import { DEFAULT_OUTDIR, hasExpectedBuildOutput, readEmitDuration, resolveBuildCachePath, resolveEntryPoints, resolveToolchainFingerprint, writeEmitDuration, } from "./build-output.js";
13
9
  import { assertSupportedTypeScript, formatDiagnostics, getCompiler, loadCompiler } from "./compiler.js";
14
- import { isWithin, rewriteSpecifiers } from "./rewrite-specifiers.js";
10
+ import { discardScratchDirs, publishOutput, removeScratchDirs } from "./publish-output.js";
11
+ import { rewriteSpecifiers } from "./rewrite-specifiers.js";
15
12
  import { resolveTsconfigChain } from "./tsconfig-chain.js";
16
13
  const SOURCE_ROOT = 'src';
17
- export async function buildPackage(packageDir, options) {
14
+ export async function buildPackage(packageDir, options = {}) {
18
15
  await loadCompiler(options.loadTypeScript);
19
16
  assertSupportedTypeScript();
20
17
  const cachePath = resolveBuildCachePath(packageDir);
21
18
  const outdir = options.outdir ?? DEFAULT_OUTDIR;
22
19
  const emitDir = resolveEmitDir(packageDir, outdir);
23
20
  const emitConfig = { outdir, declaration: true, rewriteRelativeImportExtensions: true };
24
- const entryPoints = await glob(options.entryGlobs ?? DEFAULT_ENTRY_GLOBS, {
25
- cwd: packageDir,
26
- ignore: [...(options.ignorePatterns ?? DEFAULT_IGNORE_PATTERNS), ...(options.extraIgnorePatterns ?? [])],
27
- });
21
+ const entryPoints = await resolveEntryPoints(packageDir, options);
28
22
  const configPath = resolveConfigPath(packageDir);
29
23
  const dependencies = [
30
24
  'package.json',
@@ -35,17 +29,19 @@ export async function buildPackage(packageDir, options) {
35
29
  compilerVersion: getCompiler().version,
36
30
  fingerprint: await resolveToolchainFingerprint(packageDir),
37
31
  };
38
- const { changed, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, toolchain, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints), options.style);
39
- if (!changed) {
40
- await discardScratchDirs(resolveScratchDirs(emitDir));
41
- return;
32
+ const { reason, currentHash } = await detectBuildChanges(packageDir, [...entryPoints, ...dependencies], emitConfig, toolchain, cachePath, hasExpectedBuildOutput(packageDir, outdir, entryPoints));
33
+ if (reason === 'unchanged') {
34
+ await discardScratchDirs(emitDir);
35
+ const savedMs = await recordUnchangedBuild(packageDir, currentHash, process.env);
36
+ return { kind: 'unchanged', ...(savedMs !== undefined && { savedMs }) };
42
37
  }
38
+ options.onProgress?.({ kind: reason });
43
39
  const readEmitMs = startTimer();
44
40
  const emittedFileCount = await emitPackage(packageDir, entryPoints, outdir);
45
41
  const emitMs = readEmitMs();
46
42
  await writeCacheEntry(cachePath, currentHash);
47
43
  await writeEmitDuration(packageDir, currentHash, emitMs);
48
- console.info(formatGlyphLine(NMR_GLYPHS, options.style, 'package', `${path.basename(packageDir)}: ${describeEmit(emittedFileCount, outdir)}`));
44
+ return { kind: 'emitted', fileCount: emittedFileCount, outdir };
49
45
  }
50
46
  export async function computeBuildHash(packageDir, files, emitConfig, toolchain) {
51
47
  const hash = createHash('sha256');
@@ -67,8 +63,7 @@ async function emitPackage(packageDir, entryPoints, outdir) {
67
63
  const rootNames = entryPoints.map((entry) => path.resolve(packageDir, entry));
68
64
  const sourceRoot = path.resolve(packageDir, SOURCE_ROOT);
69
65
  const emitDir = resolveEmitDir(packageDir, outdir);
70
- const scratchDirs = resolveScratchDirs(emitDir);
71
- await removeScratchDirs(scratchDirs);
66
+ await removeScratchDirs(emitDir);
72
67
  const emittedFiles = new Map();
73
68
  function collect(fileName, text, writeByteOrderMark) {
74
69
  emittedFiles.set(fileName, { text, writeByteOrderMark });
@@ -82,13 +77,7 @@ async function emitPackage(packageDir, entryPoints, outdir) {
82
77
  for (const [fileName, file] of emittedFiles) {
83
78
  stagedFiles.set(fileName, { ...file, text: rewriteSpecifiers(fileName, file.text, compilerOptions, sourceRoot) });
84
79
  }
85
- if (stagedFiles.size === 0) {
86
- await rm(emitDir, { force: true, recursive: true });
87
- return 0;
88
- }
89
- writeStagedOutput(stagedFiles, emitDir, scratchDirs.stagingDir);
90
- await swapIntoPlace(emitDir, scratchDirs);
91
- return stagedFiles.size;
80
+ return publishOutput(emitDir, stagedFiles);
92
81
  }
93
82
  function assertEmitSucceeded(result) {
94
83
  if (result.emitSkipped) {
@@ -110,17 +99,6 @@ function createCachingCompilerHost(compilerOptions) {
110
99
  };
111
100
  return host;
112
101
  }
113
- async function discardScratchDirs(scratchDirs) {
114
- try {
115
- await removeScratchDirs(scratchDirs);
116
- }
117
- catch {
118
- }
119
- }
120
- async function removeScratchDirs(scratchDirs) {
121
- await rm(scratchDirs.previousDir, { force: true, recursive: true });
122
- await rm(scratchDirs.stagingDir, { force: true, recursive: true });
123
- }
124
102
  function resolveEmitDir(packageDir, outdir) {
125
103
  const resolvedDir = path.resolve(packageDir, outdir);
126
104
  const relativePathFromPackage = path.relative(packageDir, resolvedDir);
@@ -132,24 +110,6 @@ function resolveEmitDir(packageDir, outdir) {
132
110
  }
133
111
  return resolvedDir;
134
112
  }
135
- async function swapIntoPlace(emitDir, scratchDirs) {
136
- const hadPreviousOutput = existsSync(emitDir);
137
- if (hadPreviousOutput) {
138
- await rename(emitDir, scratchDirs.previousDir);
139
- }
140
- try {
141
- await rename(scratchDirs.stagingDir, emitDir);
142
- }
143
- catch (error) {
144
- if (hadPreviousOutput && !existsSync(emitDir)) {
145
- await rename(scratchDirs.previousDir, emitDir);
146
- }
147
- throw new UserError(`failed to move the built output into ${emitDir}: ${describeError(error)}`, {
148
- cause: error,
149
- });
150
- }
151
- await discardScratchDirs(scratchDirs);
152
- }
153
113
  function synthesizeCompilerOptions(packageDir, outdir) {
154
114
  const ts = getCompiler();
155
115
  const configPath = path.join(packageDir, 'tsconfig.json');
@@ -176,38 +136,16 @@ function synthesizeCompilerOptions(packageDir, outdir) {
176
136
  noEmitOnError: false,
177
137
  };
178
138
  }
179
- function writeStagedOutput(stagedFiles, emitDir, stagingDir) {
180
- const ts = getCompiler();
181
- for (const [fileName, file] of stagedFiles) {
182
- if (!isWithin(emitDir, fileName)) {
183
- throw new Error(`the compiler emitted ${fileName}, which is outside the output directory ${emitDir}. ` +
184
- "Verify the resolved tsconfig's 'outDir' and 'declarationDir'.");
185
- }
186
- ts.sys.writeFile(path.join(stagingDir, path.relative(emitDir, fileName)), file.text, file.writeByteOrderMark);
187
- }
188
- }
189
- async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath, isOutputPresent, style) {
190
- const packageName = path.basename(packageDir);
139
+ async function detectBuildChanges(packageDir, files, emitConfig, toolchain, cachePath, isOutputPresent) {
191
140
  const previousHash = await readCacheEntry(cachePath);
192
141
  const currentHash = await computeBuildHash(packageDir, files, emitConfig, toolchain);
193
- if (previousHash === currentHash) {
194
- if (isOutputPresent) {
195
- const savedMs = await readEmitDuration(packageDir, currentHash);
196
- await writeStatedOutcome(process.env, { kind: 'build-unchanged', ...(savedMs !== undefined && { savedMs }) });
197
- const saving = formatSaving(savedMs ?? 0);
198
- const savingClause = saving === undefined ? '' : `, ${saving}`;
199
- console.info(`${STATUS_GLYPHS[style].skipped.text} ${packageName}: No changes detected. Skipping build${savingClause}.`);
200
- return { changed: false, currentHash };
201
- }
202
- console.info(formatGlyphLine(NMR_GLYPHS, style, 'package', `${packageName}: Build output is missing. Rebuilding.`));
203
- return { changed: true, currentHash };
142
+ if (previousHash !== currentHash) {
143
+ return { reason: 'inputs-changed', currentHash };
204
144
  }
205
- console.info(formatGlyphLine(NMR_GLYPHS, style, 'package', `${packageName}: Changes detected.`));
206
- return { changed: true, currentHash };
145
+ return { reason: isOutputPresent ? 'unchanged' : 'output-missing', currentHash };
207
146
  }
208
- function describeEmit(fileCount, outdir) {
209
- if (fileCount === 0) {
210
- return 'Did not emit any output.';
211
- }
212
- return `Compiled ${fileCount} files to ${outdir.replace(/\/+$/u, '')}.`;
147
+ export async function recordUnchangedBuild(packageDir, digest, env) {
148
+ const savedMs = await readEmitDuration(packageDir, digest);
149
+ await writeStatedOutcome(env, { kind: 'build-unchanged', ...(savedMs !== undefined && { savedMs }) });
150
+ return savedMs;
213
151
  }
@@ -0,0 +1,6 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
2
+ import type { CleanOutcome, CleanProgress } from './clean.js';
3
+ /** Renders the line that closes a sweep, or `undefined` for a one-package clean, whose own line concludes it. */
4
+ export declare function renderCleanOutcome(outcome: CleanOutcome, style: OutputStyle): string | undefined;
5
+ /** Renders the line that reports a completed step of a clean. */
6
+ export declare function renderCleanProgress(progress: CleanProgress, style: OutputStyle): string;
@@ -0,0 +1,20 @@
1
+ import path from 'node:path';
2
+ import { formatGlyphLine } from '@williamthorsen/nmr-core';
3
+ import { NMR_GLYPHS } from "../glyphs.js";
4
+ export function renderCleanOutcome(outcome, style) {
5
+ if (outcome.kind === 'package') {
6
+ return undefined;
7
+ }
8
+ return formatGlyphLine(NMR_GLYPHS, style, 'clean', describeSweep(outcome.cleanedDirs.length, outcome.skippedCount));
9
+ }
10
+ export function renderCleanProgress(progress, style) {
11
+ const message = progress.kind === 'check-results-removed'
12
+ ? 'Removed all recorded check results.'
13
+ : `${path.basename(progress.packageDir)}: Removed build output and cache.`;
14
+ return formatGlyphLine(NMR_GLYPHS, style, 'clean', message);
15
+ }
16
+ function describeSweep(cleanedCount, skippedCount) {
17
+ const packages = cleanedCount === 1 ? '1 package' : `${cleanedCount} packages`;
18
+ const skipClause = skippedCount === 0 ? '' : `, skipping ${skippedCount} with an empty clean override`;
19
+ return `Cleaned ${packages}${skipClause}.`;
20
+ }
@@ -1,18 +1,50 @@
1
- import { type OutputStyle } from '@williamthorsen/nmr-core';
1
+ import type { Writable } from 'node:stream';
2
+ /** What a clean did: one package's clean, or a sweep of every workspace package. */
3
+ export type CleanOutcome = {
4
+ kind: 'package';
5
+ checkResultsDir: string;
6
+ packageDir: string;
7
+ } | {
8
+ kind: 'sweep';
9
+ checkResultsDir: string;
10
+ cleanedDirs: string[];
11
+ skippedCount: number;
12
+ };
13
+ /** A step of a clean, reported as soon as it completes. */
14
+ export type CleanProgress = {
15
+ kind: 'check-results-removed';
16
+ } | {
17
+ kind: 'package-cleaned';
18
+ packageDir: string;
19
+ };
20
+ /** What a clean needs from its caller: a listener for its progress, and the streams for an override's output. */
21
+ export interface CleanOptions {
22
+ /** Called after each step completes, so that a caller can report it before a later override runs or fails. */
23
+ onProgress?: (progress: CleanProgress) => void;
24
+ /** The stream to which a package's own `clean` override writes its errors. */
25
+ stderr: Writable;
26
+ /** The stream to which a package's own `clean` override writes its output. */
27
+ stdout: Writable;
28
+ }
29
+ /**
30
+ * Cleans the package containing `cwd`, or every workspace package when run from the monorepo root.
31
+ *
32
+ * From within a package this is the built-in clean itself: `nmr` has already resolved `clean` to `nmr-clean`
33
+ * before invoking it, so resolving again would apply the package's own override twice.
34
+ */
35
+ export declare function cleanFrom(cwd: string, options: CleanOptions): Promise<CleanOutcome>;
2
36
  /**
3
37
  * Removes a package's build output and the build-cache entries that describe it, so the package does not keep
4
38
  * any state that would make the next build skip. Removal is idempotent: An already-clean package is a silent
5
39
  * no-op.
6
40
  */
7
- export declare function cleanPackage(packageDir: string, style: OutputStyle): Promise<void>;
41
+ export declare function cleanPackage(packageDir: string): Promise<void>;
8
42
  /**
9
- * Cleans the package containing `cwd`, or every workspace package when run from the monorepo root.
10
- *
11
- * From within a package this is the built-in clean itself: `nmr` has already resolved `clean` to this bin
12
- * before invoking it, so resolving again would apply the package's own override twice.
43
+ * Runs `nmr-clean` against `argv` from `cwd` and returns the exit code to report. The command takes neither an
44
+ * option nor an operand, so anything but `--help` is a usage error and removes nothing.
13
45
  *
14
46
  * This is the default `clean` script, and it is published as a bin because, under pnpm's isolated layout,
15
47
  * `pnpm exec` resolves only the bins of the consuming project's direct dependencies: pnpm links nmr's own bins
16
48
  * into the consumer's `node_modules/.bin`, but not the bins of nmr's dependencies.
17
49
  */
18
- export declare function runClean(cwd: string, style: OutputStyle): Promise<void>;
50
+ export declare function runClean(argv: string[], cwd: string): Promise<number>;
@@ -1,59 +1,81 @@
1
1
  import { rm } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { formatGlyphLine, UserError } from '@williamthorsen/nmr-core';
3
+ import process from 'node:process';
4
+ import { UserError } from '@williamthorsen/nmr-core';
5
+ import { defineCommand, runCli } from '@williamthorsen/nmr-core/cli';
4
6
  import { NMR_CLEAN_BIN } from "../bins.js";
5
7
  import { removeCheckCache } from "../check-cache-entries.js";
6
8
  import { loadRootConfig } from "../config.js";
7
9
  import { findContainingPackageDir } from "../context.js";
8
- import { NMR_GLYPHS } from "../glyphs.js";
9
10
  import { reportClosing } from "../helpers/reportClosing.js";
10
11
  import { resolveChannel } from "../output-channel.js";
12
+ import { resolveBinStyles } from "../resolveBinStyles.js";
11
13
  import { buildWorkspaceRegistry, resolveScript } from "../resolver.js";
12
14
  import { runCommand } from "../runner.js";
13
15
  import { renderChain } from "../steps.js";
14
16
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
15
17
  import { resolveBuildCachePath, resolveEmitDurationPath } from "./build-output.js";
18
+ import { renderCleanOutcome, renderCleanProgress } from "./clean-report.js";
16
19
  const CLEAN_COMMAND = 'clean';
17
20
  const BUILT_IN_CLEAN = 'nmr-clean';
18
21
  const OUTPUT_ROOT = 'dist';
19
- export async function cleanPackage(packageDir, style) {
20
- await rm(path.resolve(packageDir, OUTPUT_ROOT), { recursive: true, force: true });
21
- await rm(resolveBuildCachePath(packageDir), { force: true });
22
- await rm(resolveEmitDurationPath(packageDir), { force: true });
23
- console.info(formatGlyphLine(NMR_GLYPHS, style, 'clean', `${path.basename(packageDir)}: Removed build output and cache.`));
24
- }
25
- export async function runClean(cwd, style) {
22
+ export async function cleanFrom(cwd, options) {
26
23
  let monorepoRoot;
27
24
  try {
28
25
  monorepoRoot = findMonorepoRoot(cwd);
29
26
  }
30
27
  catch {
31
- await clearCheckCache(cwd, style);
32
- await cleanPackage(cwd, style);
33
- return;
28
+ await clearCheckCache(cwd, options);
29
+ await cleanReportedPackage(cwd, options);
30
+ return { kind: 'package', checkResultsDir: cwd, packageDir: cwd };
34
31
  }
35
- await clearCheckCache(monorepoRoot, style);
32
+ await clearCheckCache(monorepoRoot, options);
36
33
  const workspacePackageDirs = getWorkspacePackageDirs(monorepoRoot);
37
34
  const packageDir = findContainingPackageDir(cwd, workspacePackageDirs);
38
35
  if (packageDir !== undefined) {
39
- await cleanPackage(packageDir, style);
40
- return;
36
+ await cleanReportedPackage(packageDir, options);
37
+ return { kind: 'package', checkResultsDir: monorepoRoot, packageDir };
41
38
  }
42
- await sweepWorkspace(monorepoRoot, workspacePackageDirs, style);
39
+ return sweepWorkspace(monorepoRoot, workspacePackageDirs, options);
43
40
  }
44
- async function clearCheckCache(scopeDir, style) {
45
- await removeCheckCache(scopeDir);
46
- console.info(formatGlyphLine(NMR_GLYPHS, style, 'clean', 'Removed all recorded check results.'));
41
+ export async function cleanPackage(packageDir) {
42
+ await rm(path.resolve(packageDir, OUTPUT_ROOT), { recursive: true, force: true });
43
+ await rm(resolveBuildCachePath(packageDir), { force: true });
44
+ await rm(resolveEmitDurationPath(packageDir), { force: true });
45
+ }
46
+ export async function runClean(argv, cwd) {
47
+ const command = defineCommand({
48
+ summary: 'Remove the build output and build cache of this package, or of every package from the monorepo root',
49
+ run: async () => {
50
+ const style = resolveBinStyles().stdout;
51
+ const outcome = await cleanFrom(cwd, {
52
+ onProgress: (progress) => {
53
+ console.info(renderCleanProgress(progress, style));
54
+ },
55
+ stderr: process.stderr,
56
+ stdout: process.stdout,
57
+ });
58
+ const closing = renderCleanOutcome(outcome, style);
59
+ if (closing !== undefined) {
60
+ reportClosing(closing);
61
+ }
62
+ return 0;
63
+ },
64
+ });
65
+ return runCli(argv, command, { name: NMR_CLEAN_BIN, stdout: process.stdout, stderr: process.stderr, baseDir: cwd });
47
66
  }
48
- function describeSweep(cleanedCount, skippedCount) {
49
- const packages = cleanedCount === 1 ? '1 package' : `${cleanedCount} packages`;
50
- const skipClause = skippedCount === 0 ? '' : `, skipping ${skippedCount} with an empty clean override`;
51
- return `Cleaned ${packages}${skipClause}.`;
67
+ async function cleanReportedPackage(packageDir, options) {
68
+ await cleanPackage(packageDir);
69
+ options.onProgress?.({ kind: 'package-cleaned', packageDir });
70
+ }
71
+ async function clearCheckCache(scopeDir, options) {
72
+ await removeCheckCache(scopeDir);
73
+ options.onProgress?.({ kind: 'check-results-removed' });
52
74
  }
53
- async function sweepWorkspace(monorepoRoot, workspacePackageDirs, style) {
75
+ async function sweepWorkspace(monorepoRoot, workspacePackageDirs, options) {
54
76
  const config = await loadRootConfig(monorepoRoot);
55
77
  const registry = buildWorkspaceRegistry(config);
56
- let cleanedCount = 0;
78
+ const cleanedDirs = [];
57
79
  let skippedCount = 0;
58
80
  for (const packageDir of workspacePackageDirs) {
59
81
  const resolvedScript = resolveScript(CLEAN_COMMAND, registry, packageDir, false);
@@ -63,18 +85,20 @@ async function sweepWorkspace(monorepoRoot, workspacePackageDirs, style) {
63
85
  continue;
64
86
  }
65
87
  if (resolvedCommand === BUILT_IN_CLEAN) {
66
- await cleanPackage(packageDir, style);
67
- cleanedCount++;
88
+ await cleanReportedPackage(packageDir, options);
89
+ cleanedDirs.push(packageDir);
68
90
  continue;
69
91
  }
70
92
  const { exitCode } = await runCommand(resolvedCommand, packageDir, {
71
93
  bin: NMR_CLEAN_BIN,
72
- channels: { stderr: resolveChannel(process.stderr, false), stdout: resolveChannel(process.stdout, false) },
94
+ channels: { stderr: resolveChannel(options.stderr, false), stdout: resolveChannel(options.stdout, false) },
95
+ stderr: options.stderr,
96
+ stdout: options.stdout,
73
97
  });
74
98
  if (exitCode !== 0) {
75
99
  throw new UserError(`\`${resolvedCommand}\` failed in ${path.basename(packageDir)} with exit code ${exitCode}.`);
76
100
  }
77
- cleanedCount++;
101
+ cleanedDirs.push(packageDir);
78
102
  }
79
- reportClosing(formatGlyphLine(NMR_GLYPHS, style, 'clean', describeSweep(cleanedCount, skippedCount)));
103
+ return { kind: 'sweep', checkResultsDir: monorepoRoot, cleanedDirs, skippedCount };
80
104
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Runs `nmr-compile` against `argv` for the package at `packageDir` and returns the exit code to report. The command
3
+ * takes neither an option nor an operand, so anything but `--help` is a usage error and compiles nothing.
4
+ */
5
+ export declare function runCompile(argv: string[], packageDir: string): Promise<number>;
@@ -0,0 +1,32 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { defineCommand, runCli } from '@williamthorsen/nmr-core/cli';
4
+ import { NMR_COMPILE_BIN } from "../bins.js";
5
+ import { loadWorkspaceConfig } from "../config.js";
6
+ import { resolveBinStyles } from "../resolveBinStyles.js";
7
+ import { buildPackage } from "./build.js";
8
+ import { renderBuildOutcome, renderBuildProgress } from "./build-report.js";
9
+ export async function runCompile(argv, packageDir) {
10
+ const command = defineCommand({
11
+ summary: "Compile the package's src tree to dist/esm",
12
+ run: async () => {
13
+ const { stdout } = resolveBinStyles();
14
+ const { build } = await loadWorkspaceConfig(packageDir);
15
+ const packageName = path.basename(packageDir);
16
+ const outcome = await buildPackage(packageDir, {
17
+ onProgress: (progress) => {
18
+ console.info(renderBuildProgress(progress, packageName, stdout));
19
+ },
20
+ ...(build?.extraIgnorePatterns !== undefined && { extraIgnorePatterns: build.extraIgnorePatterns }),
21
+ });
22
+ console.info(renderBuildOutcome(outcome, packageName, stdout));
23
+ return 0;
24
+ },
25
+ });
26
+ return runCli(argv, command, {
27
+ name: NMR_COMPILE_BIN,
28
+ stdout: process.stdout,
29
+ stderr: process.stderr,
30
+ baseDir: packageDir,
31
+ });
32
+ }
@@ -10,6 +10,15 @@ export type FormatMode = 'check' | 'write';
10
10
  * with `-`.
11
11
  */
12
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
+ }
13
22
  /** @internal */
14
23
  export interface RunPrettierOptions {
15
24
  cliPath: string;
@@ -23,6 +32,8 @@ export interface RunPrettierOptions {
23
32
  cwd: string;
24
33
  /** Overridable so that the multi-batch path is reachable without a fixture that hits the real ceiling. */
25
34
  budgetBytes?: number;
35
+ /** Runs the Prettier CLI as `file` with `args` in `cwd`. */
36
+ spawn?: (file: string, args: string[], cwd: string) => PrettierOutcome;
26
37
  }
27
38
  /**
28
39
  * Runs Prettier over the selection, returning the first non-zero exit code. Every batch runs even after
@@ -122,7 +122,7 @@ async function findRepositoryConfig(manifestPath, cwd) {
122
122
  }
123
123
  }
124
124
  export function runPrettier(options) {
125
- const { cacheLocation, cliPath, configPath, mode, files, ignorePaths, cwd, budgetBytes = ARGUMENT_BUDGET_BYTES, } = options;
125
+ const { cacheLocation, cliPath, configPath, mode, files, ignorePaths, cwd, budgetBytes = ARGUMENT_BUDGET_BYTES, spawn = spawnInherited, } = options;
126
126
  const prettierArgs = [
127
127
  cliPath,
128
128
  ...(configPath === undefined ? [] : ['--config', configPath]),
@@ -136,7 +136,7 @@ export function runPrettier(options) {
136
136
  ];
137
137
  let firstFailureStatus = 0;
138
138
  for (const batch of batchWithinBudget(files, budgetBytes)) {
139
- const result = spawnSync(process.execPath, [...prettierArgs, ...batch], { cwd, stdio: 'inherit' });
139
+ const result = spawn(process.execPath, [...prettierArgs, ...batch], cwd);
140
140
  if (result.error) {
141
141
  reportError(NMR_FMT_BIN, `could not run \`${PRETTIER_PACKAGE}\` (${result.error.message}).`);
142
142
  return 1;
@@ -174,3 +174,7 @@ function resolveFrom(parent, specifier) {
174
174
  return undefined;
175
175
  }
176
176
  }
177
+ function spawnInherited(file, args, cwd) {
178
+ const { error, status } = spawnSync(file, args, { cwd, stdio: 'inherit' });
179
+ return { error, status };
180
+ }
@@ -0,0 +1,40 @@
1
+ /** The pair of directories through which a build publishes, both siblings of the emit directory. */
2
+ export interface ScratchDirs {
3
+ previousDir: string;
4
+ stagingDir: string;
5
+ }
6
+ /** One file emitted by the compiler, kept in memory until the whole emit is ready to publish. */
7
+ export interface StagedFile {
8
+ text: string;
9
+ writeByteOrderMark: boolean;
10
+ }
11
+ /**
12
+ * Removes both scratch directories of `emitDir` without failing the caller. `force` suppresses only a missing
13
+ * top-level path and `rm` does not retry, so a removal racing another build's leaves `ENOTEMPTY`, `EBUSY`, and
14
+ * `EPERM` live.
15
+ */
16
+ export declare function discardScratchDirs(emitDir: string): Promise<void>;
17
+ /**
18
+ * Publishes the staged emit to `emitDir` atomically: It writes the files to a staging directory and swaps that
19
+ * into place by rename. Returns how many files it published.
20
+ *
21
+ * An emit that produces nothing removes `emitDir` instead, because swapping an empty directory into place would
22
+ * leave a `dist` behind for a package whose entry points do not emit any output.
23
+ *
24
+ * A failure while staging precedes the first rename, so it leaves the previous output exactly as it was.
25
+ */
26
+ export declare function publishOutput(emitDir: string, stagedFiles: Map<string, StagedFile>): Promise<number>;
27
+ /** Removes both scratch directories of `emitDir`, tolerating their absence. */
28
+ export declare function removeScratchDirs(emitDir: string): Promise<void>;
29
+ /**
30
+ * Resolves the two scratch directories through which a build publishes: `staging`, which the emit is written to,
31
+ * and `previous`, which the outgoing output is renamed aside to. Both are siblings of the emit directory, so a
32
+ * rename between them never crosses a filesystem, and both are dot-prefixed so that a leftover stays out of the
33
+ * globs that select sources.
34
+ *
35
+ * The names are fixed rather than unique because the build removes both before use. A unique name would need a
36
+ * sweep to accomplish that removal, and a sweep cannot tell a directory orphaned by a killed run from one that a
37
+ * concurrent build is still writing. Because a directory left behind by a failed build is cleared the same way,
38
+ * the failure path does not need any cleanup of its own.
39
+ */
40
+ export declare function resolveScratchDirs(emitDir: string): ScratchDirs;
@@ -0,0 +1,66 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { rename, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { UserError } from '@williamthorsen/nmr-core';
5
+ import { describeError } from '@williamthorsen/toolbelt.errors';
6
+ import { getCompiler } from "./compiler.js";
7
+ import { isWithin } from "./rewrite-specifiers.js";
8
+ export async function discardScratchDirs(emitDir) {
9
+ try {
10
+ await removeScratchDirs(emitDir);
11
+ }
12
+ catch {
13
+ }
14
+ }
15
+ export async function publishOutput(emitDir, stagedFiles) {
16
+ if (stagedFiles.size === 0) {
17
+ await rm(emitDir, { force: true, recursive: true });
18
+ return 0;
19
+ }
20
+ writeStagedOutput(stagedFiles, emitDir);
21
+ await swapIntoPlace(emitDir);
22
+ return stagedFiles.size;
23
+ }
24
+ export async function removeScratchDirs(emitDir) {
25
+ const { previousDir, stagingDir } = resolveScratchDirs(emitDir);
26
+ await rm(previousDir, { force: true, recursive: true });
27
+ await rm(stagingDir, { force: true, recursive: true });
28
+ }
29
+ export function resolveScratchDirs(emitDir) {
30
+ const parent = path.dirname(emitDir);
31
+ const name = path.basename(emitDir);
32
+ return {
33
+ previousDir: path.join(parent, `.${name}.previous`),
34
+ stagingDir: path.join(parent, `.${name}.staging`),
35
+ };
36
+ }
37
+ async function swapIntoPlace(emitDir) {
38
+ const { previousDir, stagingDir } = resolveScratchDirs(emitDir);
39
+ const hadPreviousOutput = existsSync(emitDir);
40
+ if (hadPreviousOutput) {
41
+ await rename(emitDir, previousDir);
42
+ }
43
+ try {
44
+ await rename(stagingDir, emitDir);
45
+ }
46
+ catch (error) {
47
+ if (hadPreviousOutput && !existsSync(emitDir)) {
48
+ await rename(previousDir, emitDir);
49
+ }
50
+ throw new UserError(`failed to move the built output into ${emitDir}: ${describeError(error)}`, {
51
+ cause: error,
52
+ });
53
+ }
54
+ await discardScratchDirs(emitDir);
55
+ }
56
+ function writeStagedOutput(stagedFiles, emitDir) {
57
+ const ts = getCompiler();
58
+ const { stagingDir } = resolveScratchDirs(emitDir);
59
+ for (const [fileName, file] of stagedFiles) {
60
+ if (!isWithin(emitDir, fileName)) {
61
+ throw new Error(`the compiler emitted ${fileName}, which is outside the output directory ${emitDir}. ` +
62
+ "Verify the resolved tsconfig's 'outDir' and 'declarationDir'.");
63
+ }
64
+ ts.sys.writeFile(path.join(stagingDir, path.relative(emitDir, fileName)), file.text, file.writeByteOrderMark);
65
+ }
66
+ }
@@ -8,3 +8,8 @@ import { type OutputStyle } from '@williamthorsen/nmr-core';
8
8
  * pass runs, to keep the reader from taking that result to mean that every dependency is up to date.
9
9
  */
10
10
  export declare function reportCatalog(cwd: string, style: OutputStyle): void;
11
+ /**
12
+ * Runs `nmr-report-catalog` against `argv` from `cwd` and returns the exit code to report. The command takes neither
13
+ * an option nor an operand, so anything but `--help` is a usage error and reports nothing.
14
+ */
15
+ export declare function runReportCatalog(argv: string[], cwd: string): Promise<number>;
@@ -1,8 +1,12 @@
1
+ import process from 'node:process';
1
2
  import { formatGlyphLine } from '@williamthorsen/nmr-core';
3
+ import { defineCommand, runCli } from '@williamthorsen/nmr-core/cli';
4
+ import { NMR_REPORT_CATALOG_BIN } from "../bins.js";
2
5
  import { findContainingPackageDir } from "../context.js";
3
6
  import { NMR_GLYPHS } from "../glyphs.js";
4
7
  import { DEPENDENCY_FIELDS, readPackageJson } from "../helpers/package-json.js";
5
8
  import { reportClosing } from "../helpers/reportClosing.js";
9
+ import { resolveBinStyles } from "../resolveBinStyles.js";
6
10
  import { findMonorepoRoot, getWorkspacePackageDirs } from "../workspace.js";
7
11
  const CATALOG_PROTOCOL = 'catalog:';
8
12
  export function reportCatalog(cwd, style) {
@@ -21,6 +25,21 @@ export function reportCatalog(cwd, style) {
21
25
  }
22
26
  reportClosing(formatGlyphLine(NMR_GLYPHS, style, 'catalog', describeCatalog(dependencies.length, monorepoRoot)), console.warn);
23
27
  }
28
+ export async function runReportCatalog(argv, cwd) {
29
+ const command = defineCommand({
30
+ summary: 'Report the catalogued dependencies that a package-scoped upgrade does not read',
31
+ run: () => {
32
+ reportCatalog(cwd, resolveBinStyles().stderr);
33
+ return 0;
34
+ },
35
+ });
36
+ return runCli(argv, command, {
37
+ name: NMR_REPORT_CATALOG_BIN,
38
+ stdout: process.stdout,
39
+ stderr: process.stderr,
40
+ baseDir: cwd,
41
+ });
42
+ }
24
43
  function describeCatalog(count, monorepoRoot) {
25
44
  const subject = count === 1 ? '1 catalogued dependency went' : `${count} catalogued dependencies went`;
26
45
  const object = count === 1 ? 'it' : 'them';
@@ -4,3 +4,8 @@ import { type OutputStyle } from '@williamthorsen/nmr-core';
4
4
  * `pnpm.overrides` block left behind in the root `package.json`.
5
5
  */
6
6
  export declare function reportOverrides(monorepoRoot: string, style: OutputStyle): void;
7
+ /**
8
+ * Runs `nmr-report-overrides` against `argv` for the monorepo containing `cwd` and returns the exit code to report.
9
+ * The command takes neither an option nor an operand, so anything but `--help` is a usage error and reports nothing.
10
+ */
11
+ export declare function runReportOverrides(argv: string[], cwd: string): Promise<number>;