@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
@@ -1,6 +1,9 @@
1
+ import { formatGlyphLine, formatStatusLine, measureGlyphColumn, STATUS_GLYPHS, truncateToWidth, } from '@williamthorsen/nmr-core';
1
2
  import { bold, dim, sectionHeader } from "./format.js";
3
+ import { RELEASE_GLYPHS } from "./glyphs.js";
2
4
  import { GIT_CLIFF_NPX_ARGS } from "./runGitCliff.js";
3
5
  const CLIFF_DRY_RUN_COMMAND = `npx ${GIT_CLIFF_NPX_ARGS.join(' ')} ...`;
6
+ const SUBJECT_COLUMN_BUDGET = 72;
4
7
  export function reportPrepare(plan, options) {
5
8
  const isMultiWorkspace = plan.workspaces.some((w) => w.name !== undefined) || plan.project !== undefined;
6
9
  if (isMultiWorkspace) {
@@ -10,6 +13,7 @@ export function reportPrepare(plan, options) {
10
13
  }
11
14
  function formatSingleWorkspace(result, options) {
12
15
  const dryRun = !options.applied;
16
+ const { style } = options;
13
17
  const lines = [];
14
18
  const workspace = result.workspaces[0];
15
19
  if (workspace === undefined) {
@@ -20,11 +24,11 @@ function formatSingleWorkspace(result, options) {
20
24
  if (workspace.parsedCommitCount !== undefined) {
21
25
  lines.push(dim(` Parsed ${workspace.parsedCommitCount} typed commits`));
22
26
  }
23
- formatUnparseableWarning(lines, workspace);
24
- formatPolicyViolations(lines, workspace.policyViolations);
27
+ formatUnparseableWarning(lines, workspace, style);
28
+ formatPolicyViolations(lines, workspace.policyViolations, style);
25
29
  if (workspace.status === 'skipped') {
26
- lines.push(`⏭️ ${workspace.skipReason}`);
27
- formatWarnings(lines, result);
30
+ lines.push(formatStatusLine(style, 'skipped', workspace.skipReason));
31
+ formatWarnings(lines, result, style);
28
32
  return lines.join('\n');
29
33
  }
30
34
  if (workspace.setVersion !== undefined) {
@@ -40,49 +44,50 @@ function formatSingleWorkspace(result, options) {
40
44
  lines.push(dim(`Bumping versions (version override)...`));
41
45
  }
42
46
  if (workspace.setVersion !== undefined) {
43
- lines.push(`📦 ${workspace.currentVersion} → ${bold(workspace.newVersion)} (version override)`);
47
+ lines.push(formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${workspace.currentVersion} → ${bold(workspace.newVersion)} (version override)`));
44
48
  }
45
49
  else if (workspace.releaseType !== undefined) {
46
- lines.push(`📦 ${workspace.currentVersion} → ${bold(workspace.newVersion)} (${workspace.releaseType})`);
50
+ lines.push(formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${workspace.currentVersion} → ${bold(workspace.newVersion)} (${workspace.releaseType})`));
47
51
  }
48
52
  formatBumpFiles(lines, workspace, dryRun);
49
53
  lines.push(dim('Generating changelogs...'));
50
54
  formatChangelogFiles(lines, workspace, dryRun);
51
55
  formatPreviewFiles(lines, workspace, dryRun);
52
56
  formatFormatCommand(lines, result, options);
53
- formatWarnings(lines, result);
54
- lines.push(`✅ Release preparation complete.`, ` 🏷️ ${bold(workspace.tag)}`);
57
+ formatWarnings(lines, result, style);
58
+ lines.push(formatStatusLine(style, 'passed', 'Release preparation complete.'), ` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(workspace.tag))}`);
55
59
  return lines.join('\n');
56
60
  }
57
61
  function formatMultiWorkspace(result, options) {
58
62
  const dryRun = !options.applied;
63
+ const { style } = options;
59
64
  const lines = [];
60
65
  for (const workspace of result.workspaces) {
61
- formatWorkspaceSection(lines, workspace, dryRun);
66
+ formatWorkspaceSection(lines, workspace, dryRun, style);
62
67
  }
63
68
  if (result.project !== undefined) {
64
- formatProjectSection(lines, result.project, dryRun);
69
+ formatProjectSection(lines, result.project, dryRun, style);
65
70
  }
66
71
  formatFormatCommand(lines, result, options);
67
- formatWarnings(lines, result);
72
+ formatWarnings(lines, result, style);
68
73
  if (result.tags.length > 0) {
69
- lines.push(`\n✅ Release preparation complete.`);
74
+ lines.push(`\n${formatStatusLine(style, 'passed', 'Release preparation complete.')}`);
70
75
  for (const tag of result.tags) {
71
- lines.push(` 🏷️ ${bold(tag)}`);
76
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(tag))}`);
72
77
  }
73
78
  }
74
79
  else {
75
- lines.push(`\n⏭️ No workspaces had release-worthy changes.`);
80
+ lines.push(`\n${formatStatusLine(style, 'skipped', 'No workspaces had release-worthy changes.')}`);
76
81
  }
77
82
  return lines.join('\n');
78
83
  }
79
- function formatProjectSection(lines, project, dryRun) {
84
+ function formatProjectSection(lines, project, dryRun, style) {
80
85
  lines.push(`\n${sectionHeader('project')}`);
81
86
  const since = project.previousTag === undefined ? '(no previous release found)' : `since ${project.previousTag}`;
82
87
  lines.push(dim(` Found ${project.commitCount} commits ${since}`));
83
- formatPolicyViolations(lines, project.policyViolations, ' ');
88
+ formatPolicyViolations(lines, project.policyViolations, style, ' ');
84
89
  if (project.status === 'skipped') {
85
- lines.push(` ⏭️ ${project.skipReason}`);
90
+ lines.push(` ${formatStatusLine(style, 'skipped', project.skipReason)}`);
86
91
  return;
87
92
  }
88
93
  const { releaseType, currentVersion, newVersion, tag } = project;
@@ -92,8 +97,8 @@ function formatProjectSection(lines, project, dryRun) {
92
97
  if (project.bumpOverride !== undefined) {
93
98
  lines.push(` Using bump override: ${project.bumpOverride}`);
94
99
  }
95
- formatProjectUnparseable(lines, project);
96
- lines.push(dim(` Bumping versions (${releaseType})...`), ` 📦 ${currentVersion} → ${bold(newVersion)} (${releaseType})`);
100
+ formatProjectUnparseable(lines, project, style);
101
+ lines.push(dim(` Bumping versions (${releaseType})...`), ` ${formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${currentVersion} → ${bold(newVersion)} (${releaseType})`)}`);
97
102
  for (const file of project.bumpedFiles) {
98
103
  if (dryRun) {
99
104
  lines.push(dim(` [dry-run] Would bump ${file}`));
@@ -112,9 +117,9 @@ function formatProjectSection(lines, project, dryRun) {
112
117
  }
113
118
  }
114
119
  formatPreviewFiles(lines, project, dryRun, ' ');
115
- lines.push(` 🏷️ ${bold(tag)}`);
120
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(tag))}`);
116
121
  }
117
- function formatProjectUnparseable(lines, project) {
122
+ function formatProjectUnparseable(lines, project, style) {
118
123
  const unparseable = project.unparseableCommits;
119
124
  if (unparseable === undefined || unparseable.length === 0) {
120
125
  return;
@@ -122,35 +127,35 @@ function formatProjectUnparseable(lines, project) {
122
127
  const count = unparseable.length;
123
128
  const isPatchFloor = project.parsedCommitCount === 0;
124
129
  const suffix = isPatchFloor ? ' (defaulting to patch bump)' : '';
125
- lines.push(` ⚠️ ${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`);
130
+ lines.push(` ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`)}`);
126
131
  for (const commit of unparseable) {
127
132
  const shortHash = commit.hash.slice(0, 7);
128
- const truncatedMessage = commit.message.length > 72 ? `${commit.message.slice(0, 69)}...` : commit.message;
133
+ const truncatedMessage = truncateSubject(commit.message);
129
134
  lines.push(` · ${shortHash} ${truncatedMessage}`);
130
135
  }
131
136
  }
132
- function formatWorkspaceSection(lines, workspace, dryRun) {
137
+ function formatWorkspaceSection(lines, workspace, dryRun, style) {
133
138
  if (workspace.name !== undefined) {
134
139
  lines.push(`\n${sectionHeader(workspace.name)}`);
135
140
  }
136
141
  const since = workspace.previousTag === undefined ? '(no previous release found)' : `since ${workspace.previousTag}`;
137
142
  lines.push(dim(` Found ${workspace.commitCount} commits ${since}`));
138
143
  if (workspace.status === 'skipped') {
139
- lines.push(` ⏭️ ${workspace.skipReason}`);
144
+ lines.push(` ${formatStatusLine(style, 'skipped', workspace.skipReason)}`);
140
145
  return;
141
146
  }
142
147
  const { propagatedFrom } = workspace;
143
148
  const isPropagatedOnly = propagatedFrom !== undefined && workspace.commitCount === 0;
144
149
  formatCommitSummary(lines, workspace, propagatedFrom, isPropagatedOnly);
145
- formatUnparseableWarning(lines, workspace, ' ');
146
- formatPolicyViolations(lines, workspace.policyViolations, ' ');
150
+ formatUnparseableWarning(lines, workspace, style, ' ');
151
+ formatPolicyViolations(lines, workspace.policyViolations, style, ' ');
147
152
  formatBumpLabels(lines, workspace, isPropagatedOnly);
148
- formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly);
153
+ formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly, style);
149
154
  formatBumpFiles(lines, workspace, dryRun, ' ');
150
155
  lines.push(dim(' Generating changelogs...'));
151
156
  formatChangelogFiles(lines, workspace, dryRun, ' ');
152
157
  formatPreviewFiles(lines, workspace, dryRun, ' ');
153
- lines.push(` 🏷️ ${bold(workspace.tag)}`);
158
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(workspace.tag))}`);
154
159
  }
155
160
  function formatCommitSummary(lines, workspace, propagatedFrom, isPropagatedOnly) {
156
161
  if (isPropagatedOnly && propagatedFrom !== undefined) {
@@ -175,13 +180,14 @@ function formatBumpLabels(lines, workspace, isPropagatedOnly) {
175
180
  lines.push(dim(` Bumping versions (version override)...`));
176
181
  }
177
182
  }
178
- function formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly) {
183
+ function formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly, style) {
184
+ const versions = `${workspace.currentVersion} → ${bold(workspace.newVersion)}`;
179
185
  if (workspace.setVersion !== undefined) {
180
- lines.push(` 📦 ${workspace.currentVersion} → ${bold(workspace.newVersion)} (version override)`);
186
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${versions} (version override)`)}`);
181
187
  }
182
188
  else if (workspace.releaseType !== undefined) {
183
189
  const suffix = isPropagatedOnly ? formatPropagationSuffix(propagatedFrom) : '';
184
- lines.push(` 📦 ${workspace.currentVersion} → ${bold(workspace.newVersion)} (${workspace.releaseType}${suffix})`);
190
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${versions} (${workspace.releaseType}${suffix})`)}`);
185
191
  }
186
192
  }
187
193
  function formatBumpFiles(lines, workspace, dryRun, indent = '') {
@@ -215,7 +221,7 @@ function formatPreviewFiles(lines, release, dryRun, indent = '') {
215
221
  }
216
222
  }
217
223
  }
218
- function formatUnparseableWarning(lines, workspace, indent = '') {
224
+ function formatUnparseableWarning(lines, workspace, style, indent = '') {
219
225
  const unparseable = workspace.unparseableCommits;
220
226
  if (unparseable === undefined || unparseable.length === 0) {
221
227
  return;
@@ -223,23 +229,23 @@ function formatUnparseableWarning(lines, workspace, indent = '') {
223
229
  const count = unparseable.length;
224
230
  const isPatchFloor = workspace.parsedCommitCount === 0;
225
231
  const suffix = isPatchFloor ? ' (defaulting to patch bump)' : '';
226
- lines.push(`${indent} ⚠️ ${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`);
232
+ lines.push(`${indent} ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`)}`);
227
233
  for (const commit of unparseable) {
228
234
  const shortHash = commit.hash.slice(0, 7);
229
- const truncatedMessage = commit.message.length > 72 ? `${commit.message.slice(0, 69)}...` : commit.message;
235
+ const truncatedMessage = truncateSubject(commit.message);
230
236
  lines.push(`${indent} · ${shortHash} ${truncatedMessage}`);
231
237
  }
232
238
  }
233
- function formatPolicyViolations(lines, violations, indent = '') {
239
+ function formatPolicyViolations(lines, violations, style, indent = '') {
234
240
  if (violations === undefined || violations.length === 0) {
235
241
  return;
236
242
  }
237
243
  const count = violations.length;
238
- lines.push(`${indent} ⚠️ ${count} policy violation${count === 1 ? '' : 's'}:`);
244
+ lines.push(`${indent} ${formatStatusLine(style, 'warning', `${count} policy violation${count === 1 ? '' : 's'}:`)}`);
239
245
  for (const violation of violations) {
240
246
  const shortHash = violation.commitHash.slice(0, 7);
241
247
  const subject = violation.commitSubject;
242
- const truncatedSubject = subject.length > 72 ? `${subject.slice(0, 69)}...` : subject;
248
+ const truncatedSubject = truncateSubject(subject);
243
249
  lines.push(`${indent} · ${shortHash} '${truncatedSubject}' — type '${violation.type}' at ${violation.surface} surface`);
244
250
  }
245
251
  }
@@ -250,14 +256,14 @@ function formatPropagationSuffix(propagatedFrom) {
250
256
  const names = propagatedFrom.map((p) => p.packageName).join(', ');
251
257
  return `, dependency: ${names}`;
252
258
  }
253
- function formatWarnings(lines, result) {
259
+ function formatWarnings(lines, result, style) {
254
260
  const { warnings } = result;
255
261
  if (warnings === undefined || warnings.length === 0) {
256
262
  return;
257
263
  }
258
264
  lines.push('');
259
265
  for (const warning of warnings) {
260
- lines.push(`⚠️ ${warning}`);
266
+ lines.push(formatStatusLine(style, 'warning', warning));
261
267
  }
262
268
  }
263
269
  function formatFormatCommand(lines, result, options) {
@@ -272,5 +278,9 @@ function formatFormatCommand(lines, result, options) {
272
278
  lines.push(dim(`\n Running format command: ${result.formatCommand.command}`));
273
279
  return;
274
280
  }
275
- lines.push(`\n ⚠️ Format command failed: ${result.formatCommand.command}`, ` ${options.formatError}`);
281
+ const messageIndent = ' '.repeat(2 + measureGlyphColumn(STATUS_GLYPHS[options.style]) + 1);
282
+ lines.push(`\n ${formatStatusLine(options.style, 'warning', `Format command failed: ${result.formatCommand.command}`)}`, `${messageIndent}${options.formatError}`);
283
+ }
284
+ function truncateSubject(subject) {
285
+ return truncateToWidth(subject, { width: SUBJECT_COLUMN_BUDGET });
276
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,3 +1,4 @@
1
+ import type { OutputStyle } from '@williamthorsen/nmr-core';
1
2
  import type { ReleaseNotesConfig } from './types.js';
2
3
  export interface ResolvedReleaseNotesConfig {
3
4
  releaseNotes: ReleaseNotesConfig;
@@ -6,21 +7,19 @@ export interface ResolvedReleaseNotesConfig {
6
7
  sectionOrder: string[];
7
8
  }
8
9
  export interface ResolveReleaseNotesConfigOptions {
9
- /**
10
- * When `true`, a `loadConfig()` rejection causes `process.exit(1)` rather than a fallback to
11
- * defaults. Use from CLI commands whose entire behavior depends on the resolved config (e.g.
12
- * `create-github-release`), so a corrupt or unreadable config cannot silently send the command
13
- * to the wrong changelog path.
14
- */
15
- strictLoad?: boolean;
10
+ /** Config file to read, relative to the working directory. Defaults to `CONFIG_FILE_PATH`. */
11
+ configPath?: string;
16
12
  }
17
13
  /**
18
- * Load and validate the release-kit config.
14
+ * Loads and validates the release-kit config, resolving the release-notes settings it carries.
19
15
  *
20
- * By default, falls back to defaults when `loadConfig()` rejects (legacy publish behavior).
21
- * 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.
22
21
  */
23
- export declare function resolveReleaseNotesConfig(options?: ResolveReleaseNotesConfigOptions): Promise<ResolvedReleaseNotesConfig>;
22
+ export declare function resolveReleaseNotesConfig(stderrStyle: OutputStyle, options?: ResolveReleaseNotesConfigOptions): Promise<ResolvedReleaseNotesConfig>;
24
23
  /** Extract section headers in declaration order from a merged workTypes record. */
25
24
  export declare function deriveSectionOrder(workTypes: Record<string, {
26
25
  header: string;
@@ -1,40 +1,21 @@
1
- import { 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";
6
- export async function resolveReleaseNotesConfig(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}`);
2
+ import { resolveWorkTypes } from "./loadConfig.js";
3
+ import { loadValidatedConfig, reportConfigProblem, reportConfigWarnings } from "./loadValidatedConfig.js";
4
+ export async function resolveReleaseNotesConfig(stderrStyle, options = {}) {
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
- process.stderr.write(` ❌ ${err}\n`);
32
- }
33
- process.exit(1);
34
- }
35
- for (const warning of warnings) {
36
- console.warn(` ⚠️ ${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,
@@ -0,0 +1,10 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
2
+ /** The environment variable holding the caller's standing style preference: `auto`, `plain`, or `rich`. */
3
+ export declare const OUTPUT_STYLE_ENV_VAR = "RELEASE_KIT_OUTPUT_STYLE";
4
+ export interface ResolveStylesOptions {
5
+ readonly env: Readonly<Record<string, string | undefined>>;
6
+ readonly stderrIsTty: boolean;
7
+ readonly stdoutIsTty: boolean;
8
+ }
9
+ /** Resolves the style of each output stream, exiting with a usage error when the variable names no setting. */
10
+ export declare function resolveStylesOrExit(options: ResolveStylesOptions): StreamStyles;
@@ -0,0 +1,10 @@
1
+ import { describeInvalidOutputStyle, reportError, resolveStreamStyles, } from '@williamthorsen/nmr-core';
2
+ export const OUTPUT_STYLE_ENV_VAR = 'RELEASE_KIT_OUTPUT_STYLE';
3
+ export function resolveStylesOrExit(options) {
4
+ const { invalid, styles } = resolveStreamStyles({ ...options, envVar: OUTPUT_STYLE_ENV_VAR });
5
+ if (invalid !== undefined) {
6
+ reportError(describeInvalidOutputStyle(invalid));
7
+ process.exit(1);
8
+ }
9
+ return styles;
10
+ }
@@ -1,3 +1,4 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  /**
2
3
  * Orchestrate the CLI `show-tag-prefixes` command.
3
4
  *
@@ -5,8 +6,10 @@
5
6
  * entries, followed by an "Undeclared tag prefixes" section when candidate-shaped tags
6
7
  * exist outside the known set. Exits `0` on full derivation success and no collisions;
7
8
  * `1` on any derivation failure or collision. Undeclared candidates do not affect the
8
- * 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.
9
12
  *
10
13
  * @returns The exit code the caller should use.
11
14
  */
12
- export declare function showTagPrefixesCommand(): Promise<number>;
15
+ export declare function showTagPrefixesCommand(styles: StreamStyles, configPath?: string): Promise<number>;
@@ -1,12 +1,27 @@
1
+ import { formatStatusLine, reportError } from '@williamthorsen/nmr-core';
2
+ import { describeError } from '@williamthorsen/toolbelt.errors';
1
3
  import { detectRepoType } from "./init/detectRepoType.js";
4
+ import { loadValidatedConfig, reportConfigProblem } from "./loadValidatedConfig.js";
2
5
  import { previewTagPrefixes } from "./previewTagPrefixes.js";
3
- export async function showTagPrefixesCommand() {
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
+ }
4
12
  if (detectRepoType() === 'single-package') {
5
13
  process.stdout.write(renderSinglePackage());
6
14
  return 0;
7
15
  }
8
- const preview = await previewTagPrefixes();
9
- process.stdout.write(renderMonorepo(preview));
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
+ }
24
+ process.stdout.write(renderMonorepo(preview, styles.stdout));
10
25
  return computeExitCode(preview);
11
26
  }
12
27
  function renderSinglePackage() {
@@ -17,13 +32,13 @@ function renderSinglePackage() {
17
32
  ];
18
33
  return lines.join('\n');
19
34
  }
20
- function renderMonorepo(preview) {
35
+ function renderMonorepo(preview, style) {
21
36
  const lines = ['Workspace tag prefixes:', ''];
22
37
  for (const row of preview.workspaces) {
23
- lines.push(...renderWorkspaceRow(row));
38
+ lines.push(...renderWorkspaceRow(row, style));
24
39
  }
25
40
  if (preview.collisions.length > 0) {
26
- lines.push('', ...preview.collisions.map((collision) => `⛔ tag prefix collision: '${collision.tagPrefix}' used by ${collision.workspacePaths.join(', ')}`));
41
+ lines.push('', ...preview.collisions.map((collision) => formatStatusLine(style, 'failed', `tag prefix collision: '${collision.tagPrefix}' used by ${collision.workspacePaths.join(', ')}`)));
27
42
  }
28
43
  if (preview.undeclaredCandidates.length > 0) {
29
44
  lines.push('', 'Undeclared tag prefixes:', '', ...preview.undeclaredCandidates.map((candidate) => ` '${candidate.prefix}' — ${candidate.tagCount} tags (e.g., ${candidate.exampleTags.join(', ')})`), '', 'Suggested config snippet (adjust `dir` to match your workspace if the guess is wrong, and replace the `name` placeholder with the legacy npm name):', '', renderSuggestedSnippet(preview.undeclaredCandidates), '', "If the suggested `dir` does not match your workspace, adjust before pasting. Each legacy identity requires a `name` — replace the `TODO-fill-in-legacy-npm-name` placeholder with the package's prior npm name.");
@@ -31,20 +46,25 @@ function renderMonorepo(preview) {
31
46
  lines.push('');
32
47
  return lines.join('\n');
33
48
  }
34
- function renderWorkspaceRow(row) {
49
+ function renderWorkspaceRow(row, style) {
35
50
  const lines = [];
36
51
  if (row.derivedPrefix === null) {
37
- lines.push(` ${row.workspacePath} — ⛔ derivation failed: ${row.derivationError ?? 'unknown error'}`);
52
+ const failure = formatStatusLine(style, 'failed', `derivation failed: ${row.derivationError ?? 'unknown error'}`);
53
+ lines.push(` ${row.workspacePath} — ${failure}`);
38
54
  return lines;
39
55
  }
40
- const statusMarker = row.derivedTagCount > 0 ? `✅ ${row.derivedTagCount} tags` : '⚠️ no existing tags';
56
+ const statusMarker = row.derivedTagCount > 0
57
+ ? formatStatusLine(style, 'passed', `${row.derivedTagCount} tags`)
58
+ : formatStatusLine(style, 'warning', 'no existing tags');
41
59
  lines.push(` ${row.workspacePath} — derived prefix '${row.derivedPrefix}', ${statusMarker}`);
42
60
  for (const entry of row.legacyEntries) {
43
61
  if (entry.tagCount > 0) {
44
- lines.push(` ✅ ${entry.tagCount} legacy tags with '${entry.prefix}' prefix (recognized)`);
62
+ const recognized = `${entry.tagCount} legacy tags with '${entry.prefix}' prefix (recognized)`;
63
+ lines.push(` ${formatStatusLine(style, 'passed', recognized)}`);
45
64
  }
46
65
  else {
47
- lines.push(` ⚠️ recorded legacy prefix '${entry.prefix}' has no tags`);
66
+ const tagless = `recorded legacy prefix '${entry.prefix}' has no tags`;
67
+ lines.push(` ${formatStatusLine(style, 'warning', tagless)}`);
48
68
  }
49
69
  }
50
70
  return lines;
@@ -1,27 +1,37 @@
1
+ import { type OutputStyle, type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  import type { RepoLabelsConfig } from '../types.js';
2
3
  import type { LabelDefinition } from './types.js';
3
4
  /** Output path for the generated labels file. */
4
5
  export declare const LABELS_OUTPUT_PATH = ".github/labels.yaml";
5
- /** Formats resolved labels as YAML with generated header including preset hashes. */
6
- export declare function formatLabelsYaml(labels: LabelDefinition[], presetHashes: Map<string, string>): string;
7
6
  /**
8
- * 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.
9
15
  *
10
16
  * Reports an actionable error and returns `undefined` when the config file is missing,
11
- * 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.
12
19
  */
13
- export declare function loadRepoLabelsConfig(): Promise<RepoLabelsConfig | undefined>;
20
+ export declare function loadRepoLabelsConfig(stderrStyle: OutputStyle, configPath?: string): Promise<RepoLabelsConfig | undefined>;
14
21
  /** Options for the `sync-labels generate` subcommand. */
15
22
  interface GenerateOptions {
16
23
  /** Compare the regenerated content against the committed file instead of writing. */
17
24
  check?: boolean;
25
+ /** Config file to read, relative to the working directory. Defaults to `CONFIG_FILE_PATH`. */
26
+ configPath?: string;
27
+ styles: StreamStyles;
18
28
  }
19
29
  /**
20
30
  * Runs the `sync-labels generate` command.
21
31
  *
22
- * Loads the `repoLabels` block from `.config/release-kit.config.ts`, resolves presets and label adjustments,
23
- * 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
24
34
  * is stale, writing nothing. Returns 0 on success, 1 on failure.
25
35
  */
26
- export declare function generateCommand({ check }?: GenerateOptions): Promise<number>;
36
+ export declare function generateCommand({ check, configPath, styles }: GenerateOptions): Promise<number>;
27
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() {
23
- const result = await loadValidatedConfig();
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 } = {}) {
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();
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 } = {}) {
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
  }