@repo-toolkit/changelog 0.9.0 → 0.11.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 (5) hide show
  1. package/README.md +98 -19
  2. package/cli.js +296 -109
  3. package/index.d.ts +19 -8
  4. package/index.js +269 -88
  5. package/package.json +2 -2
package/README.md CHANGED
@@ -11,7 +11,7 @@ pnpm add -D @repo-toolkit/changelog
11
11
  ## Config File
12
12
 
13
13
  Use `--config` when you want repo-specific options such as custom commit `types`,
14
- scope filtering, ignored commits, or custom issue and commit URLs.
14
+ scope filtering, ignored commits, or custom URL formats.
15
15
 
16
16
  ```js
17
17
  /** @type {import('@repo-toolkit/changelog').ChangelogConfig} */
@@ -38,9 +38,19 @@ repo-toolkit-changelog --config changelog.config.mjs
38
38
 
39
39
  CLI flags override values from the config file.
40
40
 
41
- Use a JavaScript config file when you need `RegExp` values such as `ignoreCommits`
42
- or formatter callbacks such as `formatIssueUrl`. JSON config files only work for
43
- plain data options.
41
+ Use a JavaScript config file when you need `RegExp` values such as `ignoreCommits`.
42
+ JSON config files only work for plain data options.
43
+
44
+ > **Warning:** A JavaScript config file (`.mjs`, `.js`, `.cjs`) is loaded with
45
+ > `import()`, so it executes as trusted code in the same Node process as the
46
+ > CLI. Only point `--config` at files you control. JSON config files are parsed
47
+ > with `JSON.parse` and do not execute.
48
+
49
+ Both JSON and JS config files must export an object whose shape matches
50
+ `ChangelogConfig`. Runtime validation rejects unknown top-level keys, unknown
51
+ fields on `types` entries, and unsupported `effect` values (only `'hidden'` is
52
+ recognized) before the generator is built — invalid configs fail fast with an
53
+ actionable error instead of a silent later surprise.
44
54
 
45
55
  ## CLI
46
56
 
@@ -48,21 +58,59 @@ plain data options.
48
58
  repo-toolkit-changelog
49
59
  ```
50
60
 
51
- Useful flags:
52
-
53
- - `--config <path>`
54
- - `--cwd <path>`
55
- - `--output <path>`
56
- - `--tag-prefix <prefix>`
57
- - `--release-count <number>`
58
- - `--first-release`
59
- - `--no-skip-unstable`
60
- - `--no-output-unreleased`
61
+ All flags:
62
+
63
+ | Flag | Description | Default |
64
+ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ------------------------------- |
65
+ | `--config <path>` | Config file (JSON or JS module) with changelog options. JS modules execute trusted code. | none |
66
+ | `--cwd <path>` | Working directory for reading `package.json` and git metadata. | `process.cwd()` |
67
+ | `--output <path>` | Output file path (absolute or relative to `--cwd`). | `CHANGELOG.md` |
68
+ | `--tag-prefix <prefix>` | Tag prefix to match. Pass `--tag-prefix=` for an empty prefix. | `v` |
69
+ | `--release-count <non-negative integer>` | Number of releases to include. `0` regenerates all releases. | latest release only |
70
+ | `--append` / `--no-append` | Append generated content after the existing file. Without this flag, generated content is prepended. | `--no-append` (prepend) |
71
+ | `--first-release` / `--no-first-release` | Include all commits back to the first tag when no prior release tag exists. Overrides `--release-count`. | `--no-first-release` |
72
+ | `--skip-unstable` / `--no-skip-unstable` | Skip prerelease tags (e.g. `v1.0.0-beta.1`). | `--skip-unstable` (skip) |
73
+ | `--output-unreleased` / `--no-output-unreleased` | Include the unreleased section. | `--output-unreleased` (include) |
74
+ | `-h`, `--help` | Show help and exit. | — |
75
+
76
+ Invalid numeric `--release-count` values exit nonzero with `Invalid numeric value`,
77
+ and unsupported config fields exit nonzero with a precise field-level message.
78
+
79
+ `--first-release` takes precedence over `--release-count` and regenerates the
80
+ full changelog. Unknown arguments are rejected by default; use `--` to pass
81
+ through trailing args to nested tooling in non-strict mode only.
82
+
83
+ ## Output semantics
84
+
85
+ `generateChangelog` writes the resulting changelog atomically: it composes the
86
+ generated content into a sibling temporary file and renames it over the
87
+ destination only after the generator stream completes successfully. If the
88
+ generator throws, the existing file is left untouched and the temp file is
89
+ removed.
90
+
91
+ Existing file behavior:
92
+
93
+ - **Default (prepend):** generated content is placed above the existing content,
94
+ separated by a single blank line. Excess trailing newlines on either side are
95
+ collapsed to keep the separator stable.
96
+ - **`--append`:** generated content is placed below the existing content, also
97
+ separated by a single blank line.
98
+ - **No existing file:** the generated content is written directly to the
99
+ destination; no merge is performed.
100
+ - **Empty generated content:** the existing file is left unchanged.
101
+
102
+ The output always ends with a single trailing newline.
61
103
 
62
104
  ## JavaScript API
63
105
 
64
106
  ```ts
65
- import { generateChangelog } from '@repo-toolkit/changelog';
107
+ import {
108
+ generateChangelog,
109
+ createGenerator,
110
+ createPreset,
111
+ getDefaultTypes,
112
+ DEFAULT_TYPES,
113
+ } from '@repo-toolkit/changelog';
66
114
 
67
115
  await generateChangelog({
68
116
  outputFile: 'CHANGELOG.md',
@@ -72,6 +120,36 @@ await generateChangelog({
72
120
  });
73
121
  ```
74
122
 
123
+ ### Public exports
124
+
125
+ | Export | Kind | Signature | Returns |
126
+ | ----------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
127
+ | `generateChangelog` | async function | `(options?: GenerateChangelogOptions) => Promise<string>` | Resolves with the absolute path of the written changelog file. |
128
+ | `createGenerator` | async function | `(options?: GenerateChangelogOptions) => Promise<ConventionalChangelog>` | Resolves with the configured `conventional-changelog` generator. |
129
+ | `createPreset` | async function | `(options?: CreatePresetOptions) => Promise<ConventionalCommitsPreset & { name: 'conventionalcommits' }>` | Resolves with the preset, tagged with `name: 'conventionalcommits'`. |
130
+ | `getDefaultTypes` | function | `() => ReadonlyArray<Readonly<ChangelogType>>` | Returns the frozen `DEFAULT_TYPES` array (same reference each call). |
131
+ | `DEFAULT_TYPES` | constant | `ReadonlyArray<Readonly<ChangelogType>>` | Deeply frozen default `types` array. |
132
+ | `GenerateChangelogOptions` | interface | extends `CreatePresetOptions` | — |
133
+ | `CreatePresetOptions` | type alias | = `ConventionalCommitsPresetOptions` | — |
134
+ | `ChangelogConfig` | type alias | = `GenerateChangelogOptions` | Config file shape. |
135
+ | `ChangelogType` | interface | `{ type, section?, scope?, effect?: 'hidden', hidden? }` | — |
136
+ | `ConventionalCommitsPresetOptions` | interface | — | Mirrors the pinned conventionalcommits API. |
137
+ | `ChangelogContext`, `ChangelogReference`, `ChangelogCommit` | interfaces | — | Echoed from the upstream parser/writer contracts. |
138
+
139
+ ### Defaults immutability
140
+
141
+ `DEFAULT_TYPES` is deeply frozen at module load: nested entries cannot be
142
+ mutated, even in strict mode. Use `getDefaultTypes()` if you need a stable
143
+ accessor. Mutating attempts throw instead of silently corrupting later preset
144
+ creation.
145
+
146
+ ### `effect` contract
147
+
148
+ `effect` is the preferred field for visibility control on a `ChangelogType`.
149
+ Only `'hidden'` is supported — it maps to `hidden: true` for the upstream
150
+ conventionalcommits parser. The `hidden` boolean is still accepted on a type
151
+ entry for compatibility with older upstream versions.
152
+
75
153
  ## Supported Preset Options
76
154
 
77
155
  - `types`
@@ -80,10 +158,11 @@ await generateChangelog({
80
158
  - `scope`
81
159
  - `scopeOnly`
82
160
  - `preMajor`
83
- - `formatIssueUrl`
84
- - `formatCommitUrl`
85
- - `formatCompareUrl`
86
- - `formatUserUrl`
161
+ - `issueUrlFormat`
162
+ - `commitUrlFormat`
163
+ - `compareUrlFormat`
164
+ - `userUrlFormat`
165
+ - `bumpStrict`
87
166
 
88
167
  ## Default Sections
89
168
 
package/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { parseFlags, INTERACTIVE_FLAG, resolveCliOptions } from "@repo-toolkit/publish-package";
4
+ import { pathToFileURL } from "url";
5
+ import { parseFlags, resolveCliOptions } from "@repo-toolkit/publish-package";
5
6
 
6
7
  // src/index.ts
7
- import { createWriteStream } from "fs";
8
- import { mkdir } from "fs/promises";
8
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
9
9
  import { dirname, isAbsolute, resolve } from "path";
10
+ import { randomUUID } from "crypto";
11
+ import { isPlainObject } from "@repo-toolkit/publish-package";
10
12
  import { ConventionalChangelog } from "conventional-changelog";
11
13
  import createConventionalCommitsPreset from "conventional-changelog-conventionalcommits";
12
14
  var PIPELINE_OPTION_KEYS = [
@@ -29,65 +31,67 @@ function splitPresetOptions(options) {
29
31
  }
30
32
  return presetOptions;
31
33
  }
32
- var DEFAULT_TYPES = [
33
- {
34
- type: "feat",
35
- section: "Features"
36
- },
37
- {
38
- type: "fix",
39
- scope: "deps",
40
- effect: "hidden"
41
- },
42
- {
43
- type: "fix",
44
- section: "Bug Fixes"
45
- },
46
- {
47
- type: "revert",
48
- section: "Reverts"
49
- },
50
- {
51
- type: "docs",
52
- section: "Documentation"
53
- },
54
- {
55
- type: "refactor",
56
- section: "Code Refactoring"
57
- },
58
- {
59
- type: "perf",
60
- section: "Performance Improvements"
61
- },
62
- {
63
- type: "build",
64
- section: "Build System"
65
- },
66
- {
67
- type: "e2e",
68
- section: "End-to-end Testing"
69
- },
70
- {
71
- type: "ci",
72
- effect: "hidden"
73
- },
74
- {
75
- type: "chore",
76
- effect: "hidden"
77
- },
78
- {
79
- type: "style",
80
- effect: "hidden"
81
- },
82
- {
83
- type: "test",
84
- effect: "hidden"
85
- },
86
- {
87
- type: "release",
88
- effect: "hidden"
89
- }
90
- ];
34
+ var DEFAULT_TYPES = Object.freeze(
35
+ [
36
+ {
37
+ type: "feat",
38
+ section: "Features"
39
+ },
40
+ {
41
+ type: "fix",
42
+ scope: "deps",
43
+ effect: "hidden"
44
+ },
45
+ {
46
+ type: "fix",
47
+ section: "Bug Fixes"
48
+ },
49
+ {
50
+ type: "revert",
51
+ section: "Reverts"
52
+ },
53
+ {
54
+ type: "docs",
55
+ section: "Documentation"
56
+ },
57
+ {
58
+ type: "refactor",
59
+ section: "Code Refactoring"
60
+ },
61
+ {
62
+ type: "perf",
63
+ section: "Performance Improvements"
64
+ },
65
+ {
66
+ type: "build",
67
+ section: "Build System"
68
+ },
69
+ {
70
+ type: "e2e",
71
+ section: "End-to-end Testing"
72
+ },
73
+ {
74
+ type: "ci",
75
+ effect: "hidden"
76
+ },
77
+ {
78
+ type: "chore",
79
+ effect: "hidden"
80
+ },
81
+ {
82
+ type: "style",
83
+ effect: "hidden"
84
+ },
85
+ {
86
+ type: "test",
87
+ effect: "hidden"
88
+ },
89
+ {
90
+ type: "release",
91
+ effect: "hidden"
92
+ }
93
+ ].map((entry) => Object.freeze(entry))
94
+ );
91
95
  function normalizeTypes(types) {
92
96
  return types.map((entry) => ({
93
97
  ...entry,
@@ -100,32 +104,209 @@ function resolvePresetOptions(options = {}) {
100
104
  types: normalizeTypes(options.types ?? DEFAULT_TYPES)
101
105
  };
102
106
  }
107
+ function resolveTagOptions(options) {
108
+ const tags = {};
109
+ if (options.tagPrefix !== void 0) {
110
+ tags.prefix = options.tagPrefix;
111
+ }
112
+ if (options.skipUnstable !== void 0) {
113
+ tags.skipUnstable = options.skipUnstable;
114
+ }
115
+ return tags;
116
+ }
117
+ function resolveGeneratorOptions(options) {
118
+ const resolvedOptions = {
119
+ append: options.append ?? false,
120
+ outputUnreleased: options.outputUnreleased ?? true
121
+ };
122
+ if (options.firstRelease === true) {
123
+ resolvedOptions.releaseCount = 0;
124
+ } else if (options.releaseCount !== void 0) {
125
+ resolvedOptions.releaseCount = validateReleaseCount(options.releaseCount);
126
+ }
127
+ return resolvedOptions;
128
+ }
129
+ function validateGenerateChangelogOptions(options) {
130
+ if (options.releaseCount !== void 0) {
131
+ validateReleaseCount(options.releaseCount);
132
+ }
133
+ validatePresetOptions(splitPresetOptions(options));
134
+ }
135
+ function validateReleaseCount(value) {
136
+ if (!Number.isSafeInteger(value) || value < 0) {
137
+ throw new Error(`releaseCount must be a non-negative safe integer, got: ${value}`);
138
+ }
139
+ return value;
140
+ }
141
+ var PRESET_OPTION_KEYS = [
142
+ "types",
143
+ "ignoreCommits",
144
+ "issuePrefixes",
145
+ "scope",
146
+ "scopeOnly",
147
+ "preMajor",
148
+ "issueUrlFormat",
149
+ "commitUrlFormat",
150
+ "compareUrlFormat",
151
+ "userUrlFormat",
152
+ "bumpStrict"
153
+ ];
154
+ var STRING_FORMAT_KEYS = ["issueUrlFormat", "commitUrlFormat", "compareUrlFormat", "userUrlFormat"];
155
+ function validatePresetOptions(options) {
156
+ for (const key of Object.keys(options)) {
157
+ if (!PRESET_OPTION_KEYS.includes(key)) {
158
+ throw new Error(`Unknown changelog config option: ${key}`);
159
+ }
160
+ }
161
+ if (options.types !== void 0) {
162
+ validateTypes(options.types);
163
+ }
164
+ if (options.ignoreCommits !== void 0 && !(options.ignoreCommits instanceof RegExp)) {
165
+ throw new Error("ignoreCommits must be a RegExp");
166
+ }
167
+ if (options.issuePrefixes !== void 0) {
168
+ validateStringArray(options.issuePrefixes, "issuePrefixes");
169
+ }
170
+ if (options.scope !== void 0) {
171
+ if (typeof options.scope !== "string" && !Array.isArray(options.scope)) {
172
+ throw new Error("scope must be a string or an array of strings");
173
+ }
174
+ if (Array.isArray(options.scope)) {
175
+ validateStringArray(options.scope, "scope");
176
+ }
177
+ }
178
+ if (options.scopeOnly !== void 0 && typeof options.scopeOnly !== "boolean") {
179
+ throw new Error("scopeOnly must be a boolean");
180
+ }
181
+ if (options.preMajor !== void 0 && typeof options.preMajor !== "boolean") {
182
+ throw new Error("preMajor must be a boolean");
183
+ }
184
+ for (const key of STRING_FORMAT_KEYS) {
185
+ const value = options[key];
186
+ if (value !== void 0 && typeof value !== "string") {
187
+ throw new Error(`${key} must be a string`);
188
+ }
189
+ }
190
+ if (options.bumpStrict !== void 0 && typeof options.bumpStrict !== "boolean") {
191
+ throw new Error("bumpStrict must be a boolean");
192
+ }
193
+ }
194
+ function validateTypes(types) {
195
+ if (!Array.isArray(types)) {
196
+ throw new Error("types must be an array");
197
+ }
198
+ types.forEach((entry, index) => {
199
+ if (!isPlainObject(entry)) {
200
+ throw new Error(`types[${index}] must be an object`);
201
+ }
202
+ if (typeof entry.type !== "string" || entry.type.length === 0) {
203
+ throw new Error(`types[${index}].type must be a non-empty string`);
204
+ }
205
+ if (entry.section !== void 0 && typeof entry.section !== "string") {
206
+ throw new Error(`types[${index}].section must be a string`);
207
+ }
208
+ if (entry.scope !== void 0 && typeof entry.scope !== "string") {
209
+ throw new Error(`types[${index}].scope must be a string`);
210
+ }
211
+ if (entry.effect !== void 0 && entry.effect !== "hidden") {
212
+ throw new Error(`types[${index}].effect must be 'hidden'`);
213
+ }
214
+ if (entry.hidden !== void 0 && typeof entry.hidden !== "boolean") {
215
+ throw new Error(`types[${index}].hidden must be a boolean`);
216
+ }
217
+ const knownKeys = /* @__PURE__ */ new Set(["type", "section", "scope", "effect", "hidden"]);
218
+ for (const key of Object.keys(entry)) {
219
+ if (!knownKeys.has(key)) {
220
+ throw new Error(`types[${index}] has unknown field: ${key}`);
221
+ }
222
+ }
223
+ });
224
+ }
225
+ function validateStringArray(value, label) {
226
+ if (!Array.isArray(value)) {
227
+ throw new Error(`${label} must be an array of strings`);
228
+ }
229
+ value.forEach((entry, index) => {
230
+ if (typeof entry !== "string") {
231
+ throw new Error(`${label}[${index}] must be a string`);
232
+ }
233
+ });
234
+ }
103
235
  function resolveOutputPath(cwd, outputFile) {
104
236
  return isAbsolute(outputFile) ? outputFile : resolve(cwd, outputFile);
105
237
  }
106
- function pipeGeneratorToFile(generator, outputPath) {
238
+ async function pipeGeneratorToFile(generator, outputPath, append) {
239
+ const tempPath = `${outputPath}.${randomUUID()}.tmp`;
240
+ const generated = await readGeneratorOutput(generator.writeStream());
241
+ const existing = await readExistingOutput(outputPath);
242
+ const contents = combineChangelogContents(existing, generated, append);
243
+ try {
244
+ await writeFile(tempPath, contents, "utf8");
245
+ await rename(tempPath, outputPath);
246
+ } catch (error) {
247
+ await rm(tempPath, { force: true });
248
+ throw error;
249
+ }
250
+ return outputPath;
251
+ }
252
+ function readGeneratorOutput(stream) {
107
253
  return new Promise((resolvePromise, reject) => {
108
- const generatorStream = generator.writeStream();
109
- const fileStream = createWriteStream(outputPath);
254
+ const chunks = [];
255
+ const onData = (chunk) => {
256
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
257
+ };
110
258
  const onError = (error) => {
111
- generatorStream.off("error", onError);
112
- fileStream.off("error", onError);
113
- fileStream.off("finish", onFinish);
259
+ cleanup();
114
260
  reject(error);
115
261
  };
116
- const onFinish = () => {
117
- generatorStream.off("error", onError);
118
- fileStream.off("error", onError);
119
- fileStream.off("finish", onFinish);
120
- resolvePromise(outputPath);
262
+ const onEnd = () => {
263
+ cleanup();
264
+ resolvePromise(Buffer.concat(chunks).toString("utf8"));
121
265
  };
122
- generatorStream.on("error", onError);
123
- fileStream.on("error", onError);
124
- fileStream.on("finish", onFinish);
125
- generatorStream.pipe(fileStream);
266
+ const cleanup = () => {
267
+ stream.off("data", onData);
268
+ stream.off("error", onError);
269
+ stream.off("end", onEnd);
270
+ };
271
+ stream.on("data", onData);
272
+ stream.on("error", onError);
273
+ stream.on("end", onEnd);
126
274
  });
127
275
  }
276
+ async function readExistingOutput(outputPath) {
277
+ try {
278
+ return await readFile(outputPath, "utf8");
279
+ } catch (error) {
280
+ if (error.code === "ENOENT") {
281
+ return void 0;
282
+ }
283
+ throw error;
284
+ }
285
+ }
286
+ function combineChangelogContents(existing, generated, append) {
287
+ const next = trimTrailingNewlines(generated);
288
+ const current = trimTrailingNewlines(existing ?? "");
289
+ if (current.length === 0) {
290
+ return `${next}
291
+ `;
292
+ }
293
+ if (next.length === 0) {
294
+ return `${current}
295
+ `;
296
+ }
297
+ return append ? `${current}
298
+
299
+ ${next}
300
+ ` : `${next}
301
+
302
+ ${current}
303
+ `;
304
+ }
305
+ function trimTrailingNewlines(value) {
306
+ return value.replace(/\n+$/u, "");
307
+ }
128
308
  async function createPreset(options = {}) {
309
+ validatePresetOptions(options);
129
310
  const preset = await createConventionalCommitsPreset(resolvePresetOptions(options));
130
311
  return {
131
312
  ...preset,
@@ -134,19 +315,16 @@ async function createPreset(options = {}) {
134
315
  }
135
316
  async function createGenerator(options = {}) {
136
317
  const cwd = resolve(options.cwd ?? process.cwd());
318
+ validateGenerateChangelogOptions(options);
137
319
  const presetOptions = splitPresetOptions(options);
138
320
  const preset = await createPreset(presetOptions);
139
321
  const generator = new ConventionalChangelog(cwd);
140
- const generatorOptions = {
141
- append: options.append ?? false,
142
- releaseCount: options.releaseCount ?? 0,
143
- skipUnstable: options.skipUnstable ?? true,
144
- outputUnreleased: options.outputUnreleased ?? true,
145
- tagPrefix: options.tagPrefix ?? "v",
146
- firstRelease: options.firstRelease ?? false
147
- };
148
- generator.readPackage(resolve(cwd, "package.json")).loadPreset(preset).options(generatorOptions).config({
149
- tags: preset.tags,
322
+ generator.readPackage(resolve(cwd, "package.json")).loadPreset(preset).options(resolveGeneratorOptions(options)).tags(
323
+ resolveTagOptions({
324
+ tagPrefix: options.tagPrefix ?? "v",
325
+ skipUnstable: options.skipUnstable ?? true
326
+ })
327
+ ).config({
150
328
  commits: preset.commits,
151
329
  parser: preset.parser,
152
330
  writer: preset.writer
@@ -158,7 +336,7 @@ async function generateChangelog(options = {}) {
158
336
  const outputPath = resolveOutputPath(cwd, options.outputFile ?? "CHANGELOG.md");
159
337
  await mkdir(dirname(outputPath), { recursive: true });
160
338
  const generator = await createGenerator({ ...options, cwd });
161
- return await pipeGeneratorToFile(generator, outputPath);
339
+ return await pipeGeneratorToFile(generator, outputPath, options.append ?? false);
162
340
  }
163
341
 
164
342
  // src/cli.ts
@@ -171,8 +349,7 @@ var SPECS = [
171
349
  { name: "append", boolean: true, negatable: true },
172
350
  { name: "first-release", boolean: true, negatable: true },
173
351
  { name: "skip-unstable", boolean: true, negatable: true },
174
- { name: "output-unreleased", boolean: true, negatable: true },
175
- INTERACTIVE_FLAG
352
+ { name: "output-unreleased", boolean: true, negatable: true }
176
353
  ];
177
354
  function printHelp() {
178
355
  console.log(`repo-toolkit-changelog
@@ -181,31 +358,33 @@ Usage:
181
358
  repo-toolkit-changelog [options]
182
359
 
183
360
  Options:
184
- --config <path> Config file with changelog options such as custom types
185
- --cwd <path> Working directory to read package and git metadata from
186
- --output <path> Output file path (default: CHANGELOG.md)
187
- --tag-prefix <prefix> Tag prefix to match (default: v)
188
- --release-count <number> Number of releases to include (default: 0)
189
- --append Append to the output instead of prepending
190
- --first-release Include all commits when no prior release tag exists
191
- --no-skip-unstable Include unstable releases
192
- --no-output-unreleased Omit the unreleased section
193
- -i, --interactive Prompt for missing required values interactively
194
- -h, --help Show this help message
361
+ --config <path> Config file (JSON or JS module) with changelog options
362
+ --cwd <path> Working directory to read package and git metadata from
363
+ --output <path> Output file path (default: CHANGELOG.md)
364
+ --tag-prefix <prefix> Tag prefix to match (default: v; use --tag-prefix= for empty)
365
+ --release-count <non-negative int> Number of releases to include (default: latest release only)
366
+ --append / --no-append Append to or prepend to the output (default: prepend)
367
+ --first-release / --no-first-release Include all commits when no prior release tag exists
368
+ --skip-unstable / --no-skip-unstable Skip prerelease tags (default: skip)
369
+ --output-unreleased / --no-output-unreleased Include the unreleased section (default: include)
370
+ -h, --help Show this help message
195
371
  `);
196
372
  }
197
373
  function parseNumber(value, flag) {
198
374
  const parsed = Number.parseInt(value, 10);
199
- if (Number.isNaN(parsed)) {
375
+ if (Number.isNaN(parsed) || !Number.isSafeInteger(parsed) || parsed < 0) {
200
376
  throw new Error(`Invalid numeric value for ${flag}: ${value}`);
201
377
  }
202
378
  return parsed;
203
379
  }
380
+ function resolveGenerateChangelogCliOptions(result) {
381
+ return buildOptions(result.values);
382
+ }
204
383
  function buildOptions(values) {
205
384
  const options = {};
206
385
  if (values.cwd) options.cwd = values.cwd;
207
386
  if (values.output) options.outputFile = values.output;
208
- if (values["tag-prefix"]) options.tagPrefix = values["tag-prefix"];
387
+ if (Object.prototype.hasOwnProperty.call(values, "tag-prefix")) options.tagPrefix = values["tag-prefix"];
209
388
  if (values["release-count"] !== void 0)
210
389
  options.releaseCount = parseNumber(values["release-count"], "--release-count");
211
390
  if (values.append !== void 0) options.append = values.append === "true";
@@ -222,13 +401,21 @@ async function main() {
222
401
  }
223
402
  const merged = await resolveCliOptions({
224
403
  result,
225
- buildOptions: (flags) => buildOptions(flags.values)
404
+ buildOptions: resolveGenerateChangelogCliOptions
226
405
  });
227
406
  const outputPath = await generateChangelog(merged);
228
407
  console.log(`Changelog generated at ${outputPath}.`);
229
408
  }
230
- main().catch((error) => {
231
- const message = error instanceof Error ? error.message : String(error);
232
- console.error(message);
233
- process.exitCode = 1;
234
- });
409
+ var executedAsEntryPoint = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
410
+ if (executedAsEntryPoint) {
411
+ main().catch((error) => {
412
+ const message = error instanceof Error ? error.message : String(error);
413
+ console.error(message);
414
+ process.exitCode = 1;
415
+ });
416
+ }
417
+ export {
418
+ SPECS,
419
+ printHelp,
420
+ resolveGenerateChangelogCliOptions
421
+ };
package/index.d.ts CHANGED
@@ -4,7 +4,7 @@ interface ChangelogType {
4
4
  type: string;
5
5
  section?: string;
6
6
  scope?: string;
7
- effect?: 'bump' | 'changelog' | 'hidden';
7
+ effect?: 'hidden';
8
8
  hidden?: boolean;
9
9
  }
10
10
  interface ChangelogContext {
@@ -29,10 +29,11 @@ interface ConventionalCommitsPresetOptions {
29
29
  scope?: string | ReadonlyArray<string>;
30
30
  scopeOnly?: boolean;
31
31
  preMajor?: boolean;
32
- formatIssueUrl?: (context: ChangelogContext, reference: ChangelogReference) => string;
33
- formatCommitUrl?: (context: ChangelogContext, commit: ChangelogCommit) => string;
34
- formatCompareUrl?: (context: ChangelogContext) => string;
35
- formatUserUrl?: (context: ChangelogContext, user: string) => string;
32
+ issueUrlFormat?: string;
33
+ commitUrlFormat?: string;
34
+ compareUrlFormat?: string;
35
+ userUrlFormat?: string;
36
+ bumpStrict?: boolean;
36
37
  }
37
38
  type CreatePresetOptions = ConventionalCommitsPresetOptions;
38
39
  interface GenerateChangelogOptions extends CreatePresetOptions {
@@ -46,9 +47,19 @@ interface GenerateChangelogOptions extends CreatePresetOptions {
46
47
  firstRelease?: boolean;
47
48
  }
48
49
  type ChangelogConfig = GenerateChangelogOptions;
49
- declare const DEFAULT_TYPES: ReadonlyArray<ChangelogType>;
50
- declare function createPreset(options?: CreatePresetOptions): Promise<any>;
50
+ declare const DEFAULT_TYPES: ReadonlyArray<Readonly<ChangelogType>>;
51
+ declare function getDefaultTypes(): ReadonlyArray<Readonly<ChangelogType>>;
52
+ declare function createPreset(options?: CreatePresetOptions): Promise<{
53
+ name: string;
54
+ commits?: GetCommitsParams;
55
+ parser?: ParserStreamOptions;
56
+ writer?: WriterOptions;
57
+ whatBump?: (commits: Commit[]) => {
58
+ level: number;
59
+ reason: string;
60
+ } | null;
61
+ }>;
51
62
  declare function createGenerator(options?: GenerateChangelogOptions): Promise<ConventionalChangelog>;
52
63
  declare function generateChangelog(options?: GenerateChangelogOptions): Promise<string>;
53
64
 
54
- export { type ChangelogCommit, type ChangelogConfig, type ChangelogContext, type ChangelogReference, type ChangelogType, type ConventionalCommitsPresetOptions, type CreatePresetOptions, DEFAULT_TYPES, type GenerateChangelogOptions, createGenerator, createPreset, generateChangelog };
65
+ export { type ChangelogCommit, type ChangelogConfig, type ChangelogContext, type ChangelogReference, type ChangelogType, type ConventionalCommitsPresetOptions, type CreatePresetOptions, DEFAULT_TYPES, type GenerateChangelogOptions, createGenerator, createPreset, generateChangelog, getDefaultTypes };
package/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // src/index.ts
2
- import { createWriteStream } from "fs";
3
- import { mkdir } from "fs/promises";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
4
3
  import { dirname, isAbsolute, resolve } from "path";
4
+ import { randomUUID } from "crypto";
5
+ import { isPlainObject } from "@repo-toolkit/publish-package";
5
6
  import { ConventionalChangelog } from "conventional-changelog";
6
7
  import createConventionalCommitsPreset from "conventional-changelog-conventionalcommits";
7
8
  var PIPELINE_OPTION_KEYS = [
@@ -24,65 +25,70 @@ function splitPresetOptions(options) {
24
25
  }
25
26
  return presetOptions;
26
27
  }
27
- var DEFAULT_TYPES = [
28
- {
29
- type: "feat",
30
- section: "Features"
31
- },
32
- {
33
- type: "fix",
34
- scope: "deps",
35
- effect: "hidden"
36
- },
37
- {
38
- type: "fix",
39
- section: "Bug Fixes"
40
- },
41
- {
42
- type: "revert",
43
- section: "Reverts"
44
- },
45
- {
46
- type: "docs",
47
- section: "Documentation"
48
- },
49
- {
50
- type: "refactor",
51
- section: "Code Refactoring"
52
- },
53
- {
54
- type: "perf",
55
- section: "Performance Improvements"
56
- },
57
- {
58
- type: "build",
59
- section: "Build System"
60
- },
61
- {
62
- type: "e2e",
63
- section: "End-to-end Testing"
64
- },
65
- {
66
- type: "ci",
67
- effect: "hidden"
68
- },
69
- {
70
- type: "chore",
71
- effect: "hidden"
72
- },
73
- {
74
- type: "style",
75
- effect: "hidden"
76
- },
77
- {
78
- type: "test",
79
- effect: "hidden"
80
- },
81
- {
82
- type: "release",
83
- effect: "hidden"
84
- }
85
- ];
28
+ var DEFAULT_TYPES = Object.freeze(
29
+ [
30
+ {
31
+ type: "feat",
32
+ section: "Features"
33
+ },
34
+ {
35
+ type: "fix",
36
+ scope: "deps",
37
+ effect: "hidden"
38
+ },
39
+ {
40
+ type: "fix",
41
+ section: "Bug Fixes"
42
+ },
43
+ {
44
+ type: "revert",
45
+ section: "Reverts"
46
+ },
47
+ {
48
+ type: "docs",
49
+ section: "Documentation"
50
+ },
51
+ {
52
+ type: "refactor",
53
+ section: "Code Refactoring"
54
+ },
55
+ {
56
+ type: "perf",
57
+ section: "Performance Improvements"
58
+ },
59
+ {
60
+ type: "build",
61
+ section: "Build System"
62
+ },
63
+ {
64
+ type: "e2e",
65
+ section: "End-to-end Testing"
66
+ },
67
+ {
68
+ type: "ci",
69
+ effect: "hidden"
70
+ },
71
+ {
72
+ type: "chore",
73
+ effect: "hidden"
74
+ },
75
+ {
76
+ type: "style",
77
+ effect: "hidden"
78
+ },
79
+ {
80
+ type: "test",
81
+ effect: "hidden"
82
+ },
83
+ {
84
+ type: "release",
85
+ effect: "hidden"
86
+ }
87
+ ].map((entry) => Object.freeze(entry))
88
+ );
89
+ function getDefaultTypes() {
90
+ return DEFAULT_TYPES;
91
+ }
86
92
  function normalizeTypes(types) {
87
93
  return types.map((entry) => ({
88
94
  ...entry,
@@ -95,32 +101,209 @@ function resolvePresetOptions(options = {}) {
95
101
  types: normalizeTypes(options.types ?? DEFAULT_TYPES)
96
102
  };
97
103
  }
104
+ function resolveTagOptions(options) {
105
+ const tags = {};
106
+ if (options.tagPrefix !== void 0) {
107
+ tags.prefix = options.tagPrefix;
108
+ }
109
+ if (options.skipUnstable !== void 0) {
110
+ tags.skipUnstable = options.skipUnstable;
111
+ }
112
+ return tags;
113
+ }
114
+ function resolveGeneratorOptions(options) {
115
+ const resolvedOptions = {
116
+ append: options.append ?? false,
117
+ outputUnreleased: options.outputUnreleased ?? true
118
+ };
119
+ if (options.firstRelease === true) {
120
+ resolvedOptions.releaseCount = 0;
121
+ } else if (options.releaseCount !== void 0) {
122
+ resolvedOptions.releaseCount = validateReleaseCount(options.releaseCount);
123
+ }
124
+ return resolvedOptions;
125
+ }
126
+ function validateGenerateChangelogOptions(options) {
127
+ if (options.releaseCount !== void 0) {
128
+ validateReleaseCount(options.releaseCount);
129
+ }
130
+ validatePresetOptions(splitPresetOptions(options));
131
+ }
132
+ function validateReleaseCount(value) {
133
+ if (!Number.isSafeInteger(value) || value < 0) {
134
+ throw new Error(`releaseCount must be a non-negative safe integer, got: ${value}`);
135
+ }
136
+ return value;
137
+ }
138
+ var PRESET_OPTION_KEYS = [
139
+ "types",
140
+ "ignoreCommits",
141
+ "issuePrefixes",
142
+ "scope",
143
+ "scopeOnly",
144
+ "preMajor",
145
+ "issueUrlFormat",
146
+ "commitUrlFormat",
147
+ "compareUrlFormat",
148
+ "userUrlFormat",
149
+ "bumpStrict"
150
+ ];
151
+ var STRING_FORMAT_KEYS = ["issueUrlFormat", "commitUrlFormat", "compareUrlFormat", "userUrlFormat"];
152
+ function validatePresetOptions(options) {
153
+ for (const key of Object.keys(options)) {
154
+ if (!PRESET_OPTION_KEYS.includes(key)) {
155
+ throw new Error(`Unknown changelog config option: ${key}`);
156
+ }
157
+ }
158
+ if (options.types !== void 0) {
159
+ validateTypes(options.types);
160
+ }
161
+ if (options.ignoreCommits !== void 0 && !(options.ignoreCommits instanceof RegExp)) {
162
+ throw new Error("ignoreCommits must be a RegExp");
163
+ }
164
+ if (options.issuePrefixes !== void 0) {
165
+ validateStringArray(options.issuePrefixes, "issuePrefixes");
166
+ }
167
+ if (options.scope !== void 0) {
168
+ if (typeof options.scope !== "string" && !Array.isArray(options.scope)) {
169
+ throw new Error("scope must be a string or an array of strings");
170
+ }
171
+ if (Array.isArray(options.scope)) {
172
+ validateStringArray(options.scope, "scope");
173
+ }
174
+ }
175
+ if (options.scopeOnly !== void 0 && typeof options.scopeOnly !== "boolean") {
176
+ throw new Error("scopeOnly must be a boolean");
177
+ }
178
+ if (options.preMajor !== void 0 && typeof options.preMajor !== "boolean") {
179
+ throw new Error("preMajor must be a boolean");
180
+ }
181
+ for (const key of STRING_FORMAT_KEYS) {
182
+ const value = options[key];
183
+ if (value !== void 0 && typeof value !== "string") {
184
+ throw new Error(`${key} must be a string`);
185
+ }
186
+ }
187
+ if (options.bumpStrict !== void 0 && typeof options.bumpStrict !== "boolean") {
188
+ throw new Error("bumpStrict must be a boolean");
189
+ }
190
+ }
191
+ function validateTypes(types) {
192
+ if (!Array.isArray(types)) {
193
+ throw new Error("types must be an array");
194
+ }
195
+ types.forEach((entry, index) => {
196
+ if (!isPlainObject(entry)) {
197
+ throw new Error(`types[${index}] must be an object`);
198
+ }
199
+ if (typeof entry.type !== "string" || entry.type.length === 0) {
200
+ throw new Error(`types[${index}].type must be a non-empty string`);
201
+ }
202
+ if (entry.section !== void 0 && typeof entry.section !== "string") {
203
+ throw new Error(`types[${index}].section must be a string`);
204
+ }
205
+ if (entry.scope !== void 0 && typeof entry.scope !== "string") {
206
+ throw new Error(`types[${index}].scope must be a string`);
207
+ }
208
+ if (entry.effect !== void 0 && entry.effect !== "hidden") {
209
+ throw new Error(`types[${index}].effect must be 'hidden'`);
210
+ }
211
+ if (entry.hidden !== void 0 && typeof entry.hidden !== "boolean") {
212
+ throw new Error(`types[${index}].hidden must be a boolean`);
213
+ }
214
+ const knownKeys = /* @__PURE__ */ new Set(["type", "section", "scope", "effect", "hidden"]);
215
+ for (const key of Object.keys(entry)) {
216
+ if (!knownKeys.has(key)) {
217
+ throw new Error(`types[${index}] has unknown field: ${key}`);
218
+ }
219
+ }
220
+ });
221
+ }
222
+ function validateStringArray(value, label) {
223
+ if (!Array.isArray(value)) {
224
+ throw new Error(`${label} must be an array of strings`);
225
+ }
226
+ value.forEach((entry, index) => {
227
+ if (typeof entry !== "string") {
228
+ throw new Error(`${label}[${index}] must be a string`);
229
+ }
230
+ });
231
+ }
98
232
  function resolveOutputPath(cwd, outputFile) {
99
233
  return isAbsolute(outputFile) ? outputFile : resolve(cwd, outputFile);
100
234
  }
101
- function pipeGeneratorToFile(generator, outputPath) {
235
+ async function pipeGeneratorToFile(generator, outputPath, append) {
236
+ const tempPath = `${outputPath}.${randomUUID()}.tmp`;
237
+ const generated = await readGeneratorOutput(generator.writeStream());
238
+ const existing = await readExistingOutput(outputPath);
239
+ const contents = combineChangelogContents(existing, generated, append);
240
+ try {
241
+ await writeFile(tempPath, contents, "utf8");
242
+ await rename(tempPath, outputPath);
243
+ } catch (error) {
244
+ await rm(tempPath, { force: true });
245
+ throw error;
246
+ }
247
+ return outputPath;
248
+ }
249
+ function readGeneratorOutput(stream) {
102
250
  return new Promise((resolvePromise, reject) => {
103
- const generatorStream = generator.writeStream();
104
- const fileStream = createWriteStream(outputPath);
251
+ const chunks = [];
252
+ const onData = (chunk) => {
253
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
254
+ };
105
255
  const onError = (error) => {
106
- generatorStream.off("error", onError);
107
- fileStream.off("error", onError);
108
- fileStream.off("finish", onFinish);
256
+ cleanup();
109
257
  reject(error);
110
258
  };
111
- const onFinish = () => {
112
- generatorStream.off("error", onError);
113
- fileStream.off("error", onError);
114
- fileStream.off("finish", onFinish);
115
- resolvePromise(outputPath);
259
+ const onEnd = () => {
260
+ cleanup();
261
+ resolvePromise(Buffer.concat(chunks).toString("utf8"));
116
262
  };
117
- generatorStream.on("error", onError);
118
- fileStream.on("error", onError);
119
- fileStream.on("finish", onFinish);
120
- generatorStream.pipe(fileStream);
263
+ const cleanup = () => {
264
+ stream.off("data", onData);
265
+ stream.off("error", onError);
266
+ stream.off("end", onEnd);
267
+ };
268
+ stream.on("data", onData);
269
+ stream.on("error", onError);
270
+ stream.on("end", onEnd);
121
271
  });
122
272
  }
273
+ async function readExistingOutput(outputPath) {
274
+ try {
275
+ return await readFile(outputPath, "utf8");
276
+ } catch (error) {
277
+ if (error.code === "ENOENT") {
278
+ return void 0;
279
+ }
280
+ throw error;
281
+ }
282
+ }
283
+ function combineChangelogContents(existing, generated, append) {
284
+ const next = trimTrailingNewlines(generated);
285
+ const current = trimTrailingNewlines(existing ?? "");
286
+ if (current.length === 0) {
287
+ return `${next}
288
+ `;
289
+ }
290
+ if (next.length === 0) {
291
+ return `${current}
292
+ `;
293
+ }
294
+ return append ? `${current}
295
+
296
+ ${next}
297
+ ` : `${next}
298
+
299
+ ${current}
300
+ `;
301
+ }
302
+ function trimTrailingNewlines(value) {
303
+ return value.replace(/\n+$/u, "");
304
+ }
123
305
  async function createPreset(options = {}) {
306
+ validatePresetOptions(options);
124
307
  const preset = await createConventionalCommitsPreset(resolvePresetOptions(options));
125
308
  return {
126
309
  ...preset,
@@ -129,19 +312,16 @@ async function createPreset(options = {}) {
129
312
  }
130
313
  async function createGenerator(options = {}) {
131
314
  const cwd = resolve(options.cwd ?? process.cwd());
315
+ validateGenerateChangelogOptions(options);
132
316
  const presetOptions = splitPresetOptions(options);
133
317
  const preset = await createPreset(presetOptions);
134
318
  const generator = new ConventionalChangelog(cwd);
135
- const generatorOptions = {
136
- append: options.append ?? false,
137
- releaseCount: options.releaseCount ?? 0,
138
- skipUnstable: options.skipUnstable ?? true,
139
- outputUnreleased: options.outputUnreleased ?? true,
140
- tagPrefix: options.tagPrefix ?? "v",
141
- firstRelease: options.firstRelease ?? false
142
- };
143
- generator.readPackage(resolve(cwd, "package.json")).loadPreset(preset).options(generatorOptions).config({
144
- tags: preset.tags,
319
+ generator.readPackage(resolve(cwd, "package.json")).loadPreset(preset).options(resolveGeneratorOptions(options)).tags(
320
+ resolveTagOptions({
321
+ tagPrefix: options.tagPrefix ?? "v",
322
+ skipUnstable: options.skipUnstable ?? true
323
+ })
324
+ ).config({
145
325
  commits: preset.commits,
146
326
  parser: preset.parser,
147
327
  writer: preset.writer
@@ -153,11 +333,12 @@ async function generateChangelog(options = {}) {
153
333
  const outputPath = resolveOutputPath(cwd, options.outputFile ?? "CHANGELOG.md");
154
334
  await mkdir(dirname(outputPath), { recursive: true });
155
335
  const generator = await createGenerator({ ...options, cwd });
156
- return await pipeGeneratorToFile(generator, outputPath);
336
+ return await pipeGeneratorToFile(generator, outputPath, options.append ?? false);
157
337
  }
158
338
  export {
159
339
  DEFAULT_TYPES,
160
340
  createGenerator,
161
341
  createPreset,
162
- generateChangelog
342
+ generateChangelog,
343
+ getDefaultTypes
163
344
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@repo-toolkit/changelog",
3
3
  "description": "Shared conventional changelog preset, generator, and CLI for repository releases",
4
- "version": "0.9.0",
4
+ "version": "0.11.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "keywords": [
@@ -27,7 +27,7 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "dependencies": {
30
- "@repo-toolkit/publish-package": "0.9.0",
30
+ "@repo-toolkit/publish-package": "0.11.0",
31
31
  "conventional-changelog": "^7.2.1",
32
32
  "conventional-changelog-conventionalcommits": "^9.3.1"
33
33
  },