@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
@@ -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,8 +1,9 @@
1
- import { formatGlyphLine, formatStatusLine, measureGlyphColumn, STATUS_GLYPHS, } from '@williamthorsen/nmr-core';
1
+ import { formatGlyphLine, formatStatusLine, measureGlyphColumn, STATUS_GLYPHS, truncateToWidth, } from '@williamthorsen/nmr-core';
2
2
  import { bold, dim, sectionHeader } from "./format.js";
3
3
  import { RELEASE_GLYPHS } from "./glyphs.js";
4
4
  import { GIT_CLIFF_NPX_ARGS } from "./runGitCliff.js";
5
5
  const CLIFF_DRY_RUN_COMMAND = `npx ${GIT_CLIFF_NPX_ARGS.join(' ')} ...`;
6
+ const SUBJECT_COLUMN_BUDGET = 72;
6
7
  export function reportPrepare(plan, options) {
7
8
  const isMultiWorkspace = plan.workspaces.some((w) => w.name !== undefined) || plan.project !== undefined;
8
9
  if (isMultiWorkspace) {
@@ -129,7 +130,7 @@ function formatProjectUnparseable(lines, project, style) {
129
130
  lines.push(` ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`)}`);
130
131
  for (const commit of unparseable) {
131
132
  const shortHash = commit.hash.slice(0, 7);
132
- const truncatedMessage = commit.message.length > 72 ? `${commit.message.slice(0, 69)}...` : commit.message;
133
+ const truncatedMessage = truncateSubject(commit.message);
133
134
  lines.push(` · ${shortHash} ${truncatedMessage}`);
134
135
  }
135
136
  }
@@ -231,7 +232,7 @@ function formatUnparseableWarning(lines, workspace, style, indent = '') {
231
232
  lines.push(`${indent} ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`)}`);
232
233
  for (const commit of unparseable) {
233
234
  const shortHash = commit.hash.slice(0, 7);
234
- const truncatedMessage = commit.message.length > 72 ? `${commit.message.slice(0, 69)}...` : commit.message;
235
+ const truncatedMessage = truncateSubject(commit.message);
235
236
  lines.push(`${indent} · ${shortHash} ${truncatedMessage}`);
236
237
  }
237
238
  }
@@ -244,7 +245,7 @@ function formatPolicyViolations(lines, violations, style, indent = '') {
244
245
  for (const violation of violations) {
245
246
  const shortHash = violation.commitHash.slice(0, 7);
246
247
  const subject = violation.commitSubject;
247
- const truncatedSubject = subject.length > 72 ? `${subject.slice(0, 69)}...` : subject;
248
+ const truncatedSubject = truncateSubject(subject);
248
249
  lines.push(`${indent} · ${shortHash} '${truncatedSubject}' — type '${violation.type}' at ${violation.surface} surface`);
249
250
  }
250
251
  }
@@ -280,3 +281,6 @@ function formatFormatCommand(lines, result, options) {
280
281
  const messageIndent = ' '.repeat(2 + measureGlyphColumn(STATUS_GLYPHS[options.style]) + 1);
281
282
  lines.push(`\n ${formatStatusLine(options.style, 'warning', `Format command failed: ${result.formatCommand.command}`)}`, `${messageIndent}${options.formatError}`);
282
283
  }
284
+ function truncateSubject(subject) {
285
+ return truncateToWidth(subject, { width: SUBJECT_COLUMN_BUDGET });
286
+ }
@@ -3,7 +3,8 @@ import { type ResolvedTag } from './resolveReleaseTags.js';
3
3
  * Discover workspaces, resolve release tags from HEAD, validate `--tags` names against the
4
4
  * full resolved tag names (e.g., `nmr-core-v1.3.0`), and return the filtered tag list.
5
5
  * Works in both single-package and monorepo modes. Exits with an error message on any validation
6
- * failure — including `deriveWorkspaceConfig()` throws for workspaces missing a `package.json` `name` field.
6
+ * failure — including `deriveWorkspaceConfig()` throws for workspaces missing a `package.json` `name` field,
7
+ * and a workspace declaring patterns that resolve to no package, which is not single-package mode.
7
8
  *
8
9
  * In both modes `deriveWorkspaceConfig` is called so `WorkspaceConfig.isPublishable` (read
9
10
  * from `package.json#private`) propagates onto each `ResolvedTag`. Single-package mode now
@@ -11,4 +12,4 @@ import { type ResolvedTag } from './resolveReleaseTags.js';
11
12
  * single-package mode skipping the derive step need a `name` field on `./package.json`,
12
13
  * but in practice every npm/pnpm package already has one.
13
14
  */
14
- export declare function resolveCommandTags(tags: string[] | undefined): Promise<ResolvedTag[]>;
15
+ export declare function resolveCommandTags(tags: string[] | undefined): ResolvedTag[];
@@ -1,25 +1,29 @@
1
1
  import { reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { deriveWorkspaceConfig } from "./deriveWorkspaceConfig.js";
4
- import { discoverWorkspaces } from "./discoverWorkspaces.js";
4
+ import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
5
5
  import { resolveReleaseTags } from "./resolveReleaseTags.js";
6
- export async function resolveCommandTags(tags) {
7
- let discoveredPaths;
6
+ export function resolveCommandTags(tags) {
7
+ let workspace;
8
8
  try {
9
- discoveredPaths = await discoverWorkspaces();
9
+ workspace = discoverWorkspaces();
10
10
  }
11
11
  catch (error) {
12
12
  reportError(`Failed to discover workspaces: ${describeError(error)}`);
13
13
  process.exit(1);
14
14
  }
15
+ if (workspace.kind === 'empty') {
16
+ reportError(`No workspace package to tag. ${describeEmptyWorkspace(workspace)}`);
17
+ process.exit(1);
18
+ }
15
19
  let workspaces;
16
20
  let singleWorkspace;
17
21
  try {
18
- if (discoveredPaths === undefined) {
22
+ if (workspace.kind === 'single-package') {
19
23
  singleWorkspace = deriveWorkspaceConfig('.');
20
24
  }
21
25
  else {
22
- workspaces = discoveredPaths.map((workspacePath) => deriveWorkspaceConfig(workspacePath));
26
+ workspaces = workspace.packageDirs.map((workspacePath) => deriveWorkspaceConfig(workspacePath));
23
27
  }
24
28
  }
25
29
  catch (error) {
@@ -1,4 +1,4 @@
1
- import { type OutputStyle } from '@williamthorsen/nmr-core';
1
+ import type { OutputStyle } from '@williamthorsen/nmr-core';
2
2
  import type { ReleaseNotesConfig } from './types.js';
3
3
  export interface ResolvedReleaseNotesConfig {
4
4
  releaseNotes: ReleaseNotesConfig;
@@ -7,19 +7,17 @@ export interface ResolvedReleaseNotesConfig {
7
7
  sectionOrder: string[];
8
8
  }
9
9
  export interface ResolveReleaseNotesConfigOptions {
10
- /**
11
- * When `true`, a `loadConfig()` rejection causes `process.exit(1)` rather than a fallback to
12
- * defaults. Use from CLI commands whose entire behavior depends on the resolved config (e.g.
13
- * `create-github-release`), so a corrupt or unreadable config cannot silently send the command
14
- * to the wrong changelog path.
15
- */
16
- strictLoad?: boolean;
10
+ /** Config file to read, relative to the working directory. Defaults to `CONFIG_FILE_PATH`. */
11
+ configPath?: string;
17
12
  }
18
13
  /**
19
- * Load and validate the release-kit config.
14
+ * Loads and validates the release-kit config, resolving the release-notes settings it carries.
20
15
  *
21
- * By default, falls back to defaults when `loadConfig()` rejects (legacy publish behavior).
22
- * When `strictLoad` is `true`, a load failure prints an error and calls `process.exit(1)`.
16
+ * An unusable config reports to stderr and calls `process.exit(1)`, whether the file failed to load or failed
17
+ * validation, and whether it was named by the caller or the default one. Both callers are CLI commands whose
18
+ * whole behavior depends on the resolved config, so neither could proceed on derived defaults without silently
19
+ * dropping a configured `releaseNotes.shouldInjectIntoReadme`, `changelogJson.outputPath`, or `workTypes`.
20
+ * An absent default config is a supported state and resolves to the defaults.
23
21
  */
24
22
  export declare function resolveReleaseNotesConfig(stderrStyle: OutputStyle, options?: ResolveReleaseNotesConfigOptions): Promise<ResolvedReleaseNotesConfig>;
25
23
  /** Extract section headers in declaration order from a merged workTypes record. */
@@ -1,40 +1,21 @@
1
- import { formatStatusLine, printError, reportError } from '@williamthorsen/nmr-core';
2
- import { describeError } from '@williamthorsen/toolbelt.errors';
3
1
  import { DEFAULT_CHANGELOG_JSON_CONFIG, DEFAULT_RELEASE_NOTES_CONFIG } from "./defaults.js";
4
- import { loadConfig, resolveWorkTypes } from "./loadConfig.js";
5
- import { validateConfig } from "./validateConfig.js";
2
+ import { resolveWorkTypes } from "./loadConfig.js";
3
+ import { loadValidatedConfig, reportConfigProblem, reportConfigWarnings } from "./loadValidatedConfig.js";
6
4
  export async function resolveReleaseNotesConfig(stderrStyle, options = {}) {
7
- const { strictLoad = false } = options;
8
- let rawConfig;
9
- try {
10
- rawConfig = await loadConfig();
11
- }
12
- catch (error) {
13
- const message = describeError(error);
14
- if (strictLoad) {
15
- reportError(`Failed to load config: ${message}`);
16
- process.exit(1);
17
- }
18
- console.warn(`Warning: failed to load config; using defaults: ${message}`);
5
+ const result = await loadValidatedConfig(options.configPath);
6
+ if (result.status === 'invalid') {
7
+ reportConfigProblem(result.problem, stderrStyle);
8
+ process.exit(1);
19
9
  }
20
- if (rawConfig === undefined) {
10
+ if (result.status === 'missing') {
21
11
  return {
22
12
  releaseNotes: { ...DEFAULT_RELEASE_NOTES_CONFIG },
23
13
  changelogJsonOutputPath: DEFAULT_CHANGELOG_JSON_CONFIG.outputPath,
24
14
  sectionOrder: deriveSectionOrder(resolveWorkTypes()),
25
15
  };
26
16
  }
27
- const { config, errors, warnings } = validateConfig(rawConfig);
28
- if (errors.length > 0) {
29
- process.stderr.write('Invalid config:\n');
30
- for (const err of errors) {
31
- printError(err, stderrStyle);
32
- }
33
- process.exit(1);
34
- }
35
- for (const warning of warnings) {
36
- console.warn(` ${formatStatusLine(stderrStyle, 'warning', warning)}`);
37
- }
17
+ reportConfigWarnings(result.warnings, stderrStyle);
18
+ const { config } = result;
38
19
  return {
39
20
  releaseNotes: {
40
21
  shouldInjectIntoReadme: config.releaseNotes?.shouldInjectIntoReadme ?? DEFAULT_RELEASE_NOTES_CONFIG.shouldInjectIntoReadme,
@@ -6,8 +6,10 @@ import { type StreamStyles } from '@williamthorsen/nmr-core';
6
6
  * entries, followed by an "Undeclared tag prefixes" section when candidate-shaped tags
7
7
  * exist outside the known set. Exits `0` on full derivation success and no collisions;
8
8
  * `1` on any derivation failure or collision. Undeclared candidates do not affect the
9
- * exit code.
9
+ * exit code. A config that exists and either fails to load or fails validation reports to stderr and
10
+ * exits `1` in both repo modes, rather than falling back to a default-config preview that would
11
+ * understate the declared legacy prefixes. An absent default config previews against derived defaults.
10
12
  *
11
13
  * @returns The exit code the caller should use.
12
14
  */
13
- export declare function showTagPrefixesCommand(styles: StreamStyles): Promise<number>;
15
+ export declare function showTagPrefixesCommand(styles: StreamStyles, configPath?: string): Promise<number>;
@@ -1,12 +1,26 @@
1
- import { formatStatusLine } from '@williamthorsen/nmr-core';
1
+ import { formatStatusLine, reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
2
3
  import { detectRepoType } from "./init/detectRepoType.js";
4
+ import { loadValidatedConfig, reportConfigProblem } from "./loadValidatedConfig.js";
3
5
  import { previewTagPrefixes } from "./previewTagPrefixes.js";
4
- export async function showTagPrefixesCommand(styles) {
6
+ export async function showTagPrefixesCommand(styles, configPath) {
7
+ const result = await loadValidatedConfig(configPath);
8
+ if (result.status === 'invalid') {
9
+ reportConfigProblem(result.problem, styles.stderr);
10
+ return 1;
11
+ }
5
12
  if (detectRepoType() === 'single-package') {
6
13
  process.stdout.write(renderSinglePackage());
7
14
  return 0;
8
15
  }
9
- const preview = await previewTagPrefixes();
16
+ let preview;
17
+ try {
18
+ preview = previewTagPrefixes(result.status === 'ok' ? result.config : undefined);
19
+ }
20
+ catch (error) {
21
+ reportError(describeError(error));
22
+ return 1;
23
+ }
10
24
  process.stdout.write(renderMonorepo(preview, styles.stdout));
11
25
  return computeExitCode(preview);
12
26
  }
@@ -3,27 +3,35 @@ import type { RepoLabelsConfig } from '../types.js';
3
3
  import type { LabelDefinition } from './types.js';
4
4
  /** Output path for the generated labels file. */
5
5
  export declare const LABELS_OUTPUT_PATH = ".github/labels.yaml";
6
- /** Formats resolved labels as YAML with generated header including preset hashes. */
7
- export declare function formatLabelsYaml(labels: LabelDefinition[], presetHashes: Map<string, string>): string;
8
6
  /**
9
- * Loads `.config/release-kit.config.ts` and returns its `repoLabels` block.
7
+ * Formats resolved labels as YAML with generated header including preset hashes.
8
+ *
9
+ * `configFilePath` is recorded in the header so that a reader of the generated file can find the config it came
10
+ * from; it defaults to `CONFIG_FILE_PATH`.
11
+ */
12
+ export declare function formatLabelsYaml(labels: LabelDefinition[], presetHashes: Map<string, string>, configFilePath?: string): string;
13
+ /**
14
+ * Loads the config file and returns its `repoLabels` block.
10
15
  *
11
16
  * Reports an actionable error and returns `undefined` when the config file is missing,
12
- * fails to load or validate, or carries no `repoLabels` block.
17
+ * fails to load or validate, or carries no `repoLabels` block. Every message names the file
18
+ * this run read, so that `--config` never points the reader at the default path.
13
19
  */
14
- export declare function loadRepoLabelsConfig(stderrStyle: OutputStyle): Promise<RepoLabelsConfig | undefined>;
20
+ export declare function loadRepoLabelsConfig(stderrStyle: OutputStyle, configPath?: string): Promise<RepoLabelsConfig | undefined>;
15
21
  /** Options for the `sync-labels generate` subcommand. */
16
22
  interface GenerateOptions {
17
23
  /** Compare the regenerated content against the committed file instead of writing. */
18
24
  check?: boolean;
25
+ /** Config file to read, relative to the working directory. Defaults to `CONFIG_FILE_PATH`. */
26
+ configPath?: string;
19
27
  styles: StreamStyles;
20
28
  }
21
29
  /**
22
30
  * Runs the `sync-labels generate` command.
23
31
  *
24
- * Loads the `repoLabels` block from `.config/release-kit.config.ts`, resolves presets and label adjustments,
25
- * and writes `.github/labels.yaml` -- or, with `check`, regenerates in memory and reports whether the committed file
32
+ * Loads the `repoLabels` block from the config file, resolves presets and label adjustments, and writes
33
+ * `.github/labels.yaml` -- or, with `check`, regenerates in memory and reports whether the committed file
26
34
  * is stale, writing nothing. Returns 0 on success, 1 on failure.
27
35
  */
28
- export declare function generateCommand({ check, styles }: GenerateOptions): Promise<number>;
36
+ export declare function generateCommand({ check, configPath, styles }: GenerateOptions): Promise<number>;
29
37
  export {};
@@ -4,13 +4,13 @@ import { reportError } from '@williamthorsen/nmr-core';
4
4
  import { describeError } from '@williamthorsen/toolbelt.errors';
5
5
  import { stringify } from 'yaml';
6
6
  import { CONFIG_FILE_PATH } from "../loadConfig.js";
7
- import { loadValidatedConfig } from "../loadValidatedConfig.js";
7
+ import { loadValidatedConfig, reportConfigProblem } from "../loadValidatedConfig.js";
8
8
  import { hashPresetFile } from "./presets.js";
9
9
  import { resolveLabels } from "./resolveLabels.js";
10
10
  import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
11
11
  export const LABELS_OUTPUT_PATH = '.github/labels.yaml';
12
- export function formatLabelsYaml(labels, presetHashes) {
13
- const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${CONFIG_FILE_PATH}`];
12
+ export function formatLabelsYaml(labels, presetHashes, configFilePath = CONFIG_FILE_PATH) {
13
+ const headerLines = ['# Generated by release-kit sync-labels — do not edit.', `# Source: ${configFilePath}`];
14
14
  const sortedPresetHashes = [...presetHashes].sort(([a], [b]) => a.localeCompare(b));
15
15
  for (const [name, hash] of sortedPresetHashes) {
16
16
  headerLines.push(`# ${name} preset hash: ${hash}`);
@@ -19,26 +19,27 @@ export function formatLabelsYaml(labels, presetHashes) {
19
19
  const yamlBody = stringify(declared, { singleQuote: true, lineWidth: 0 });
20
20
  return headerLines.join('\n') + '\n' + yamlBody;
21
21
  }
22
- export async function loadRepoLabelsConfig(stderrStyle) {
23
- const result = await loadValidatedConfig(stderrStyle);
22
+ export async function loadRepoLabelsConfig(stderrStyle, configPath) {
23
+ const result = await loadValidatedConfig(configPath);
24
24
  if (result.status === 'missing') {
25
- reportError(`No config file found at ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` first.`);
25
+ reportError(`No config file found at ${result.configFilePath}. Run \`release-kit sync-labels init\` first.`);
26
26
  return undefined;
27
27
  }
28
28
  if (result.status === 'invalid') {
29
+ reportConfigProblem(result.problem, stderrStyle);
29
30
  return undefined;
30
31
  }
31
32
  if (result.config.repoLabels === undefined) {
32
- reportError(`No \`repoLabels\` block found in ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` to seed one.`);
33
+ reportError(`No \`repoLabels\` block found in ${result.configFilePath}. Run \`release-kit sync-labels init\` to seed one.`);
33
34
  return undefined;
34
35
  }
35
36
  return result.config.repoLabels;
36
37
  }
37
- export async function generateCommand({ check = false, styles }) {
38
- if (checkRetiredSyncLabelsConfig()) {
38
+ export async function generateCommand({ check = false, configPath, styles }) {
39
+ if (checkRetiredSyncLabelsConfig(configPath)) {
39
40
  return 1;
40
41
  }
41
- const repoLabels = await loadRepoLabelsConfig(styles.stderr);
42
+ const repoLabels = await loadRepoLabelsConfig(styles.stderr, configPath);
42
43
  if (repoLabels === undefined) {
43
44
  return 1;
44
45
  }
@@ -56,7 +57,7 @@ export async function generateCommand({ check = false, styles }) {
56
57
  for (const presetName of presetNames) {
57
58
  presetHashes.set(presetName, hashPresetFile(presetName));
58
59
  }
59
- const content = formatLabelsYaml(labels, presetHashes);
60
+ const content = formatLabelsYaml(labels, presetHashes, configPath);
60
61
  if (check) {
61
62
  return compareAgainstCommittedFile(content);
62
63
  }
@@ -1,6 +1,8 @@
1
1
  import { type StreamStyles } from '@williamthorsen/nmr-core';
2
2
  /** Options for the `sync-labels init` subcommand. */
3
3
  interface InitOptions {
4
+ /** Config file to read, relative to the working directory. Defaults to `CONFIG_FILE_PATH`. */
5
+ configPath?: string;
4
6
  dryRun: boolean;
5
7
  force: boolean;
6
8
  styles: StreamStyles;
@@ -9,9 +11,12 @@ interface InitOptions {
9
11
  * Run the `sync-labels init` subcommand.
10
12
  *
11
13
  * Discovers workspaces and retired packages, scaffolds the caller workflow, then seeds the
12
- * `repoLabels` block: into a new `.config/release-kit.config.ts` when none exists, or
13
- * printed to stdout for manual paste when the file is already there — an existing,
14
- * hand-authored config is never rewritten. Returns 0 on success, 1 on failure.
14
+ * `repoLabels` block: into a new config file when none exists, or printed to stdout for manual
15
+ * paste when the file is already there — an existing, hand-authored config is never rewritten.
16
+ * Returns 0 on success, 1 on failure.
17
+ *
18
+ * `configPath` names the config to read. Naming one that does not exist fails the command, so the
19
+ * scaffolding branch is reached only under the default path and always writes to `CONFIG_FILE_PATH`.
15
20
  */
16
- export declare function syncLabelsInitCommand({ dryRun, force, styles }: InitOptions): Promise<number>;
21
+ export declare function syncLabelsInitCommand({ configPath, dryRun, force, styles }: InitOptions): Promise<number>;
17
22
  export {};
@@ -1,47 +1,52 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { reportWriteResult, writeFileWithCheck } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
- import { discoverWorkspaces } from "../discoverWorkspaces.js";
4
+ import { describeEmptyWorkspace, discoverWorkspaces } from "../discoverWorkspaces.js";
5
5
  import { CONFIG_FILE_PATH } from "../loadConfig.js";
6
- import { loadValidatedConfig } from "../loadValidatedConfig.js";
6
+ import { loadValidatedConfig, reportConfigProblem } from "../loadValidatedConfig.js";
7
7
  import { generateCommand, LABELS_OUTPUT_PATH } from "./generateCommand.js";
8
8
  import { checkRetiredSyncLabelsConfig } from "./retiredConfig.js";
9
9
  import { buildScopeLabels, renderRepoLabelsBlock, repoLabelsConfigScript, syncLabelsWorkflow } from "./templates.js";
10
10
  const WORKFLOW_PATH = '.github/workflows/sync-labels.yaml';
11
- export async function syncLabelsInitCommand({ dryRun, force, styles }) {
12
- if (checkRetiredSyncLabelsConfig()) {
11
+ export async function syncLabelsInitCommand({ configPath, dryRun, force, styles }) {
12
+ const configFilePath = configPath ?? CONFIG_FILE_PATH;
13
+ if (checkRetiredSyncLabelsConfig(configPath)) {
13
14
  return 1;
14
15
  }
15
16
  if (dryRun) {
16
17
  console.info('[dry-run mode]');
17
18
  }
18
19
  console.info('\n> Discovering workspaces');
19
- let workspacePaths;
20
+ let workspace;
20
21
  try {
21
- workspacePaths = await discoverWorkspaces();
22
+ workspace = discoverWorkspaces();
22
23
  }
23
24
  catch (error) {
24
25
  const message = describeError(error);
25
26
  process.stderr.write(` Failed to discover workspaces: ${message}\n`);
26
27
  return 1;
27
28
  }
28
- if (workspacePaths === undefined) {
29
+ if (workspace.kind === 'empty') {
30
+ process.stderr.write(` No workspace package to label. ${describeEmptyWorkspace(workspace)}\n`);
31
+ return 1;
32
+ }
33
+ if (workspace.kind === 'single-package') {
29
34
  console.info(' No pnpm workspaces found (single-package repo)');
30
35
  }
31
36
  else {
32
- console.info(` Found ${String(workspacePaths.length)} workspaces`);
37
+ console.info(` Found ${String(workspace.packageDirs.length)} workspaces`);
33
38
  }
34
- const configExists = existsSync(CONFIG_FILE_PATH);
35
- const retiredNames = configExists ? await loadRetiredPackageNames(styles) : [];
39
+ const configExists = configPath !== undefined || existsSync(CONFIG_FILE_PATH);
40
+ const retiredNames = configExists ? await loadRetiredPackageNames(styles, configPath) : [];
36
41
  if (retiredNames === undefined) {
37
42
  return 1;
38
43
  }
39
- const scopeLabels = workspacePaths === undefined ? [] : buildScopeLabels(workspacePaths, retiredNames);
44
+ const scopeLabels = workspace.kind === 'single-package' ? [] : buildScopeLabels(workspace.packageDirs, retiredNames);
40
45
  console.info('\n> Scaffolding files');
41
46
  const workflowResult = writeFileWithCheck(WORKFLOW_PATH, syncLabelsWorkflow(), { dryRun, overwrite: force });
42
47
  reportWriteResult(workflowResult, dryRun, styles);
43
48
  if (configExists) {
44
- console.info(`\n> ${CONFIG_FILE_PATH} already exists; add this block to the object passed to defineConfig:\n`);
49
+ console.info(`\n> ${configFilePath} already exists; add this block to the object passed to defineConfig:\n`);
45
50
  console.info(renderRepoLabelsBlock(scopeLabels));
46
51
  if (workflowResult.outcome === 'failed') {
47
52
  process.stderr.write('Failed to scaffold one or more files.\n');
@@ -49,7 +54,7 @@ export async function syncLabelsInitCommand({ dryRun, force, styles }) {
49
54
  }
50
55
  console.info(`
51
56
  > Next steps
52
- 1. Paste the block above into ${CONFIG_FILE_PATH}.
57
+ 1. Paste the block above into ${configFilePath}.
53
58
  2. Run \`release-kit sync-labels generate\` to produce ${LABELS_OUTPUT_PATH}.
54
59
  3. Commit the changes.
55
60
  4. Run \`release-kit sync-labels sync\` to apply labels to your GitHub repo.
@@ -87,9 +92,10 @@ export async function syncLabelsInitCommand({ dryRun, force, styles }) {
87
92
  `);
88
93
  return 0;
89
94
  }
90
- async function loadRetiredPackageNames(styles) {
91
- const result = await loadValidatedConfig(styles.stderr);
95
+ async function loadRetiredPackageNames(styles, configPath) {
96
+ const result = await loadValidatedConfig(configPath);
92
97
  if (result.status === 'invalid') {
98
+ reportConfigProblem(result.problem, styles.stderr);
93
99
  return undefined;
94
100
  }
95
101
  if (result.status === 'missing') {
@@ -5,6 +5,8 @@ export declare const RETIRED_SYNC_LABELS_CONFIG_PATH = ".config/sync-labels.conf
5
5
  *
6
6
  * Returns `true` when the file exists, in which case the caller must abort. The check is
7
7
  * unconditional on config content: file presence alone triggers it, so a half-migrated
8
- * repo cannot silently lose its custom labels.
8
+ * repo cannot silently lose its custom labels. `configPath` names the config to move the labels
9
+ * into; it defaults to `CONFIG_FILE_PATH`, which `sync-labels sync` relies on because it takes
10
+ * no `--config`.
9
11
  */
10
- export declare function checkRetiredSyncLabelsConfig(): boolean;
12
+ export declare function checkRetiredSyncLabelsConfig(configPath?: string): boolean;
@@ -2,10 +2,10 @@ import { existsSync } from 'node:fs';
2
2
  import { reportError } from '@williamthorsen/nmr-core';
3
3
  import { CONFIG_FILE_PATH } from "../loadConfig.js";
4
4
  export const RETIRED_SYNC_LABELS_CONFIG_PATH = '.config/sync-labels.config.ts';
5
- export function checkRetiredSyncLabelsConfig() {
5
+ export function checkRetiredSyncLabelsConfig(configPath = CONFIG_FILE_PATH) {
6
6
  if (!existsSync(RETIRED_SYNC_LABELS_CONFIG_PATH)) {
7
7
  return false;
8
8
  }
9
- reportError(`${RETIRED_SYNC_LABELS_CONFIG_PATH} is no longer read. Move its labels into the \`repoLabels\` block of ${CONFIG_FILE_PATH} (presets move to \`repoLabels.extends\`; each label becomes a \`'name': { color, description }\` entry under \`repoLabels.labels\`), then delete the file.`);
9
+ reportError(`${RETIRED_SYNC_LABELS_CONFIG_PATH} is no longer read. Move its labels into the \`repoLabels\` block of ${configPath} (presets move to \`repoLabels.extends\`; each label becomes a \`'name': { color, description }\` entry under \`repoLabels.labels\`), then delete the file.`);
10
10
  return true;
11
11
  }
@@ -1,5 +1,7 @@
1
1
  import { type OutputStyle, type StreamStyles } from '@williamthorsen/nmr-core';
2
2
  import { type ValidateAllChangelogOverridesInputs, type ValidateAllChangelogOverridesResult } from './changelogOverrides.js';
3
+ import { type WorkspaceDiscovery } from './discoverWorkspaces.js';
4
+ import { type LoadValidatedConfigResult } from './loadValidatedConfig.js';
3
5
  import type { ChangelogEntry, ReleaseConfig } from './types.js';
4
6
  /**
5
7
  * Result of {@link validateOverridesCommand}: tiered exit code paired with a human-readable
@@ -16,8 +18,8 @@ export interface ValidateOverridesCommandResult {
16
18
  }
17
19
  /** Injection seams for unit testing. Production callers leave defaults; tests substitute deterministic fakes. */
18
20
  export interface ValidateOverridesCommandDependencies {
19
- discoverWorkspaces?: () => Promise<string[] | undefined>;
20
- loadConfig?: () => Promise<unknown>;
21
+ discoverWorkspaces?: () => WorkspaceDiscovery;
22
+ loadValidatedConfig?: () => Promise<LoadValidatedConfigResult>;
21
23
  /**
22
24
  * Build changelog entries for a scope. Defaults to `buildChangelogEntries`, the same path
23
25
  * `release-kit prepare` uses — anchoring `validate`'s hash universe to `prepare`'s by
@@ -34,9 +36,13 @@ export interface ValidateOverridesCommandDependencies {
34
36
  * {@link validateAllChangelogOverrides}.
35
37
  *
36
38
  * Single-package and monorepo modes are handled uniformly: single-package collapses to one
37
- * project scope; monorepo expands to a project scope plus one scope per workspace.
39
+ * project scope; monorepo expands to a project scope plus one scope per workspace. A workspace declaring
40
+ * patterns that resolve to no package is neither, and exits `2`.
41
+ *
42
+ * `configPath` names the config file to read, relative to the working directory; it defaults to
43
+ * `CONFIG_FILE_PATH`.
38
44
  */
39
- export declare function validateOverridesCommand(styles: StreamStyles, dependencies?: ValidateOverridesCommandDependencies): Promise<ValidateOverridesCommandResult>;
45
+ export declare function validateOverridesCommand(styles: StreamStyles, configPath?: string, dependencies?: ValidateOverridesCommandDependencies): Promise<ValidateOverridesCommandResult>;
40
46
  /**
41
47
  * Pure formatter — take an aggregated validation result, return the tiered exit code and a
42
48
  * rendered message. Exported for unit testing without going through the full discovery path.
@@ -2,43 +2,40 @@ import { formatErrorLine, formatStatusLine } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { buildChangelogEntries } from "./buildChangelogEntries.js";
4
4
  import { resolveOverridePath, validateAllChangelogOverrides, } from "./changelogOverrides.js";
5
- import { discoverWorkspaces } from "./discoverWorkspaces.js";
5
+ import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
6
6
  import { buildTagPattern, getAllTagPrefixes } from "./generateChangelogs.js";
7
- import { loadConfig, mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
8
- import { validateConfig } from "./validateConfig.js";
7
+ import { mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
8
+ import { loadValidatedConfig } from "./loadValidatedConfig.js";
9
9
  const SYNTHETIC_VALIDATE_TAG = 'validate-only';
10
- export async function validateOverridesCommand(styles, dependencies = {}) {
10
+ export async function validateOverridesCommand(styles, configPath, dependencies = {}) {
11
11
  const discover = dependencies.discoverWorkspaces ?? discoverWorkspaces;
12
- const load = dependencies.loadConfig ?? loadConfig;
12
+ const load = dependencies.loadValidatedConfig ?? (() => loadValidatedConfig(configPath));
13
13
  const buildEntries = dependencies.buildEntries ?? defaultBuildEntries;
14
14
  const validate = dependencies.validate ?? validateAllChangelogOverrides;
15
- let rawConfig;
16
- try {
17
- rawConfig = await load();
18
- }
19
- catch (error) {
20
- return { exitCode: 2, message: formatErrorLine(`Failed to load config: ${describeError(error)}`) };
21
- }
22
- let userConfig;
23
- try {
24
- userConfig = validateLoadedConfig(rawConfig);
25
- }
26
- catch (error) {
27
- return { exitCode: 2, message: describeError(error) };
15
+ const configResult = await load();
16
+ if (configResult.status === 'invalid') {
17
+ return { exitCode: 2, message: formatConfigProblem(configResult.problem) };
28
18
  }
29
- let discoveredPaths;
19
+ const userConfig = configResult.status === 'ok' ? configResult.config : undefined;
20
+ let workspace;
30
21
  try {
31
- discoveredPaths = await discover();
22
+ workspace = discover();
32
23
  }
33
24
  catch (error) {
34
25
  return { exitCode: 2, message: formatErrorLine(`Failed to discover workspaces: ${describeError(error)}`) };
35
26
  }
27
+ if (workspace.kind === 'empty') {
28
+ return {
29
+ exitCode: 2,
30
+ message: formatErrorLine(`No workspace package to validate. ${describeEmptyWorkspace(workspace)}`),
31
+ };
32
+ }
36
33
  let inputs;
37
34
  try {
38
35
  inputs =
39
- discoveredPaths === undefined
36
+ workspace.kind === 'single-package'
40
37
  ? buildSinglePackageInputs(userConfig, buildEntries)
41
- : buildMonorepoInputs(discoveredPaths, userConfig, buildEntries);
38
+ : buildMonorepoInputs(workspace.packageDirs, userConfig, buildEntries);
42
39
  }
43
40
  catch (error) {
44
41
  return { exitCode: 2, message: formatErrorLine(`Failed to resolve overrides scope: ${describeError(error)}`) };
@@ -94,15 +91,10 @@ function flattenEntriesToHashes(entries) {
94
91
  }
95
92
  return hashes;
96
93
  }
97
- function validateLoadedConfig(rawConfig) {
98
- if (rawConfig === undefined) {
99
- return undefined;
100
- }
101
- const { config, errors } = validateConfig(rawConfig);
102
- if (errors.length > 0) {
103
- throw new Error(`Invalid config:\n - ${errors.join('\n - ')}`);
104
- }
105
- return config;
94
+ function formatConfigProblem(problem) {
95
+ return problem.kind === 'load'
96
+ ? formatErrorLine(`Failed to load config: ${problem.message}`)
97
+ : `Invalid config:\n - ${problem.errors.join('\n - ')}`;
106
98
  }
107
99
  function buildSinglePackageInputs(userConfig, buildEntries) {
108
100
  const config = mergeSinglePackageConfig(userConfig);
@@ -4,6 +4,16 @@ The fields of `.config/release-kit.config.ts`, how a workspace declares its prio
4
4
 
5
5
  A minimal config file is in the [README](../README.md#configuration).
6
6
 
7
+ ## Config file location
8
+
9
+ release-kit reads `.config/release-kit.config.ts`, resolved against the working directory. A run reads a different file when the subcommand is given `--config <path>`, which is also resolved against the working directory.
10
+
11
+ The flag is accepted by every subcommand that reads a config: `prepare`, `publish`, `create-github-release`, `show-tag-prefixes`, `overrides validate`, `sync-labels init`, and `sync-labels generate`. `commit`, `tag`, `push`, and `sync-labels sync` read no config and reject it.
12
+
13
+ The two paths differ in what an absent file means. An absent default path means the repo declares no config, and the run proceeds on discovered defaults. An absent `--config` path fails the command with an error naming the resolved path, because a caller who names a file expects that file to be read. A file that exists and fails validation is an error on both paths alike, and so is one that exists and fails to load.
14
+
15
+ `--config` names a file to read, never a write target. `release-kit init --with-config` scaffolds to `.config/release-kit.config.ts` and takes no `--config`, and `sync-labels init` writes its seeded `repoLabels` block to that same path.
16
+
7
17
  ## `ReleaseKitConfig` reference
8
18
 
9
19
  | Field | Type | Description |
@@ -89,6 +99,8 @@ Print a per-workspace table of derived tag prefixes, tag counts, and declared le
89
99
 
90
100
  Exits `0` when every workspace derives a prefix and there are no cross-workspace collisions; exits `1` on any derivation failure or collision. Undeclared candidates do not affect the exit code — they surface as a warning via the `legacy tag prefixes are declared` readyup check.
91
101
 
102
+ A config file that exists and either fails to load or fails schema validation reports the failure on stderr and exits `1`, printing no preview, because a config that cannot be read leaves the declared-versus-undeclared verdict wrong in the one dimension the command exists to report on. That holds on the default path and under `--config`, and in single-package mode as well as monorepo mode. An absent default config is a supported state and still previews against derived defaults.
103
+
92
104
  In single-package mode, prints a single row with `workspacePath = .` and `derivedPrefix = v`; legacy entries and undeclared-candidate scanning are not applicable.
93
105
 
94
106
  ## `VersionPatterns`