@williamthorsen/release-kit 16.1.1 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.meta/changelog.json +79 -0
  2. package/.readyup/kits/default.js +2 -2
  3. package/.readyup/kits/npm-auto-publish.js +1 -1
  4. package/.readyup/manifest.json +204 -16
  5. package/CHANGELOG.md +28 -0
  6. package/README.md +7 -2
  7. package/bin/release-kit.js +1 -1
  8. package/dist/esm/assertCleanWorkingTree.js +14 -7
  9. package/dist/esm/assertReleasesOnNpm.js +11 -3
  10. package/dist/esm/assertTaggedBaseline.js +2 -1
  11. package/dist/esm/assertTarballsComplete.js +16 -5
  12. package/dist/esm/bin.d.ts +2 -0
  13. package/dist/esm/bin.js +1 -0
  14. package/dist/esm/buildChangelogEntries.js +2 -2
  15. package/dist/esm/buildDependencyGraph.js +5 -4
  16. package/dist/esm/bumpVersion.js +3 -2
  17. package/dist/esm/changelogJsonUtils.js +2 -1
  18. package/dist/esm/changelogOverrides.js +3 -2
  19. package/dist/esm/cli/commandKit.d.ts +18 -0
  20. package/dist/esm/cli/commandKit.js +27 -0
  21. package/dist/esm/cli/maintenanceCommands.d.ts +6 -0
  22. package/dist/esm/cli/maintenanceCommands.js +129 -0
  23. package/dist/esm/cli/releaseCommands.d.ts +7 -0
  24. package/dist/esm/cli/releaseCommands.js +156 -0
  25. package/dist/esm/cli.js +9 -237
  26. package/dist/esm/commitCommand.d.ts +4 -1
  27. package/dist/esm/commitCommand.js +12 -10
  28. package/dist/esm/compareVersions.js +2 -1
  29. package/dist/esm/createGithubRelease.js +7 -1
  30. package/dist/esm/createGithubReleaseCommand.d.ts +10 -4
  31. package/dist/esm/createGithubReleaseCommand.js +4 -18
  32. package/dist/esm/createTags.js +6 -4
  33. package/dist/esm/deleteFileIfExists.js +2 -1
  34. package/dist/esm/deriveDirectoryConfig.js +7 -7
  35. package/dist/esm/deriveWorkspaceConfig.js +4 -4
  36. package/dist/esm/detectPackageManager.js +1 -1
  37. package/dist/esm/enterRepoRoot.js +2 -1
  38. package/dist/esm/enumerateReleaseWindows.js +2 -2
  39. package/dist/esm/findMissingManifestPaths.js +1 -1
  40. package/dist/esm/hasPrettierConfig.js +1 -1
  41. package/dist/esm/init/initCommand.js +10 -1
  42. package/dist/esm/init/parseJsonRecord.js +1 -1
  43. package/dist/esm/init/updateTemplatesCommand.js +10 -1
  44. package/dist/esm/isPublishableManifest.js +1 -1
  45. package/dist/esm/loadChangelogOverrides.js +1 -1
  46. package/dist/esm/loadConfig.js +6 -6
  47. package/dist/esm/loadValidatedConfig.d.ts +6 -1
  48. package/dist/esm/loadValidatedConfig.js +11 -3
  49. package/dist/esm/mergeConfig.js +5 -4
  50. package/dist/esm/monorepoConfigAssertions.js +6 -5
  51. package/dist/esm/npmRegistry.js +1 -1
  52. package/dist/esm/packFromCleanCheckout.js +12 -4
  53. package/dist/esm/parseChangeRecordBlock.js +1 -1
  54. package/dist/esm/planVersionBump.js +6 -5
  55. package/dist/esm/prepareArgs.d.ts +1 -49
  56. package/dist/esm/prepareArgs.js +4 -51
  57. package/dist/esm/prepareCommand.d.ts +15 -3
  58. package/dist/esm/prepareCommand.js +26 -44
  59. package/dist/esm/prepareComposite.js +2 -1
  60. package/dist/esm/prepareRunContext.js +2 -2
  61. package/dist/esm/previewTagPrefixes.js +2 -2
  62. package/dist/esm/publish.js +7 -1
  63. package/dist/esm/publishCommand.d.ts +12 -3
  64. package/dist/esm/publishCommand.js +4 -33
  65. package/dist/esm/pushCommand.d.ts +12 -4
  66. package/dist/esm/pushCommand.js +6 -27
  67. package/dist/esm/pushRelease.js +18 -6
  68. package/dist/esm/releaseFiles.js +2 -2
  69. package/dist/esm/releasePlan.js +2 -2
  70. package/dist/esm/resolveCommandTags.d.ts +4 -4
  71. package/dist/esm/resolveCommandTags.js +8 -12
  72. package/dist/esm/resolveReleaseDeclaration.js +9 -8
  73. package/dist/esm/resolveReleaseTags.js +11 -4
  74. package/dist/esm/resolveReleaseTargets.js +2 -1
  75. package/dist/esm/{resolveStylesOrExit.d.ts → resolveStyles.d.ts} +2 -2
  76. package/dist/esm/resolveStyles.js +10 -0
  77. package/dist/esm/route.d.ts +2 -0
  78. package/dist/esm/route.js +39 -0
  79. package/dist/esm/showTagPrefixesCommand.js +6 -3
  80. package/dist/esm/sync-labels/generateCommand.js +11 -7
  81. package/dist/esm/sync-labels/initCommand.js +7 -1
  82. package/dist/esm/sync-labels/presets.js +7 -8
  83. package/dist/esm/sync-labels/resolveLabels.js +4 -3
  84. package/dist/esm/sync-labels/retiredConfig.js +2 -1
  85. package/dist/esm/sync-labels/syncCommand.js +4 -3
  86. package/dist/esm/sync-labels/templates.js +2 -2
  87. package/dist/esm/tagCommand.d.ts +8 -3
  88. package/dist/esm/tagCommand.js +2 -15
  89. package/dist/esm/typeGuards.d.ts +0 -2
  90. package/dist/esm/typeGuards.js +0 -3
  91. package/dist/esm/user-error-chaining.d.ts +13 -0
  92. package/dist/esm/user-error-chaining.js +11 -0
  93. package/dist/esm/validateConfig.js +1 -1
  94. package/dist/esm/validateOverridesCommand.js +12 -5
  95. package/docs/configuration.md +1 -1
  96. package/docs/editorial-overrides.md +2 -0
  97. package/docs/labels.md +2 -0
  98. package/docs/releasing.md +1 -1
  99. package/package.json +3 -3
  100. package/dist/esm/configFlagSchema.d.ts +0 -12
  101. package/dist/esm/configFlagSchema.js +0 -3
  102. package/dist/esm/help/initHelp.d.ts +0 -4
  103. package/dist/esm/help/initHelp.js +0 -31
  104. package/dist/esm/help/overridesHelp.d.ts +0 -4
  105. package/dist/esm/help/overridesHelp.js +0 -32
  106. package/dist/esm/help/prepareHelp.d.ts +0 -7
  107. package/dist/esm/help/prepareHelp.js +0 -30
  108. package/dist/esm/help/releaseStepsHelp.d.ts +0 -12
  109. package/dist/esm/help/releaseStepsHelp.js +0 -91
  110. package/dist/esm/help/syncLabelsHelp.d.ts +0 -8
  111. package/dist/esm/help/syncLabelsHelp.js +0 -55
  112. package/dist/esm/help/usage.d.ts +0 -2
  113. package/dist/esm/help/usage.js +0 -29
  114. package/dist/esm/resolveConfigFlag.d.ts +0 -7
  115. package/dist/esm/resolveConfigFlag.js +0 -4
  116. package/dist/esm/resolveStylesOrExit.js +0 -10
@@ -2,8 +2,9 @@ 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 { isRecord, UserError } from '@williamthorsen/nmr-core';
5
6
  import { resolveExecutable } from "./detectPackageManager.js";
6
- import { isRecord } from "./typeGuards.js";
7
+ import { chainUserError } from "./user-error-chaining.js";
7
8
  export function packFromCleanCheckout(workspacePaths, packageManager) {
8
9
  const repoRoot = runStep('find the repository root', 'git', ['rev-parse', '--show-toplevel'], process.cwd()).trim();
9
10
  const headSha = runStep('resolve HEAD', 'git', ['rev-parse', 'HEAD'], repoRoot).trim();
@@ -51,7 +52,14 @@ const PACKAGE_MANAGER_COMMANDS = {
51
52
  },
52
53
  };
53
54
  function declaresPrepublishOnly(packageDir) {
54
- const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
55
+ const manifestPath = join(packageDir, 'package.json');
56
+ let manifest;
57
+ try {
58
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
59
+ }
60
+ catch (error) {
61
+ throw chainUserError(`Failed to parse ${manifestPath}`, error);
62
+ }
55
63
  const scripts = isRecord(manifest) ? manifest['scripts'] : undefined;
56
64
  return isRecord(scripts) && typeof scripts['prepublishOnly'] === 'string';
57
65
  }
@@ -59,7 +67,7 @@ function findTarball(packDir, workspacePath) {
59
67
  const tarballs = readdirSync(packDir).filter((name) => name.endsWith('.tgz'));
60
68
  const [tarball] = tarballs;
61
69
  if (tarballs.length !== 1 || tarball === undefined) {
62
- throw new Error(`Could not pack ${workspacePath}: expected one tarball, found ${tarballs.length}.`);
70
+ throw new UserError(`Could not pack ${workspacePath}: expected one tarball, found ${tarballs.length}.`);
63
71
  }
64
72
  return join(packDir, tarball);
65
73
  }
@@ -77,5 +85,5 @@ function runStep(step, command, args, cwd) {
77
85
  }
78
86
  const outcome = result.error === undefined ? `exited with status ${result.status}` : result.error.message;
79
87
  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}`}`);
88
+ throw new UserError(`Could not ${step}: \`${[command, ...args].join(' ')}\` ${outcome}.${output === '' ? '' : `\n${output}`}`);
81
89
  }
@@ -1,5 +1,5 @@
1
+ import { isRecord } from '@williamthorsen/nmr-core';
1
2
  import { parseDocument } from 'yaml';
2
- import { isRecord } from "./typeGuards.js";
3
3
  export function parseChangeRecordBlock(message) {
4
4
  const lines = message.split(/\r?\n/);
5
5
  const fence = findFences(lines).at(-1);
@@ -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,55 +1,7 @@
1
- import type { ReleaseType } from './types.js';
2
1
  /** Canonical `N.N.N` semver pattern for validating `--set-version` input. */
3
2
  export declare const CANONICAL_SEMVER_PATTERN: RegExp;
4
- export declare const prepareFlagSchema: {
5
- isDryRun: {
6
- long: string;
7
- type: "boolean";
8
- };
9
- shouldForce: {
10
- long: string;
11
- type: "boolean";
12
- };
13
- shouldSkipGitChecks: {
14
- long: string;
15
- type: "boolean";
16
- short: string;
17
- };
18
- bump: {
19
- long: string;
20
- type: "string";
21
- };
22
- setVersion: {
23
- long: string;
24
- type: "string";
25
- };
26
- only: {
27
- long: string;
28
- type: "string";
29
- };
30
- shouldIncludeReleaseNotes: {
31
- long: string;
32
- type: "boolean";
33
- };
34
- config: {
35
- long: string;
36
- type: "string";
37
- };
38
- };
39
- /** Parses CLI arguments into structured options. Prints a usage error and exits on invalid input. */
40
- export declare function parseArgs(argv: string[]): {
41
- configPath: string | undefined;
42
- isDryRun: boolean;
43
- shouldForce: boolean;
44
- shouldSkipGitChecks: boolean;
45
- bumpOverride: ReleaseType | undefined;
46
- onlyNames: string[] | undefined;
47
- /** The raw `--set-version` value, whose accepted form depends on the mode. */
48
- setVersion: string | undefined;
49
- shouldIncludeReleaseNotes: boolean;
50
- };
51
3
  /**
52
4
  * Parses a monorepo `--set-version` value, a comma-separated list of `<target>@N.N.N` entries, into versions keyed by
53
- * target name. Prints a usage error and exits on a malformed entry, an unknown target, or a repeated one.
5
+ * target name. Throws a `UsageError` for a malformed entry, an unknown target, or a repeated one.
54
6
  */
55
7
  export declare function parseSetVersions(value: string, knownNames: readonly string[]): Map<string, string>;
@@ -1,46 +1,5 @@
1
- import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
- import { configFlagSchema } from "./configFlagSchema.js";
3
- const VALID_BUMP_TYPES = ['major', 'minor', 'patch'];
1
+ import { UsageError } from '@williamthorsen/nmr-core/cli';
4
2
  export const CANONICAL_SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
5
- export const prepareFlagSchema = {
6
- ...configFlagSchema,
7
- isDryRun: { long: '--dry-run', type: 'boolean' },
8
- shouldForce: { long: '--force', type: 'boolean' },
9
- shouldSkipGitChecks: {
10
- long: '--no-git-checks',
11
- type: 'boolean',
12
- short: '-n',
13
- },
14
- bump: { long: '--bump', type: 'string' },
15
- setVersion: { long: '--set-version', type: 'string' },
16
- only: { long: '--only', type: 'string' },
17
- shouldIncludeReleaseNotes: { long: '--with-release-notes', type: 'boolean' },
18
- };
19
- export function parseArgs(argv) {
20
- const { flags } = parseArgsOrExit(argv, prepareFlagSchema);
21
- let bumpOverride;
22
- if (flags.bump !== undefined) {
23
- if (!isReleaseType(flags.bump)) {
24
- reportError(`Invalid bump type "${flags.bump}". Must be one of: ${VALID_BUMP_TYPES.join(', ')}`);
25
- process.exit(1);
26
- }
27
- bumpOverride = flags.bump;
28
- }
29
- let onlyNames;
30
- if (flags.only !== undefined) {
31
- onlyNames = flags.only.split(',');
32
- }
33
- return {
34
- configPath: flags.config,
35
- isDryRun: flags.isDryRun,
36
- shouldForce: flags.shouldForce,
37
- shouldSkipGitChecks: flags.shouldSkipGitChecks,
38
- bumpOverride,
39
- onlyNames,
40
- setVersion: flags.setVersion,
41
- shouldIncludeReleaseNotes: flags.shouldIncludeReleaseNotes,
42
- };
43
- }
44
3
  export function parseSetVersions(value, knownNames) {
45
4
  const setVersions = new Map();
46
5
  for (const entry of value.split(',')) {
@@ -48,22 +7,16 @@ export function parseSetVersions(value, knownNames) {
48
7
  const name = entry.slice(0, Math.max(0, separatorIndex));
49
8
  const version = entry.slice(separatorIndex + 1);
50
9
  if (separatorIndex <= 0 || !CANONICAL_SEMVER_PATTERN.test(version)) {
51
- reportError(`Invalid --set-version entry "${entry}". In monorepo mode, each entry must be <target>@N.N.N ` +
10
+ throw new UsageError(`Invalid --set-version entry "${entry}". In monorepo mode, each entry must be <target>@N.N.N ` +
52
11
  '(canonical semver, no pre-release suffix), e.g. --set-version=arrays@1.0.0.');
53
- process.exit(1);
54
12
  }
55
13
  if (!knownNames.includes(name)) {
56
- reportError(`Unknown release target "${name}" in --set-version. Known targets: ${knownNames.join(', ')}`);
57
- process.exit(1);
14
+ throw new UsageError(`Unknown release target "${name}" in --set-version. Known targets: ${knownNames.join(', ')}`);
58
15
  }
59
16
  if (setVersions.has(name)) {
60
- reportError(`--set-version names target "${name}" more than once`);
61
- process.exit(1);
17
+ throw new UsageError(`--set-version names target "${name}" more than once`);
62
18
  }
63
19
  setVersions.set(name, version);
64
20
  }
65
21
  return setVersions;
66
22
  }
67
- function isReleaseType(value) {
68
- return VALID_BUMP_TYPES.includes(value);
69
- }
@@ -1,8 +1,20 @@
1
1
  import { type StreamStyles } from '@williamthorsen/nmr-core';
2
+ import type { ReleaseType } from './types.js';
3
+ export interface PrepareCommandOptions {
4
+ bumpOverride: ReleaseType | undefined;
5
+ configPath: string | undefined;
6
+ isDryRun: boolean;
7
+ /** The release targets that `--only` names, or `undefined` to release every target that has changes. */
8
+ onlyNames: string[] | undefined;
9
+ /** The raw `--set-version` value, whose accepted form depends on the mode. */
10
+ setVersion: string | undefined;
11
+ shouldForce: boolean;
12
+ shouldIncludeReleaseNotes: boolean;
13
+ shouldSkipGitChecks: boolean;
14
+ styles: StreamStyles;
15
+ }
2
16
  /**
3
17
  * Orchestrates the CLI `prepare` command: checks the working tree, loads and validates the config, discovers the
4
18
  * workspaces, and prepares the release over the targets of the repo shape.
5
- *
6
- * A relative `--config` resolves against `invocationDir`.
7
19
  */
8
- export declare function prepareCommand(argv: string[], styles: StreamStyles, invocationDir: string): Promise<void>;
20
+ export declare function prepareCommand({ bumpOverride, configPath, isDryRun, onlyNames, setVersion, shouldForce, shouldIncludeReleaseNotes, shouldSkipGitChecks, styles, }: PrepareCommandOptions): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { formatGlyphLine, reportError } from '@williamthorsen/nmr-core';
2
+ import { formatGlyphLine, isAnticipatedFailure, UserError, } from '@williamthorsen/nmr-core';
3
+ import { UsageError } from '@williamthorsen/nmr-core/cli';
3
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
5
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
5
6
  import { assertReleasesOnNpm } from "./assertReleasesOnNpm.js";
@@ -7,16 +8,16 @@ import { assertTarballsComplete } from "./assertTarballsComplete.js";
7
8
  import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
8
9
  import { dim } from "./format.js";
9
10
  import { RELEASE_GLYPHS } from "./glyphs.js";
10
- import { loadValidatedConfig, reportConfigProblem, reportConfigWarnings } from "./loadValidatedConfig.js";
11
- import { CANONICAL_SEMVER_PATTERN, parseArgs, parseSetVersions } from "./prepareArgs.js";
11
+ import { ConfigProblemError, loadValidatedConfig, reportConfigWarnings } from "./loadValidatedConfig.js";
12
+ import { CANONICAL_SEMVER_PATTERN, parseSetVersions } from "./prepareArgs.js";
12
13
  import { prepareReleaseTargets } from "./prepareReleaseTargets.js";
13
14
  import { RELEASE_SUMMARY_FILE, RELEASE_TAGS_FILE } from "./releaseFiles.js";
14
15
  import { applyReleasePlan } from "./releasePlan.js";
15
16
  import { reportPrepare } from "./reportPrepare.js";
16
- import { resolveConfigFlag } from "./resolveConfigFlag.js";
17
17
  import { isSinglePackageTargets, PROJECT_TARGET_ID, resolveReleaseTargets, SINGLE_PACKAGE_TARGET_ID, } from "./resolveReleaseTargets.js";
18
- export async function prepareCommand(argv, styles, invocationDir) {
19
- const { configPath: configFlag, isDryRun, shouldForce, shouldSkipGitChecks, bumpOverride, onlyNames, setVersion, shouldIncludeReleaseNotes, } = parseArgs(argv);
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.';
20
+ export async function prepareCommand({ bumpOverride, configPath, isDryRun, onlyNames, setVersion, shouldForce, shouldIncludeReleaseNotes, shouldSkipGitChecks, styles, }) {
20
21
  const options = {
21
22
  shouldForce,
22
23
  ...(bumpOverride !== undefined && { bumpOverride }),
@@ -26,18 +27,11 @@ export async function prepareCommand(argv, styles, invocationDir) {
26
27
  console.info(`\n${formatGlyphLine(RELEASE_GLYPHS, styles.stdout, 'dryRun', 'DRY RUN: release-kit will not modify any files')}\n`);
27
28
  }
28
29
  if (!isDryRun && !shouldSkipGitChecks) {
29
- try {
30
- assertCleanWorkingTree();
31
- }
32
- catch (error) {
33
- reportError(describeError(error));
34
- process.exit(1);
35
- }
30
+ assertCleanWorkingTree();
36
31
  }
37
- const configResult = await loadValidatedConfig(resolveConfigFlag(configFlag, invocationDir));
32
+ const configResult = await loadValidatedConfig(configPath);
38
33
  if (configResult.status === 'invalid') {
39
- reportConfigProblem(configResult.problem, styles.stderr);
40
- process.exit(1);
34
+ throw new ConfigProblemError(configResult.problem);
41
35
  }
42
36
  let userConfig;
43
37
  if (configResult.status === 'ok') {
@@ -49,14 +43,12 @@ export async function prepareCommand(argv, styles, invocationDir) {
49
43
  workspaceDiscovery = discoverWorkspaces();
50
44
  }
51
45
  catch (error) {
52
- reportError(`Failed to discover workspaces: ${describeError(error)}`);
53
- process.exit(1);
46
+ throw prefixUserError('Failed to discover workspaces', error);
54
47
  }
55
48
  if (workspaceDiscovery.kind === 'empty') {
56
- reportError(`No workspace package to release. ${describeEmptyWorkspace(workspaceDiscovery)}`);
57
- process.exit(1);
49
+ throw new UserError(`No workspace package to release. ${describeEmptyWorkspace(workspaceDiscovery)}`);
58
50
  }
59
- const config = resolveTargetsOrExit(userConfig, workspaceDiscovery, onlyNames, setVersion);
51
+ const config = resolveSelectedTargets(userConfig, workspaceDiscovery, onlyNames, setVersion);
60
52
  const targetOptions = {
61
53
  ...options,
62
54
  ...(onlyNames !== undefined && { onlyTargetIds: onlyNames }),
@@ -74,15 +66,13 @@ function findReleasedPackagePaths(plan, targets) {
74
66
  const releasedIds = new Set(plan.targets.flatMap((result) => result.status === 'released' && result.kind === 'package' ? result.targetId : []));
75
67
  return targets.flatMap((target) => target.kind === 'package' && releasedIds.has(target.id) ? target.packagePath : []);
76
68
  }
77
- function resolveTargetsOrExit(userConfig, discovery, onlyNames, setVersion) {
69
+ function resolveSelectedTargets(userConfig, discovery, onlyNames, setVersion) {
78
70
  if (discovery.kind === 'single-package') {
79
71
  if (onlyNames !== undefined) {
80
- reportError('--only is only supported for monorepo configurations');
81
- process.exit(1);
72
+ throw new UsageError('--only is only supported for monorepo configurations');
82
73
  }
83
74
  if (setVersion !== undefined && !CANONICAL_SEMVER_PATTERN.test(setVersion)) {
84
- reportError(`Invalid --set-version value "${setVersion}". Must be canonical semver (N.N.N, no pre-release suffix).`);
85
- process.exit(1);
75
+ throw new UsageError(`Invalid --set-version value "${setVersion}". Must be canonical semver (N.N.N, no pre-release suffix).`);
86
76
  }
87
77
  return resolveReleaseTargets(userConfig, discovery);
88
78
  }
@@ -91,14 +81,12 @@ function resolveTargetsOrExit(userConfig, discovery, onlyNames, setVersion) {
91
81
  config = resolveReleaseTargets(userConfig, discovery);
92
82
  }
93
83
  catch (error) {
94
- reportError(`Failed to resolve workspaces: ${describeError(error)}`);
95
- process.exit(1);
84
+ throw prefixUserError('Failed to resolve workspaces', error);
96
85
  }
97
86
  if (setVersion !== undefined && config.targets.some((target) => target.kind === 'composite')) {
98
- reportError('--set-version cannot be combined with a project release. ' +
87
+ throw new UsageError('--set-version cannot be combined with a project release. ' +
99
88
  "--set-version sets individual workspaces' versions; a project release rolls up every " +
100
89
  'contributing workspace. To use --set-version, run on a config without a `project` block or a `releases` composite.');
101
- process.exit(1);
102
90
  }
103
91
  const knownNames = listSelectableIds(config.targets);
104
92
  const requestedNames = onlyNames ?? [];
@@ -106,8 +94,7 @@ function resolveTargetsOrExit(userConfig, discovery, onlyNames, setVersion) {
106
94
  if (knownNames.includes(name)) {
107
95
  continue;
108
96
  }
109
- reportError(`Unknown release target "${name}". Known targets: ${knownNames.join(', ')}`);
110
- process.exit(1);
97
+ throw new UsageError(`Unknown release target "${name}". Known targets: ${knownNames.join(', ')}`);
111
98
  }
112
99
  return config;
113
100
  }
@@ -129,31 +116,26 @@ function runAndReport(computePlan, isDryRun, style) {
129
116
  plan = computePlan();
130
117
  }
131
118
  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);
119
+ if (isAnticipatedFailure(error)) {
120
+ throw new UserError(`${error.message}\n${UNCHANGED_TREE_NOTE}`, { cause: error });
121
+ }
122
+ process.stderr.write(`${UNCHANGED_TREE_NOTE}\n`);
123
+ throw error;
135
124
  }
136
125
  if (isDryRun) {
137
126
  process.stdout.write(reportPrepare(plan, { applied: false, style }) + '\n');
138
127
  reportPlanFiles(plan, true);
139
128
  return;
140
129
  }
141
- try {
142
- applyReleasePlan(plan);
143
- }
144
- catch (error) {
145
- reportError(describeError(error));
146
- process.exit(1);
147
- }
130
+ applyReleasePlan(plan);
148
131
  const formatError = runFormatCommand(plan.formatCommand);
149
132
  process.stdout.write(reportPrepare(plan, { applied: true, style, ...(formatError !== undefined && { formatError }) }) + '\n');
150
133
  reportPlanFiles(plan, false);
151
134
  if (formatError === undefined) {
152
135
  return;
153
136
  }
154
- reportError(`The format command failed, but the release is prepared and ${RELEASE_TAGS_FILE} is written. ` +
137
+ throw new UserError(`The format command failed, but the release is prepared and ${RELEASE_TAGS_FILE} is written. ` +
155
138
  `Format the release files and run 'release-kit commit'.`);
156
- process.exit(1);
157
139
  }
158
140
  function runFormatCommand(formatCommand) {
159
141
  if (formatCommand === undefined) {
@@ -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,16 @@
1
- import { type StreamStyles } from '@williamthorsen/nmr-core';
1
+ import type { StreamStyles } from '@williamthorsen/nmr-core';
2
+ export interface PublishCommandOptions {
3
+ configPath: string | undefined;
4
+ isDryRun: boolean;
5
+ shouldGenerateProvenance: boolean;
6
+ shouldSkipGitChecks: boolean;
7
+ /** The tag names that `--tags` selects, or `undefined` to publish every publishable release tag on HEAD. */
8
+ tags: string[] | undefined;
9
+ styles: StreamStyles;
10
+ }
2
11
  /**
3
12
  * Orchestrates the CLI `publish` command: checks for a clean working tree, resolves the tags on HEAD, checks the
4
13
  * tarballs of the publishable ones from a clean checkout, and publishes each one, injecting release notes into its
5
- * README for the duration of the publish. A relative `--config` resolves against `invocationDir`.
14
+ * README for the duration of the publish.
6
15
  */
7
- export declare function publishCommand(argv: string[], styles: StreamStyles, invocationDir: string): Promise<void>;
16
+ export declare function publishCommand({ configPath, isDryRun, shouldGenerateProvenance, shouldSkipGitChecks, tags: requestedTags, styles, }: PublishCommandOptions): Promise<void>;
@@ -1,10 +1,7 @@
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';
5
3
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
6
4
  import { assertTarballsComplete } from "./assertTarballsComplete.js";
7
- import { configFlagSchema } from "./configFlagSchema.js";
8
5
  import { deriveReleaseNotesConfig } from "./deriveReleaseNotesConfig.js";
9
6
  import { detectPackageManager } from "./detectPackageManager.js";
10
7
  import { formatDirectoryPublishSkip } from "./formatDirectoryPublishSkip.js";
@@ -12,32 +9,13 @@ import { formatPrivateSkip } from "./formatPrivateSkip.js";
12
9
  import { formatProjectPublishSkip } from "./formatProjectPublishSkip.js";
13
10
  import { injectReleaseNotesIntoReadme, resolveReadmePath } from "./injectReleaseNotesIntoReadme.js";
14
11
  import { loadUsableConfig } from "./loadValidatedConfig.js";
15
- import { parseRequestedTags } from "./parseRequestedTags.js";
16
12
  import { publishPackage } from "./publish.js";
17
13
  import { resolveCommandTags } from "./resolveCommandTags.js";
18
- import { resolveConfigFlag } from "./resolveConfigFlag.js";
19
- const publishFlagSchema = {
20
- ...configFlagSchema,
21
- isDryRun: { long: '--dry-run', type: 'boolean' },
22
- shouldSkipGitChecks: { long: '--no-git-checks', type: 'boolean' },
23
- shouldGenerateProvenance: { long: '--provenance', type: 'boolean' },
24
- tags: { long: '--tags', type: 'string' },
25
- };
26
- export async function publishCommand(argv, styles, invocationDir) {
27
- const parsedArgs = parseArgsOrExit(argv, publishFlagSchema);
28
- const configPath = resolveConfigFlag(parsedArgs.flags.config, invocationDir);
29
- const { isDryRun, shouldSkipGitChecks, shouldGenerateProvenance } = parsedArgs.flags;
14
+ export async function publishCommand({ configPath, isDryRun, shouldGenerateProvenance, shouldSkipGitChecks, tags: requestedTags, styles, }) {
30
15
  const userConfig = await loadUsableConfig(styles.stderr, configPath);
31
16
  if (!isDryRun && !shouldSkipGitChecks) {
32
- try {
33
- assertCleanWorkingTree();
34
- }
35
- catch (error) {
36
- reportError(describeError(error));
37
- process.exit(1);
38
- }
17
+ assertCleanWorkingTree();
39
18
  }
40
- const requestedTags = parseRequestedTags(parsedArgs.flags.tags);
41
19
  const resolvedTags = resolveCommandTags(requestedTags, userConfig);
42
20
  if (resolvedTags.length === 0) {
43
21
  return;
@@ -47,13 +25,7 @@ export async function publishCommand(argv, styles, invocationDir) {
47
25
  console.info('Nothing to publish.');
48
26
  return;
49
27
  }
50
- try {
51
- assertTarballsComplete(publishableTags.map(({ workspacePath }) => workspacePath), userConfig?.devExportConditions ?? []);
52
- }
53
- catch (error) {
54
- reportError(describeError(error));
55
- process.exit(1);
56
- }
28
+ assertTarballsComplete(publishableTags.map(({ workspacePath }) => workspacePath), userConfig?.devExportConditions ?? []);
57
29
  const packageManager = detectPackageManager();
58
30
  const { releaseNotes, changelogJsonOutputPath, sectionOrder } = deriveReleaseNotesConfig(userConfig);
59
31
  const shouldInject = releaseNotes.shouldInjectIntoReadme;
@@ -90,8 +62,7 @@ export async function publishCommand(argv, styles, invocationDir) {
90
62
  console.warn(` ${tag}`);
91
63
  }
92
64
  }
93
- reportError(describeError(error));
94
- process.exit(1);
65
+ throw error;
95
66
  }
96
67
  }
97
68
  function filterPublishableTags(resolvedTags, isExplicit) {
@@ -1,6 +1,14 @@
1
- import { type StreamStyles } from '@williamthorsen/nmr-core';
1
+ import type { StreamStyles } from '@williamthorsen/nmr-core';
2
+ export interface PushCommandOptions {
3
+ configPath: string | undefined;
4
+ isDryRun: boolean;
5
+ /** The tag names that `--tags` selects, or `undefined` to push every release tag on HEAD. */
6
+ tags: string[] | undefined;
7
+ tagsOnly: boolean;
8
+ styles: StreamStyles;
9
+ }
2
10
  /**
3
- * Orchestrates the CLI `push` command: parses flags, loads the config, resolves tags from HEAD, and pushes
4
- * the release commit and each tag individually. A relative `--config` resolves against `invocationDir`.
11
+ * Orchestrates the CLI `push` command: loads the config, resolves tags from HEAD, and pushes the release commit and
12
+ * each tag individually.
5
13
  */
6
- export declare function pushCommand(argv: string[], styles: StreamStyles, invocationDir: string): Promise<void>;
14
+ export declare function pushCommand({ configPath, isDryRun, tags, tagsOnly, styles }: PushCommandOptions): Promise<void>;
@@ -1,24 +1,9 @@
1
- import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
- import { describeError } from '@williamthorsen/toolbelt.errors';
3
- import { configFlagSchema } from "./configFlagSchema.js";
4
1
  import { loadUsableConfig } from "./loadValidatedConfig.js";
5
- import { parseRequestedTags } from "./parseRequestedTags.js";
6
2
  import { pushRelease } from "./pushRelease.js";
7
3
  import { resolveCommandTags } from "./resolveCommandTags.js";
8
- import { resolveConfigFlag } from "./resolveConfigFlag.js";
9
- const pushFlagSchema = {
10
- ...configFlagSchema,
11
- isDryRun: { long: '--dry-run', type: 'boolean' },
12
- tags: { long: '--tags', type: 'string' },
13
- tagsOnly: { long: '--tags-only', type: 'boolean' },
14
- };
15
- export async function pushCommand(argv, styles, invocationDir) {
16
- const parsedArgs = parseArgsOrExit(argv, pushFlagSchema);
17
- const configPath = resolveConfigFlag(parsedArgs.flags.config, invocationDir);
18
- const { isDryRun, tagsOnly } = parsedArgs.flags;
4
+ export async function pushCommand({ configPath, isDryRun, tags, tagsOnly, styles }) {
19
5
  const userConfig = await loadUsableConfig(styles.stderr, configPath);
20
- const requestedTags = parseRequestedTags(parsedArgs.flags.tags);
21
- const resolvedTags = resolveCommandTags(requestedTags, userConfig);
6
+ const resolvedTags = resolveCommandTags(tags, userConfig);
22
7
  if (resolvedTags.length === 0) {
23
8
  return;
24
9
  }
@@ -32,16 +17,10 @@ export async function pushCommand(argv, styles, invocationDir) {
32
17
  for (const { tag } of resolvedTags) {
33
18
  console.info(` ${tag}`);
34
19
  }
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
- }
20
+ const steps = pushRelease(resolvedTags, { isDryRun, tagsOnly });
21
+ if (isDryRun) {
22
+ for (const step of steps) {
23
+ console.info(`[dry-run] ${step.command.join(' ')}`);
41
24
  }
42
25
  }
43
- catch (error) {
44
- reportError(describeError(error));
45
- process.exit(1);
46
- }
47
26
  }