@williamthorsen/release-kit 16.1.0 → 16.1.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 (63) hide show
  1. package/.meta/changelog.json +45 -0
  2. package/.readyup/kits/default.js +2 -2
  3. package/.readyup/kits/npm-auto-publish.js +1 -1
  4. package/.readyup/manifest.json +6 -6
  5. package/CHANGELOG.md +16 -0
  6. package/README.md +3 -5
  7. package/bin/release-kit.js +7 -3
  8. package/dist/esm/assertCleanWorkingTree.js +14 -7
  9. package/dist/esm/assertReleasesOnNpm.js +11 -2
  10. package/dist/esm/assertTaggedBaseline.js +2 -1
  11. package/dist/esm/assertTarballsComplete.js +15 -3
  12. package/dist/esm/buildChangelogEntries.js +2 -2
  13. package/dist/esm/buildDependencyGraph.js +5 -4
  14. package/dist/esm/bumpVersion.js +3 -2
  15. package/dist/esm/changelogOverrides.js +3 -2
  16. package/dist/esm/cli.js +11 -0
  17. package/dist/esm/commitCommand.js +11 -5
  18. package/dist/esm/compareVersions.js +2 -1
  19. package/dist/esm/createGithubRelease.js +7 -1
  20. package/dist/esm/createGithubReleaseCommand.js +3 -4
  21. package/dist/esm/createTags.js +6 -4
  22. package/dist/esm/deleteFileIfExists.js +2 -1
  23. package/dist/esm/deriveDirectoryConfig.js +7 -6
  24. package/dist/esm/deriveWorkspaceConfig.js +4 -3
  25. package/dist/esm/enterRepoRoot.js +2 -1
  26. package/dist/esm/enumerateReleaseWindows.js +2 -2
  27. package/dist/esm/init/initCommand.js +10 -1
  28. package/dist/esm/init/updateTemplatesCommand.js +10 -1
  29. package/dist/esm/loadConfig.js +6 -5
  30. package/dist/esm/mergeConfig.js +5 -4
  31. package/dist/esm/monorepoConfigAssertions.js +6 -5
  32. package/dist/esm/packFromCleanCheckout.js +12 -3
  33. package/dist/esm/planVersionBump.js +6 -5
  34. package/dist/esm/prepareCommand.js +12 -22
  35. package/dist/esm/prepareComposite.js +2 -1
  36. package/dist/esm/prepareRunContext.js +2 -2
  37. package/dist/esm/previewTagPrefixes.js +2 -2
  38. package/dist/esm/publish.js +7 -1
  39. package/dist/esm/publishCommand.js +4 -18
  40. package/dist/esm/pushCommand.js +5 -12
  41. package/dist/esm/pushRelease.js +18 -6
  42. package/dist/esm/releaseFiles.js +2 -2
  43. package/dist/esm/releasePlan.js +2 -2
  44. package/dist/esm/resolveCommandTags.d.ts +4 -4
  45. package/dist/esm/resolveCommandTags.js +3 -5
  46. package/dist/esm/resolveReleaseDeclaration.js +9 -8
  47. package/dist/esm/resolveReleaseTags.js +11 -4
  48. package/dist/esm/resolveReleaseTargets.js +2 -1
  49. package/dist/esm/runCli.d.ts +5 -0
  50. package/dist/esm/runCli.js +224 -0
  51. package/dist/esm/showTagPrefixesCommand.js +5 -3
  52. package/dist/esm/sync-labels/generateCommand.js +4 -1
  53. package/dist/esm/sync-labels/initCommand.js +7 -1
  54. package/dist/esm/sync-labels/presets.js +7 -7
  55. package/dist/esm/sync-labels/resolveLabels.js +4 -3
  56. package/dist/esm/sync-labels/templates.js +2 -2
  57. package/dist/esm/tagCommand.js +2 -9
  58. package/dist/esm/user-error-chaining.d.ts +13 -0
  59. package/dist/esm/user-error-chaining.js +11 -0
  60. package/dist/esm/validateOverridesCommand.js +7 -1
  61. package/package.json +6 -6
  62. package/dist/esm/bin/release-kit.js +0 -240
  63. /package/dist/esm/{bin/release-kit.d.ts → cli.d.ts} +0 -0
@@ -1,11 +1,12 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { UserError } from '@williamthorsen/nmr-core';
3
4
  import { findMonorepoRoot } from '@williamthorsen/nmr-core/workspace';
4
5
  export function enterRepoRoot(startDir = process.cwd()) {
5
6
  const invocationDir = path.resolve(startDir);
6
7
  const root = findMonorepoRoot(invocationDir) ?? findSinglePackageRoot(invocationDir);
7
8
  if (root === undefined) {
8
- throw new Error(`No repo root found from ${invocationDir}: expected a pnpm-workspace.yaml in it or any parent directory, ` +
9
+ throw new UserError(`No repo root found from ${invocationDir}: expected a pnpm-workspace.yaml in it or any parent directory, ` +
9
10
  'or a package.json in it. Run release-kit from inside a monorepo or at the root of a single-package repo.');
10
11
  }
11
12
  process.chdir(root);
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { GIT_OUTPUT_LIMIT_BYTES } from '@williamthorsen/nmr-core';
3
- import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
4
3
  import { matchesTagPrefix } from "./matchesTagPrefix.js";
4
+ import { chainUserError } from "./user-error-chaining.js";
5
5
  const FIELD_SEPARATOR = '\u{1F}';
6
6
  const RECORD_SEPARATOR = '\u{1E}';
7
7
  export function enumerateReleaseWindows(options) {
@@ -117,6 +117,6 @@ function runGit(args, description) {
117
117
  }).trim();
118
118
  }
119
119
  catch (error) {
120
- throw chainError(`Failed to run ${description}`, error);
120
+ throw chainUserError(`Failed to run ${description}`, error);
121
121
  }
122
122
  }
@@ -1,4 +1,4 @@
1
- import { printError, printSkip, printStep, printSuccess, reportWriteResult, } from '@williamthorsen/nmr-core';
1
+ import { isAnticipatedFailure, printError, printSkip, printStep, printSuccess, reportWriteResult, } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { hasPackageJson, isGitRepo, usesPnpm } from "./checks.js";
4
4
  import { detectRepoType } from "./detectRepoType.js";
@@ -19,6 +19,9 @@ export function initCommand({ isDryRun, shouldForce, styles, shouldScaffoldConfi
19
19
  eligible = checkEligibility(styles);
20
20
  }
21
21
  catch (error) {
22
+ if (!isAnticipatedFailure(error)) {
23
+ throw error;
24
+ }
22
25
  printError(`Eligibility check failed: ${describeError(error)}`, styles.stderr);
23
26
  return 1;
24
27
  }
@@ -30,6 +33,9 @@ export function initCommand({ isDryRun, shouldForce, styles, shouldScaffoldConfi
30
33
  repoType = detectRepoType();
31
34
  }
32
35
  catch (error) {
36
+ if (!isAnticipatedFailure(error)) {
37
+ throw error;
38
+ }
33
39
  printError(`Failed to detect repo type: ${describeError(error)}`, styles.stderr);
34
40
  return 1;
35
41
  }
@@ -46,6 +52,9 @@ export function initCommand({ isDryRun, shouldForce, styles, shouldScaffoldConfi
46
52
  results = scaffoldFiles({ repoType, isDryRun, shouldOverwrite: shouldForce, publishable, shouldScaffoldConfig });
47
53
  }
48
54
  catch (error) {
55
+ if (!isAnticipatedFailure(error)) {
56
+ throw error;
57
+ }
49
58
  printError(`Failed to scaffold files: ${describeError(error)}`, styles.stderr);
50
59
  return 1;
51
60
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { printError, printSkip, printStep, printSuccess, reportTemplateUpdate, updateManagedFile, } from '@williamthorsen/nmr-core';
2
+ import { isAnticipatedFailure, printError, printSkip, printStep, printSuccess, reportTemplateUpdate, updateManagedFile, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { readSyncLabelsWorkflowTemplate } from "../sync-labels/templates.js";
5
5
  import { detectRepoType } from "./detectRepoType.js";
@@ -18,6 +18,9 @@ export function updateTemplatesCommand({ isDryRun, styles }) {
18
18
  eligible = checkEligibility(styles);
19
19
  }
20
20
  catch (error) {
21
+ if (!isAnticipatedFailure(error)) {
22
+ throw error;
23
+ }
21
24
  printError(`Eligibility check failed: ${describeError(error)}`, styles.stderr);
22
25
  return 1;
23
26
  }
@@ -29,6 +32,9 @@ export function updateTemplatesCommand({ isDryRun, styles }) {
29
32
  repoType = detectRepoType();
30
33
  }
31
34
  catch (error) {
35
+ if (!isAnticipatedFailure(error)) {
36
+ throw error;
37
+ }
32
38
  printError(`Failed to detect repo type: ${describeError(error)}`, styles.stderr);
33
39
  return 1;
34
40
  }
@@ -43,6 +49,9 @@ export function updateTemplatesCommand({ isDryRun, styles }) {
43
49
  results = files.map(([filePath, content]) => updateManagedFile(filePath, content, { isDryRun }));
44
50
  }
45
51
  catch (error) {
52
+ if (!isAnticipatedFailure(error)) {
53
+ throw error;
54
+ }
46
55
  printError(`Failed to render workflows: ${describeError(error)}`, styles.stderr);
47
56
  return 1;
48
57
  }
@@ -9,8 +9,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
- import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
12
+ import { UserError } from '@williamthorsen/nmr-core';
13
13
  import { isRecord } from "./typeGuards.js";
14
+ import { chainUserError } from "./user-error-chaining.js";
14
15
  export const ROOT_PACKAGE_JSON_PATH = 'package.json';
15
16
  export function readRootPackageVersion() {
16
17
  const absolutePath = path.resolve(process.cwd(), ROOT_PACKAGE_JSON_PATH);
@@ -22,14 +23,14 @@ export function readRootPackageVersion() {
22
23
  contents = readFileSync(absolutePath, 'utf8');
23
24
  }
24
25
  catch (error) {
25
- throw chainError(`Failed to read root ${ROOT_PACKAGE_JSON_PATH}`, error);
26
+ throw chainUserError(`Failed to read root ${ROOT_PACKAGE_JSON_PATH}`, error);
26
27
  }
27
28
  let parsedJson;
28
29
  try {
29
30
  parsedJson = JSON.parse(contents);
30
31
  }
31
32
  catch (error) {
32
- throw chainError(`Failed to parse root ${ROOT_PACKAGE_JSON_PATH}`, error);
33
+ throw chainUserError(`Failed to parse root ${ROOT_PACKAGE_JSON_PATH}`, error);
33
34
  }
34
35
  if (!isRecord(parsedJson)) {
35
36
  return { doesExist: true, version: undefined };
@@ -43,12 +44,12 @@ export async function loadConfig(configPath) {
43
44
  if (configPath === undefined) {
44
45
  return undefined;
45
46
  }
46
- throw new Error(`Config file not found: ${absoluteConfigPath}`);
47
+ throw new UserError(`Config file not found: ${absoluteConfigPath}`);
47
48
  }
48
49
  const importedModule = await import(__rewriteRelativeImportExtension(pathToFileURL(absoluteConfigPath).href));
49
50
  const resolvedConfig = isRecord(importedModule) ? (importedModule['default'] ?? importedModule['config']) : undefined;
50
51
  if (resolvedConfig === undefined) {
51
- throw new Error('Config file must have a default export or a named `config` export (e.g., `export default { ... }` or `export const config = { ... }`)');
52
+ throw new UserError('Config file must have a default export or a named `config` export (e.g., `export default { ... }` or `export const config = { ... }`)');
52
53
  }
53
54
  return resolvedConfig;
54
55
  }
@@ -1,3 +1,4 @@
1
+ import { UserError } from '@williamthorsen/nmr-core';
1
2
  import { DEFAULT_CHANGELOG_JSON_CONFIG, DEFAULT_PROJECT_TAG_PREFIX, DEFAULT_RELEASE_NOTES_CONFIG, DEFAULT_VERSION_PATTERNS, DEFAULT_WORK_TYPES, } from "./defaults.js";
2
3
  import { deriveDirectoryConfig } from "./deriveDirectoryConfig.js";
3
4
  import { deriveWorkspaceConfig } from "./deriveWorkspaceConfig.js";
@@ -45,10 +46,10 @@ export function mergeMonorepoConfig(discoveredPaths, userConfig, rootPackage) {
45
46
  }
46
47
  export function mergeSinglePackageConfig(userConfig) {
47
48
  if (userConfig?.project !== undefined) {
48
- throw new Error('project block is not supported in single-package mode');
49
+ throw new UserError('project block is not supported in single-package mode');
49
50
  }
50
51
  if (userConfig?.releases !== undefined) {
51
- throw new Error('releases block is not supported in single-package mode');
52
+ throw new UserError('releases block is not supported in single-package mode');
52
53
  }
53
54
  const workTypes = resolveWorkTypes(userConfig?.workTypes);
54
55
  const versionPatterns = userConfig?.versionPatterns === undefined ? { ...DEFAULT_VERSION_PATTERNS } : { ...userConfig.versionPatterns };
@@ -104,10 +105,10 @@ function resolveProjectConfig(declaredComposite, rootPackage, workspaces) {
104
105
  }
105
106
  const subject = declaredComposite.name === undefined ? 'project block' : `composite release '${declaredComposite.name}'`;
106
107
  if (rootPackage === undefined || !rootPackage.doesExist) {
107
- throw new Error(`${subject} requires a root ${ROOT_PACKAGE_JSON_PATH}; create one with a 'version' field at the repo root`);
108
+ throw new UserError(`${subject} requires a root ${ROOT_PACKAGE_JSON_PATH}; create one with a 'version' field at the repo root`);
108
109
  }
109
110
  if (rootPackage.version === undefined) {
110
- throw new Error(`${subject} requires root ${ROOT_PACKAGE_JSON_PATH} to have a 'version' field; add a 'version' field to the root package.json`);
111
+ throw new UserError(`${subject} requires root ${ROOT_PACKAGE_JSON_PATH} to have a 'version' field; add a 'version' field to the root package.json`);
111
112
  }
112
113
  return {
113
114
  paths: declaredComposite.paths ?? workspaces.flatMap((workspace) => workspace.paths),
@@ -1,9 +1,10 @@
1
+ import { UserError } from '@williamthorsen/nmr-core';
1
2
  import { ROOT_PACKAGE_JSON_PATH } from "./loadConfig.js";
2
3
  import { getTargetName, normalizeWorkspacePath } from "./resolveReleaseDeclaration.js";
3
4
  export function assertLegacyIdentityDoesNotMatchCurrent(dir, currentName, currentTagPrefix, legacyIdentities) {
4
5
  for (const identity of legacyIdentities) {
5
6
  if (identity.name === currentName && identity.tagPrefix === currentTagPrefix) {
6
- throw new Error(`Workspace '${dir}': legacyIdentities must not match the current identity ` +
7
+ throw new UserError(`Workspace '${dir}': legacyIdentities must not match the current identity ` +
7
8
  `(name='${currentName}', tagPrefix='${currentTagPrefix}'). ` +
8
9
  'The current identity is always searched; listing it again is a no-op.');
9
10
  }
@@ -31,7 +32,7 @@ export function assertNoSharedVersionFiles(workspaces, directories, project) {
31
32
  for (const [index, directory] of directories.entries()) {
32
33
  const peer = owners.slice(index + 1).find((owner) => owner.versionFiles.includes(directory.versionFile));
33
34
  if (peer !== undefined) {
34
- throw new Error(`directory release '${directory.name}' and ${peer.label} share the version file '${directory.versionFile}', ` +
35
+ throw new UserError(`directory release '${directory.name}' and ${peer.label} share the version file '${directory.versionFile}', ` +
35
36
  "so each release would overwrite the other's version and changelog; declare only one of them");
36
37
  }
37
38
  }
@@ -84,7 +85,7 @@ export function assertNoTagPrefixCollisions(workspaces, directories, retiredPack
84
85
  if (a === undefined || b === undefined || a.owner === b.owner)
85
86
  continue;
86
87
  if (isPrefixCollision(a.prefix, b.prefix)) {
87
- throw new Error(`Tag prefix collision: '${a.prefix}' (${a.label}) and '${b.prefix}' (${b.label}). ` +
88
+ throw new UserError(`Tag prefix collision: '${a.prefix}' (${a.label}) and '${b.prefix}' (${b.label}). ` +
88
89
  'One prefix is identical to or a strict prefix of the other; ' +
89
90
  'a tag under the longer prefix can then match the shorter one and be counted among its releases.');
90
91
  }
@@ -99,7 +100,7 @@ export function assertRetiredPackagesDoNotCollideWithActive(workspaces, retiredP
99
100
  for (const retiredPackage of retiredPackages) {
100
101
  const activeWorkspace = workspaceByDerivedPrefix.get(retiredPackage.tagPrefix);
101
102
  if (activeWorkspace !== undefined) {
102
- throw new Error(`retiredPackages: tagPrefix '${retiredPackage.tagPrefix}' collides with active workspace '${activeWorkspace.dir}' ` +
103
+ throw new UserError(`retiredPackages: tagPrefix '${retiredPackage.tagPrefix}' collides with active workspace '${activeWorkspace.dir}' ` +
103
104
  `(derived prefix '${activeWorkspace.tagPrefix}'). A retired package's tagPrefix cannot belong to an active workspace.`);
104
105
  }
105
106
  }
@@ -117,7 +118,7 @@ export function assertUniqueTagPrefixes(workspaces) {
117
118
  }
118
119
  for (const [prefix, paths] of pathsByPrefix) {
119
120
  if (paths.length > 1) {
120
- throw new Error(`Duplicate tag prefix '${prefix}' for workspaces: ${paths.join(', ')}`);
121
+ throw new UserError(`Duplicate tag prefix '${prefix}' for workspaces: ${paths.join(', ')}`);
121
122
  }
122
123
  }
123
124
  }
@@ -2,8 +2,10 @@ import { spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { UserError } from '@williamthorsen/nmr-core';
5
6
  import { resolveExecutable } from "./detectPackageManager.js";
6
7
  import { isRecord } from "./typeGuards.js";
8
+ import { chainUserError } from "./user-error-chaining.js";
7
9
  export function packFromCleanCheckout(workspacePaths, packageManager) {
8
10
  const repoRoot = runStep('find the repository root', 'git', ['rev-parse', '--show-toplevel'], process.cwd()).trim();
9
11
  const headSha = runStep('resolve HEAD', 'git', ['rev-parse', 'HEAD'], repoRoot).trim();
@@ -51,7 +53,14 @@ const PACKAGE_MANAGER_COMMANDS = {
51
53
  },
52
54
  };
53
55
  function declaresPrepublishOnly(packageDir) {
54
- const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
56
+ const manifestPath = join(packageDir, 'package.json');
57
+ let manifest;
58
+ try {
59
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
60
+ }
61
+ catch (error) {
62
+ throw chainUserError(`Failed to parse ${manifestPath}`, error);
63
+ }
55
64
  const scripts = isRecord(manifest) ? manifest['scripts'] : undefined;
56
65
  return isRecord(scripts) && typeof scripts['prepublishOnly'] === 'string';
57
66
  }
@@ -59,7 +68,7 @@ function findTarball(packDir, workspacePath) {
59
68
  const tarballs = readdirSync(packDir).filter((name) => name.endsWith('.tgz'));
60
69
  const [tarball] = tarballs;
61
70
  if (tarballs.length !== 1 || tarball === undefined) {
62
- throw new Error(`Could not pack ${workspacePath}: expected one tarball, found ${tarballs.length}.`);
71
+ throw new UserError(`Could not pack ${workspacePath}: expected one tarball, found ${tarballs.length}.`);
63
72
  }
64
73
  return join(packDir, tarball);
65
74
  }
@@ -77,5 +86,5 @@ function runStep(step, command, args, cwd) {
77
86
  }
78
87
  const outcome = result.error === undefined ? `exited with status ${result.status}` : result.error.message;
79
88
  const output = [result.stdout, result.stderr].filter((text) => text !== '').join('\n');
80
- throw new Error(`Could not ${step}: \`${[command, ...args].join(' ')}\` ${outcome}.${output === '' ? '' : `\n${output}`}`);
89
+ throw new UserError(`Could not ${step}: \`${[command, ...args].join(' ')}\` ${outcome}.${output === '' ? '' : `\n${output}`}`);
81
90
  }
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
2
+ import { UserError } from '@williamthorsen/nmr-core';
3
3
  import { bumpVersion } from "./bumpVersion.js";
4
4
  import { isForwardVersion } from "./compareVersions.js";
5
+ import { chainUserError } from "./user-error-chaining.js";
5
6
  function isPackageJson(value) {
6
7
  return typeof value === 'object' && value !== null && 'version' in value && typeof value.version === 'string';
7
8
  }
@@ -19,7 +20,7 @@ export function planVersionSet(packageFiles, newVersion) {
19
20
  const { firstFile, firstPackageJson } = readPrimaryPackage(packageFiles);
20
21
  const currentVersion = firstPackageJson.version;
21
22
  if (!isForwardVersion(currentVersion, newVersion)) {
22
- throw new Error(`--set-version ${newVersion} is not greater than current version ${currentVersion}`);
23
+ throw new UserError(`--set-version ${newVersion} is not greater than current version ${currentVersion}`);
23
24
  }
24
25
  return {
25
26
  currentVersion,
@@ -50,17 +51,17 @@ function readPackageJson(filePath) {
50
51
  content = readFileSync(filePath, 'utf8');
51
52
  }
52
53
  catch (error) {
53
- throw chainError(`Failed to read ${filePath}`, error);
54
+ throw chainUserError(`Failed to read ${filePath}`, error);
54
55
  }
55
56
  let parsedJson;
56
57
  try {
57
58
  parsedJson = JSON.parse(content);
58
59
  }
59
60
  catch (error) {
60
- throw chainError(`Failed to parse JSON in ${filePath}`, error);
61
+ throw chainUserError(`Failed to parse JSON in ${filePath}`, error);
61
62
  }
62
63
  if (!isPackageJson(parsedJson)) {
63
- throw new Error(`No valid 'version' field found in ${filePath}`);
64
+ throw new UserError(`No valid 'version' field found in ${filePath}`);
64
65
  }
65
66
  return parsedJson;
66
67
  }
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { formatGlyphLine, reportError } from '@williamthorsen/nmr-core';
2
+ import { formatGlyphLine, isAnticipatedFailure, reportError, UserError, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
5
5
  import { assertReleasesOnNpm } from "./assertReleasesOnNpm.js";
@@ -15,6 +15,8 @@ import { applyReleasePlan } from "./releasePlan.js";
15
15
  import { reportPrepare } from "./reportPrepare.js";
16
16
  import { resolveConfigFlag } from "./resolveConfigFlag.js";
17
17
  import { isSinglePackageTargets, PROJECT_TARGET_ID, resolveReleaseTargets, SINGLE_PACKAGE_TARGET_ID, } from "./resolveReleaseTargets.js";
18
+ import { prefixUserError } from "./user-error-chaining.js";
19
+ const UNCHANGED_TREE_NOTE = 'The command did not write any files; the working tree is unchanged.';
18
20
  export async function prepareCommand(argv, styles, invocationDir) {
19
21
  const { configPath: configFlag, isDryRun, shouldForce, shouldSkipGitChecks, bumpOverride, onlyNames, setVersion, shouldIncludeReleaseNotes, } = parseArgs(argv);
20
22
  const options = {
@@ -26,13 +28,7 @@ export async function prepareCommand(argv, styles, invocationDir) {
26
28
  console.info(`\n${formatGlyphLine(RELEASE_GLYPHS, styles.stdout, 'dryRun', 'DRY RUN: release-kit will not modify any files')}\n`);
27
29
  }
28
30
  if (!isDryRun && !shouldSkipGitChecks) {
29
- try {
30
- assertCleanWorkingTree();
31
- }
32
- catch (error) {
33
- reportError(describeError(error));
34
- process.exit(1);
35
- }
31
+ assertCleanWorkingTree();
36
32
  }
37
33
  const configResult = await loadValidatedConfig(resolveConfigFlag(configFlag, invocationDir));
38
34
  if (configResult.status === 'invalid') {
@@ -49,8 +45,7 @@ export async function prepareCommand(argv, styles, invocationDir) {
49
45
  workspaceDiscovery = discoverWorkspaces();
50
46
  }
51
47
  catch (error) {
52
- reportError(`Failed to discover workspaces: ${describeError(error)}`);
53
- process.exit(1);
48
+ throw prefixUserError('Failed to discover workspaces', error);
54
49
  }
55
50
  if (workspaceDiscovery.kind === 'empty') {
56
51
  reportError(`No workspace package to release. ${describeEmptyWorkspace(workspaceDiscovery)}`);
@@ -91,8 +86,7 @@ function resolveTargetsOrExit(userConfig, discovery, onlyNames, setVersion) {
91
86
  config = resolveReleaseTargets(userConfig, discovery);
92
87
  }
93
88
  catch (error) {
94
- reportError(`Failed to resolve workspaces: ${describeError(error)}`);
95
- process.exit(1);
89
+ throw prefixUserError('Failed to resolve workspaces', error);
96
90
  }
97
91
  if (setVersion !== undefined && config.targets.some((target) => target.kind === 'composite')) {
98
92
  reportError('--set-version cannot be combined with a project release. ' +
@@ -129,22 +123,18 @@ function runAndReport(computePlan, isDryRun, style) {
129
123
  plan = computePlan();
130
124
  }
131
125
  catch (error) {
132
- reportError(describeError(error));
133
- process.stderr.write('The command did not write any files; the working tree is unchanged.\n');
134
- process.exit(1);
126
+ if (isAnticipatedFailure(error)) {
127
+ throw new UserError(`${error.message}\n${UNCHANGED_TREE_NOTE}`, { cause: error });
128
+ }
129
+ process.stderr.write(`${UNCHANGED_TREE_NOTE}\n`);
130
+ throw error;
135
131
  }
136
132
  if (isDryRun) {
137
133
  process.stdout.write(reportPrepare(plan, { applied: false, style }) + '\n');
138
134
  reportPlanFiles(plan, true);
139
135
  return;
140
136
  }
141
- try {
142
- applyReleasePlan(plan);
143
- }
144
- catch (error) {
145
- reportError(describeError(error));
146
- process.exit(1);
147
- }
137
+ applyReleasePlan(plan);
148
138
  const formatError = runFormatCommand(plan.formatCommand);
149
139
  process.stdout.write(reportPrepare(plan, { applied: true, style, ...(formatError !== undefined && { formatError }) }) + '\n');
150
140
  reportPlanFiles(plan, false);
@@ -1,3 +1,4 @@
1
+ import { UserError } from '@williamthorsen/nmr-core';
1
2
  import { assertTaggedBaseline } from "./assertTaggedBaseline.js";
2
3
  import { collect } from "./prepareRunContext.js";
3
4
  import { buildSkippedTargetResult, decideTargetRelease, planTargetRelease, readTargetHistory, } from "./prepareTarget.js";
@@ -24,7 +25,7 @@ export function prepareComposite(compositeTarget, context, options, collectedRel
24
25
  return buildSkippedTargetResult(compositeTarget, history, decision.skipReason);
25
26
  }
26
27
  if (decision.setVersion !== undefined) {
27
- throw new Error('A composite target cannot take --set-version');
28
+ throw new UserError('A composite target cannot take --set-version');
28
29
  }
29
30
  const plannedRelease = planTargetRelease({
30
31
  ...context,
@@ -1,5 +1,5 @@
1
- import { chainError } from '@williamthorsen/toolbelt.errors/candidate';
2
1
  import { isRepoRoot } from "./prepareTarget.js";
2
+ import { prefixUserError } from "./user-error-chaining.js";
3
3
  export function collect(collectedReleases, plannedRelease) {
4
4
  collectedReleases.tags.push(plannedRelease.tag);
5
5
  collectedReleases.writes.push(...plannedRelease.writes);
@@ -34,6 +34,6 @@ export function runStage(stage, fn) {
34
34
  return fn();
35
35
  }
36
36
  catch (error) {
37
- throw chainError(stage, error);
37
+ throw prefixUserError(stage, error);
38
38
  }
39
39
  }
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { basename } from 'node:path';
3
- import { GIT_OUTPUT_LIMIT_BYTES } from '@williamthorsen/nmr-core';
3
+ import { GIT_OUTPUT_LIMIT_BYTES, UserError } from '@williamthorsen/nmr-core';
4
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  import { DEFAULT_PROJECT_TAG_PREFIX } from "./defaults.js";
6
6
  import { deriveDirectoryConfig } from "./deriveDirectoryConfig.js";
@@ -12,7 +12,7 @@ import { normalizeWorkspacePath } from "./resolveReleaseDeclaration.js";
12
12
  export function previewTagPrefixes(config) {
13
13
  const workspace = discoverWorkspaces();
14
14
  if (workspace.kind === 'empty') {
15
- throw new Error(`No workspace package to preview. ${describeEmptyWorkspace(workspace)}`);
15
+ throw new UserError(`No workspace package to preview. ${describeEmptyWorkspace(workspace)}`);
16
16
  }
17
17
  const workspacePaths = workspace.kind === 'packages' ? workspace.packageDirs : [];
18
18
  const overridesByPath = buildOverrideMap(config, workspacePaths);
@@ -1,11 +1,17 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { resolveExecutable } from "./detectPackageManager.js";
3
+ import { asUserError } from "./user-error-chaining.js";
3
4
  export function publishPackage(resolvedTag, packageManager, options) {
4
5
  const { isDryRun, shouldGenerateProvenance } = options;
5
6
  const executable = resolveExecutable(packageManager);
6
7
  const args = buildPublishArgs(packageManager, { isDryRun, shouldGenerateProvenance });
7
8
  console.info(`\n${isDryRun ? '[dry-run] ' : ''}Running: ${executable} ${args.join(' ')} (cwd: ${resolvedTag.workspacePath})`);
8
- execFileSync(executable, args, { cwd: resolvedTag.workspacePath, stdio: 'inherit' });
9
+ try {
10
+ execFileSync(executable, args, { cwd: resolvedTag.workspacePath, stdio: 'inherit' });
11
+ }
12
+ catch (error) {
13
+ throw asUserError(error);
14
+ }
9
15
  }
10
16
  function buildPublishArgs(packageManager, options) {
11
17
  const args = packageManager === 'yarn-berry' ? ['npm', 'publish'] : ['publish'];
@@ -1,7 +1,6 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
4
- import { describeError } from '@williamthorsen/toolbelt.errors';
3
+ import { parseArgsOrExit } from '@williamthorsen/nmr-core';
5
4
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
6
5
  import { assertTarballsComplete } from "./assertTarballsComplete.js";
7
6
  import { configFlagSchema } from "./configFlagSchema.js";
@@ -29,13 +28,7 @@ export async function publishCommand(argv, styles, invocationDir) {
29
28
  const { isDryRun, shouldSkipGitChecks, shouldGenerateProvenance } = parsedArgs.flags;
30
29
  const userConfig = await loadUsableConfig(styles.stderr, configPath);
31
30
  if (!isDryRun && !shouldSkipGitChecks) {
32
- try {
33
- assertCleanWorkingTree();
34
- }
35
- catch (error) {
36
- reportError(describeError(error));
37
- process.exit(1);
38
- }
31
+ assertCleanWorkingTree();
39
32
  }
40
33
  const requestedTags = parseRequestedTags(parsedArgs.flags.tags);
41
34
  const resolvedTags = resolveCommandTags(requestedTags, userConfig);
@@ -47,13 +40,7 @@ export async function publishCommand(argv, styles, invocationDir) {
47
40
  console.info('Nothing to publish.');
48
41
  return;
49
42
  }
50
- try {
51
- assertTarballsComplete(publishableTags.map(({ workspacePath }) => workspacePath), userConfig?.devExportConditions ?? []);
52
- }
53
- catch (error) {
54
- reportError(describeError(error));
55
- process.exit(1);
56
- }
43
+ assertTarballsComplete(publishableTags.map(({ workspacePath }) => workspacePath), userConfig?.devExportConditions ?? []);
57
44
  const packageManager = detectPackageManager();
58
45
  const { releaseNotes, changelogJsonOutputPath, sectionOrder } = deriveReleaseNotesConfig(userConfig);
59
46
  const shouldInject = releaseNotes.shouldInjectIntoReadme;
@@ -90,8 +77,7 @@ export async function publishCommand(argv, styles, invocationDir) {
90
77
  console.warn(` ${tag}`);
91
78
  }
92
79
  }
93
- reportError(describeError(error));
94
- process.exit(1);
80
+ throw error;
95
81
  }
96
82
  }
97
83
  function filterPublishableTags(resolvedTags, isExplicit) {
@@ -1,5 +1,4 @@
1
- import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
- import { describeError } from '@williamthorsen/toolbelt.errors';
1
+ import { parseArgsOrExit } from '@williamthorsen/nmr-core';
3
2
  import { configFlagSchema } from "./configFlagSchema.js";
4
3
  import { loadUsableConfig } from "./loadValidatedConfig.js";
5
4
  import { parseRequestedTags } from "./parseRequestedTags.js";
@@ -32,16 +31,10 @@ export async function pushCommand(argv, styles, invocationDir) {
32
31
  for (const { tag } of resolvedTags) {
33
32
  console.info(` ${tag}`);
34
33
  }
35
- try {
36
- const steps = pushRelease(resolvedTags, { isDryRun, tagsOnly });
37
- if (isDryRun) {
38
- for (const step of steps) {
39
- console.info(`[dry-run] ${step.command.join(' ')}`);
40
- }
34
+ const steps = pushRelease(resolvedTags, { isDryRun, tagsOnly });
35
+ if (isDryRun) {
36
+ for (const step of steps) {
37
+ console.info(`[dry-run] ${step.command.join(' ')}`);
41
38
  }
42
39
  }
43
- catch (error) {
44
- reportError(describeError(error));
45
- process.exit(1);
46
- }
47
40
  }
@@ -1,14 +1,21 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { GIT_OUTPUT_LIMIT_BYTES } from '@williamthorsen/nmr-core';
3
+ import { asUserError } from "./user-error-chaining.js";
3
4
  export function pushRelease(resolvedTags, options = {}) {
4
5
  const { isDryRun = false, tagsOnly = false } = options;
5
6
  const steps = [];
6
7
  if (!tagsOnly) {
7
- const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
8
- encoding: 'utf8',
9
- maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
10
- stdio: ['pipe', 'pipe', 'pipe'],
11
- }).trim();
8
+ let branch;
9
+ try {
10
+ branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
11
+ encoding: 'utf8',
12
+ maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
13
+ stdio: ['pipe', 'pipe', 'pipe'],
14
+ }).trim();
15
+ }
16
+ catch (error) {
17
+ throw asUserError(error);
18
+ }
12
19
  const command = ['git', 'push', 'origin', branch];
13
20
  steps.push({ type: 'branch', ref: branch, command });
14
21
  }
@@ -18,7 +25,12 @@ export function pushRelease(resolvedTags, options = {}) {
18
25
  }
19
26
  if (!isDryRun) {
20
27
  for (const step of steps) {
21
- execFileSync(step.command[0], step.command.slice(1), { stdio: 'inherit' });
28
+ try {
29
+ execFileSync(step.command[0], step.command.slice(1), { stdio: 'inherit' });
30
+ }
31
+ catch (error) {
32
+ throw asUserError(error);
33
+ }
22
34
  }
23
35
  }
24
36
  return steps;
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import { hasErrnoCode } from '@williamthorsen/nmr-core';
3
+ import { hasErrnoCode, UserError } from '@williamthorsen/nmr-core';
4
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  export const RELEASE_TAGS_FILE = 'tmp/.release-tags';
6
6
  export const RELEASE_SUMMARY_FILE = 'tmp/.release-summary';
@@ -13,7 +13,7 @@ export function readReleaseTags() {
13
13
  content = readFileSync(RELEASE_TAGS_FILE, 'utf8');
14
14
  }
15
15
  catch (error) {
16
- throw new Error(describeTagsReadFailure(error), { cause: error });
16
+ throw new UserError(describeTagsReadFailure(error), { cause: error });
17
17
  }
18
18
  return content
19
19
  .split('\n')
@@ -1,4 +1,4 @@
1
- import { writeFileWithCheck } from '@williamthorsen/nmr-core';
1
+ import { UserError, writeFileWithCheck } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { deleteFileIfExists } from "./deleteFileIfExists.js";
4
4
  import { RELEASE_SUMMARY_FILE, RELEASE_TAGS_FILE } from "./releaseFiles.js";
@@ -10,7 +10,7 @@ export function applyReleasePlan(plan) {
10
10
  const outcome = applyStep(step);
11
11
  if (outcome.outcome === 'failed') {
12
12
  const unwrittenPaths = orderedSteps.slice(index).map((pending) => pending.path);
13
- throw new Error(describeApplyFailure({ failedStep: step, error: outcome.error, appliedWrites, unwrittenPaths }));
13
+ throw new UserError(describeApplyFailure({ failedStep: step, error: outcome.error, appliedWrites, unwrittenPaths }));
14
14
  }
15
15
  appliedWrites.push({ path: step.path, created: outcome.outcome === 'created' });
16
16
  }
@@ -2,10 +2,10 @@ import type { ReleaseKitConfig } from './configSchema.js';
2
2
  import { type ResolvedTag } from './resolveReleaseTags.js';
3
3
  /**
4
4
  * Discovers workspaces, resolves release tags from HEAD, validates `--tags` names against the full resolved tag
5
- * names (e.g., `nmr-core-v1.3.0`), and returns the filtered tag list. Exits with an error message on any validation
6
- * failure, including a workspace whose `package.json` does not have a `name` field, a config that
7
- * `mergeMonorepoConfig` rejects, and a workspace that does not resolve to any package, which is not single-package
8
- * mode.
5
+ * names (e.g., `nmr-core-v1.3.0`), and returns the filtered tag list. Throws a `UserError` when workspace discovery
6
+ * fails, when a workspace's `package.json` does not have a `name` field, and when `mergeMonorepoConfig` rejects the
7
+ * config. Exits with an error message on any other validation failure, including a workspace that does not resolve to
8
+ * any package, which is not single-package mode.
9
9
  *
10
10
  * In monorepo mode the workspace list is `userConfig` merged over the discovered workspaces, as `prepare` builds it,
11
11
  * and each directory target and a declared composite (a `project` block or a `releases` composite) contribute their