@williamthorsen/release-kit 10.9.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 (45) hide show
  1. package/.meta/changelog.json +57 -0
  2. package/.readyup/kits/default.js +1 -1
  3. package/.readyup/kits/npm-auto-publish.js +42 -22
  4. package/.readyup/manifest.json +5 -5
  5. package/CHANGELOG.md +48 -0
  6. package/README.md +37 -18
  7. package/dist/esm/bin/release-kit.js +30 -22
  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.js +6 -2
  12. package/dist/esm/discoverWorkspaces.d.ts +53 -8
  13. package/dist/esm/discoverWorkspaces.js +57 -37
  14. package/dist/esm/help/prepareHelp.d.ts +1 -1
  15. package/dist/esm/help/prepareHelp.js +1 -0
  16. package/dist/esm/loadConfig.d.ts +7 -4
  17. package/dist/esm/loadConfig.js +6 -3
  18. package/dist/esm/loadValidatedConfig.d.ts +46 -5
  19. package/dist/esm/loadValidatedConfig.js +32 -15
  20. package/dist/esm/prepareCommand.d.ts +5 -0
  21. package/dist/esm/prepareCommand.js +26 -35
  22. package/dist/esm/previewTagPrefixes.d.ts +9 -6
  23. package/dist/esm/previewTagPrefixes.js +12 -24
  24. package/dist/esm/publishCommand.d.ts +2 -2
  25. package/dist/esm/publishCommand.js +8 -2
  26. package/dist/esm/pushCommand.d.ts +1 -1
  27. package/dist/esm/pushCommand.js +2 -2
  28. package/dist/esm/reportPrepare.js +8 -4
  29. package/dist/esm/resolveCommandTags.d.ts +3 -2
  30. package/dist/esm/resolveCommandTags.js +10 -6
  31. package/dist/esm/resolveReleaseNotesConfig.d.ts +9 -11
  32. package/dist/esm/resolveReleaseNotesConfig.js +9 -28
  33. package/dist/esm/showTagPrefixesCommand.d.ts +4 -2
  34. package/dist/esm/showTagPrefixesCommand.js +17 -3
  35. package/dist/esm/sync-labels/generateCommand.d.ts +16 -8
  36. package/dist/esm/sync-labels/generateCommand.js +12 -11
  37. package/dist/esm/sync-labels/initCommand.d.ts +9 -4
  38. package/dist/esm/sync-labels/initCommand.js +21 -15
  39. package/dist/esm/sync-labels/retiredConfig.d.ts +4 -2
  40. package/dist/esm/sync-labels/retiredConfig.js +2 -2
  41. package/dist/esm/validateOverridesCommand.d.ts +10 -4
  42. package/dist/esm/validateOverridesCommand.js +23 -31
  43. package/docs/configuration.md +12 -0
  44. package/docs/labels.md +2 -0
  45. package/package.json +5 -6
@@ -1,13 +1,58 @@
1
+ import { type EmptyWorkspaceCause } from '@williamthorsen/nmr-core/workspace';
2
+ export type { EmptyWorkspaceCause } from '@williamthorsen/nmr-core/workspace';
1
3
  /**
2
- * Reads `pnpm-workspace.yaml` and resolves its `packages` globs to discover workspace directories.
4
+ * What release-kit's workspace discovery produced: the repo releases as a single package, it resolves to a
5
+ * set of package directories, or it declares a workspace that resolves to none and the cause says which
6
+ * condition emptied it.
3
7
  *
4
- * Returns an array of workspace-relative directory paths (e.g., `['packages/arrays', 'packages/strings']`).
5
- * Only directories containing a `package.json` are included.
8
+ * Held apart from nmr-core's `WorkspaceResolution`, whose `packageDirs` are absolute, because these are
9
+ * relative to the monorepo root. The two carry incompatible path contracts, and a distinct `kind` for the
10
+ * single-package case keeps either from standing in for the other.
11
+ */
12
+ export type WorkspaceDiscovery = {
13
+ kind: 'empty';
14
+ cause: FailingWorkspaceCause;
15
+ patterns: string[];
16
+ } | {
17
+ kind: 'packages';
18
+ packageDirs: string[];
19
+ patterns: string[];
20
+ } | {
21
+ kind: 'single-package';
22
+ };
23
+ /** A workspace that resolved to no package directory, which every release-kit command treats as a failure. */
24
+ export type EmptyWorkspace = Extract<WorkspaceDiscovery, {
25
+ kind: 'empty';
26
+ }>;
27
+ /**
28
+ * The empty-workspace causes that reach a command as a failure.
29
+ *
30
+ * `no-packages-list` is absent because a manifest declaring nothing releases as a single package, so no
31
+ * command ever composes a message for it.
32
+ */
33
+ export type FailingWorkspaceCause = Exclude<EmptyWorkspaceCause, 'no-packages-list'>;
34
+ /**
35
+ * Returns the sentence naming which of the five conditions left a workspace holding no package directory, and
36
+ * the remedy for that one. Each quotes the `packages` list the manifest declares, apart from the two that
37
+ * reach the matcher with no list to quote.
38
+ *
39
+ * The `package.json` requirement is stated under `no-package` because it is a divergence from pnpm, which
40
+ * recognizes two further manifests, and the reader of a workspace that pnpm resolves has no way to infer it.
41
+ */
42
+ export declare function describeEmptyWorkspace(workspace: EmptyWorkspace): string;
43
+ /**
44
+ * Reads `pnpm-workspace.yaml` at `monorepoRoot` and resolves its `packages` patterns, applying pnpm's
45
+ * semantics — `!`-prefixed exclusions included.
6
46
  *
7
- * Note: The returned paths are full relative paths from the repo root (e.g., `packages/arrays`,
8
- * `libs/core`). These paths are passed directly to `deriveWorkspaceConfig()`, which uses them for all
9
- * file path construction and derives the display name via `path.basename()`.
47
+ * A directory holding no manifest, and one whose manifest declares no `packages` list, both release as a
48
+ * single package: pnpm resolves each to the root package alone, and a workspace file kept for `catalog:`,
49
+ * `overrides:`, or `onlyBuiltDependencies:` is the second of them. Every other empty resolution is a failure,
50
+ * whether the reader declared patterns that matched nothing or a `packages` value the resolver could not read
51
+ * as patterns at all.
10
52
  *
11
- * @returns The discovered workspace paths, or `undefined` if no workspace config is found.
53
+ * `packageDirs` come back relative to `monorepoRoot` as POSIX paths, because that is what every consumer
54
+ * needs: `WorkspaceConfig.paths` feeds `git log -- <paths>`, and `packageFiles` and `changelogPaths` are read
55
+ * from disk relative to the working directory. The workspace root itself relativizes to `.`, the path
56
+ * `deriveWorkspaceConfig` reads a root package's manifest through.
12
57
  */
13
- export declare function discoverWorkspaces(): Promise<string[] | undefined>;
58
+ export declare function discoverWorkspaces(monorepoRoot?: string): WorkspaceDiscovery;
@@ -1,42 +1,62 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { describeError } from '@williamthorsen/toolbelt.errors';
4
- import { glob } from 'glob';
5
- import { parse } from 'yaml';
6
- import { isRecord } from "./typeGuards.js";
7
- export async function discoverWorkspaces() {
8
- const workspaceFile = 'pnpm-workspace.yaml';
9
- if (!existsSync(workspaceFile)) {
10
- return undefined;
11
- }
12
- let content;
13
- try {
14
- content = readFileSync(workspaceFile, 'utf8');
15
- }
16
- catch (error) {
17
- console.warn(`Warning: Failed to read ${workspaceFile}: ${describeError(error)}`);
18
- return undefined;
19
- }
20
- const parsed = parse(content);
21
- if (!isRecord(parsed)) {
22
- return undefined;
1
+ import path from 'node:path';
2
+ import { resolveWorkspace } from '@williamthorsen/nmr-core/workspace';
3
+ export function describeEmptyWorkspace(workspace) {
4
+ const declaredClause = describeDeclaredPatterns(workspace.patterns);
5
+ switch (workspace.cause) {
6
+ case 'all-excluded':
7
+ return (`pnpm-workspace.yaml ${declaredClause}, whose \`!\` entries exclude every directory matched by the ` +
8
+ 'positive patterns. Drop or narrow the exclusion.');
9
+ case 'no-package':
10
+ return (`pnpm-workspace.yaml ${declaredClause}, and the matcher found no directory holding a \`package.json\`. ` +
11
+ 'Add one to the directory that should be a package, or declare a pattern reaching a directory that ' +
12
+ 'holds one.');
13
+ case 'no-pattern':
14
+ return (`pnpm-workspace.yaml ${declaredClause}, so no pattern reaches the matcher. Declare a positive pattern ` +
15
+ 'such as `packages/*`, and quote any `!` entry, which YAML reads as a tag rather than a string where ' +
16
+ 'it stands bare.');
17
+ case 'unreadable-manifest':
18
+ return ('pnpm-workspace.yaml holds no valid YAML, so nothing it declares reaches the matcher. Repair the ' +
19
+ 'syntax error and run the command again. An unterminated quoted string and a mis-indented entry are ' +
20
+ 'the usual ones.');
21
+ case 'unreadable-packages':
22
+ return ('pnpm-workspace.yaml declares a `packages` value that is not a list of strings, so nothing it ' +
23
+ 'declares reaches the matcher. Make it a list whose every entry is a quoted pattern; a bare value ' +
24
+ 'and an entry YAML read as a number or a map are the usual ones.');
25
+ default: {
26
+ const unhandledCause = workspace.cause;
27
+ throw new Error(`Unhandled empty-workspace cause: ${String(unhandledCause)}`);
28
+ }
23
29
  }
24
- const packagesField = parsed['packages'];
25
- if (!Array.isArray(packagesField)) {
26
- return undefined;
30
+ }
31
+ export function discoverWorkspaces(monorepoRoot = process.cwd()) {
32
+ const resolution = resolveWorkspace(monorepoRoot);
33
+ if (resolution.kind === 'not-a-workspace') {
34
+ return { kind: 'single-package' };
27
35
  }
28
- const patterns = packagesField.filter((p) => typeof p === 'string');
29
- if (patterns.length === 0) {
30
- return undefined;
36
+ if (resolution.kind === 'empty') {
37
+ const { cause, patterns } = resolution;
38
+ return cause === 'no-packages-list' ? { kind: 'single-package' } : { cause, kind: 'empty', patterns };
31
39
  }
32
- const directories = [];
33
- for (const pattern of patterns) {
34
- const matches = await glob(pattern, { posix: true });
35
- for (const match of matches) {
36
- if (existsSync(join(match, 'package.json'))) {
37
- directories.push(match);
38
- }
39
- }
40
+ return {
41
+ ...resolution,
42
+ packageDirs: resolution.packageDirs.map((packageDir) => relativizeToPosix(monorepoRoot, packageDir)),
43
+ };
44
+ }
45
+ function describeDeclaredPatterns(patterns) {
46
+ const quotablePatterns = patterns.filter((pattern) => pattern.trim() !== '');
47
+ const emptiedCount = patterns.length - quotablePatterns.length;
48
+ if (emptiedCount === 0) {
49
+ return `declares ${renderQuotedList(patterns)}`;
40
50
  }
41
- return directories.length > 0 ? [...directories].sort() : undefined;
51
+ const emptiedClause = `${String(emptiedCount)} ${emptiedCount === 1 ? 'entry' : 'entries'} that YAML left empty`;
52
+ return quotablePatterns.length === 0
53
+ ? `declares ${emptiedClause}`
54
+ : `declares ${renderQuotedList(quotablePatterns)}, beside ${emptiedClause}`;
55
+ }
56
+ function relativizeToPosix(monorepoRoot, packageDir) {
57
+ const relativeDir = path.relative(monorepoRoot, packageDir);
58
+ return relativeDir === '' ? '.' : relativeDir.split(path.sep).join('/');
59
+ }
60
+ function renderQuotedList(patterns) {
61
+ return patterns.map((pattern) => `\`${pattern}\``).join(', ');
42
62
  }
@@ -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
@@ -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,19 +1,60 @@
1
1
  import { type OutputStyle } from '@williamthorsen/nmr-core';
2
2
  import type { ReleaseKitConfig } from './types.js';
3
3
  /**
4
- * Outcome of loading and validating the consumer config file. `missing` is not reported
5
- * 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.
6
22
  */
7
23
  export type LoadValidatedConfigResult = {
8
24
  status: 'invalid';
25
+ configFilePath: string;
26
+ problem: ConfigProblem;
9
27
  } | {
10
28
  status: 'missing';
29
+ configFilePath: string;
11
30
  } | {
12
31
  status: 'ok';
13
32
  config: ReleaseKitConfig;
33
+ configFilePath: string;
34
+ warnings: string[];
14
35
  };
15
36
  /**
16
- * Load `.config/release-kit.config.ts` and validate it against the shared schema,
17
- * 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.
18
55
  */
19
- export declare function loadValidatedConfig(stderrStyle: OutputStyle): 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 { printError, 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(stderrStyle) {
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
- printError(err, stderrStyle);
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
  }
@@ -30,9 +30,14 @@ export declare const prepareFlagSchema: {
30
30
  long: string;
31
31
  type: "boolean";
32
32
  };
33
+ config: {
34
+ long: string;
35
+ type: "string";
36
+ };
33
37
  };
34
38
  /** Parses CLI arguments into structured options. Prints a usage error and exits on invalid input. */
35
39
  export declare function parseArgs(argv: string[]): {
40
+ configPath: string | undefined;
36
41
  dryRun: boolean;
37
42
  force: boolean;
38
43
  noGitChecks: boolean;
@@ -1,19 +1,20 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { formatGlyphLine, formatStatusLine, parseArgsOrExit, printError, 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
10
  import { RELEASE_GLYPHS } from "./glyphs.js";
10
- import { loadConfig, mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
11
+ import { mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
12
+ import { loadValidatedConfig, reportConfigProblem, reportConfigWarnings } from "./loadValidatedConfig.js";
11
13
  import { RELEASE_SUMMARY_FILE, RELEASE_TAGS_FILE } from "./releaseFiles.js";
12
14
  import { applyReleasePlan } from "./releasePlan.js";
13
15
  import { releasePrepare } from "./releasePrepare.js";
14
16
  import { releasePrepareMono } from "./releasePrepareMono.js";
15
17
  import { reportPrepare } from "./reportPrepare.js";
16
- import { validateConfig } from "./validateConfig.js";
17
18
  import { validateOnlyExcludesStrandedDependents } from "./validateOnlyExcludesStrandedDependents.js";
18
19
  const VALID_BUMP_TYPES = ['major', 'minor', 'patch'];
19
20
  const CANONICAL_SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
@@ -21,6 +22,7 @@ function isReleaseType(value) {
21
22
  return VALID_BUMP_TYPES.includes(value);
22
23
  }
23
24
  export const prepareFlagSchema = {
25
+ ...configFlagSchema,
24
26
  dryRun: { long: '--dry-run', type: 'boolean' },
25
27
  force: { long: '--force', type: 'boolean' },
26
28
  noGitChecks: {
@@ -64,6 +66,7 @@ export function parseArgs(argv) {
64
66
  process.exit(1);
65
67
  }
66
68
  return {
69
+ configPath: flags.config,
67
70
  dryRun: flags.dryRun,
68
71
  force: flags.force,
69
72
  noGitChecks: flags.noGitChecks,
@@ -74,7 +77,7 @@ export function parseArgs(argv) {
74
77
  };
75
78
  }
76
79
  export async function prepareCommand(argv, styles) {
77
- const { dryRun, force, noGitChecks, bumpOverride, only, setVersion, withReleaseNotes } = parseArgs(argv);
80
+ const { configPath, dryRun, force, noGitChecks, bumpOverride, only, setVersion, withReleaseNotes } = parseArgs(argv);
78
81
  const options = {
79
82
  force,
80
83
  ...(bumpOverride !== undefined && { bumpOverride }),
@@ -93,20 +96,33 @@ export async function prepareCommand(argv, styles) {
93
96
  process.exit(1);
94
97
  }
95
98
  }
96
- const userConfig = await loadAndValidateConfig(styles.stderr);
97
- 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;
98
110
  try {
99
- discoveredPaths = await discoverWorkspaces();
111
+ workspace = discoverWorkspaces();
100
112
  }
101
113
  catch (error) {
102
114
  reportError(`Failed to discover workspaces: ${describeError(error)}`);
103
115
  process.exit(1);
104
116
  }
105
- if (discoveredPaths === undefined) {
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') {
106
122
  runSinglePackageMode(userConfig, options, only, dryRun, styles.stdout);
107
123
  }
108
124
  else {
109
- runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion, dryRun, styles.stdout);
125
+ runMonorepoMode(workspace.packageDirs, userConfig, options, only, setVersion, dryRun, styles.stdout);
110
126
  }
111
127
  }
112
128
  function runSinglePackageMode(userConfig, options, only, dryRun, style) {
@@ -180,31 +196,6 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
180
196
  }
181
197
  runAndReport(() => releasePrepareMono(config, { ...options, ...(only !== undefined && { only }) }), dryRun, style);
182
198
  }
183
- async function loadAndValidateConfig(stderrStyle) {
184
- let rawConfig;
185
- try {
186
- rawConfig = await loadConfig();
187
- }
188
- catch (error) {
189
- reportError(`Failed to load config: ${describeError(error)}`);
190
- process.exit(1);
191
- }
192
- if (rawConfig === undefined) {
193
- return undefined;
194
- }
195
- const { config, errors, warnings } = validateConfig(rawConfig);
196
- if (errors.length > 0) {
197
- process.stderr.write('Invalid config:\n');
198
- for (const err of errors) {
199
- printError(err, stderrStyle);
200
- }
201
- process.exit(1);
202
- }
203
- for (const warning of warnings) {
204
- console.warn(` ${formatStatusLine(stderrStyle, 'warning', warning)}`);
205
- }
206
- return config;
207
- }
208
199
  function runAndReport(computePlan, dryRun, style) {
209
200
  let plan;
210
201
  try {
@@ -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,6 +1,6 @@
1
1
  import { type StreamStyles } from '@williamthorsen/nmr-core';
2
2
  /**
3
- * Orchestrate the CLI `publish` command: parse flags, discover workspaces, resolve tags from HEAD,
4
- * 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.
5
5
  */
6
6
  export declare function publishCommand(argv: string[], styles: StreamStyles): Promise<void>;
@@ -3,14 +3,17 @@ 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' },
@@ -19,6 +22,7 @@ const publishFlagSchema = {
19
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, styles) {
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, styles) {
39
43
  return;
40
44
  }
41
45
  const packageManager = detectPackageManager();
42
- const { releaseNotes, changelogJsonOutputPath, sectionOrder } = await resolveReleaseNotesConfig(styles.stderr);
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;