@williamthorsen/release-kit 10.8.0 → 11.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 (69) hide show
  1. package/.meta/changelog.json +100 -3
  2. package/.readyup/kits/default.js +3 -3
  3. package/.readyup/kits/npm-auto-publish.js +43 -23
  4. package/.readyup/manifest.json +9 -9
  5. package/CHANGELOG.md +82 -3
  6. package/README.md +46 -11
  7. package/dist/esm/bin/release-kit.js +46 -120
  8. package/dist/esm/buildChangelogEntries.js +1 -0
  9. package/dist/esm/configFlagSchema.d.ts +12 -0
  10. package/dist/esm/configFlagSchema.js +3 -0
  11. package/dist/esm/createGithubReleaseCommand.d.ts +2 -1
  12. package/dist/esm/createGithubReleaseCommand.js +9 -3
  13. package/dist/esm/createTags.d.ts +2 -0
  14. package/dist/esm/createTags.js +5 -4
  15. package/dist/esm/discoverWorkspaces.d.ts +53 -8
  16. package/dist/esm/discoverWorkspaces.js +57 -37
  17. package/dist/esm/format.d.ts +5 -5
  18. package/dist/esm/format.js +5 -4
  19. package/dist/esm/glyphs.d.ts +3 -0
  20. package/dist/esm/glyphs.js +6 -0
  21. package/dist/esm/help/prepareHelp.d.ts +1 -1
  22. package/dist/esm/help/prepareHelp.js +1 -0
  23. package/dist/esm/init/initCommand.d.ts +3 -1
  24. package/dist/esm/init/initCommand.js +15 -15
  25. package/dist/esm/loadConfig.d.ts +7 -4
  26. package/dist/esm/loadConfig.js +6 -3
  27. package/dist/esm/loadValidatedConfig.d.ts +47 -5
  28. package/dist/esm/loadValidatedConfig.js +32 -15
  29. package/dist/esm/prepareCommand.d.ts +7 -1
  30. package/dist/esm/prepareCommand.js +37 -45
  31. package/dist/esm/previewTagPrefixes.d.ts +9 -6
  32. package/dist/esm/previewTagPrefixes.js +12 -24
  33. package/dist/esm/publishCommand.d.ts +4 -3
  34. package/dist/esm/publishCommand.js +9 -3
  35. package/dist/esm/pushCommand.d.ts +1 -1
  36. package/dist/esm/pushCommand.js +2 -2
  37. package/dist/esm/reportPrepare.d.ts +2 -0
  38. package/dist/esm/reportPrepare.js +51 -41
  39. package/dist/esm/resolveCommandTags.d.ts +3 -2
  40. package/dist/esm/resolveCommandTags.js +10 -6
  41. package/dist/esm/resolveReleaseNotesConfig.d.ts +10 -11
  42. package/dist/esm/resolveReleaseNotesConfig.js +10 -29
  43. package/dist/esm/resolveStylesOrExit.d.ts +10 -0
  44. package/dist/esm/resolveStylesOrExit.js +10 -0
  45. package/dist/esm/showTagPrefixesCommand.d.ts +5 -2
  46. package/dist/esm/showTagPrefixesCommand.js +31 -11
  47. package/dist/esm/sync-labels/generateCommand.d.ts +18 -8
  48. package/dist/esm/sync-labels/generateCommand.js +12 -11
  49. package/dist/esm/sync-labels/initCommand.d.ts +11 -4
  50. package/dist/esm/sync-labels/initCommand.js +24 -18
  51. package/dist/esm/sync-labels/retiredConfig.d.ts +4 -2
  52. package/dist/esm/sync-labels/retiredConfig.js +2 -2
  53. package/dist/esm/tagCommand.d.ts +2 -1
  54. package/dist/esm/tagCommand.js +2 -2
  55. package/dist/esm/validateOverridesCommand.d.ts +13 -7
  56. package/dist/esm/validateOverridesCommand.js +28 -36
  57. package/docs/configuration.md +35 -1
  58. package/docs/labels.md +2 -0
  59. package/docs/work-types.md +11 -8
  60. package/package.json +13 -14
  61. package/presets/labels/common.yaml +2 -2
  62. package/dist/esm/checkWorkTypesDrift.d.ts +0 -35
  63. package/dist/esm/checkWorkTypesDrift.js +0 -117
  64. package/dist/esm/syncWorkTypes.d.ts +0 -28
  65. package/dist/esm/syncWorkTypes.js +0 -94
  66. package/dist/esm/work-types.json +0 -149
  67. package/dist/esm/work-types.schema.json +0 -110
  68. package/dist/esm/workTypesUtils.d.ts +0 -22
  69. package/dist/esm/workTypesUtils.js +0 -16
@@ -3,6 +3,6 @@
3
3
  * dispatcher and asserted by the help drift guard test; kept free of import-time side
4
4
  * effects so both can load it.
5
5
  */
6
- export declare const prepareHelpText = "\nUsage: release-kit prepare [options]\n\nRun release preparation with automatic workspace discovery.\n\nOptions:\n --dry-run Run without modifying any files\n --bump=major|minor|patch Override the bump type for all workspaces\n --set-version=X.Y.Z Set an explicit version; bypasses commit-derived bumps. In monorepo\n mode it requires --only, and is rejected when a 'project' block is configured.\n --force Release even when no commits or no bump-worthy commits exist\n since the last tag. In monorepo and project mode, defaults to\n patch when --bump is not given; use --bump=X for a different\n level. In single-package mode, a bare --force is rejected \u2014\n pass --bump=major|minor|patch.\n --no-git-checks, -n Skip the clean-working-tree check\n --only=name1,name2 Only process the named workspaces (comma-separated, monorepo only).\n When a 'project' block is configured, the project release is skipped.\n --with-release-notes Also write per-workspace release-notes previews under <workspacePath>/docs/\n (docs/README.v<version>.md and docs/RELEASE_NOTES.v<version>.md).\n Recommended .gitignore entry: packages/*/docs/*.v*.md (or docs/*.v*.md).\n --help, -h Show this help message\n";
6
+ export declare const prepareHelpText = "\nUsage: release-kit prepare [options]\n\nRun release preparation with automatic workspace discovery.\n\nOptions:\n --config <path> Path to config file (default: .config/release-kit.config.ts)\n --dry-run Run without modifying any files\n --bump=major|minor|patch Override the bump type for all workspaces\n --set-version=X.Y.Z Set an explicit version; bypasses commit-derived bumps. In monorepo\n mode it requires --only, and is rejected when a 'project' block is configured.\n --force Release even when no commits or no bump-worthy commits exist\n since the last tag. In monorepo and project mode, defaults to\n patch when --bump is not given; use --bump=X for a different\n level. In single-package mode, a bare --force is rejected \u2014\n pass --bump=major|minor|patch.\n --no-git-checks, -n Skip the clean-working-tree check\n --only=name1,name2 Only process the named workspaces (comma-separated, monorepo only).\n When a 'project' block is configured, the project release is skipped.\n --with-release-notes Also write per-workspace release-notes previews under <workspacePath>/docs/\n (docs/README.v<version>.md and docs/RELEASE_NOTES.v<version>.md).\n Recommended .gitignore entry: packages/*/docs/*.v*.md (or docs/*.v*.md).\n --help, -h Show this help message\n";
7
7
  /** Prints the prepare command's help text. */
8
8
  export declare function showPrepareHelp(): void;
@@ -4,6 +4,7 @@ Usage: release-kit prepare [options]
4
4
  Run release preparation with automatic workspace discovery.
5
5
 
6
6
  Options:
7
+ --config <path> Path to config file (default: .config/release-kit.config.ts)
7
8
  --dry-run Run without modifying any files
8
9
  --bump=major|minor|patch Override the bump type for all workspaces
9
10
  --set-version=X.Y.Z Set an explicit version; bypasses commit-derived bumps. In monorepo
@@ -1,6 +1,8 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  interface InitOptions {
2
3
  dryRun: boolean;
3
4
  force: boolean;
5
+ styles: StreamStyles;
4
6
  withConfig: boolean;
5
7
  }
6
8
  /**
@@ -9,5 +11,5 @@ interface InitOptions {
9
11
  * Checks eligibility, detects repo type, scaffolds files, and prints next steps.
10
12
  * Returns the process exit code (0 for success, 1 for failure).
11
13
  */
12
- export declare function initCommand({ dryRun, force, withConfig }: InitOptions): number;
14
+ export declare function initCommand({ dryRun, force, styles, withConfig }: InitOptions): number;
13
15
  export {};
@@ -1,36 +1,36 @@
1
- import { printError, printStep, printSuccess, reportWriteResult } from '@williamthorsen/nmr-core';
1
+ import { printError, 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";
5
5
  import { scaffoldFiles } from "./scaffold.js";
6
- function runRequiredCheck(label, result) {
6
+ function runRequiredCheck(label, result, styles) {
7
7
  if (result.ok) {
8
- printSuccess(label);
8
+ printSuccess(label, styles.stdout);
9
9
  return true;
10
10
  }
11
- printError(result.message ?? `${label} failed`);
11
+ printError(result.message ?? `${label} failed`, styles.stderr);
12
12
  return false;
13
13
  }
14
- function checkEligibility() {
14
+ function checkEligibility(styles) {
15
15
  printStep('Checking eligibility');
16
- if (!runRequiredCheck('Git repository detected', isGitRepo()))
16
+ if (!runRequiredCheck('Git repository detected', isGitRepo(), styles))
17
17
  return false;
18
- if (!runRequiredCheck('package.json found', hasPackageJson()))
18
+ if (!runRequiredCheck('package.json found', hasPackageJson(), styles))
19
19
  return false;
20
- if (!runRequiredCheck('pnpm detected', usesPnpm()))
20
+ if (!runRequiredCheck('pnpm detected', usesPnpm(), styles))
21
21
  return false;
22
22
  return true;
23
23
  }
24
- export function initCommand({ dryRun, force, withConfig }) {
24
+ export function initCommand({ dryRun, force, styles, withConfig }) {
25
25
  if (dryRun) {
26
26
  console.info('[dry-run mode]');
27
27
  }
28
28
  let eligible;
29
29
  try {
30
- eligible = checkEligibility();
30
+ eligible = checkEligibility(styles);
31
31
  }
32
32
  catch (error) {
33
- printError(`Eligibility check failed: ${describeError(error)}`);
33
+ printError(`Eligibility check failed: ${describeError(error)}`, styles.stderr);
34
34
  return 1;
35
35
  }
36
36
  if (!eligible)
@@ -41,21 +41,21 @@ export function initCommand({ dryRun, force, withConfig }) {
41
41
  repoType = detectRepoType();
42
42
  }
43
43
  catch (error) {
44
- printError(`Failed to detect repo type: ${describeError(error)}`);
44
+ printError(`Failed to detect repo type: ${describeError(error)}`, styles.stderr);
45
45
  return 1;
46
46
  }
47
- printSuccess(`Detected: ${repoType}`);
47
+ printSuccess(`Detected: ${repoType}`, styles.stdout);
48
48
  printStep('Scaffolding files');
49
49
  let results;
50
50
  try {
51
51
  results = scaffoldFiles({ repoType, dryRun, overwrite: force, withConfig });
52
52
  }
53
53
  catch (error) {
54
- printError(`Failed to scaffold files: ${describeError(error)}`);
54
+ printError(`Failed to scaffold files: ${describeError(error)}`, styles.stderr);
55
55
  return 1;
56
56
  }
57
57
  for (const result of results) {
58
- reportWriteResult(result, dryRun);
58
+ reportWriteResult(result, dryRun, styles);
59
59
  }
60
60
  if (results.some((r) => r.outcome === 'failed')) {
61
61
  return 1;
@@ -15,11 +15,14 @@ export declare function readRootPackageVersion(): {
15
15
  /** The path where the consumer-facing config file is expected. */
16
16
  export declare const CONFIG_FILE_PATH = ".config/release-kit.config.ts";
17
17
  /**
18
- * Loads the config file at `.config/release-kit.config.ts`, returning the raw config object, or `undefined` when the
19
- * file does not exist. Throws when the file exists but cannot be imported, or exports neither a default nor a named
20
- * `config`.
18
+ * Loads the config file, returning the raw config object.
19
+ *
20
+ * `configPath` is resolved against the working directory and defaults to `CONFIG_FILE_PATH`. An absent default
21
+ * path returns `undefined`, because a repo that declares no config is a supported state; an absent named path
22
+ * throws, because the caller asked for that file by name. Also throws when the file exists but cannot be
23
+ * imported, or exports neither a default nor a named `config`.
21
24
  */
22
- export declare function loadConfig(): Promise<unknown>;
25
+ export declare function loadConfig(configPath?: string): Promise<unknown>;
23
26
  /**
24
27
  * Information about the root `package.json` passed into `mergeMonorepoConfig` when a
25
28
  * `project` block is configured. The async I/O lives in `readRootPackageVersion`; this
@@ -39,10 +39,13 @@ export function readRootPackageVersion() {
39
39
  return { exists: true, version: typeof parsed['version'] === 'string' ? parsed['version'] : undefined };
40
40
  }
41
41
  export const CONFIG_FILE_PATH = '.config/release-kit.config.ts';
42
- export async function loadConfig() {
43
- const absoluteConfigPath = path.resolve(process.cwd(), CONFIG_FILE_PATH);
42
+ export async function loadConfig(configPath) {
43
+ const absoluteConfigPath = path.resolve(process.cwd(), configPath ?? CONFIG_FILE_PATH);
44
44
  if (!existsSync(absoluteConfigPath)) {
45
- return undefined;
45
+ if (configPath === undefined) {
46
+ return undefined;
47
+ }
48
+ throw new Error(`Config file not found: ${absoluteConfigPath}`);
46
49
  }
47
50
  const imported = await import(__rewriteRelativeImportExtension(pathToFileURL(absoluteConfigPath).href));
48
51
  const resolved = isRecord(imported) ? (imported['default'] ?? imported['config']) : undefined;
@@ -1,18 +1,60 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  import type { ReleaseKitConfig } from './types.js';
2
3
  /**
3
- * Outcome of loading and validating the consumer config file. `missing` is not reported
4
- * here — whether an absent config file is an error is the caller's call.
4
+ * Why a config file could not be used: the import threw, or the contents failed the schema.
5
+ *
6
+ * The distinction exists so that a caller can render the two differently. Every caller aborts on either.
7
+ */
8
+ export type ConfigProblem = {
9
+ kind: 'load';
10
+ message: string;
11
+ } | {
12
+ kind: 'validation';
13
+ errors: string[];
14
+ };
15
+ /**
16
+ * Outcome of loading and validating the consumer config file. `missing` is not an error
17
+ * here — whether an absent config file is one is the caller's call.
18
+ *
19
+ * `configFilePath` names the file the run read: the caller's `configPath`, or `CONFIG_FILE_PATH` when none was
20
+ * given. It stays relative to the working directory, so a message or a generated artifact that splices it reads
21
+ * the same on every machine.
5
22
  */
6
23
  export type LoadValidatedConfigResult = {
7
24
  status: 'invalid';
25
+ configFilePath: string;
26
+ problem: ConfigProblem;
8
27
  } | {
9
28
  status: 'missing';
29
+ configFilePath: string;
10
30
  } | {
11
31
  status: 'ok';
12
32
  config: ReleaseKitConfig;
33
+ configFilePath: string;
34
+ warnings: string[];
13
35
  };
14
36
  /**
15
- * Load `.config/release-kit.config.ts` and validate it against the shared schema,
16
- * reporting load failures and validation errors to stderr.
37
+ * Loads the consumer config file and validates it against the shared schema, returning the outcome.
38
+ *
39
+ * Writes nothing to any stream: the caller picks the sink, which is what lets a command that must stay silent
40
+ * reuse this loader. `reportConfigProblem` and `reportConfigWarnings` render the stderr form.
41
+ *
42
+ * `configPath` defaults to `CONFIG_FILE_PATH`. A named path that does not exist is a load failure, so it reports
43
+ * as `invalid` rather than `missing`.
44
+ */
45
+ export declare function loadValidatedConfig(configPath?: string): Promise<LoadValidatedConfigResult>;
46
+ /**
47
+ * Loads and validates the config, reporting the problem and exiting 1 when it is unusable, so that a command
48
+ * whose later work may never reach a loader still fails on a config that does.
49
+ *
50
+ * An absent default config returns cleanly, which is what keeps a repo that declares no config a supported state.
51
+ * Reports problems but never warnings: the command's own later load stays the single place that emits those, so
52
+ * a warning is not printed twice.
53
+ *
54
+ * The second load costs nothing: `import()` caches by URL and `validateConfig` is pure.
17
55
  */
18
- export declare function loadValidatedConfig(): Promise<LoadValidatedConfigResult>;
56
+ export declare function assertConfigUsable(stderrStyle: OutputStyle, configPath?: string): Promise<void>;
57
+ /** Writes a config problem to stderr in the form every CLI command uses. */
58
+ export declare function reportConfigProblem(problem: ConfigProblem, stderrStyle: OutputStyle): void;
59
+ /** Writes validation warnings to stderr as indented status lines. */
60
+ export declare function reportConfigWarnings(warnings: readonly string[], stderrStyle: OutputStyle): void;
@@ -1,27 +1,44 @@
1
- import { reportError } from '@williamthorsen/nmr-core';
1
+ import { formatStatusLine, printError, reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
- import { loadConfig } from "./loadConfig.js";
3
+ import { CONFIG_FILE_PATH, loadConfig } from "./loadConfig.js";
4
4
  import { validateConfig } from "./validateConfig.js";
5
- export async function loadValidatedConfig() {
5
+ export async function loadValidatedConfig(configPath) {
6
+ const configFilePath = configPath ?? CONFIG_FILE_PATH;
6
7
  let raw;
7
8
  try {
8
- raw = await loadConfig();
9
+ raw = await loadConfig(configPath);
9
10
  }
10
11
  catch (error) {
11
- const message = describeError(error);
12
- reportError(`Failed to load config: ${message}`);
13
- return { status: 'invalid' };
12
+ return { status: 'invalid', configFilePath, problem: { kind: 'load', message: describeError(error) } };
14
13
  }
15
14
  if (raw === undefined) {
16
- return { status: 'missing' };
15
+ return { status: 'missing', configFilePath };
17
16
  }
18
- const { config, errors } = validateConfig(raw);
17
+ const { config, errors, warnings } = validateConfig(raw);
19
18
  if (errors.length > 0) {
20
- process.stderr.write('Invalid config:\n');
21
- for (const err of errors) {
22
- process.stderr.write(` ❌ ${err}\n`);
23
- }
24
- return { status: 'invalid' };
19
+ return { status: 'invalid', configFilePath, problem: { kind: 'validation', errors } };
20
+ }
21
+ return { status: 'ok', config, configFilePath, warnings };
22
+ }
23
+ export async function assertConfigUsable(stderrStyle, configPath) {
24
+ const result = await loadValidatedConfig(configPath);
25
+ if (result.status === 'invalid') {
26
+ reportConfigProblem(result.problem, stderrStyle);
27
+ process.exit(1);
28
+ }
29
+ }
30
+ export function reportConfigProblem(problem, stderrStyle) {
31
+ if (problem.kind === 'load') {
32
+ reportError(`Failed to load config: ${problem.message}`);
33
+ return;
34
+ }
35
+ process.stderr.write('Invalid config:\n');
36
+ for (const error of problem.errors) {
37
+ printError(error, stderrStyle);
38
+ }
39
+ }
40
+ export function reportConfigWarnings(warnings, stderrStyle) {
41
+ for (const warning of warnings) {
42
+ console.warn(` ${formatStatusLine(stderrStyle, 'warning', warning)}`);
25
43
  }
26
- return { status: 'ok', config };
27
44
  }
@@ -1,3 +1,4 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  import type { ReleaseType } from './types.js';
2
3
  export declare const prepareFlagSchema: {
3
4
  dryRun: {
@@ -29,9 +30,14 @@ export declare const prepareFlagSchema: {
29
30
  long: string;
30
31
  type: "boolean";
31
32
  };
33
+ config: {
34
+ long: string;
35
+ type: "string";
36
+ };
32
37
  };
33
38
  /** Parses CLI arguments into structured options. Prints a usage error and exits on invalid input. */
34
39
  export declare function parseArgs(argv: string[]): {
40
+ configPath: string | undefined;
35
41
  dryRun: boolean;
36
42
  force: boolean;
37
43
  noGitChecks: boolean;
@@ -49,4 +55,4 @@ export declare function parseArgs(argv: string[]): {
49
55
  * 4. Delegates to `releasePrepare` or `releasePrepareMono` to compute the release plan.
50
56
  * 5. Applies the plan, runs the format command, and prints the result via `reportPrepare`.
51
57
  */
52
- export declare function prepareCommand(argv: string[]): Promise<void>;
58
+ export declare function prepareCommand(argv: string[], styles: StreamStyles): Promise<void>;
@@ -1,18 +1,20 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
+ import { formatGlyphLine, parseArgsOrExit, reportError, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
5
5
  import { buildDependencyGraph } from "./buildDependencyGraph.js";
6
- import { discoverWorkspaces } from "./discoverWorkspaces.js";
6
+ import { configFlagSchema } from "./configFlagSchema.js";
7
+ import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
7
8
  import { dim } from "./format.js";
8
9
  import { getCommitsSinceTarget } from "./getCommitsSinceTarget.js";
9
- import { loadConfig, mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
10
+ import { RELEASE_GLYPHS } from "./glyphs.js";
11
+ import { mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
12
+ import { loadValidatedConfig, reportConfigProblem, reportConfigWarnings } from "./loadValidatedConfig.js";
10
13
  import { RELEASE_SUMMARY_FILE, RELEASE_TAGS_FILE } from "./releaseFiles.js";
11
14
  import { applyReleasePlan } from "./releasePlan.js";
12
15
  import { releasePrepare } from "./releasePrepare.js";
13
16
  import { releasePrepareMono } from "./releasePrepareMono.js";
14
17
  import { reportPrepare } from "./reportPrepare.js";
15
- import { validateConfig } from "./validateConfig.js";
16
18
  import { validateOnlyExcludesStrandedDependents } from "./validateOnlyExcludesStrandedDependents.js";
17
19
  const VALID_BUMP_TYPES = ['major', 'minor', 'patch'];
18
20
  const CANONICAL_SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
@@ -20,6 +22,7 @@ function isReleaseType(value) {
20
22
  return VALID_BUMP_TYPES.includes(value);
21
23
  }
22
24
  export const prepareFlagSchema = {
25
+ ...configFlagSchema,
23
26
  dryRun: { long: '--dry-run', type: 'boolean' },
24
27
  force: { long: '--force', type: 'boolean' },
25
28
  noGitChecks: {
@@ -63,6 +66,7 @@ export function parseArgs(argv) {
63
66
  process.exit(1);
64
67
  }
65
68
  return {
69
+ configPath: flags.config,
66
70
  dryRun: flags.dryRun,
67
71
  force: flags.force,
68
72
  noGitChecks: flags.noGitChecks,
@@ -72,8 +76,8 @@ export function parseArgs(argv) {
72
76
  withReleaseNotes: flags.withReleaseNotes,
73
77
  };
74
78
  }
75
- export async function prepareCommand(argv) {
76
- const { dryRun, force, noGitChecks, bumpOverride, only, setVersion, withReleaseNotes } = parseArgs(argv);
79
+ export async function prepareCommand(argv, styles) {
80
+ const { configPath, dryRun, force, noGitChecks, bumpOverride, only, setVersion, withReleaseNotes } = parseArgs(argv);
77
81
  const options = {
78
82
  force,
79
83
  ...(bumpOverride !== undefined && { bumpOverride }),
@@ -81,7 +85,7 @@ export async function prepareCommand(argv) {
81
85
  ...(withReleaseNotes && { withReleaseNotes: true }),
82
86
  };
83
87
  if (dryRun) {
84
- console.info('\n🔍 DRY RUN — no files will be modified\n');
88
+ console.info(`\n${formatGlyphLine(RELEASE_GLYPHS, styles.stdout, 'dryRun', 'DRY RUN — no files will be modified')}\n`);
85
89
  }
86
90
  if (!dryRun && !noGitChecks) {
87
91
  try {
@@ -92,23 +96,36 @@ export async function prepareCommand(argv) {
92
96
  process.exit(1);
93
97
  }
94
98
  }
95
- const userConfig = await loadAndValidateConfig();
96
- let discoveredPaths;
99
+ const configResult = await loadValidatedConfig(configPath);
100
+ if (configResult.status === 'invalid') {
101
+ reportConfigProblem(configResult.problem, styles.stderr);
102
+ process.exit(1);
103
+ }
104
+ let userConfig;
105
+ if (configResult.status === 'ok') {
106
+ reportConfigWarnings(configResult.warnings, styles.stderr);
107
+ userConfig = configResult.config;
108
+ }
109
+ let workspace;
97
110
  try {
98
- discoveredPaths = await discoverWorkspaces();
111
+ workspace = discoverWorkspaces();
99
112
  }
100
113
  catch (error) {
101
114
  reportError(`Failed to discover workspaces: ${describeError(error)}`);
102
115
  process.exit(1);
103
116
  }
104
- if (discoveredPaths === undefined) {
105
- runSinglePackageMode(userConfig, options, only, dryRun);
117
+ if (workspace.kind === 'empty') {
118
+ reportError(`No workspace package to release. ${describeEmptyWorkspace(workspace)}`);
119
+ process.exit(1);
120
+ }
121
+ if (workspace.kind === 'single-package') {
122
+ runSinglePackageMode(userConfig, options, only, dryRun, styles.stdout);
106
123
  }
107
124
  else {
108
- runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion, dryRun);
125
+ runMonorepoMode(workspace.packageDirs, userConfig, options, only, setVersion, dryRun, styles.stdout);
109
126
  }
110
127
  }
111
- function runSinglePackageMode(userConfig, options, only, dryRun) {
128
+ function runSinglePackageMode(userConfig, options, only, dryRun, style) {
112
129
  if (only !== undefined) {
113
130
  reportError('--only is only supported for monorepo configurations');
114
131
  process.exit(1);
@@ -119,9 +136,9 @@ function runSinglePackageMode(userConfig, options, only, dryRun) {
119
136
  process.exit(1);
120
137
  }
121
138
  const config = mergeSinglePackageConfig(userConfig);
122
- runAndReport(() => releasePrepare(config, options), dryRun);
139
+ runAndReport(() => releasePrepare(config, options), dryRun, style);
123
140
  }
124
- function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion, dryRun) {
141
+ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion, dryRun, style) {
125
142
  let config;
126
143
  try {
127
144
  const rootPackage = readRootPackageVersion();
@@ -177,34 +194,9 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
177
194
  process.exit(1);
178
195
  }
179
196
  }
180
- runAndReport(() => releasePrepareMono(config, { ...options, ...(only !== undefined && { only }) }), dryRun);
181
- }
182
- async function loadAndValidateConfig() {
183
- let rawConfig;
184
- try {
185
- rawConfig = await loadConfig();
186
- }
187
- catch (error) {
188
- reportError(`Failed to load config: ${describeError(error)}`);
189
- process.exit(1);
190
- }
191
- if (rawConfig === undefined) {
192
- return undefined;
193
- }
194
- const { config, errors, warnings } = validateConfig(rawConfig);
195
- if (errors.length > 0) {
196
- process.stderr.write('Invalid config:\n');
197
- for (const err of errors) {
198
- process.stderr.write(` ❌ ${err}\n`);
199
- }
200
- process.exit(1);
201
- }
202
- for (const warning of warnings) {
203
- console.warn(` ⚠️ ${warning}`);
204
- }
205
- return config;
197
+ runAndReport(() => releasePrepareMono(config, { ...options, ...(only !== undefined && { only }) }), dryRun, style);
206
198
  }
207
- function runAndReport(computePlan, dryRun) {
199
+ function runAndReport(computePlan, dryRun, style) {
208
200
  let plan;
209
201
  try {
210
202
  plan = computePlan();
@@ -215,7 +207,7 @@ function runAndReport(computePlan, dryRun) {
215
207
  process.exit(1);
216
208
  }
217
209
  if (dryRun) {
218
- process.stdout.write(reportPrepare(plan, { applied: false }) + '\n');
210
+ process.stdout.write(reportPrepare(plan, { applied: false, style }) + '\n');
219
211
  reportPlanFiles(plan, true);
220
212
  return;
221
213
  }
@@ -227,7 +219,7 @@ function runAndReport(computePlan, dryRun) {
227
219
  process.exit(1);
228
220
  }
229
221
  const formatError = runFormatCommand(plan.formatCommand);
230
- process.stdout.write(reportPrepare(plan, { applied: true, ...(formatError !== undefined && { formatError }) }) + '\n');
222
+ process.stdout.write(reportPrepare(plan, { applied: true, style, ...(formatError !== undefined && { formatError }) }) + '\n');
231
223
  reportPlanFiles(plan, false);
232
224
  if (formatError !== undefined) {
233
225
  reportError(`The format command failed, but the release is prepared and ${RELEASE_TAGS_FILE} is written. ` +
@@ -1,4 +1,5 @@
1
1
  import { type UndeclaredTagPrefix } from './detectUndeclaredTagPrefixes.js';
2
+ import type { ReleaseKitConfig } from './types.js';
2
3
  /** One workspace's preview row in the tag-prefix preview. */
3
4
  export interface TagPrefixPreviewRow {
4
5
  /** Workspace-relative directory path (e.g., `packages/core`). */
@@ -41,10 +42,12 @@ export interface TagPrefixPreview {
41
42
  /**
42
43
  * Build a structured preview of tag-prefix state for every discovered workspace.
43
44
  *
44
- * Discovers workspaces via `discoverWorkspaces()`, derives each workspace's tag prefix
45
- * via `deriveWorkspaceConfig()`, and records the derivation error on failure rather than aborting.
46
- * Loads `.config/release-kit.config.ts` to surface declared legacy prefixes per workspace,
47
- * scans local git tags for undeclared candidate prefixes via `detectUndeclaredTagPrefixes`,
48
- * and reports collisions across successfully-derived prefixes.
45
+ * Derives each workspace's tag prefix via `deriveWorkspaceConfig()`, recording the derivation error on failure
46
+ * rather than aborting. A repo releasing as a single package previews no row; one declaring patterns that
47
+ * resolve to no package throws, because the manifest is then what needs repairing and a clean table would
48
+ * hide that.
49
+ * Reads the already-validated `config` to surface declared legacy prefixes per workspace, scans local
50
+ * git tags for undeclared candidate prefixes via `detectUndeclaredTagPrefixes`, and reports collisions
51
+ * across successfully-derived prefixes.
49
52
  */
50
- export declare function previewTagPrefixes(): Promise<TagPrefixPreview>;
53
+ export declare function previewTagPrefixes(config?: ReleaseKitConfig): TagPrefixPreview;
@@ -4,38 +4,26 @@ import { GIT_OUTPUT_LIMIT } from '@williamthorsen/nmr-core';
4
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  import { deriveWorkspaceConfig } from "./deriveWorkspaceConfig.js";
6
6
  import { detectUndeclaredTagPrefixes } from "./detectUndeclaredTagPrefixes.js";
7
- import { discoverWorkspaces } from "./discoverWorkspaces.js";
8
- import { loadConfig } from "./loadConfig.js";
9
- import { validateConfig } from "./validateConfig.js";
10
- export async function previewTagPrefixes() {
11
- const workspacePaths = (await discoverWorkspaces()) ?? [];
12
- const userConfig = await loadUserConfig();
13
- const overridesByDir = buildOverrideMap(userConfig);
7
+ import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
8
+ export function previewTagPrefixes(config) {
9
+ const workspace = discoverWorkspaces();
10
+ if (workspace.kind === 'empty') {
11
+ throw new Error(`No workspace package to preview. ${describeEmptyWorkspace(workspace)}`);
12
+ }
13
+ const workspacePaths = workspace.kind === 'packages' ? workspace.packageDirs : [];
14
+ const overridesByDir = buildOverrideMap(config);
14
15
  const workspaces = Array.from(workspacePaths, (workspacePath) => buildPreviewRow(workspacePath, overridesByDir));
15
- const retiredPackages = buildRetiredPreviewEntries(userConfig?.retiredPackages ?? []);
16
+ const retiredPackages = buildRetiredPreviewEntries(config?.retiredPackages ?? []);
16
17
  const collisions = detectCollisions(workspaces);
17
18
  const knownPrefixes = collectKnownPrefixes(workspaces, retiredPackages);
18
19
  const undeclaredCandidates = detectUndeclaredTagPrefixes(knownPrefixes);
19
20
  return { workspaces, collisions, undeclaredCandidates, retiredPackages };
20
21
  }
21
- async function loadUserConfig() {
22
- let raw;
23
- try {
24
- raw = await loadConfig();
25
- }
26
- catch {
27
- return undefined;
28
- }
29
- if (raw === undefined)
30
- return undefined;
31
- const { config, errors } = validateConfig(raw);
32
- return errors.length === 0 ? config : undefined;
33
- }
34
- function buildOverrideMap(userConfig) {
22
+ function buildOverrideMap(config) {
35
23
  const map = new Map();
36
- if (userConfig?.workspaces === undefined)
24
+ if (config?.workspaces === undefined)
37
25
  return map;
38
- for (const entry of userConfig.workspaces) {
26
+ for (const entry of config.workspaces) {
39
27
  if (entry.legacyIdentities !== undefined) {
40
28
  map.set(entry.dir, entry.legacyIdentities);
41
29
  }
@@ -1,5 +1,6 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  /**
2
- * Orchestrate the CLI `publish` command: parse flags, discover workspaces, resolve tags from HEAD,
3
- * detect the package manager, validate `--tags`, and publish each tag with inject/restore lifecycle.
3
+ * Orchestrate the CLI `publish` command: parse flags, open a named config, discover workspaces, resolve tags
4
+ * from HEAD, detect the package manager, validate `--tags`, and publish each tag with inject/restore lifecycle.
4
5
  */
5
- export declare function publishCommand(argv: string[]): Promise<void>;
6
+ export declare function publishCommand(argv: string[], styles: StreamStyles): Promise<void>;
@@ -3,22 +3,26 @@ import { join } from 'node:path';
3
3
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
4
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
6
+ import { configFlagSchema } from "./configFlagSchema.js";
6
7
  import { detectPackageManager } from "./detectPackageManager.js";
7
8
  import { formatPrivateSkip } from "./formatPrivateSkip.js";
8
9
  import { injectReleaseNotesIntoReadme, resolveReadmePath } from "./injectReleaseNotesIntoReadme.js";
10
+ import { assertConfigUsable } from "./loadValidatedConfig.js";
9
11
  import { parseRequestedTags } from "./parseRequestedTags.js";
10
12
  import { publishPackage } from "./publish.js";
11
13
  import { resolveCommandTags } from "./resolveCommandTags.js";
12
14
  import { resolveReleaseNotesConfig } from "./resolveReleaseNotesConfig.js";
13
15
  const publishFlagSchema = {
16
+ ...configFlagSchema,
14
17
  dryRun: { long: '--dry-run', type: 'boolean' },
15
18
  noGitChecks: { long: '--no-git-checks', type: 'boolean' },
16
19
  provenance: { long: '--provenance', type: 'boolean' },
17
20
  tags: { long: '--tags', type: 'string' },
18
21
  };
19
- export async function publishCommand(argv) {
22
+ export async function publishCommand(argv, styles) {
20
23
  const parsed = parseArgsOrExit(argv, publishFlagSchema);
21
24
  const { dryRun, noGitChecks, provenance } = parsed.flags;
25
+ await assertConfigUsable(styles.stderr, parsed.flags.config);
22
26
  if (!dryRun && !noGitChecks) {
23
27
  try {
24
28
  assertCleanWorkingTree();
@@ -29,7 +33,7 @@ export async function publishCommand(argv) {
29
33
  }
30
34
  }
31
35
  const requestedTags = parseRequestedTags(parsed.flags.tags);
32
- const resolvedTags = await resolveCommandTags(requestedTags);
36
+ const resolvedTags = resolveCommandTags(requestedTags);
33
37
  if (resolvedTags.length === 0) {
34
38
  return;
35
39
  }
@@ -39,7 +43,9 @@ export async function publishCommand(argv) {
39
43
  return;
40
44
  }
41
45
  const packageManager = detectPackageManager();
42
- const { releaseNotes, changelogJsonOutputPath, sectionOrder } = await resolveReleaseNotesConfig();
46
+ const { releaseNotes, changelogJsonOutputPath, sectionOrder } = await resolveReleaseNotesConfig(styles.stderr, {
47
+ ...(parsed.flags.config !== undefined && { configPath: parsed.flags.config }),
48
+ });
43
49
  const shouldInject = releaseNotes.shouldInjectIntoReadme;
44
50
  console.info(dryRun ? '[dry-run] Would publish:' : 'Publishing:');
45
51
  for (const { tag, workspacePath } of publishableTags) {
@@ -2,4 +2,4 @@
2
2
  * Orchestrate the CLI `push` command: parse flags, resolve tags from HEAD, and push
3
3
  * the release commit and each tag individually.
4
4
  */
5
- export declare function pushCommand(argv: string[]): Promise<void>;
5
+ export declare function pushCommand(argv: string[]): void;
@@ -8,11 +8,11 @@ const pushFlagSchema = {
8
8
  tags: { long: '--tags', type: 'string' },
9
9
  tagsOnly: { long: '--tags-only', type: 'boolean' },
10
10
  };
11
- export async function pushCommand(argv) {
11
+ export function pushCommand(argv) {
12
12
  const parsed = parseArgsOrExit(argv, pushFlagSchema);
13
13
  const { dryRun, tagsOnly } = parsed.flags;
14
14
  const requestedTags = parseRequestedTags(parsed.flags.tags);
15
- const resolvedTags = await resolveCommandTags(requestedTags);
15
+ const resolvedTags = resolveCommandTags(requestedTags);
16
16
  if (resolvedTags.length === 0) {
17
17
  return;
18
18
  }
@@ -1,3 +1,4 @@
1
+ import { type OutputStyle } from '@williamthorsen/nmr-core';
1
2
  import type { PrepareResult } from './types.js';
2
3
  /** How the plan was carried out, which decides the tense the report is rendered in. */
3
4
  export interface ReportPrepareOptions {
@@ -5,6 +6,7 @@ export interface ReportPrepareOptions {
5
6
  applied: boolean;
6
7
  /** The format command's failure message, when it ran and failed. */
7
8
  formatError?: string;
9
+ style: OutputStyle;
8
10
  }
9
11
  /**
10
12
  * Format a release plan into styled terminal output.