@williamthorsen/release-kit 10.7.0 → 10.9.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 (58) hide show
  1. package/.meta/changelog.json +71 -3
  2. package/.readyup/kits/default.js +3 -3
  3. package/.readyup/kits/npm-auto-publish.js +1 -1
  4. package/.readyup/manifest.json +9 -9
  5. package/CHANGELOG.md +49 -3
  6. package/README.md +21 -15
  7. package/dist/esm/bin/release-kit.js +20 -102
  8. package/dist/esm/buildChangelogEntries.d.ts +1 -1
  9. package/dist/esm/buildChangelogEntries.js +14 -5
  10. package/dist/esm/createGithubReleaseCommand.d.ts +2 -1
  11. package/dist/esm/createGithubReleaseCommand.js +4 -2
  12. package/dist/esm/createTags.d.ts +2 -0
  13. package/dist/esm/createTags.js +5 -4
  14. package/dist/esm/format.d.ts +5 -5
  15. package/dist/esm/format.js +5 -4
  16. package/dist/esm/glyphs.d.ts +3 -0
  17. package/dist/esm/glyphs.js +6 -0
  18. package/dist/esm/init/initCommand.d.ts +3 -1
  19. package/dist/esm/init/initCommand.js +15 -15
  20. package/dist/esm/loadValidatedConfig.d.ts +2 -1
  21. package/dist/esm/loadValidatedConfig.js +3 -3
  22. package/dist/esm/parseCommitMessage.d.ts +4 -5
  23. package/dist/esm/prepareCommand.d.ts +2 -1
  24. package/dist/esm/prepareCommand.js +17 -16
  25. package/dist/esm/publishCommand.d.ts +2 -1
  26. package/dist/esm/publishCommand.js +2 -2
  27. package/dist/esm/reportPrepare.d.ts +2 -0
  28. package/dist/esm/reportPrepare.js +44 -38
  29. package/dist/esm/resolveReleaseNotesConfig.d.ts +2 -1
  30. package/dist/esm/resolveReleaseNotesConfig.js +4 -4
  31. package/dist/esm/resolveStylesOrExit.d.ts +10 -0
  32. package/dist/esm/resolveStylesOrExit.js +10 -0
  33. package/dist/esm/showTagPrefixesCommand.d.ts +2 -1
  34. package/dist/esm/showTagPrefixesCommand.js +16 -10
  35. package/dist/esm/sync-labels/generateCommand.d.ts +4 -2
  36. package/dist/esm/sync-labels/generateCommand.js +4 -4
  37. package/dist/esm/sync-labels/initCommand.d.ts +3 -1
  38. package/dist/esm/sync-labels/initCommand.js +7 -7
  39. package/dist/esm/tagCommand.d.ts +2 -1
  40. package/dist/esm/tagCommand.js +2 -2
  41. package/dist/esm/types.d.ts +8 -7
  42. package/dist/esm/validateOverridesCommand.d.ts +4 -4
  43. package/dist/esm/validateOverridesCommand.js +6 -6
  44. package/dist/esm/workTypesData.d.ts +10 -9
  45. package/dist/esm/workTypesData.js +97 -11
  46. package/docs/changelogs.md +8 -8
  47. package/docs/configuration.md +23 -1
  48. package/docs/work-types.md +28 -30
  49. package/package.json +11 -11
  50. package/presets/labels/common.yaml +2 -2
  51. package/dist/esm/checkWorkTypesDrift.d.ts +0 -36
  52. package/dist/esm/checkWorkTypesDrift.js +0 -117
  53. package/dist/esm/syncWorkTypes.d.ts +0 -28
  54. package/dist/esm/syncWorkTypes.js +0 -94
  55. package/dist/esm/work-types.json +0 -133
  56. package/dist/esm/work-types.schema.json +0 -100
  57. package/dist/esm/workTypesUtils.d.ts +0 -22
  58. package/dist/esm/workTypesUtils.js +0 -16
@@ -1,4 +1,6 @@
1
+ import { formatGlyphLine, formatStatusLine, measureGlyphColumn, STATUS_GLYPHS, } 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(' ')} ...`;
4
6
  export function reportPrepare(plan, options) {
@@ -10,6 +12,7 @@ export function reportPrepare(plan, options) {
10
12
  }
11
13
  function formatSingleWorkspace(result, options) {
12
14
  const dryRun = !options.applied;
15
+ const { style } = options;
13
16
  const lines = [];
14
17
  const workspace = result.workspaces[0];
15
18
  if (workspace === undefined) {
@@ -20,11 +23,11 @@ function formatSingleWorkspace(result, options) {
20
23
  if (workspace.parsedCommitCount !== undefined) {
21
24
  lines.push(dim(` Parsed ${workspace.parsedCommitCount} typed commits`));
22
25
  }
23
- formatUnparseableWarning(lines, workspace);
24
- formatPolicyViolations(lines, workspace.policyViolations);
26
+ formatUnparseableWarning(lines, workspace, style);
27
+ formatPolicyViolations(lines, workspace.policyViolations, style);
25
28
  if (workspace.status === 'skipped') {
26
- lines.push(`⏭️ ${workspace.skipReason}`);
27
- formatWarnings(lines, result);
29
+ lines.push(formatStatusLine(style, 'skipped', workspace.skipReason));
30
+ formatWarnings(lines, result, style);
28
31
  return lines.join('\n');
29
32
  }
30
33
  if (workspace.setVersion !== undefined) {
@@ -40,49 +43,50 @@ function formatSingleWorkspace(result, options) {
40
43
  lines.push(dim(`Bumping versions (version override)...`));
41
44
  }
42
45
  if (workspace.setVersion !== undefined) {
43
- lines.push(`📦 ${workspace.currentVersion} → ${bold(workspace.newVersion)} (version override)`);
46
+ lines.push(formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${workspace.currentVersion} → ${bold(workspace.newVersion)} (version override)`));
44
47
  }
45
48
  else if (workspace.releaseType !== undefined) {
46
- lines.push(`📦 ${workspace.currentVersion} → ${bold(workspace.newVersion)} (${workspace.releaseType})`);
49
+ lines.push(formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${workspace.currentVersion} → ${bold(workspace.newVersion)} (${workspace.releaseType})`));
47
50
  }
48
51
  formatBumpFiles(lines, workspace, dryRun);
49
52
  lines.push(dim('Generating changelogs...'));
50
53
  formatChangelogFiles(lines, workspace, dryRun);
51
54
  formatPreviewFiles(lines, workspace, dryRun);
52
55
  formatFormatCommand(lines, result, options);
53
- formatWarnings(lines, result);
54
- lines.push(`✅ Release preparation complete.`, ` 🏷️ ${bold(workspace.tag)}`);
56
+ formatWarnings(lines, result, style);
57
+ lines.push(formatStatusLine(style, 'passed', 'Release preparation complete.'), ` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(workspace.tag))}`);
55
58
  return lines.join('\n');
56
59
  }
57
60
  function formatMultiWorkspace(result, options) {
58
61
  const dryRun = !options.applied;
62
+ const { style } = options;
59
63
  const lines = [];
60
64
  for (const workspace of result.workspaces) {
61
- formatWorkspaceSection(lines, workspace, dryRun);
65
+ formatWorkspaceSection(lines, workspace, dryRun, style);
62
66
  }
63
67
  if (result.project !== undefined) {
64
- formatProjectSection(lines, result.project, dryRun);
68
+ formatProjectSection(lines, result.project, dryRun, style);
65
69
  }
66
70
  formatFormatCommand(lines, result, options);
67
- formatWarnings(lines, result);
71
+ formatWarnings(lines, result, style);
68
72
  if (result.tags.length > 0) {
69
- lines.push(`\n✅ Release preparation complete.`);
73
+ lines.push(`\n${formatStatusLine(style, 'passed', 'Release preparation complete.')}`);
70
74
  for (const tag of result.tags) {
71
- lines.push(` 🏷️ ${bold(tag)}`);
75
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(tag))}`);
72
76
  }
73
77
  }
74
78
  else {
75
- lines.push(`\n⏭️ No workspaces had release-worthy changes.`);
79
+ lines.push(`\n${formatStatusLine(style, 'skipped', 'No workspaces had release-worthy changes.')}`);
76
80
  }
77
81
  return lines.join('\n');
78
82
  }
79
- function formatProjectSection(lines, project, dryRun) {
83
+ function formatProjectSection(lines, project, dryRun, style) {
80
84
  lines.push(`\n${sectionHeader('project')}`);
81
85
  const since = project.previousTag === undefined ? '(no previous release found)' : `since ${project.previousTag}`;
82
86
  lines.push(dim(` Found ${project.commitCount} commits ${since}`));
83
- formatPolicyViolations(lines, project.policyViolations, ' ');
87
+ formatPolicyViolations(lines, project.policyViolations, style, ' ');
84
88
  if (project.status === 'skipped') {
85
- lines.push(` ⏭️ ${project.skipReason}`);
89
+ lines.push(` ${formatStatusLine(style, 'skipped', project.skipReason)}`);
86
90
  return;
87
91
  }
88
92
  const { releaseType, currentVersion, newVersion, tag } = project;
@@ -92,8 +96,8 @@ function formatProjectSection(lines, project, dryRun) {
92
96
  if (project.bumpOverride !== undefined) {
93
97
  lines.push(` Using bump override: ${project.bumpOverride}`);
94
98
  }
95
- formatProjectUnparseable(lines, project);
96
- lines.push(dim(` Bumping versions (${releaseType})...`), ` 📦 ${currentVersion} → ${bold(newVersion)} (${releaseType})`);
99
+ formatProjectUnparseable(lines, project, style);
100
+ lines.push(dim(` Bumping versions (${releaseType})...`), ` ${formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${currentVersion} → ${bold(newVersion)} (${releaseType})`)}`);
97
101
  for (const file of project.bumpedFiles) {
98
102
  if (dryRun) {
99
103
  lines.push(dim(` [dry-run] Would bump ${file}`));
@@ -112,9 +116,9 @@ function formatProjectSection(lines, project, dryRun) {
112
116
  }
113
117
  }
114
118
  formatPreviewFiles(lines, project, dryRun, ' ');
115
- lines.push(` 🏷️ ${bold(tag)}`);
119
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(tag))}`);
116
120
  }
117
- function formatProjectUnparseable(lines, project) {
121
+ function formatProjectUnparseable(lines, project, style) {
118
122
  const unparseable = project.unparseableCommits;
119
123
  if (unparseable === undefined || unparseable.length === 0) {
120
124
  return;
@@ -122,35 +126,35 @@ function formatProjectUnparseable(lines, project) {
122
126
  const count = unparseable.length;
123
127
  const isPatchFloor = project.parsedCommitCount === 0;
124
128
  const suffix = isPatchFloor ? ' (defaulting to patch bump)' : '';
125
- lines.push(` ⚠️ ${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`);
129
+ lines.push(` ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`)}`);
126
130
  for (const commit of unparseable) {
127
131
  const shortHash = commit.hash.slice(0, 7);
128
132
  const truncatedMessage = commit.message.length > 72 ? `${commit.message.slice(0, 69)}...` : commit.message;
129
133
  lines.push(` · ${shortHash} ${truncatedMessage}`);
130
134
  }
131
135
  }
132
- function formatWorkspaceSection(lines, workspace, dryRun) {
136
+ function formatWorkspaceSection(lines, workspace, dryRun, style) {
133
137
  if (workspace.name !== undefined) {
134
138
  lines.push(`\n${sectionHeader(workspace.name)}`);
135
139
  }
136
140
  const since = workspace.previousTag === undefined ? '(no previous release found)' : `since ${workspace.previousTag}`;
137
141
  lines.push(dim(` Found ${workspace.commitCount} commits ${since}`));
138
142
  if (workspace.status === 'skipped') {
139
- lines.push(` ⏭️ ${workspace.skipReason}`);
143
+ lines.push(` ${formatStatusLine(style, 'skipped', workspace.skipReason)}`);
140
144
  return;
141
145
  }
142
146
  const { propagatedFrom } = workspace;
143
147
  const isPropagatedOnly = propagatedFrom !== undefined && workspace.commitCount === 0;
144
148
  formatCommitSummary(lines, workspace, propagatedFrom, isPropagatedOnly);
145
- formatUnparseableWarning(lines, workspace, ' ');
146
- formatPolicyViolations(lines, workspace.policyViolations, ' ');
149
+ formatUnparseableWarning(lines, workspace, style, ' ');
150
+ formatPolicyViolations(lines, workspace.policyViolations, style, ' ');
147
151
  formatBumpLabels(lines, workspace, isPropagatedOnly);
148
- formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly);
152
+ formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly, style);
149
153
  formatBumpFiles(lines, workspace, dryRun, ' ');
150
154
  lines.push(dim(' Generating changelogs...'));
151
155
  formatChangelogFiles(lines, workspace, dryRun, ' ');
152
156
  formatPreviewFiles(lines, workspace, dryRun, ' ');
153
- lines.push(` 🏷️ ${bold(workspace.tag)}`);
157
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'tag', bold(workspace.tag))}`);
154
158
  }
155
159
  function formatCommitSummary(lines, workspace, propagatedFrom, isPropagatedOnly) {
156
160
  if (isPropagatedOnly && propagatedFrom !== undefined) {
@@ -175,13 +179,14 @@ function formatBumpLabels(lines, workspace, isPropagatedOnly) {
175
179
  lines.push(dim(` Bumping versions (version override)...`));
176
180
  }
177
181
  }
178
- function formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly) {
182
+ function formatVersionLine(lines, workspace, propagatedFrom, isPropagatedOnly, style) {
183
+ const versions = `${workspace.currentVersion} → ${bold(workspace.newVersion)}`;
179
184
  if (workspace.setVersion !== undefined) {
180
- lines.push(` 📦 ${workspace.currentVersion} → ${bold(workspace.newVersion)} (version override)`);
185
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${versions} (version override)`)}`);
181
186
  }
182
187
  else if (workspace.releaseType !== undefined) {
183
188
  const suffix = isPropagatedOnly ? formatPropagationSuffix(propagatedFrom) : '';
184
- lines.push(` 📦 ${workspace.currentVersion} → ${bold(workspace.newVersion)} (${workspace.releaseType}${suffix})`);
189
+ lines.push(` ${formatGlyphLine(RELEASE_GLYPHS, style, 'bump', `${versions} (${workspace.releaseType}${suffix})`)}`);
185
190
  }
186
191
  }
187
192
  function formatBumpFiles(lines, workspace, dryRun, indent = '') {
@@ -215,7 +220,7 @@ function formatPreviewFiles(lines, release, dryRun, indent = '') {
215
220
  }
216
221
  }
217
222
  }
218
- function formatUnparseableWarning(lines, workspace, indent = '') {
223
+ function formatUnparseableWarning(lines, workspace, style, indent = '') {
219
224
  const unparseable = workspace.unparseableCommits;
220
225
  if (unparseable === undefined || unparseable.length === 0) {
221
226
  return;
@@ -223,19 +228,19 @@ function formatUnparseableWarning(lines, workspace, indent = '') {
223
228
  const count = unparseable.length;
224
229
  const isPatchFloor = workspace.parsedCommitCount === 0;
225
230
  const suffix = isPatchFloor ? ' (defaulting to patch bump)' : '';
226
- lines.push(`${indent} ⚠️ ${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`);
231
+ lines.push(`${indent} ${formatStatusLine(style, 'warning', `${count} commit${count === 1 ? '' : 's'} could not be parsed${suffix}`)}`);
227
232
  for (const commit of unparseable) {
228
233
  const shortHash = commit.hash.slice(0, 7);
229
234
  const truncatedMessage = commit.message.length > 72 ? `${commit.message.slice(0, 69)}...` : commit.message;
230
235
  lines.push(`${indent} · ${shortHash} ${truncatedMessage}`);
231
236
  }
232
237
  }
233
- function formatPolicyViolations(lines, violations, indent = '') {
238
+ function formatPolicyViolations(lines, violations, style, indent = '') {
234
239
  if (violations === undefined || violations.length === 0) {
235
240
  return;
236
241
  }
237
242
  const count = violations.length;
238
- lines.push(`${indent} ⚠️ ${count} policy violation${count === 1 ? '' : 's'}:`);
243
+ lines.push(`${indent} ${formatStatusLine(style, 'warning', `${count} policy violation${count === 1 ? '' : 's'}:`)}`);
239
244
  for (const violation of violations) {
240
245
  const shortHash = violation.commitHash.slice(0, 7);
241
246
  const subject = violation.commitSubject;
@@ -250,14 +255,14 @@ function formatPropagationSuffix(propagatedFrom) {
250
255
  const names = propagatedFrom.map((p) => p.packageName).join(', ');
251
256
  return `, dependency: ${names}`;
252
257
  }
253
- function formatWarnings(lines, result) {
258
+ function formatWarnings(lines, result, style) {
254
259
  const { warnings } = result;
255
260
  if (warnings === undefined || warnings.length === 0) {
256
261
  return;
257
262
  }
258
263
  lines.push('');
259
264
  for (const warning of warnings) {
260
- lines.push(`⚠️ ${warning}`);
265
+ lines.push(formatStatusLine(style, 'warning', warning));
261
266
  }
262
267
  }
263
268
  function formatFormatCommand(lines, result, options) {
@@ -272,5 +277,6 @@ function formatFormatCommand(lines, result, options) {
272
277
  lines.push(dim(`\n Running format command: ${result.formatCommand.command}`));
273
278
  return;
274
279
  }
275
- lines.push(`\n ⚠️ Format command failed: ${result.formatCommand.command}`, ` ${options.formatError}`);
280
+ const messageIndent = ' '.repeat(2 + measureGlyphColumn(STATUS_GLYPHS[options.style]) + 1);
281
+ lines.push(`\n ${formatStatusLine(options.style, 'warning', `Format command failed: ${result.formatCommand.command}`)}`, `${messageIndent}${options.formatError}`);
276
282
  }
@@ -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;
@@ -20,7 +21,7 @@ export interface ResolveReleaseNotesConfigOptions {
20
21
  * By default, falls back to defaults when `loadConfig()` rejects (legacy publish behavior).
21
22
  * When `strictLoad` is `true`, a load failure prints an error and calls `process.exit(1)`.
22
23
  */
23
- export declare function resolveReleaseNotesConfig(options?: ResolveReleaseNotesConfigOptions): Promise<ResolvedReleaseNotesConfig>;
24
+ export declare function resolveReleaseNotesConfig(stderrStyle: OutputStyle, options?: ResolveReleaseNotesConfigOptions): Promise<ResolvedReleaseNotesConfig>;
24
25
  /** Extract section headers in declaration order from a merged workTypes record. */
25
26
  export declare function deriveSectionOrder(workTypes: Record<string, {
26
27
  header: string;
@@ -1,9 +1,9 @@
1
- import { reportError } from '@williamthorsen/nmr-core';
1
+ import { formatStatusLine, printError, reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
3
  import { DEFAULT_CHANGELOG_JSON_CONFIG, DEFAULT_RELEASE_NOTES_CONFIG } from "./defaults.js";
4
4
  import { loadConfig, resolveWorkTypes } from "./loadConfig.js";
5
5
  import { validateConfig } from "./validateConfig.js";
6
- export async function resolveReleaseNotesConfig(options = {}) {
6
+ export async function resolveReleaseNotesConfig(stderrStyle, options = {}) {
7
7
  const { strictLoad = false } = options;
8
8
  let rawConfig;
9
9
  try {
@@ -28,12 +28,12 @@ export async function resolveReleaseNotesConfig(options = {}) {
28
28
  if (errors.length > 0) {
29
29
  process.stderr.write('Invalid config:\n');
30
30
  for (const err of errors) {
31
- process.stderr.write(` ❌ ${err}\n`);
31
+ printError(err, stderrStyle);
32
32
  }
33
33
  process.exit(1);
34
34
  }
35
35
  for (const warning of warnings) {
36
- console.warn(` ⚠️ ${warning}`);
36
+ console.warn(` ${formatStatusLine(stderrStyle, 'warning', warning)}`);
37
37
  }
38
38
  return {
39
39
  releaseNotes: {
@@ -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
  *
@@ -9,4 +10,4 @@
9
10
  *
10
11
  * @returns The exit code the caller should use.
11
12
  */
12
- export declare function showTagPrefixesCommand(): Promise<number>;
13
+ export declare function showTagPrefixesCommand(styles: StreamStyles): Promise<number>;
@@ -1,12 +1,13 @@
1
+ import { formatStatusLine } from '@williamthorsen/nmr-core';
1
2
  import { detectRepoType } from "./init/detectRepoType.js";
2
3
  import { previewTagPrefixes } from "./previewTagPrefixes.js";
3
- export async function showTagPrefixesCommand() {
4
+ export async function showTagPrefixesCommand(styles) {
4
5
  if (detectRepoType() === 'single-package') {
5
6
  process.stdout.write(renderSinglePackage());
6
7
  return 0;
7
8
  }
8
9
  const preview = await previewTagPrefixes();
9
- process.stdout.write(renderMonorepo(preview));
10
+ process.stdout.write(renderMonorepo(preview, styles.stdout));
10
11
  return computeExitCode(preview);
11
12
  }
12
13
  function renderSinglePackage() {
@@ -17,13 +18,13 @@ function renderSinglePackage() {
17
18
  ];
18
19
  return lines.join('\n');
19
20
  }
20
- function renderMonorepo(preview) {
21
+ function renderMonorepo(preview, style) {
21
22
  const lines = ['Workspace tag prefixes:', ''];
22
23
  for (const row of preview.workspaces) {
23
- lines.push(...renderWorkspaceRow(row));
24
+ lines.push(...renderWorkspaceRow(row, style));
24
25
  }
25
26
  if (preview.collisions.length > 0) {
26
- lines.push('', ...preview.collisions.map((collision) => `⛔ tag prefix collision: '${collision.tagPrefix}' used by ${collision.workspacePaths.join(', ')}`));
27
+ lines.push('', ...preview.collisions.map((collision) => formatStatusLine(style, 'failed', `tag prefix collision: '${collision.tagPrefix}' used by ${collision.workspacePaths.join(', ')}`)));
27
28
  }
28
29
  if (preview.undeclaredCandidates.length > 0) {
29
30
  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 +32,25 @@ function renderMonorepo(preview) {
31
32
  lines.push('');
32
33
  return lines.join('\n');
33
34
  }
34
- function renderWorkspaceRow(row) {
35
+ function renderWorkspaceRow(row, style) {
35
36
  const lines = [];
36
37
  if (row.derivedPrefix === null) {
37
- lines.push(` ${row.workspacePath} — ⛔ derivation failed: ${row.derivationError ?? 'unknown error'}`);
38
+ const failure = formatStatusLine(style, 'failed', `derivation failed: ${row.derivationError ?? 'unknown error'}`);
39
+ lines.push(` ${row.workspacePath} — ${failure}`);
38
40
  return lines;
39
41
  }
40
- const statusMarker = row.derivedTagCount > 0 ? `✅ ${row.derivedTagCount} tags` : '⚠️ no existing tags';
42
+ const statusMarker = row.derivedTagCount > 0
43
+ ? formatStatusLine(style, 'passed', `${row.derivedTagCount} tags`)
44
+ : formatStatusLine(style, 'warning', 'no existing tags');
41
45
  lines.push(` ${row.workspacePath} — derived prefix '${row.derivedPrefix}', ${statusMarker}`);
42
46
  for (const entry of row.legacyEntries) {
43
47
  if (entry.tagCount > 0) {
44
- lines.push(` ✅ ${entry.tagCount} legacy tags with '${entry.prefix}' prefix (recognized)`);
48
+ const recognized = `${entry.tagCount} legacy tags with '${entry.prefix}' prefix (recognized)`;
49
+ lines.push(` ${formatStatusLine(style, 'passed', recognized)}`);
45
50
  }
46
51
  else {
47
- lines.push(` ⚠️ recorded legacy prefix '${entry.prefix}' has no tags`);
52
+ const tagless = `recorded legacy prefix '${entry.prefix}' has no tags`;
53
+ lines.push(` ${formatStatusLine(style, 'warning', tagless)}`);
48
54
  }
49
55
  }
50
56
  return lines;
@@ -1,3 +1,4 @@
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. */
@@ -10,11 +11,12 @@ export declare function formatLabelsYaml(labels: LabelDefinition[], presetHashes
10
11
  * Reports an actionable error and returns `undefined` when the config file is missing,
11
12
  * fails to load or validate, or carries no `repoLabels` block.
12
13
  */
13
- export declare function loadRepoLabelsConfig(): Promise<RepoLabelsConfig | undefined>;
14
+ export declare function loadRepoLabelsConfig(stderrStyle: OutputStyle): Promise<RepoLabelsConfig | undefined>;
14
15
  /** Options for the `sync-labels generate` subcommand. */
15
16
  interface GenerateOptions {
16
17
  /** Compare the regenerated content against the committed file instead of writing. */
17
18
  check?: boolean;
19
+ styles: StreamStyles;
18
20
  }
19
21
  /**
20
22
  * Runs the `sync-labels generate` command.
@@ -23,5 +25,5 @@ interface GenerateOptions {
23
25
  * and writes `.github/labels.yaml` -- or, with `check`, regenerates in memory and reports whether the committed file
24
26
  * is stale, writing nothing. Returns 0 on success, 1 on failure.
25
27
  */
26
- export declare function generateCommand({ check }?: GenerateOptions): Promise<number>;
28
+ export declare function generateCommand({ check, styles }: GenerateOptions): Promise<number>;
27
29
  export {};
@@ -19,8 +19,8 @@ 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) {
23
+ const result = await loadValidatedConfig(stderrStyle);
24
24
  if (result.status === 'missing') {
25
25
  reportError(`No config file found at ${CONFIG_FILE_PATH}. Run \`release-kit sync-labels init\` first.`);
26
26
  return undefined;
@@ -34,11 +34,11 @@ export async function loadRepoLabelsConfig() {
34
34
  }
35
35
  return result.config.repoLabels;
36
36
  }
37
- export async function generateCommand({ check = false } = {}) {
37
+ export async function generateCommand({ check = false, styles }) {
38
38
  if (checkRetiredSyncLabelsConfig()) {
39
39
  return 1;
40
40
  }
41
- const repoLabels = await loadRepoLabelsConfig();
41
+ const repoLabels = await loadRepoLabelsConfig(styles.stderr);
42
42
  if (repoLabels === undefined) {
43
43
  return 1;
44
44
  }
@@ -1,7 +1,9 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  /** Options for the `sync-labels init` subcommand. */
2
3
  interface InitOptions {
3
4
  dryRun: boolean;
4
5
  force: boolean;
6
+ styles: StreamStyles;
5
7
  }
6
8
  /**
7
9
  * Run the `sync-labels init` subcommand.
@@ -11,5 +13,5 @@ interface InitOptions {
11
13
  * printed to stdout for manual paste when the file is already there — an existing,
12
14
  * hand-authored config is never rewritten. Returns 0 on success, 1 on failure.
13
15
  */
14
- export declare function syncLabelsInitCommand({ dryRun, force }: InitOptions): Promise<number>;
16
+ export declare function syncLabelsInitCommand({ dryRun, force, styles }: InitOptions): Promise<number>;
15
17
  export {};
@@ -8,7 +8,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 }) {
11
+ export async function syncLabelsInitCommand({ dryRun, force, styles }) {
12
12
  if (checkRetiredSyncLabelsConfig()) {
13
13
  return 1;
14
14
  }
@@ -32,14 +32,14 @@ export async function syncLabelsInitCommand({ dryRun, force }) {
32
32
  console.info(` Found ${String(workspacePaths.length)} workspaces`);
33
33
  }
34
34
  const configExists = existsSync(CONFIG_FILE_PATH);
35
- const retiredNames = configExists ? await loadRetiredPackageNames() : [];
35
+ const retiredNames = configExists ? await loadRetiredPackageNames(styles) : [];
36
36
  if (retiredNames === undefined) {
37
37
  return 1;
38
38
  }
39
39
  const scopeLabels = workspacePaths === undefined ? [] : buildScopeLabels(workspacePaths, retiredNames);
40
40
  console.info('\n> Scaffolding files');
41
41
  const workflowResult = writeFileWithCheck(WORKFLOW_PATH, syncLabelsWorkflow(), { dryRun, overwrite: force });
42
- reportWriteResult(workflowResult, dryRun);
42
+ reportWriteResult(workflowResult, dryRun, styles);
43
43
  if (configExists) {
44
44
  console.info(`\n> ${CONFIG_FILE_PATH} already exists; add this block to the object passed to defineConfig:\n`);
45
45
  console.info(renderRepoLabelsBlock(scopeLabels));
@@ -60,7 +60,7 @@ export async function syncLabelsInitCommand({ dryRun, force }) {
60
60
  dryRun,
61
61
  overwrite: force,
62
62
  });
63
- reportWriteResult(configResult, dryRun);
63
+ reportWriteResult(configResult, dryRun, styles);
64
64
  if (workflowResult.outcome === 'failed' || configResult.outcome === 'failed') {
65
65
  process.stderr.write('Failed to scaffold one or more files.\n');
66
66
  return 1;
@@ -70,7 +70,7 @@ export async function syncLabelsInitCommand({ dryRun, force }) {
70
70
  }
71
71
  else {
72
72
  console.info('\n> Generating labels');
73
- const generateExitCode = await generateCommand();
73
+ const generateExitCode = await generateCommand({ styles });
74
74
  if (generateExitCode !== 0) {
75
75
  return generateExitCode;
76
76
  }
@@ -87,8 +87,8 @@ export async function syncLabelsInitCommand({ dryRun, force }) {
87
87
  `);
88
88
  return 0;
89
89
  }
90
- async function loadRetiredPackageNames() {
91
- const result = await loadValidatedConfig();
90
+ async function loadRetiredPackageNames(styles) {
91
+ const result = await loadValidatedConfig(styles.stderr);
92
92
  if (result.status === 'invalid') {
93
93
  return undefined;
94
94
  }
@@ -1,2 +1,3 @@
1
+ import { type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  /** Orchestrate the CLI `tag` command: parse flags and delegate to `createTags`. */
2
- export declare function tagCommand(argv: string[]): void;
3
+ export declare function tagCommand(argv: string[], styles: StreamStyles): void;
@@ -5,10 +5,10 @@ const tagFlagSchema = {
5
5
  dryRun: { long: '--dry-run', type: 'boolean' },
6
6
  noGitChecks: { long: '--no-git-checks', type: 'boolean' },
7
7
  };
8
- export function tagCommand(argv) {
8
+ export function tagCommand(argv, styles) {
9
9
  const { dryRun, noGitChecks } = parseArgsOrExit(argv, tagFlagSchema).flags;
10
10
  try {
11
- createTags({ dryRun, noGitChecks });
11
+ createTags({ dryRun, noGitChecks, style: styles.stdout });
12
12
  }
13
13
  catch (error) {
14
14
  reportError(describeError(error));
@@ -11,19 +11,20 @@ export interface ChangelogItem {
11
11
  /**
12
12
  * Whether this item represents a breaking change.
13
13
  *
14
- * `true` when the commit subject carries the `!` prefix (e.g. `feat!:` or `drop(scope)!:`).
15
- * The `BREAKING CHANGE:` body footer is intentionally NOT considered here — only the prefix
16
- * `!` marks a changelog item as breaking. Renderers prefix breaking-item bullets with the
17
- * marker constructed from `WORK_TYPES_DATA.markers.breaking` (rendered as `🚨 **Breaking:** `
18
- * with the canonical SSOT values) to surface them prominently in release notes.
14
+ * `true` when the commit subject carries the `!` prefix (e.g. `feat!:` or `drop(scope)!:`) and
15
+ * the commit's work-type policy permits it, so the item agrees with the version bump.
16
+ * The `BREAKING CHANGE:` body footer is intentionally NOT considered here. Renderers prefix
17
+ * breaking-item bullets with the marker constructed from `WORK_TYPES_DATA.markers.breaking`
18
+ * (rendered as `🚨 **Breaking:** ` with the canonical SSOT values) to surface them prominently
19
+ * in release notes.
19
20
  */
20
21
  breaking?: boolean;
21
22
  /**
22
23
  * Migration instruction for a consumer, taken from the `Migration:` paragraph of `body`.
23
24
  *
24
25
  * Derived from `body` wherever `body` is set, and absent where the body carries no labeled
25
- * paragraph. Independent of `breaking`: a `fix` that tightens validation cannot carry `!` under
26
- * the default breaking policy and still imposes a migration. `body` keeps the paragraph, so
26
+ * paragraph. Independent of `breaking`: a `deprecate` commit cannot carry `!` under the default
27
+ * breaking policy and still calls for a migration. `body` keeps the paragraph, so
27
28
  * `CHANGELOG.md` goes on rendering it in place.
28
29
  */
29
30
  migration?: string;
@@ -1,9 +1,9 @@
1
+ import { type OutputStyle, type StreamStyles } from '@williamthorsen/nmr-core';
1
2
  import { type ValidateAllChangelogOverridesInputs, type ValidateAllChangelogOverridesResult } from './changelogOverrides.js';
2
3
  import type { ChangelogEntry, ReleaseConfig } from './types.js';
3
4
  /**
4
5
  * Result of {@link validateOverridesCommand}: tiered exit code paired with a human-readable
5
- * message. Mirrors the shape of `checkWorkTypesDrift` so the CLI dispatch layer can stay
6
- * uniformly thin.
6
+ * message, which keeps the CLI dispatch layer thin.
7
7
  *
8
8
  * Exit codes:
9
9
  * - `0` — clean: no errors, no warnings.
@@ -36,9 +36,9 @@ export interface ValidateOverridesCommandDependencies {
36
36
  * Single-package and monorepo modes are handled uniformly: single-package collapses to one
37
37
  * project scope; monorepo expands to a project scope plus one scope per workspace.
38
38
  */
39
- export declare function validateOverridesCommand(dependencies?: ValidateOverridesCommandDependencies): Promise<ValidateOverridesCommandResult>;
39
+ export declare function validateOverridesCommand(styles: StreamStyles, dependencies?: ValidateOverridesCommandDependencies): Promise<ValidateOverridesCommandResult>;
40
40
  /**
41
41
  * Pure formatter — take an aggregated validation result, return the tiered exit code and a
42
42
  * rendered message. Exported for unit testing without going through the full discovery path.
43
43
  */
44
- export declare function formatValidateOverridesResult(result: ValidateAllChangelogOverridesResult): ValidateOverridesCommandResult;
44
+ export declare function formatValidateOverridesResult(result: ValidateAllChangelogOverridesResult, style: OutputStyle): ValidateOverridesCommandResult;
@@ -1,4 +1,4 @@
1
- import { formatErrorLine } from '@williamthorsen/nmr-core';
1
+ 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";
@@ -7,7 +7,7 @@ import { buildTagPattern, getAllTagPrefixes } from "./generateChangelogs.js";
7
7
  import { loadConfig, mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
8
8
  import { validateConfig } from "./validateConfig.js";
9
9
  const SYNTHETIC_VALIDATE_TAG = 'validate-only';
10
- export async function validateOverridesCommand(dependencies = {}) {
10
+ export async function validateOverridesCommand(styles, dependencies = {}) {
11
11
  const discover = dependencies.discoverWorkspaces ?? discoverWorkspaces;
12
12
  const load = dependencies.loadConfig ?? loadConfig;
13
13
  const buildEntries = dependencies.buildEntries ?? defaultBuildEntries;
@@ -44,17 +44,17 @@ export async function validateOverridesCommand(dependencies = {}) {
44
44
  return { exitCode: 2, message: formatErrorLine(`Failed to resolve overrides scope: ${describeError(error)}`) };
45
45
  }
46
46
  const result = validate(inputs);
47
- return formatValidateOverridesResult(result);
47
+ return formatValidateOverridesResult(result, styles.stderr);
48
48
  }
49
- export function formatValidateOverridesResult(result) {
49
+ export function formatValidateOverridesResult(result, style) {
50
50
  const { errors, warnings } = result;
51
51
  if (errors.length === 0 && warnings.length === 0) {
52
52
  return { exitCode: 0, message: 'All override files are valid (no errors, no stale keys).' };
53
53
  }
54
54
  const exitCode = errors.length > 0 ? 2 : 1;
55
55
  const summary = formatSummaryLine(errors.length, warnings.length);
56
- const errorLines = errors.map((message) => ` ❌ ${message}`);
57
- const warningLines = warnings.map((message) => ` ⚠️ ${message}`);
56
+ const errorLines = errors.map((message) => ` ${formatStatusLine(style, 'failed', message)}`);
57
+ const warningLines = warnings.map((message) => ` ${formatStatusLine(style, 'warning', message)}`);
58
58
  const message = [summary, '', ...errorLines, ...warningLines].join('\n');
59
59
  return { exitCode, message };
60
60
  }