keepchanges 0.0.2 → 1.0.0-beta.1

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.
package/dist/cli.mjs CHANGED
@@ -1,107 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import { a as insertRelease, c as defaultConfig, i as hasRelease, n as parseCommits, o as readChangelog, r as generateChangelog, s as writeChangelog } from "./commit-FX77u6Sj.mjs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { normalizeFull } from "verkit";
3
5
  import process from "node:process";
4
- import { fileURLToPath } from "node:url";
5
6
  import { cac } from "cac";
6
7
  import { resolve } from "node:path";
7
8
  import ansis from "ansis";
8
- import { readFile, writeFile } from "node:fs/promises";
9
- import { normalizeFull } from "verkit";
10
9
  import { x } from "tinyexec";
11
10
  //#region package.json
12
- var version = "0.0.2";
13
- //#endregion
14
- //#region src/changelog.ts
15
- function createChangelog(version, commits, repository, comparisonFrom) {
16
- const body = [
17
- ...renderSection(commits.filter((commit) => commit.isBreaking), "🚨 Breaking Changes", repository),
18
- ...renderSection(commits.filter((commit) => !commit.isBreaking && commit.type === "feat"), "🚀 Features", repository),
19
- ...renderSection(commits.filter((commit) => !commit.isBreaking && commit.type === "fix"), "🐞 Bug Fixes", repository),
20
- ...repository && comparisonFrom ? ["", `#####     [View changes on ${repository.provider.name}](${repository.provider.compareUrl(repository, comparisonFrom, `v${version}`)})`] : []
21
- ].join("\n").trim();
22
- return {
23
- body,
24
- release: [
25
- `## v${version}`,
26
- "",
27
- body,
28
- ""
29
- ].join("\n")
30
- };
31
- }
32
- async function readChangelog(path) {
33
- return readFile(path, "utf8").catch((error) => {
34
- if (error.code === "ENOENT") return "# Changelog\n";
35
- throw error;
36
- });
37
- }
38
- function writeChangelog(path, changelog) {
39
- return writeFile(path, changelog);
40
- }
41
- function hasRelease(changelog, version) {
42
- const releaseVersion = normalizeFull(version);
43
- return releaseHeadings(changelog).some((heading) => heading.version === releaseVersion);
44
- }
45
- function insertRelease(changelog, release) {
46
- const releaseVersion = releaseHeadings(release)[0]?.version;
47
- const headings = releaseHeadings(changelog);
48
- const existingReleaseIndex = headings.findIndex((heading) => heading.version === releaseVersion);
49
- if (existingReleaseIndex !== -1) {
50
- const start = headings[existingReleaseIndex].index;
51
- const end = headings[existingReleaseIndex + 1]?.index ?? changelog.length;
52
- changelog = changelog.slice(0, start) + changelog.slice(end);
53
- }
54
- const firstRelease = releaseHeadings(changelog)[0];
55
- if (!firstRelease) return `${changelog.trimEnd()}\n\n${release.trim()}\n`;
56
- const preamble = changelog.slice(0, firstRelease.index).trimEnd();
57
- const history = changelog.slice(firstRelease.index).trim();
58
- return `${preamble}\n\n${release.trim()}\n\n${history}\n`;
59
- }
60
- function releaseHeadings(changelog) {
61
- return [...changelog.matchAll(/^##\s+(\S+).*$/gm)].flatMap((heading) => {
62
- const version = normalizeFull(heading[1]);
63
- return version ? [{
64
- index: heading.index,
65
- version
66
- }] : [];
67
- });
68
- }
69
- function renderSection(commits, title, repository) {
70
- const lines = commits.map((commit) => {
71
- const scope = commit.scope ? `**${escapeHtml(commit.scope)}**: ` : "";
72
- const reference = repository ? `[<samp>(${commit.hash.slice(0, 5)})</samp>](${repository.provider.commitUrl(repository, commit.hash)})` : "";
73
- const authorNames = commit.authors.map((author) => author.login ? `@${author.login}` : `**${escapeHtml(author.name)}**`);
74
- const authors = authorNames.length > 1 ? `${authorNames.slice(0, -1).join(", ")} and ${authorNames.at(-1)}` : authorNames[0] || "";
75
- const pullRequests = commit.pullRequests.map((pullRequest) => repository ? `[${pullRequest}](${repository.provider.pullRequestUrl(repository, pullRequest)})` : pullRequest).join(", ");
76
- const details = [
77
- authors ? `by ${authors}` : "",
78
- pullRequests ? `in ${pullRequests}` : "",
79
- reference
80
- ].filter(Boolean).join(" ");
81
- const suffix = details ? ` &nbsp;-&nbsp; ${details}` : "";
82
- return `${scope}${escapeHtml(capitalize$1(commit.description))}${suffix}`;
83
- }).reverse();
84
- if (!lines.length) return [];
85
- return [
86
- "",
87
- `### ${title}`,
88
- "",
89
- ...lines.map((line) => `- ${line}`)
90
- ];
91
- }
92
- function capitalize$1(value) {
93
- return value.charAt(0).toUpperCase() + value.slice(1);
94
- }
95
- function escapeHtml(value) {
96
- const htmlEntities = {
97
- "&": "&amp;",
98
- "<": "&lt;",
99
- ">": "&gt;",
100
- "\"": "&quot;",
101
- "'": "&#39;"
102
- };
103
- return value.replace(/[&<>"']/g, (character) => htmlEntities[character]);
104
- }
11
+ var version = "1.0.0-beta.1";
105
12
  //#endregion
106
13
  //#region src/git.ts
107
14
  async function git(cwd, ...args) {
@@ -130,11 +37,8 @@ async function getRemoteTagCommit(cwd, tag) {
130
37
  const refs = (await git(cwd, "ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`)).trim().split("\n").filter(Boolean);
131
38
  return (refs.find((line) => line.endsWith("^{}")) || refs[0])?.split(/\s+/)[0];
132
39
  }
133
- async function readCommits(cwd, from, to) {
134
- return parseGitLog(await git(cwd, "log", from ? `${from}..${to}` : to, "--format=%h%x00%an%x00%ae%x00%s%x00%b%x00")).map((commit) => parseCommit(commit.hash, commit.subject, commit.body, commit.author)).filter((commit) => commit !== null);
135
- }
136
- function parseGitLog(log) {
137
- const fields = log.split("\0");
40
+ async function readGitCommits(cwd, from, to) {
41
+ const fields = (await git(cwd, "log", from ? `${from}..${to}` : to, "--format=%h%x00%an%x00%ae%x00%s%x00%b%x00")).split("\0");
138
42
  const commits = [];
139
43
  for (let index = 0; index + 4 < fields.length; index += 5) {
140
44
  const subject = fields[index + 3].trim();
@@ -150,38 +54,100 @@ function parseGitLog(log) {
150
54
  }
151
55
  return commits;
152
56
  }
153
- function parseCommit(hash, subject, body, author) {
154
- const match = /^(?<type>[a-z]+)(?:\((?<scope>[^()\r\n]+)\))?(?<breaking>!)?: (?<description>.+)$/i.exec(subject);
155
- if (!match?.groups) return null;
156
- const pullRequestPattern = /\([ a-z]*(#\d+)\s*\)/gi;
157
- const pullRequests = [...new Set([...match.groups.description.matchAll(pullRequestPattern)].map((reference) => reference[1]))];
158
- const authors = [author];
159
- for (const coAuthor of body.matchAll(/^Co-Authored-By:\s*(.+?)\s*<([^>]+)>$/gim)) {
160
- const email = coAuthor[2].trim();
161
- if (!authors.some((author) => author.email === email)) authors.push({
162
- name: coAuthor[1].trim(),
163
- email
57
+ //#endregion
58
+ //#region src/repositories/gitea.ts
59
+ const giteaRepository = {
60
+ name: "Gitea",
61
+ parse(source) {
62
+ try {
63
+ const url = new URL(source.replace(/^git\+/, ""));
64
+ if (!/^https?:$/.test(url.protocol)) return;
65
+ const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
66
+ if (!path.includes("/")) return;
67
+ return {
68
+ provider: giteaRepository,
69
+ path,
70
+ webUrl: `${url.origin}/${path}`
71
+ };
72
+ } catch {}
73
+ },
74
+ token(explicit, env) {
75
+ return explicit || env.GITEA_TOKEN;
76
+ },
77
+ commitUrl(repository, hash) {
78
+ return `${repository.webUrl}/commit/${hash}`;
79
+ },
80
+ pullRequestUrl(repository, reference) {
81
+ return `${repository.webUrl}/pulls/${reference.replace(/^#/, "")}`;
82
+ },
83
+ compareUrl(repository, from, to) {
84
+ return `${repository.webUrl}/compare/${from}...${to}`;
85
+ },
86
+ manualReleaseUrl(repository, release) {
87
+ const url = new URL(`${repository.webUrl}/releases/new`);
88
+ url.search = new URLSearchParams({ tag: release.tag }).toString();
89
+ return url.toString();
90
+ },
91
+ async resolveAuthors(commits, repository, token, fetch) {
92
+ const headers = {
93
+ accept: "application/json",
94
+ authorization: `token ${token}`
95
+ };
96
+ const commitsByEmail = new Map(commits.flatMap((commit) => commit.authors[0] ? [[commit.authors[0].email, commit]] : []));
97
+ const loginsByEmail = /* @__PURE__ */ new Map();
98
+ await Promise.all([...commitsByEmail].map(async ([email, commit]) => {
99
+ try {
100
+ const response = await fetch(`${new URL(repository.webUrl).origin}/api/v1/repos/${repository.path}/git/commits/${commit.hash}`, { headers });
101
+ if (!response.ok) return;
102
+ const data = await response.json();
103
+ if (data.author?.login) loginsByEmail.set(email, data.author.login);
104
+ } catch {}
105
+ }));
106
+ for (const commit of commits) for (const author of commit.authors) author.login = loginsByEmail.get(author.email);
107
+ },
108
+ async publishRelease(repository, release, token, fetch) {
109
+ const headers = {
110
+ "accept": "application/json",
111
+ "authorization": `token ${token}`,
112
+ "content-type": "application/json"
113
+ };
114
+ const releasesUrl = `${new URL(repository.webUrl).origin}/api/v1/repos/${repository.path}/releases`;
115
+ const existing = await fetch(`${releasesUrl}/tags/${encodeURIComponent(release.tag)}`, { headers });
116
+ let url = releasesUrl;
117
+ let method = "POST";
118
+ let action = "created";
119
+ if (existing.ok) {
120
+ url = `${releasesUrl}/${(await existing.json()).id}`;
121
+ method = "PATCH";
122
+ action = "updated";
123
+ } else if (existing.status !== 404) throw new Error(`Gitea release lookup failed (${existing.status})`);
124
+ const response = await fetch(url, {
125
+ method,
126
+ headers,
127
+ body: JSON.stringify({
128
+ tag_name: release.tag,
129
+ name: release.name,
130
+ body: release.body,
131
+ prerelease: release.prerelease,
132
+ draft: release.draft
133
+ })
164
134
  });
135
+ if (!response.ok) throw new Error(`Gitea release publishing failed (${response.status})`);
136
+ return {
137
+ url: (await response.json()).html_url,
138
+ action
139
+ };
165
140
  }
166
- return {
167
- hash,
168
- authors: authors.filter((author) => !/\[bot\]|dependabot|\(bot\)/i.test(author.name)),
169
- type: match.groups.type.toLowerCase(),
170
- scope: match.groups.scope || "",
171
- description: match.groups.description.replace(pullRequestPattern, "").trim(),
172
- pullRequests,
173
- isBreaking: Boolean(match.groups.breaking) || /^BREAKING(?: |-)CHANGE:/im.test(body)
174
- };
175
- }
141
+ };
176
142
  //#endregion
177
- //#region src/providers/github.ts
178
- const githubProvider = {
143
+ //#region src/repositories/github.ts
144
+ const githubRepository = {
179
145
  name: "GitHub",
180
146
  parse(source) {
181
147
  const match = /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/.exec(source);
182
148
  if (!match) return;
183
149
  return {
184
- provider: githubProvider,
150
+ provider: githubRepository,
185
151
  path: match[1],
186
152
  webUrl: `https://github.com/${match[1]}`
187
153
  };
@@ -225,8 +191,8 @@ const githubProvider = {
225
191
  },
226
192
  async publishRelease(repository, release, token, fetch) {
227
193
  const headers = {
228
- accept: "application/vnd.github+json",
229
- authorization: `Bearer ${token}`,
194
+ "accept": "application/vnd.github+json",
195
+ "authorization": `Bearer ${token}`,
230
196
  "content-type": "application/json"
231
197
  };
232
198
  const releasesUrl = `https://api.github.com/repos/${repository.path}/releases`;
@@ -246,7 +212,8 @@ const githubProvider = {
246
212
  tag_name: release.tag,
247
213
  name: release.name,
248
214
  body: release.body,
249
- prerelease: release.prerelease
215
+ prerelease: release.prerelease,
216
+ draft: release.draft
250
217
  })
251
218
  });
252
219
  if (!response.ok) throw new Error(`GitHub release publishing failed (${response.status})`);
@@ -258,18 +225,28 @@ const githubProvider = {
258
225
  };
259
226
  //#endregion
260
227
  //#region src/repository.ts
261
- const providers$1 = [githubProvider];
262
- async function resolveRepository(cwd) {
228
+ const repositoryProviders = {
229
+ github: githubRepository,
230
+ gitea: giteaRepository
231
+ };
232
+ async function resolveRepository(cwd, explicit) {
233
+ if (explicit) {
234
+ const repository = parseRepository(/^[^/:]+\/[^/]+$/.test(explicit) ? `https://github.com/${explicit}` : explicit, Object.values(repositoryProviders));
235
+ if (!repository) throw new Error(`Unsupported repository: ${explicit}`);
236
+ return repository;
237
+ }
238
+ let providerName = "";
263
239
  let source = await readFile(resolve(cwd, "package.json"), "utf8").then((contents) => {
264
240
  const repository = JSON.parse(contents).repository;
241
+ if (typeof repository === "object") providerName = repository?.provider?.toLowerCase() || "";
265
242
  return typeof repository === "string" ? repository : repository?.url || "";
266
243
  }).catch(() => "");
267
- if (!source) source = (await x("git", [
268
- "remote",
269
- "get-url",
270
- "origin"
271
- ], { nodeOptions: { cwd } })).stdout.trim();
272
- for (const provider of providers$1) {
244
+ if (!source) source = await git(cwd, "remote", "get-url", "origin").then((output) => output.trim()).catch(() => "");
245
+ if (providerName) return repositoryProviders[providerName]?.parse(source);
246
+ return parseRepository(source, [githubRepository]);
247
+ }
248
+ function parseRepository(source, providers) {
249
+ for (const provider of providers) {
273
250
  const repository = provider.parse(source);
274
251
  if (repository) return repository;
275
252
  }
@@ -298,22 +275,41 @@ async function updateVersion(cwd, version) {
298
275
  }
299
276
  }
300
277
  //#endregion
301
- //#region src/run.ts
302
- const defaultAuthor = "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>";
303
- async function runChangelog(options, environment) {
304
- const { version: version$1 } = options;
305
- const tag = `v${version$1}`;
306
- const gitIdentity = options.commit || options.release ? resolveGitIdentity(options.author) : [];
278
+ //#region src/cli/output.ts
279
+ function printChangesPreview(preview, stdout, colors) {
280
+ stdout([
281
+ colors.dim(`keep${colors.bold("changes")} v${version}`),
282
+ `${colors.cyan(preview.from)}${colors.dim(" -> ")}${colors.blue(preview.tag)}${colors.dim(` (${preview.commitCount} commits)`)}`,
283
+ colors.dim("--------------"),
284
+ "",
285
+ preview.body.replaceAll("&nbsp;", ""),
286
+ "",
287
+ colors.dim("--------------"),
288
+ ""
289
+ ].join("\n"));
290
+ }
291
+ function printManualReleaseUrl(repository, release, stdout, colors) {
292
+ const url = repository.provider.manualReleaseUrl?.(repository, release);
293
+ if (url) stdout(`${colors.yellow("Using the following link to create it manually:")}\n${colors.yellow(url)}\n`);
294
+ }
295
+ function printPublishedRelease(provider, result, stdout, colors) {
296
+ const action = result.action.charAt(0).toUpperCase() + result.action.slice(1);
297
+ stdout(`${colors.green(`${action} ${provider} release: ${result.url}`)}\n`);
298
+ }
299
+ //#endregion
300
+ //#region src/cli/createChanges.ts
301
+ async function createChanges(options, environment) {
302
+ if (options.commit && options.to !== defaultConfig.cli.to) {
303
+ const [toCommit, headCommit] = await Promise.all([git(environment.cwd, "rev-parse", options.to).then((value) => value.trim()), git(environment.cwd, "rev-parse", "HEAD").then((value) => value.trim())]);
304
+ if (toCommit !== headCommit) throw new Error("--to must resolve to HEAD when used with --commit");
305
+ }
307
306
  const env = environment.env ?? process.env;
308
307
  const stdout = environment.stdout ?? ((value) => process.stdout.write(value));
309
308
  const colors = environment.colors ?? ansis;
310
- const latestTag = await getLatestTag(environment.cwd);
311
- const repository = await resolveRepository(environment.cwd);
309
+ const tag = `v${options.version}`;
310
+ const repository = await resolveRepository(environment.cwd, options.repository);
312
311
  const token = repository?.provider.token(options.token, env);
313
- if (options.release) {
314
- if (!repository) throw new Error("A supported repository is required to release");
315
- if (!repository.provider.publishRelease && !repository.provider.manualReleaseUrl) throw new Error(`${repository.provider.name} does not support releases`);
316
- }
312
+ validateReleaseSupport(options, repository);
317
313
  let taggedCommit = options.release ? await getTagCommit(environment.cwd, tag) : void 0;
318
314
  let releaseRef = taggedCommit ? tag : void 0;
319
315
  const remoteTaggedCommit = options.release ? await getRemoteTagCommit(environment.cwd, tag) : void 0;
@@ -326,85 +322,89 @@ async function runChangelog(options, environment) {
326
322
  taggedCommit = await getTagCommit(environment.cwd, tag);
327
323
  releaseRef = tag;
328
324
  }
329
- const from = taggedCommit ? await getPreviousTag(environment.cwd, tag, releaseRef) : latestTag;
330
- const to = releaseRef || "HEAD";
331
- const comparisonFrom = from || (repository ? (await git(environment.cwd, "rev-list", "--max-parents=0", to)).trim() : "");
332
- const commits = await readCommits(environment.cwd, from, to);
325
+ const from = options.from ?? (taggedCommit ? await getPreviousTag(environment.cwd, tag, releaseRef) : await getLatestTag(environment.cwd));
326
+ const to = releaseRef || options.to;
327
+ const comparisonFrom = from || (repository ? await git(environment.cwd, "rev-list", "--max-parents=0", to).then((value) => value.trim()) : "");
328
+ const commits = parseCommits(await readGitCommits(environment.cwd, from, to));
333
329
  if (token && repository && !(options.release && options.dry)) await repository.provider.resolveAuthors?.(commits, repository, token, environment.fetch ?? globalThis.fetch);
334
- const { body: releaseBody, release } = createChangelog(version$1, commits, repository, comparisonFrom);
330
+ const style = {
331
+ emoji: options.emoji,
332
+ capitalize: options.capitalize,
333
+ group: options.group
334
+ };
335
+ const { body, release } = generateChangelog({
336
+ version: options.version,
337
+ commits,
338
+ repository,
339
+ comparisonFrom
340
+ }, style);
335
341
  const repositoryRelease = {
336
342
  tag,
337
- name: tag,
338
- body: releaseBody,
339
- prerelease: version$1.includes("-")
340
- };
341
- const printReleasePreview = () => {
342
- stdout([
343
- colors.dim(`keep${colors.bold("changes")} v${version}`),
344
- `${colors.cyan(from || comparisonFrom)}${colors.dim(" -> ")}${colors.blue(tag)}${colors.dim(` (${commits.length} commits)`)}`,
345
- colors.dim("--------------"),
346
- "",
347
- releaseBody.replaceAll("&nbsp;", ""),
348
- "",
349
- colors.dim("--------------"),
350
- ""
351
- ].join("\n"));
352
- };
353
- const printManualReleaseUrl = () => {
354
- const url = repository.provider.manualReleaseUrl?.(repository, repositoryRelease);
355
- if (url) stdout(`${colors.yellow("Using the following link to create it manually:")}\n${colors.yellow(url)}\n`);
356
- return url;
343
+ name: options.name ?? tag,
344
+ body,
345
+ prerelease: options.prerelease ?? options.version.includes("-"),
346
+ draft: options.draft
357
347
  };
358
- const publishRepositoryRelease = async () => {
359
- printReleasePreview();
360
- if (!token || !repository.provider.publishRelease) {
361
- if (!repository.provider.manualReleaseUrl) throw new Error("A repository token is required to release");
362
- stdout(`${colors.red(`No ${repository.provider.name} token found, specify it via GITHUB_TOKEN env. Release skipped.`)}\n\n`);
363
- printManualReleaseUrl();
364
- return;
365
- }
366
- const result = await repository.provider.publishRelease(repository, repositoryRelease, token, environment.fetch ?? globalThis.fetch);
367
- stdout(`${colors.green(`${capitalize(result.action)} ${repository.provider.name} release: ${result.url}`)}\n`);
348
+ const preview = {
349
+ from: from || comparisonFrom,
350
+ tag,
351
+ commitCount: commits.length,
352
+ body
368
353
  };
369
- const outputPath = resolve(environment.cwd, options.output || "CHANGELOG.md");
370
- const currentChangelog = await readChangelog(outputPath);
371
- const releaseExists = hasRelease(currentChangelog, version$1);
372
- const changelog = insertRelease(currentChangelog, release);
373
354
  if (options.dry) {
355
+ printChangesPreview(preview, stdout, colors);
374
356
  if (options.release) {
375
- printReleasePreview();
376
357
  stdout(`${colors.yellow("Dry run. Release skipped.")}\n\n`);
377
- printManualReleaseUrl();
378
- } else stdout(changelog);
358
+ printManualReleaseUrl(repository, repositoryRelease, stdout, colors);
359
+ }
379
360
  return;
380
361
  }
381
362
  if (options.release && taggedCommit) {
382
363
  if (!remoteTaggedCommit) await git(environment.cwd, "push", "origin", `refs/tags/${tag}`);
383
- await publishRepositoryRelease();
364
+ await publishRelease(repository, repositoryRelease, token, preview, environment);
384
365
  return;
385
366
  }
386
- await writeChangelog(outputPath, changelog);
387
- const versionPath = await updateVersion(environment.cwd, version$1);
388
- if (options.commit || options.release) {
389
- const releasePaths = [outputPath, versionPath].filter((path) => path !== void 0);
390
- if ((await git(environment.cwd, "status", "--porcelain", "--", ...releasePaths)).trim()) {
391
- await git(environment.cwd, "add", "--", ...releasePaths);
392
- const versionChanges = versionPath ? await git(environment.cwd, "status", "--porcelain", "--", versionPath) : "";
393
- const commitMessage = versionPath && !versionChanges.trim() ? `docs(changelog): ${releaseExists ? "update" : "add"} v${version$1} release notes` : `chore(release): v${version$1}`;
394
- await git(environment.cwd, ...gitIdentity, "commit", "-m", commitMessage, ...options.author ? ["--author", options.author] : [], "--only", "--", ...releasePaths);
395
- }
396
- }
367
+ const outputPath = resolve(environment.cwd, options.output);
368
+ const currentChangelog = await readChangelog(outputPath);
369
+ const releaseExists = hasRelease(currentChangelog, options.version);
370
+ await writeChangelog(outputPath, insertRelease(currentChangelog, release));
371
+ const versionPath = await updateVersion(environment.cwd, options.version);
372
+ if (options.commit || options.release) await commitReleaseFiles(options, environment.cwd, outputPath, versionPath, releaseExists);
397
373
  if (options.release) {
374
+ const gitIdentity = resolveGitIdentity(options.author);
398
375
  await git(environment.cwd, ...gitIdentity, "tag", "-a", tag, "-m", tag);
399
376
  await git(environment.cwd, "push", "origin", "HEAD", `refs/tags/${tag}`);
400
- await publishRepositoryRelease();
377
+ await publishRelease(repository, repositoryRelease, token, preview, environment);
401
378
  }
402
379
  }
403
- function capitalize(value) {
404
- return value.charAt(0).toUpperCase() + value.slice(1);
380
+ function validateReleaseSupport(options, repository) {
381
+ if (!options.release) return;
382
+ if (!repository) throw new Error("A supported repository is required to release");
383
+ if (!repository.provider.publishRelease && !repository.provider.manualReleaseUrl) throw new Error(`${repository.provider.name} does not support releases`);
384
+ }
385
+ async function commitReleaseFiles(options, cwd, outputPath, versionPath, releaseExists) {
386
+ const releasePaths = [outputPath, versionPath].filter((path) => path !== void 0);
387
+ if (!(await git(cwd, "status", "--porcelain", "--", ...releasePaths)).trim()) return;
388
+ await git(cwd, "add", "--", ...releasePaths);
389
+ const versionChanges = versionPath ? await git(cwd, "status", "--porcelain", "--", versionPath) : "";
390
+ const commitMessage = versionPath && !versionChanges.trim() ? `docs(changelog): ${releaseExists ? "update" : "add"} v${options.version} release notes` : `chore(release): v${options.version}`;
391
+ await git(cwd, ...resolveGitIdentity(options.author), "commit", "-m", commitMessage, ...options.author ? ["--author", options.author] : [], "--only", "--", ...releasePaths);
392
+ }
393
+ async function publishRelease(repository, release, token, preview, environment) {
394
+ const stdout = environment.stdout ?? ((value) => process.stdout.write(value));
395
+ const colors = environment.colors ?? ansis;
396
+ printChangesPreview(preview, stdout, colors);
397
+ if (!token || !repository.provider.publishRelease) {
398
+ if (!repository.provider.manualReleaseUrl) throw new Error("A repository token is required to release");
399
+ stdout(`${colors.red(`No ${repository.provider.name} token found, specify it via --token or environment variable. Release skipped.`)}\n\n`);
400
+ printManualReleaseUrl(repository, release, stdout, colors);
401
+ return;
402
+ }
403
+ const result = await repository.provider.publishRelease(repository, release, token, environment.fetch ?? globalThis.fetch);
404
+ printPublishedRelease(repository.provider.name, result, stdout, colors);
405
405
  }
406
- function resolveGitIdentity(author = defaultAuthor) {
407
- const match = /^(.+?)\s*<([^<>]+)>$/.exec(author);
406
+ function resolveGitIdentity(author) {
407
+ const match = /^([^<>\r\n]+)<([^<>\r\n]+)>$/.exec(author);
408
408
  if (!match) throw new Error("Author must use the \"Name <email>\" format");
409
409
  return [
410
410
  "-c",
@@ -414,31 +414,43 @@ function resolveGitIdentity(author = defaultAuthor) {
414
414
  ];
415
415
  }
416
416
  //#endregion
417
- //#region src/cli.ts
418
- async function runCli(args, environment) {
419
- const cli = cac("changelog").version(version).option("--output <path>", "Changelog file path").option("--dry", "Print the changelog without writing it").option("--commit", "Commit the changelog").option("--release", "Publish a repository release").option("--author <author>", "Commit author in \"Name <email>\" format").option("--token <token>", "Repository token for resolving authors").help();
420
- cli.command("[version]").usage("<version> [options]").action(async (versionArgument, options) => {
421
- if (!versionArgument) throw new Error("A release version is required");
422
- await runChangelog({
423
- version: versionArgument.replace(/^v/, ""),
424
- output: options.output,
425
- dry: options.dry,
426
- commit: options.commit,
427
- release: options.release,
428
- author: options.author,
429
- token: options.token
430
- }, environment);
431
- });
432
- cli.parse([
433
- "node",
434
- "changelog",
435
- ...args
436
- ], { run: false });
437
- await cli.runMatchedCommand();
417
+ //#region src/cli/options.ts
418
+ function resolveOptions(versionArgument, options) {
419
+ if (!versionArgument) throw new Error("A release version is required");
420
+ const version = normalizeFull(versionArgument);
421
+ if (!version) throw new Error(`Invalid release version: ${versionArgument}`);
422
+ const commit = options.commit ?? defaultConfig.cli.commit;
423
+ const release = options.release ?? defaultConfig.cli.release;
424
+ if (options.to !== void 0 && release) throw new Error("--to cannot be used with --release");
425
+ if (options.author !== void 0 && !commit && !release) throw new Error("--author requires --commit or --release");
426
+ if (!release && (options.name !== void 0 || options.draft !== void 0 || options.prerelease !== void 0)) throw new Error("--name, --draft, and --prerelease require --release");
427
+ return {
428
+ version,
429
+ from: options.from,
430
+ to: options.to ?? defaultConfig.cli.to,
431
+ repository: options.repository,
432
+ output: options.output ?? defaultConfig.cli.output,
433
+ dry: options.dry ?? defaultConfig.cli.dry,
434
+ commit,
435
+ release,
436
+ author: options.author ?? defaultConfig.cli.author,
437
+ token: options.token,
438
+ name: options.name,
439
+ draft: options.draft ?? defaultConfig.cli.draft,
440
+ prerelease: options.prerelease,
441
+ emoji: options.emoji ?? defaultConfig.changelog.emoji,
442
+ capitalize: options.capitalize ?? defaultConfig.changelog.capitalize,
443
+ group: options.group ?? defaultConfig.changelog.group
444
+ };
438
445
  }
439
- if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) runCli(process.argv.slice(2), { cwd: process.cwd() }).catch((error) => {
440
- console.error(error instanceof Error ? error.message : String(error));
441
- process.exitCode = 1;
446
+ //#endregion
447
+ //#region src/cli.ts
448
+ const cli = cac("keepchanges").option("--from <ref>", "Start Git reference").option("--to <ref>", "End Git reference").option("--repository <source>", "Repository slug or URL").option("--output <path>", "Changelog file path").option("--dry", "Preview without modifying files or remotes").option("--commit", "Commit the changelog and version update").option("--release", "Publish a repository release").option("--author <author>", "Commit author in \"Name <email>\" format").option("-t, --token <token>", "Repository token").option("--name <name>", "Repository release name").option("-d, --draft", "Create a draft repository release").option("--prerelease", "Mark the repository release as prerelease").option("--emoji", "Use emojis in changelog section titles").option("--capitalize", "Capitalize changelog entries").option("--group", "Group repeated commit scopes");
449
+ cli.command("[version]").usage("<version> [options]").action(async (versionArgument, options) => {
450
+ await createChanges(resolveOptions(versionArgument, options), { cwd: process.cwd() });
442
451
  });
452
+ cli.help();
453
+ cli.version(version);
454
+ cli.parse();
443
455
  //#endregion
444
- export { runCli };
456
+ export {};