@ucdjs/release-scripts 0.1.0-beta.5 → 0.1.0-beta.51

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/index.mjs CHANGED
@@ -1,16 +1,114 @@
1
- import { t as Eta } from "./eta-Boh7yPZi.mjs";
1
+ import { t as Eta } from "./eta-DAZlmVBQ.mjs";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+ import { getCommits, groupByType } from "commit-parser";
2
5
  import process from "node:process";
3
- import { getCommits } from "commit-parser";
6
+ import readline from "node:readline";
4
7
  import farver from "farver";
8
+ import mri from "mri";
5
9
  import { exec } from "tinyexec";
6
10
  import { dedent } from "@luxass/utils";
7
- import { join } from "node:path";
8
- import { readFile, writeFile } from "node:fs/promises";
11
+ import semver, { compare, gt } from "semver";
9
12
  import prompts from "prompts";
10
13
 
11
- //#region src/utils.ts
12
- const globalOptions = { dryRun: false };
14
+ //#region src/operations/changelog-format.ts
15
+ function formatCommitLine({ commit, owner, repo, authors }) {
16
+ const commitUrl = `https://github.com/${owner}/${repo}/commit/${commit.hash}`;
17
+ let line = `${commit.description}`;
18
+ const references = commit.references ?? [];
19
+ for (const ref of references) {
20
+ if (!ref.value) continue;
21
+ const number = Number.parseInt(ref.value.replace(/^#/, ""), 10);
22
+ if (Number.isNaN(number)) continue;
23
+ if (ref.type === "issue") {
24
+ line += ` ([Issue ${ref.value}](https://github.com/${owner}/${repo}/issues/${number}))`;
25
+ continue;
26
+ }
27
+ line += ` ([PR ${ref.value}](https://github.com/${owner}/${repo}/pull/${number}))`;
28
+ }
29
+ line += ` ([${commit.shortHash}](${commitUrl}))`;
30
+ if (authors.length > 0) {
31
+ const authorList = authors.map((author) => author.login ? `[@${author.login}](https://github.com/${author.login})` : author.name).join(", ");
32
+ line += ` (by ${authorList})`;
33
+ }
34
+ return line;
35
+ }
36
+ function buildTemplateGroups(options) {
37
+ const { commits, owner, repo, types, commitAuthors } = options;
38
+ const grouped = groupByType(commits, {
39
+ includeNonConventional: false,
40
+ mergeKeys: Object.fromEntries(Object.entries(types).map(([key, value]) => [key, value.types ?? [key]]))
41
+ });
42
+ return Object.entries(types).map(([key, value]) => {
43
+ const formattedCommits = (grouped.get(key) ?? []).map((commit) => ({ line: formatCommitLine({
44
+ commit,
45
+ owner,
46
+ repo,
47
+ authors: commitAuthors.get(commit.hash) ?? []
48
+ }) }));
49
+ return {
50
+ name: key,
51
+ title: value.title,
52
+ commits: formattedCommits
53
+ };
54
+ });
55
+ }
56
+
57
+ //#endregion
58
+ //#region src/shared/utils.ts
59
+ const args = mri(process.argv.slice(2));
60
+ const isDryRun = !!args.dry;
61
+ const isVerbose$1 = !!args.verbose;
62
+ const isForce = !!args.force;
63
+ const ucdjsReleaseOverridesPath = ".github/ucdjs-release.overrides.json";
13
64
  const isCI = typeof process.env.CI === "string" && process.env.CI !== "" && process.env.CI.toLowerCase() !== "false";
65
+ const logger = {
66
+ info: (...args) => {
67
+ console.info(...args);
68
+ },
69
+ warn: (...args) => {
70
+ console.warn(` ${farver.yellow("⚠")}`, ...args);
71
+ },
72
+ error: (...args) => {
73
+ console.error(` ${farver.red("✖")}`, ...args);
74
+ },
75
+ verbose: (...args) => {
76
+ if (!isVerbose$1) return;
77
+ if (args.length === 0) {
78
+ console.log();
79
+ return;
80
+ }
81
+ if (args.length > 1 && typeof args[0] === "string") {
82
+ console.log(farver.dim(args[0]), ...args.slice(1));
83
+ return;
84
+ }
85
+ console.log(...args);
86
+ },
87
+ section: (title) => {
88
+ console.log();
89
+ console.log(` ${farver.bold(title)}`);
90
+ console.log(` ${farver.gray("─".repeat(title.length + 2))}`);
91
+ },
92
+ emptyLine: () => {
93
+ console.log();
94
+ },
95
+ item: (message, ...args) => {
96
+ console.log(` ${message}`, ...args);
97
+ },
98
+ step: (message) => {
99
+ console.log(` ${farver.blue("→")} ${message}`);
100
+ },
101
+ success: (message) => {
102
+ console.log(` ${farver.green("✓")} ${message}`);
103
+ },
104
+ clearScreen: () => {
105
+ const repeatCount = process.stdout.rows - 2;
106
+ const blank = repeatCount > 0 ? "\n".repeat(repeatCount) : "";
107
+ console.log(blank);
108
+ readline.cursorTo(process.stdout, 0, 0);
109
+ readline.clearScreenDown(process.stdout);
110
+ }
111
+ };
14
112
  async function run(bin, args, opts = {}) {
15
113
  return exec(bin, args, {
16
114
  throwOnError: true,
@@ -22,288 +120,177 @@ async function run(bin, args, opts = {}) {
22
120
  });
23
121
  }
24
122
  async function dryRun(bin, args, opts) {
25
- return console.log(farver.blue(`[dryrun] ${bin} ${args.join(" ")}`), opts || "");
123
+ return logger.verbose(farver.blue(`[dryrun] ${bin} ${args.join(" ")}`), opts || "");
124
+ }
125
+ const runIfNotDry = isDryRun ? dryRun : run;
126
+ if (isDryRun || isVerbose$1 || isForce) {
127
+ logger.verbose(farver.inverse(farver.yellow(" Running with special flags ")));
128
+ logger.verbose({
129
+ isDryRun,
130
+ isVerbose: isVerbose$1,
131
+ isForce
132
+ });
133
+ logger.verbose();
26
134
  }
27
- const runIfNotDry = globalOptions.dryRun ? dryRun : run;
28
135
 
29
136
  //#endregion
30
- //#region src/commits.ts
31
- async function getLastPackageTag(packageName, workspaceRoot) {
32
- const { stdout } = await run("git", ["tag", "--list"], { nodeOptions: {
33
- cwd: workspaceRoot,
34
- stdio: "pipe"
35
- } });
36
- return stdout.split("\n").map((tag) => tag.trim()).filter(Boolean).reverse().find((tag) => tag.startsWith(`${packageName}@`));
137
+ //#region src/shared/errors.ts
138
+ const isVerbose = !!mri(process.argv.slice(2)).verbose;
139
+ function isRecord(value) {
140
+ return typeof value === "object" && value !== null;
37
141
  }
38
- function determineHighestBump(commits) {
39
- if (commits.length === 0) return "none";
40
- let highestBump = "none";
41
- for (const commit of commits) {
42
- const bump = determineBumpType(commit);
43
- if (bump === "major") return "major";
44
- if (bump === "minor") highestBump = "minor";
45
- else if (bump === "patch" && highestBump === "none") highestBump = "patch";
142
+ function toTrimmedString(value) {
143
+ if (typeof value === "string") {
144
+ const normalized = value.trim();
145
+ return normalized.length > 0 ? normalized : void 0;
46
146
  }
47
- return highestBump;
48
- }
49
- async function getPackageCommits(pkg, workspaceRoot) {
50
- const lastTag = await getLastPackageTag(pkg.name, workspaceRoot);
51
- const allCommits = getCommits({
52
- from: lastTag,
53
- to: "HEAD"
54
- });
55
- console.log(`Found ${allCommits.length} commits for ${pkg.name} since ${lastTag || "beginning"}`);
56
- const touchedCommitHashes = await getCommitsTouchingPackage(lastTag || "HEAD", "HEAD", pkg.path, workspaceRoot);
57
- const touchedSet = new Set(touchedCommitHashes);
58
- const packageCommits = allCommits.filter((commit) => touchedSet.has(commit.shortHash));
59
- console.log(`${packageCommits.length} commits affect ${pkg.name}`);
60
- return packageCommits;
61
- }
62
- async function analyzePackageCommits(pkg, workspaceRoot) {
63
- return determineHighestBump(await getPackageCommits(pkg, workspaceRoot));
64
- }
65
- /**
66
- * Analyze commits for multiple packages to determine version bumps
67
- *
68
- * @param packages - Packages to analyze
69
- * @param workspaceRoot - Root directory of the workspace
70
- * @returns Map of package names to their bump types
71
- */
72
- async function analyzeCommits(packages, workspaceRoot) {
73
- const changedPackages = /* @__PURE__ */ new Map();
74
- for (const pkg of packages) {
75
- const bump = await analyzePackageCommits(pkg, workspaceRoot);
76
- if (bump !== "none") changedPackages.set(pkg.name, bump);
147
+ if (value instanceof Uint8Array) {
148
+ const normalized = new TextDecoder().decode(value).trim();
149
+ return normalized.length > 0 ? normalized : void 0;
77
150
  }
78
- return changedPackages;
79
- }
80
- function determineBumpType(commit) {
81
- if (commit.isBreaking) return "major";
82
- if (!commit.isConventional || !commit.type) return "none";
83
- switch (commit.type) {
84
- case "feat": return "minor";
85
- case "fix":
86
- case "perf": return "patch";
87
- case "docs":
88
- case "style":
89
- case "refactor":
90
- case "test":
91
- case "build":
92
- case "ci":
93
- case "chore":
94
- case "revert": return "none";
95
- default: return "none";
96
- }
97
- }
98
- async function getCommitsTouchingPackage(from, to, packagePath, workspaceRoot) {
99
- try {
100
- const { stdout } = await run("git", [
101
- "log",
102
- "--pretty=format:%h",
103
- from === "HEAD" ? "HEAD" : `${from}...${to}`,
104
- "--",
105
- packagePath
106
- ], { nodeOptions: {
107
- cwd: workspaceRoot,
108
- stdio: "pipe"
109
- } });
110
- return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
111
- } catch (error) {
112
- console.error(`Error getting commits touching package: ${error}`);
113
- return [];
151
+ if (isRecord(value) && typeof value.toString === "function") {
152
+ const rendered = value.toString();
153
+ if (typeof rendered === "string" && rendered !== "[object Object]") {
154
+ const normalized = rendered.trim();
155
+ return normalized.length > 0 ? normalized : void 0;
156
+ }
114
157
  }
115
158
  }
116
-
117
- //#endregion
118
- //#region src/git.ts
119
- /**
120
- * Check if the working directory is clean (no uncommitted changes)
121
- * @param {string} workspaceRoot - The root directory of the workspace
122
- * @returns {Promise<boolean>} A Promise resolving to true if clean, false otherwise
123
- */
124
- async function isWorkingDirectoryClean(workspaceRoot) {
125
- try {
126
- if ((await run("git", ["status", "--porcelain"], { nodeOptions: {
127
- cwd: workspaceRoot,
128
- stdio: "pipe"
129
- } })).stdout.trim() !== "") return false;
130
- return true;
131
- } catch (err) {
132
- console.error("Error checking git status:", err);
133
- return false;
159
+ function getNestedField(record, keys) {
160
+ let current = record;
161
+ for (const key of keys) {
162
+ if (!isRecord(current) || !(key in current)) return;
163
+ current = current[key];
134
164
  }
165
+ return current;
135
166
  }
136
- /**
137
- * Check if a git branch exists locally
138
- * @param {string} branch - The branch name to check
139
- * @param {string} workspaceRoot - The root directory of the workspace
140
- * @returns {Promise<boolean>} Promise resolving to true if branch exists, false otherwise
141
- */
142
- async function doesBranchExist(branch, workspaceRoot) {
143
- try {
144
- await run("git", [
145
- "rev-parse",
146
- "--verify",
147
- branch
148
- ], { nodeOptions: {
149
- cwd: workspaceRoot,
150
- stdio: "pipe"
151
- } });
152
- return true;
153
- } catch {
154
- return false;
167
+ function extractStderrLike(record) {
168
+ const candidates = [
169
+ record.stderr,
170
+ record.stdout,
171
+ record.shortMessage,
172
+ record.originalMessage,
173
+ getNestedField(record, ["result", "stderr"]),
174
+ getNestedField(record, ["result", "stdout"]),
175
+ getNestedField(record, ["output", "stderr"]),
176
+ getNestedField(record, ["output", "stdout"]),
177
+ getNestedField(record, ["cause", "stderr"]),
178
+ getNestedField(record, ["cause", "stdout"]),
179
+ getNestedField(record, ["cause", "shortMessage"]),
180
+ getNestedField(record, ["cause", "originalMessage"])
181
+ ];
182
+ for (const candidate of candidates) {
183
+ const rendered = toTrimmedString(candidate);
184
+ if (rendered) return rendered;
155
185
  }
156
186
  }
157
- /**
158
- * Pull latest changes from remote branch
159
- * @param branch - The branch name to pull from
160
- * @param workspaceRoot - The root directory of the workspace
161
- * @returns Promise resolving to true if pull succeeded, false otherwise
162
- */
163
- async function pullLatestChanges(branch, workspaceRoot) {
164
- try {
165
- await run("git", [
166
- "pull",
167
- "origin",
168
- branch
169
- ], { nodeOptions: {
170
- cwd: workspaceRoot,
171
- stdio: "pipe"
172
- } });
173
- return true;
174
- } catch {
175
- return false;
187
+ function formatUnknownError(error) {
188
+ if (error instanceof Error) {
189
+ const base = {
190
+ message: error.message || error.name,
191
+ stack: error.stack
192
+ };
193
+ const maybeError = error;
194
+ if (typeof maybeError.code === "string") base.code = maybeError.code;
195
+ if (typeof maybeError.status === "number") base.status = maybeError.status;
196
+ base.stderr = extractStderrLike(maybeError);
197
+ if (typeof maybeError.shortMessage === "string" && maybeError.shortMessage.trim() && base.message.startsWith("Process exited with non-zero status")) base.message = maybeError.shortMessage.trim();
198
+ if (!base.stderr && typeof maybeError.cause === "string" && maybeError.cause.trim()) base.stderr = maybeError.cause.trim();
199
+ return base;
176
200
  }
177
- }
178
- /**
179
- * Create a new git branch
180
- * @param branch - The new branch name
181
- * @param base - The base branch to create from
182
- * @param workspaceRoot - The root directory of the workspace
183
- */
184
- async function createBranch(branch, base, workspaceRoot) {
185
- await runIfNotDry("git", [
186
- "checkout",
187
- "-b",
188
- branch,
189
- base
190
- ], { nodeOptions: { cwd: workspaceRoot } });
191
- }
192
- /**
193
- * Checkout a git branch
194
- * @param branch - The branch name to checkout
195
- * @param workspaceRoot - The root directory of the workspace
196
- * @returns Promise resolving to true if checkout succeeded, false otherwise
197
- */
198
- async function checkoutBranch(branch, workspaceRoot) {
199
- try {
200
- await run("git", ["checkout", branch], { nodeOptions: { cwd: workspaceRoot } });
201
- return true;
202
- } catch {
203
- return false;
201
+ if (typeof error === "string") return { message: error };
202
+ if (isRecord(error)) {
203
+ const formatted = { message: typeof error.message === "string" ? error.message : typeof error.error === "string" ? error.error : JSON.stringify(error) };
204
+ if (typeof error.code === "string") formatted.code = error.code;
205
+ if (typeof error.status === "number") formatted.status = error.status;
206
+ formatted.stderr = extractStderrLike(error);
207
+ return formatted;
204
208
  }
209
+ return { message: String(error) };
205
210
  }
206
- /**
207
- * Get the current branch name
208
- * @param workspaceRoot - The root directory of the workspace
209
- * @returns Promise resolving to the current branch name
210
- */
211
- async function getCurrentBranch(workspaceRoot) {
212
- return (await run("git", [
213
- "rev-parse",
214
- "--abbrev-ref",
215
- "HEAD"
216
- ], { nodeOptions: {
217
- cwd: workspaceRoot,
218
- stdio: "pipe"
219
- } })).stdout.trim();
220
- }
221
- /**
222
- * Rebase current branch onto another branch
223
- * @param ontoBranch - The target branch to rebase onto
224
- * @param workspaceRoot - The root directory of the workspace
225
- */
226
- async function rebaseBranch(ontoBranch, workspaceRoot) {
227
- await run("git", ["rebase", ontoBranch], { nodeOptions: { cwd: workspaceRoot } });
228
- }
229
- /**
230
- * Check if local branch is ahead of remote (has commits to push)
231
- * @param branch - The branch name to check
232
- * @param workspaceRoot - The root directory of the workspace
233
- * @returns Promise resolving to true if local is ahead, false otherwise
234
- */
235
- async function isBranchAheadOfRemote(branch, workspaceRoot) {
236
- try {
237
- const result = await run("git", [
238
- "rev-list",
239
- `origin/${branch}..${branch}`,
240
- "--count"
241
- ], { nodeOptions: {
242
- cwd: workspaceRoot,
243
- stdio: "pipe"
244
- } });
245
- return Number.parseInt(result.stdout.trim(), 10) > 0;
246
- } catch {
247
- return true;
211
+ function exitWithError(message, hint, cause) {
212
+ console.error(` ${farver.red("✖")} ${farver.bold(message)}`);
213
+ if (cause !== void 0) {
214
+ const formatted = formatUnknownError(cause);
215
+ if (formatted.message && formatted.message !== message) console.error(farver.gray(` Cause: ${formatted.message}`));
216
+ if (formatted.code) console.error(farver.gray(` Code: ${formatted.code}`));
217
+ if (typeof formatted.status === "number") console.error(farver.gray(` Status: ${formatted.status}`));
218
+ if (formatted.stderr) {
219
+ console.error(farver.gray(" Stderr:"));
220
+ console.error(farver.gray(` ${formatted.stderr}`));
221
+ }
222
+ if (isVerbose && formatted.stack) {
223
+ console.error(farver.gray(" Stack:"));
224
+ console.error(farver.gray(` ${formatted.stack}`));
225
+ }
248
226
  }
249
- }
250
- /**
251
- * Check if there are any changes to commit (staged or unstaged)
252
- * @param workspaceRoot - The root directory of the workspace
253
- * @returns Promise resolving to true if there are changes, false otherwise
254
- */
255
- async function hasChangesToCommit(workspaceRoot) {
256
- return (await run("git", ["status", "--porcelain"], { nodeOptions: {
257
- cwd: workspaceRoot,
258
- stdio: "pipe"
259
- } })).stdout.trim() !== "";
260
- }
261
- /**
262
- * Commit changes with a message
263
- * @param message - The commit message
264
- * @param workspaceRoot - The root directory of the workspace
265
- * @returns Promise resolving to true if commit was made, false if there were no changes
266
- */
267
- async function commitChanges(message, workspaceRoot) {
268
- await run("git", ["add", "."], { nodeOptions: { cwd: workspaceRoot } });
269
- if (!await hasChangesToCommit(workspaceRoot)) return false;
270
- await run("git", [
271
- "commit",
272
- "-m",
273
- message
274
- ], { nodeOptions: { cwd: workspaceRoot } });
275
- return true;
276
- }
277
- /**
278
- * Push branch to remote
279
- * @param branch - The branch name to push
280
- * @param workspaceRoot - The root directory of the workspace
281
- * @param options - Push options
282
- * @param options.force - Force push (overwrite remote)
283
- * @param options.forceWithLease - Force push with safety check (won't overwrite unexpected changes)
284
- */
285
- async function pushBranch(branch, workspaceRoot, options) {
286
- const args = [
287
- "push",
288
- "origin",
289
- branch
290
- ];
291
- if (options?.forceWithLease) args.push("--force-with-lease");
292
- else if (options?.force) args.push("--force");
293
- await run("git", args, { nodeOptions: { cwd: workspaceRoot } });
227
+ if (hint) console.error(farver.gray(` ${hint}`));
228
+ process.exit(1);
294
229
  }
295
230
 
296
231
  //#endregion
297
- //#region src/github.ts
298
- async function getExistingPullRequest({ owner, repo, branch, githubToken }) {
299
- try {
300
- const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch}`, { headers: {
301
- Accept: "application/vnd.github.v3+json",
302
- Authorization: `token ${githubToken}`
303
- } });
304
- if (!res.ok) throw new Error(`GitHub API request failed with status ${res.status}`);
305
- const pulls = await res.json();
306
- if (pulls == null || !Array.isArray(pulls) || pulls.length === 0) return null;
232
+ //#region src/core/github.ts
233
+ function toGitHubError(operation, error) {
234
+ const formatted = formatUnknownError(error);
235
+ return {
236
+ type: "github",
237
+ operation,
238
+ message: formatted.message,
239
+ status: formatted.status
240
+ };
241
+ }
242
+ var GitHubClient = class {
243
+ owner;
244
+ repo;
245
+ githubToken;
246
+ apiBase = "https://api.github.com";
247
+ constructor({ owner, repo, githubToken }) {
248
+ this.owner = owner;
249
+ this.repo = repo;
250
+ this.githubToken = githubToken;
251
+ }
252
+ async request(path, init = {}) {
253
+ const url = path.startsWith("http") ? path : `${this.apiBase}${path}`;
254
+ const method = init.method ?? "GET";
255
+ let res;
256
+ try {
257
+ res = await fetch(url, {
258
+ ...init,
259
+ headers: {
260
+ ...init.headers,
261
+ "Accept": "application/vnd.github.v3+json",
262
+ "Authorization": `token ${this.githubToken}`,
263
+ "User-Agent": "ucdjs-release-scripts (+https://github.com/ucdjs/ucdjs-release-scripts)"
264
+ }
265
+ });
266
+ } catch (error) {
267
+ throw Object.assign(/* @__PURE__ */ new Error(`[${method} ${path}] GitHub request failed: ${formatUnknownError(error).message}`), { status: void 0 });
268
+ }
269
+ if (!res.ok) {
270
+ const errorText = await res.text();
271
+ const parsedMessage = (() => {
272
+ try {
273
+ const parsed = JSON.parse(errorText);
274
+ if (typeof parsed.message === "string" && parsed.message.trim()) {
275
+ if (Array.isArray(parsed.errors) && parsed.errors.length > 0) return `${parsed.message} (${JSON.stringify(parsed.errors)})`;
276
+ return parsed.message;
277
+ }
278
+ return errorText;
279
+ } catch {
280
+ return errorText;
281
+ }
282
+ })();
283
+ throw Object.assign(/* @__PURE__ */ new Error(`[${method} ${path}] GitHub API request failed (${res.status} ${res.statusText}): ${parsedMessage || "No response body"}`), { status: res.status });
284
+ }
285
+ if (res.status === 204) return;
286
+ return res.json();
287
+ }
288
+ async getExistingPullRequest(branch) {
289
+ const head = branch.includes(":") ? branch : `${this.owner}:${branch}`;
290
+ const endpoint = `/repos/${this.owner}/${this.repo}/pulls?state=open&head=${encodeURIComponent(head)}`;
291
+ logger.verbose(`Requesting pull request for branch: ${branch} (url: ${this.apiBase}${endpoint})`);
292
+ const pulls = await this.request(endpoint);
293
+ if (!Array.isArray(pulls) || pulls.length === 0) return null;
307
294
  const firstPullRequest = pulls[0];
308
295
  if (typeof firstPullRequest !== "object" || firstPullRequest === null || !("number" in firstPullRequest) || typeof firstPullRequest.number !== "number" || !("title" in firstPullRequest) || typeof firstPullRequest.title !== "string" || !("body" in firstPullRequest) || typeof firstPullRequest.body !== "string" || !("draft" in firstPullRequest) || typeof firstPullRequest.draft !== "boolean" || !("html_url" in firstPullRequest) || typeof firstPullRequest.html_url !== "string") throw new TypeError("Pull request data validation failed");
309
296
  const pullRequest = {
@@ -311,20 +298,15 @@ async function getExistingPullRequest({ owner, repo, branch, githubToken }) {
311
298
  title: firstPullRequest.title,
312
299
  body: firstPullRequest.body,
313
300
  draft: firstPullRequest.draft,
314
- html_url: firstPullRequest.html_url
301
+ html_url: firstPullRequest.html_url,
302
+ head: "head" in firstPullRequest && typeof firstPullRequest.head === "object" && firstPullRequest.head !== null && "sha" in firstPullRequest.head && typeof firstPullRequest.head.sha === "string" ? { sha: firstPullRequest.head.sha } : void 0
315
303
  };
316
- console.info(`Found existing pull request: ${farver.yellow(`#${pullRequest.number}`)}`);
304
+ logger.info(`Found existing pull request: ${farver.yellow(`#${pullRequest.number}`)}`);
317
305
  return pullRequest;
318
- } catch (err) {
319
- console.error("Error fetching pull request:", err);
320
- return null;
321
306
  }
322
- }
323
- async function upsertPullRequest({ owner, repo, title, body, head, base, pullNumber, githubToken }) {
324
- try {
325
- const isUpdate = pullNumber != null;
326
- const url = isUpdate ? `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` : `https://api.github.com/repos/${owner}/${repo}/pulls`;
327
- const method = isUpdate ? "PATCH" : "POST";
307
+ async upsertPullRequest({ title, body, head, base, pullNumber }) {
308
+ const isUpdate = typeof pullNumber === "number";
309
+ const endpoint = isUpdate ? `/repos/${this.owner}/${this.repo}/pulls/${pullNumber}` : `/repos/${this.owner}/${this.repo}/pulls`;
328
310
  const requestBody = isUpdate ? {
329
311
  title,
330
312
  body
@@ -332,21 +314,17 @@ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNum
332
314
  title,
333
315
  body,
334
316
  head,
335
- base
317
+ base,
318
+ draft: true
336
319
  };
337
- const res = await fetch(url, {
338
- method,
339
- headers: {
340
- Accept: "application/vnd.github.v3+json",
341
- Authorization: `token ${githubToken}`
342
- },
320
+ logger.verbose(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${this.apiBase}${endpoint})`);
321
+ const pr = await this.request(endpoint, {
322
+ method: isUpdate ? "PATCH" : "POST",
343
323
  body: JSON.stringify(requestBody)
344
324
  });
345
- if (!res.ok) throw new Error(`GitHub API request failed with status ${res.status}`);
346
- const pr = await res.json();
347
325
  if (typeof pr !== "object" || pr === null || !("number" in pr) || typeof pr.number !== "number" || !("title" in pr) || typeof pr.title !== "string" || !("body" in pr) || typeof pr.body !== "string" || !("draft" in pr) || typeof pr.draft !== "boolean" || !("html_url" in pr) || typeof pr.html_url !== "string") throw new TypeError("Pull request data validation failed");
348
326
  const action = isUpdate ? "Updated" : "Created";
349
- console.info(`${action} pull request: ${farver.yellow(`#${pr.number}`)}`);
327
+ logger.info(`${action} pull request: ${farver.yellow(`#${pr.number}`)}`);
350
328
  return {
351
329
  number: pr.number,
352
330
  title: pr.title,
@@ -354,27 +332,97 @@ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNum
354
332
  draft: pr.draft,
355
333
  html_url: pr.html_url
356
334
  };
357
- } catch (err) {
358
- console.error(`Error upserting pull request:`, err);
359
- throw err;
360
335
  }
336
+ async setCommitStatus({ sha, state, targetUrl, description, context }) {
337
+ const endpoint = `/repos/${this.owner}/${this.repo}/statuses/${sha}`;
338
+ logger.verbose(`Setting commit status on ${sha} to ${state} (url: ${this.apiBase}${endpoint})`);
339
+ await this.request(endpoint, {
340
+ method: "POST",
341
+ body: JSON.stringify({
342
+ state,
343
+ target_url: targetUrl,
344
+ description: description || "",
345
+ context
346
+ })
347
+ });
348
+ logger.info(`Commit status set to ${farver.cyan(state)} for ${farver.gray(sha.substring(0, 7))}`);
349
+ }
350
+ async upsertReleaseByTag({ tagName, name, body, prerelease = false }) {
351
+ const encodedTag = encodeURIComponent(tagName);
352
+ let existingRelease = null;
353
+ try {
354
+ existingRelease = await this.request(`/repos/${this.owner}/${this.repo}/releases/tags/${encodedTag}`);
355
+ } catch (error) {
356
+ if (formatUnknownError(error).status !== 404) throw error;
357
+ }
358
+ if (existingRelease) {
359
+ logger.verbose(`Updating release for tag ${farver.cyan(tagName)}`);
360
+ const updated = await this.request(`/repos/${this.owner}/${this.repo}/releases/${existingRelease.id}`, {
361
+ method: "PATCH",
362
+ body: JSON.stringify({
363
+ name,
364
+ body,
365
+ prerelease,
366
+ draft: false
367
+ })
368
+ });
369
+ logger.info(`Updated GitHub release for ${farver.cyan(tagName)}`);
370
+ return {
371
+ release: {
372
+ id: updated.id,
373
+ tagName: updated.tag_name,
374
+ name: updated.name ?? name,
375
+ htmlUrl: updated.html_url
376
+ },
377
+ created: false
378
+ };
379
+ }
380
+ logger.verbose(`Creating release for tag ${farver.cyan(tagName)}`);
381
+ const created = await this.request(`/repos/${this.owner}/${this.repo}/releases`, {
382
+ method: "POST",
383
+ body: JSON.stringify({
384
+ tag_name: tagName,
385
+ name,
386
+ body,
387
+ prerelease,
388
+ draft: false,
389
+ generate_release_notes: body == null
390
+ })
391
+ });
392
+ logger.info(`Created GitHub release for ${farver.cyan(tagName)}`);
393
+ return {
394
+ release: {
395
+ id: created.id,
396
+ tagName: created.tag_name,
397
+ name: created.name ?? name,
398
+ htmlUrl: created.html_url
399
+ },
400
+ created: true
401
+ };
402
+ }
403
+ async resolveAuthorInfo(info) {
404
+ if (info.login) return info;
405
+ try {
406
+ const q = encodeURIComponent(`${info.email} type:user in:email`);
407
+ const data = await this.request(`/search/users?q=${q}`);
408
+ if (!data.items || data.items.length === 0) return info;
409
+ info.login = data.items[0].login;
410
+ } catch (err) {
411
+ logger.warn(`Failed to resolve author info for email ${info.email}: ${formatUnknownError(err).message}`);
412
+ }
413
+ if (info.login) return info;
414
+ if (info.commits.length > 0) try {
415
+ const data = await this.request(`/repos/${this.owner}/${this.repo}/commits/${info.commits[0]}`);
416
+ if (data.author && data.author.login) info.login = data.author.login;
417
+ } catch (err) {
418
+ logger.warn(`Failed to resolve author info from commits for email ${info.email}: ${formatUnknownError(err).message}`);
419
+ }
420
+ return info;
421
+ }
422
+ };
423
+ function createGitHubClient(options) {
424
+ return new GitHubClient(options);
361
425
  }
362
- const defaultTemplate = dedent`
363
- This PR was automatically generated by the release script.
364
-
365
- The following packages have been prepared for release:
366
-
367
- <% it.packages.forEach((pkg) => { %>
368
- - **<%= pkg.name %>**: <%= pkg.currentVersion %> → <%= pkg.newVersion %> (<%= pkg.bumpType %>)
369
- <% }) %>
370
-
371
- Please review the changes and merge when ready.
372
-
373
- For a more in-depth look at the changes, please refer to the individual package changelogs.
374
-
375
- > [!NOTE]
376
- > When this PR is merged, the release process will be triggered automatically, publishing the new package versions to the registry.
377
- `;
378
426
  function dedentString(str) {
379
427
  const lines = str.split("\n");
380
428
  const minIndent = lines.filter((line) => line.trim().length > 0).reduce((min, line) => Math.min(min, line.search(/\S/)), Infinity);
@@ -382,7 +430,7 @@ function dedentString(str) {
382
430
  }
383
431
  function generatePullRequestBody(updates, body) {
384
432
  const eta = new Eta();
385
- const bodyTemplate = body ? dedentString(body) : defaultTemplate;
433
+ const bodyTemplate = body ? dedentString(body) : DEFAULT_PR_BODY_TEMPLATE;
386
434
  return eta.renderString(bodyTemplate, { packages: updates.map((u) => ({
387
435
  name: u.package.name,
388
436
  currentVersion: u.currentVersion,
@@ -393,94 +441,1126 @@ function generatePullRequestBody(updates, body) {
393
441
  }
394
442
 
395
443
  //#endregion
396
- //#region src/version.ts
397
- function isValidSemver(version) {
398
- return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(version);
444
+ //#region src/options.ts
445
+ const DEFAULT_PR_BODY_TEMPLATE = dedent`
446
+ This PR was automatically generated by the UCD release scripts.
447
+
448
+ The following packages have been prepared for release:
449
+
450
+ <% if (it.packages.length > 0) { %>
451
+ <% it.packages.forEach((pkg) => { %>
452
+ - **<%= pkg.name %>**: <%= pkg.currentVersion %> → <%= pkg.newVersion %> (<%= pkg.bumpType %>)
453
+ <% }) %>
454
+ <% } else { %>
455
+ There are no packages to release.
456
+ <% } %>
457
+
458
+ Please review the changes and merge when ready.
459
+
460
+ > [!NOTE]
461
+ > When this PR is merged, the release process will be triggered automatically, publishing the new package versions to the registry.
462
+ `;
463
+ const DEFAULT_CHANGELOG_TEMPLATE = dedent`
464
+ <% if (it.previousVersion) { -%>
465
+ ## [<%= it.version %>](<%= it.compareUrl %>) (<%= it.date %>)
466
+ <% } else { -%>
467
+ ## <%= it.version %> (<%= it.date %>)
468
+ <% } %>
469
+
470
+ <% it.groups.forEach((group) => { %>
471
+ <% if (group.commits.length > 0) { %>
472
+
473
+ ### <%= group.title %>
474
+ <% group.commits.forEach((commit) => { %>
475
+
476
+ * <%= commit.line %>
477
+ <% }); %>
478
+
479
+ <% } %>
480
+ <% }); %>
481
+ `;
482
+ const DEFAULT_TYPES = {
483
+ feat: { title: "🚀 Features" },
484
+ fix: { title: "🐞 Bug Fixes" },
485
+ perf: { title: "🏎 Performance" },
486
+ docs: { title: "📚 Documentation" },
487
+ style: { title: "🎨 Styles" }
488
+ };
489
+ function normalizeReleaseScriptsOptions(options) {
490
+ const { workspaceRoot = process.cwd(), githubToken = "", repo: fullRepo, packages = true, branch = {}, globalCommitMode = "dependencies", pullRequest = {}, changelog = {}, types, safeguards = true, dryRun = false, npm = {}, prompts = {} } = options;
491
+ const token = githubToken.trim();
492
+ if (!token) throw new Error("GitHub token is required. Pass it in via options.");
493
+ if (!fullRepo || !fullRepo.trim() || !fullRepo.includes("/")) throw new Error("Repository (repo) is required. Specify in 'owner/repo' format (e.g., 'octocat/hello-world').");
494
+ const [owner, repo] = fullRepo.split("/");
495
+ if (!owner || !repo) throw new Error(`Invalid repo format: "${fullRepo}". Expected format: "owner/repo" (e.g., "octocat/hello-world").`);
496
+ const normalizedPackages = typeof packages === "object" && !Array.isArray(packages) ? {
497
+ exclude: packages.exclude ?? [],
498
+ include: packages.include ?? [],
499
+ excludePrivate: packages.excludePrivate ?? false
500
+ } : packages;
501
+ const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
502
+ return {
503
+ dryRun,
504
+ workspaceRoot,
505
+ githubToken: token,
506
+ owner,
507
+ repo,
508
+ githubClient: createGitHubClient({
509
+ owner,
510
+ repo,
511
+ githubToken: token
512
+ }),
513
+ packages: normalizedPackages,
514
+ branch: {
515
+ release: branch.release ?? "release/next",
516
+ default: branch.default ?? "main"
517
+ },
518
+ globalCommitMode,
519
+ safeguards,
520
+ pullRequest: {
521
+ title: pullRequest.title ?? "chore: release new version",
522
+ body: pullRequest.body ?? DEFAULT_PR_BODY_TEMPLATE
523
+ },
524
+ changelog: {
525
+ enabled: changelog.enabled ?? true,
526
+ template: changelog.template ?? DEFAULT_CHANGELOG_TEMPLATE,
527
+ emojis: changelog.emojis ?? true
528
+ },
529
+ types: types ? {
530
+ ...DEFAULT_TYPES,
531
+ ...types
532
+ } : DEFAULT_TYPES,
533
+ npm: {
534
+ otp: npm.otp,
535
+ provenance: npm.provenance ?? true,
536
+ access: npm.access ?? "public"
537
+ },
538
+ prompts: {
539
+ versions: prompts.versions ?? !isCI,
540
+ packages: prompts.packages ?? !isCI
541
+ }
542
+ };
399
543
  }
400
- function validateSemver(version) {
401
- if (!isValidSemver(version)) throw new Error(`Invalid semver version: ${version}`);
544
+
545
+ //#endregion
546
+ //#region src/types.ts
547
+ function ok(value) {
548
+ return {
549
+ ok: true,
550
+ value
551
+ };
402
552
  }
403
- /**
404
- * Calculate the new version based on current version and bump type
405
- * Pure function - no side effects, easily testable
406
- */
407
- function calculateNewVersion(currentVersion, bump) {
408
- if (bump === "none") return currentVersion;
409
- validateSemver(currentVersion);
410
- const match = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
411
- if (!match) throw new Error(`Invalid semver version: ${currentVersion}`);
412
- const [, major, minor, patch] = match;
413
- let newMajor = Number.parseInt(major, 10);
414
- let newMinor = Number.parseInt(minor, 10);
415
- let newPatch = Number.parseInt(patch, 10);
416
- switch (bump) {
417
- case "major":
418
- newMajor += 1;
419
- newMinor = 0;
420
- newPatch = 0;
421
- break;
422
- case "minor":
423
- newMinor += 1;
424
- newPatch = 0;
425
- break;
426
- case "patch":
427
- newPatch += 1;
428
- break;
429
- }
430
- return `${newMajor}.${newMinor}.${newPatch}`;
553
+ function err(error) {
554
+ return {
555
+ ok: false,
556
+ error
557
+ };
431
558
  }
432
- /**
433
- * Create a version update object
434
- */
435
- function createVersionUpdate(pkg, bump, hasDirectChanges) {
436
- const newVersion = calculateNewVersion(pkg.version, bump);
559
+
560
+ //#endregion
561
+ //#region src/core/git.ts
562
+ function toGitError(operation, error) {
563
+ const formatted = formatUnknownError(error);
437
564
  return {
438
- package: pkg,
439
- currentVersion: pkg.version,
440
- newVersion,
441
- bumpType: bump,
442
- hasDirectChanges
565
+ type: "git",
566
+ operation,
567
+ message: formatted.message,
568
+ stderr: formatted.stderr
443
569
  };
444
570
  }
445
- /**
446
- * Update a package.json file with new version and dependency versions
447
- */
448
- async function updatePackageJson(pkg, newVersion, dependencyUpdates) {
449
- const packageJsonPath = join(pkg.path, "package.json");
450
- const content = await readFile(packageJsonPath, "utf-8");
451
- const packageJson = JSON.parse(content);
452
- packageJson.version = newVersion;
453
- for (const [depName, depVersion] of dependencyUpdates) {
454
- if (packageJson.dependencies?.[depName]) {
455
- if (packageJson.dependencies[depName] === "workspace:*") continue;
456
- packageJson.dependencies[depName] = `^${depVersion}`;
457
- }
458
- if (packageJson.devDependencies?.[depName]) {
459
- if (packageJson.devDependencies[depName] === "workspace:*") continue;
460
- packageJson.devDependencies[depName] = `^${depVersion}`;
461
- }
462
- if (packageJson.peerDependencies?.[depName]) {
463
- if (packageJson.peerDependencies[depName] === "workspace:*") continue;
464
- packageJson.peerDependencies[depName] = `^${depVersion}`;
465
- }
571
+ async function isWorkingDirectoryClean(workspaceRoot) {
572
+ try {
573
+ return ok((await run("git", ["status", "--porcelain"], { nodeOptions: {
574
+ cwd: workspaceRoot,
575
+ stdio: "pipe"
576
+ } })).stdout.trim() === "");
577
+ } catch (error) {
578
+ return err(toGitError("isWorkingDirectoryClean", error));
466
579
  }
467
- await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
468
580
  }
469
581
  /**
470
- * Get all dependency updates needed for a package
582
+ * Check if a git branch exists locally
583
+ * @param {string} branch - The branch name to check
584
+ * @param {string} workspaceRoot - The root directory of the workspace
585
+ * @returns {Promise<boolean>} Promise resolving to true if branch exists, false otherwise
471
586
  */
472
- function getDependencyUpdates(pkg, allUpdates) {
473
- const updates = /* @__PURE__ */ new Map();
474
- const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
475
- for (const dep of allDeps) {
476
- const update = allUpdates.find((u) => u.package.name === dep);
477
- if (update) updates.set(dep, update.newVersion);
587
+ async function doesBranchExist(branch, workspaceRoot) {
588
+ try {
589
+ await run("git", [
590
+ "rev-parse",
591
+ "--verify",
592
+ branch
593
+ ], { nodeOptions: {
594
+ cwd: workspaceRoot,
595
+ stdio: "pipe"
596
+ } });
597
+ return ok(true);
598
+ } catch (error) {
599
+ logger.verbose(`Failed to verify branch "${branch}": ${formatUnknownError(error).message}`);
600
+ return ok(false);
478
601
  }
479
- return updates;
602
+ }
603
+ /**
604
+ * Retrieves the name of the current branch in the repository.
605
+ * @param {string} workspaceRoot - The root directory of the workspace
606
+ * @returns {Promise<string>} A Promise resolving to the current branch name as a string
607
+ */
608
+ async function getCurrentBranch(workspaceRoot) {
609
+ try {
610
+ return ok((await run("git", [
611
+ "rev-parse",
612
+ "--abbrev-ref",
613
+ "HEAD"
614
+ ], { nodeOptions: {
615
+ cwd: workspaceRoot,
616
+ stdio: "pipe"
617
+ } })).stdout.trim());
618
+ } catch (error) {
619
+ return err(toGitError("getCurrentBranch", error));
620
+ }
621
+ }
622
+ /**
623
+ * Creates a new branch from the specified base branch.
624
+ * @param {string} branch - The name of the new branch to create
625
+ * @param {string} base - The base branch to create the new branch from
626
+ * @param {string} workspaceRoot - The root directory of the workspace
627
+ * @returns {Promise<void>} A Promise that resolves when the branch is created
628
+ */
629
+ async function createBranch(branch, base, workspaceRoot) {
630
+ try {
631
+ logger.info(`Creating branch: ${farver.green(branch)} from ${farver.cyan(base)}`);
632
+ await runIfNotDry("git", [
633
+ "branch",
634
+ branch,
635
+ base
636
+ ], { nodeOptions: {
637
+ cwd: workspaceRoot,
638
+ stdio: "pipe"
639
+ } });
640
+ return ok(void 0);
641
+ } catch (error) {
642
+ return err(toGitError("createBranch", error));
643
+ }
644
+ }
645
+ async function checkoutBranch(branch, workspaceRoot) {
646
+ try {
647
+ logger.info(`Switching to branch: ${farver.green(branch)}`);
648
+ const output = (await run("git", ["checkout", branch], { nodeOptions: {
649
+ cwd: workspaceRoot,
650
+ stdio: "pipe"
651
+ } })).stderr.trim();
652
+ const match = output.match(/Switched to (?:a new )?branch '(.+)'/);
653
+ if (match && match[1] === branch) {
654
+ logger.info(`Successfully switched to branch: ${farver.green(branch)}`);
655
+ return ok(true);
656
+ }
657
+ console.warn(`Unexpected git checkout output: ${output}`);
658
+ return ok(false);
659
+ } catch (error) {
660
+ const gitError = toGitError("checkoutBranch", error);
661
+ logger.error(`Git checkout failed: ${gitError.message}`);
662
+ if (gitError.stderr) logger.error(`Git stderr: ${gitError.stderr}`);
663
+ try {
664
+ const branchResult = await run("git", ["branch", "-a"], { nodeOptions: {
665
+ cwd: workspaceRoot,
666
+ stdio: "pipe"
667
+ } });
668
+ logger.verbose(`Available branches:\n${branchResult.stdout}`);
669
+ } catch (error) {
670
+ logger.verbose(`Could not list available branches: ${formatUnknownError(error).message}`);
671
+ }
672
+ return err(gitError);
673
+ }
674
+ }
675
+ async function pullLatestChanges(branch, workspaceRoot) {
676
+ try {
677
+ await run("git", [
678
+ "pull",
679
+ "origin",
680
+ branch
681
+ ], { nodeOptions: {
682
+ cwd: workspaceRoot,
683
+ stdio: "pipe"
684
+ } });
685
+ return ok(true);
686
+ } catch (error) {
687
+ return err(toGitError("pullLatestChanges", error));
688
+ }
689
+ }
690
+ async function rebaseBranch(ontoBranch, workspaceRoot) {
691
+ try {
692
+ logger.info(`Rebasing onto: ${farver.cyan(ontoBranch)}`);
693
+ await runIfNotDry("git", ["rebase", ontoBranch], { nodeOptions: {
694
+ cwd: workspaceRoot,
695
+ stdio: "pipe"
696
+ } });
697
+ return ok(void 0);
698
+ } catch (error) {
699
+ return err(toGitError("rebaseBranch", error));
700
+ }
701
+ }
702
+ async function isBranchAheadOfRemote(branch, workspaceRoot) {
703
+ try {
704
+ const result = await run("git", [
705
+ "rev-list",
706
+ `origin/${branch}..${branch}`,
707
+ "--count"
708
+ ], { nodeOptions: {
709
+ cwd: workspaceRoot,
710
+ stdio: "pipe"
711
+ } });
712
+ return ok(Number.parseInt(result.stdout.trim(), 10) > 0);
713
+ } catch (error) {
714
+ logger.verbose(`Failed to compare branch "${branch}" with remote: ${formatUnknownError(error).message}`);
715
+ return ok(true);
716
+ }
717
+ }
718
+ async function commitChanges(message, workspaceRoot) {
719
+ try {
720
+ await run("git", ["add", "."], { nodeOptions: {
721
+ cwd: workspaceRoot,
722
+ stdio: "pipe"
723
+ } });
724
+ const isClean = await isWorkingDirectoryClean(workspaceRoot);
725
+ if (!isClean.ok || isClean.value) return ok(false);
726
+ logger.info(`Committing changes: ${farver.dim(message)}`);
727
+ await runIfNotDry("git", [
728
+ "commit",
729
+ "-m",
730
+ message
731
+ ], { nodeOptions: {
732
+ cwd: workspaceRoot,
733
+ stdio: "pipe"
734
+ } });
735
+ return ok(true);
736
+ } catch (error) {
737
+ const gitError = toGitError("commitChanges", error);
738
+ logger.error(`Git commit failed: ${gitError.message}`);
739
+ if (gitError.stderr) logger.error(`Git stderr: ${gitError.stderr}`);
740
+ return err(gitError);
741
+ }
742
+ }
743
+ async function pushBranch(branch, workspaceRoot, options) {
744
+ try {
745
+ const args = [
746
+ "push",
747
+ "origin",
748
+ branch
749
+ ];
750
+ if (options?.forceWithLease) try {
751
+ await run("git", [
752
+ "fetch",
753
+ "origin",
754
+ branch
755
+ ], { nodeOptions: {
756
+ cwd: workspaceRoot,
757
+ stdio: "pipe"
758
+ } });
759
+ args.push("--force-with-lease");
760
+ logger.info(`Pushing branch: ${farver.green(branch)} ${farver.dim("(with lease)")}`);
761
+ } catch (error) {
762
+ const fetchError = toGitError("pushBranch.fetch", error);
763
+ if (fetchError.stderr?.includes("couldn't find remote ref") || fetchError.message.includes("couldn't find remote ref")) logger.verbose(`Remote branch origin/${branch} does not exist yet, falling back to regular push without --force-with-lease.`);
764
+ else return err(fetchError);
765
+ }
766
+ else if (options?.force) {
767
+ args.push("--force");
768
+ logger.info(`Force pushing branch: ${farver.green(branch)}`);
769
+ } else logger.info(`Pushing branch: ${farver.green(branch)}`);
770
+ await runIfNotDry("git", args, { nodeOptions: {
771
+ cwd: workspaceRoot,
772
+ stdio: "pipe"
773
+ } });
774
+ return ok(true);
775
+ } catch (error) {
776
+ return err(toGitError("pushBranch", error));
777
+ }
778
+ }
779
+ async function readFileFromGit(workspaceRoot, ref, filePath) {
780
+ try {
781
+ return ok((await run("git", ["show", `${ref}:${filePath}`], { nodeOptions: {
782
+ cwd: workspaceRoot,
783
+ stdio: "pipe"
784
+ } })).stdout);
785
+ } catch (error) {
786
+ logger.verbose(`Failed to read ${filePath} from ${ref}: ${formatUnknownError(error).message}`);
787
+ return ok(null);
788
+ }
789
+ }
790
+ async function getMostRecentPackageTag(workspaceRoot, packageName) {
791
+ try {
792
+ const { stdout } = await run("git", [
793
+ "tag",
794
+ "--list",
795
+ `${packageName}@*`
796
+ ], { nodeOptions: {
797
+ cwd: workspaceRoot,
798
+ stdio: "pipe"
799
+ } });
800
+ const tags = stdout.split("\n").map((tag) => tag.trim()).filter(Boolean);
801
+ if (tags.length === 0) return ok(void 0);
802
+ return ok(tags.reverse()[0]);
803
+ } catch (error) {
804
+ return err(toGitError("getMostRecentPackageTag", error));
805
+ }
806
+ }
807
+ /**
808
+ * Builds a mapping of commit SHAs to the list of files changed in each commit
809
+ * within a given inclusive range.
810
+ *
811
+ * Internally runs:
812
+ * git log --name-only --format=%H <from>^..<to>
813
+ *
814
+ * Notes
815
+ * - This includes the commit identified by `from` (via `from^..to`).
816
+ * - Order of commits in the resulting Map follows `git log` output
817
+ * (reverse chronological, newest first).
818
+ * - On failure (e.g., invalid refs), the function returns null.
819
+ *
820
+ * @param {string} workspaceRoot Absolute path to the git repository root used as cwd.
821
+ * @param {string} from Starting commit/ref (inclusive).
822
+ * @param {string} to Ending commit/ref (inclusive).
823
+ * @returns {Promise<Map<string, string[]> | null>} Promise resolving to a Map where keys are commit SHAs and values are
824
+ * arrays of file paths changed by that commit, or null on error.
825
+ */
826
+ async function getGroupedFilesByCommitSha(workspaceRoot, from, to) {
827
+ const commitsMap = /* @__PURE__ */ new Map();
828
+ try {
829
+ const { stdout } = await run("git", [
830
+ "log",
831
+ "--name-only",
832
+ "--format=%H",
833
+ `${from}^..${to}`
834
+ ], { nodeOptions: {
835
+ cwd: workspaceRoot,
836
+ stdio: "pipe"
837
+ } });
838
+ const lines = stdout.trim().split("\n").filter((line) => line.trim() !== "");
839
+ let currentSha = null;
840
+ const HASH_REGEX = /^[0-9a-f]{40}$/i;
841
+ for (const line of lines) {
842
+ const trimmedLine = line.trim();
843
+ if (HASH_REGEX.test(trimmedLine)) {
844
+ currentSha = trimmedLine;
845
+ commitsMap.set(currentSha, []);
846
+ continue;
847
+ }
848
+ if (currentSha === null) continue;
849
+ commitsMap.get(currentSha).push(trimmedLine);
850
+ }
851
+ return ok(commitsMap);
852
+ } catch (error) {
853
+ return err(toGitError("getGroupedFilesByCommitSha", error));
854
+ }
855
+ }
856
+ /**
857
+ * Create a git tag for a package release
858
+ * @param packageName - The package name (e.g., "@scope/name")
859
+ * @param version - The version to tag (e.g., "1.2.3")
860
+ * @param workspaceRoot - The root directory of the workspace
861
+ * @returns Result indicating success or failure
862
+ */
863
+ async function createPackageTag(packageName, version, workspaceRoot) {
864
+ const tagName = `${packageName}@${version}`;
865
+ try {
866
+ logger.info(`Creating tag: ${farver.green(tagName)}`);
867
+ await runIfNotDry("git", ["tag", tagName], { nodeOptions: {
868
+ cwd: workspaceRoot,
869
+ stdio: "pipe"
870
+ } });
871
+ return ok(void 0);
872
+ } catch (error) {
873
+ return err(toGitError("createPackageTag", error));
874
+ }
875
+ }
876
+ /**
877
+ * Push a specific tag to the remote repository
878
+ * @param tagName - The tag name to push
879
+ * @param workspaceRoot - The root directory of the workspace
880
+ * @returns Result indicating success or failure
881
+ */
882
+ async function pushTag(tagName, workspaceRoot) {
883
+ try {
884
+ logger.info(`Pushing tag: ${farver.green(tagName)}`);
885
+ await runIfNotDry("git", [
886
+ "push",
887
+ "origin",
888
+ tagName
889
+ ], { nodeOptions: {
890
+ cwd: workspaceRoot,
891
+ stdio: "pipe"
892
+ } });
893
+ return ok(void 0);
894
+ } catch (error) {
895
+ return err(toGitError("pushTag", error));
896
+ }
897
+ }
898
+ /**
899
+ * Create and push a package tag in one operation
900
+ * @param packageName - The package name
901
+ * @param version - The version to tag
902
+ * @param workspaceRoot - The root directory of the workspace
903
+ * @returns Result indicating success or failure
904
+ */
905
+ async function createAndPushPackageTag(packageName, version, workspaceRoot) {
906
+ const createResult = await createPackageTag(packageName, version, workspaceRoot);
907
+ if (!createResult.ok) return createResult;
908
+ return pushTag(`${packageName}@${version}`, workspaceRoot);
909
+ }
910
+
911
+ //#endregion
912
+ //#region src/core/changelog.ts
913
+ const excludeAuthors = [
914
+ /\[bot\]/i,
915
+ /dependabot/i,
916
+ /\(bot\)/i
917
+ ];
918
+ async function generateChangelogEntry(options) {
919
+ const { packageName, version, previousVersion, date, commits, owner, repo, types, template, githubClient } = options;
920
+ const templateData = {
921
+ packageName,
922
+ version,
923
+ previousVersion,
924
+ date,
925
+ compareUrl: previousVersion ? `https://github.com/${owner}/${repo}/compare/${packageName}@${previousVersion}...${packageName}@${version}` : void 0,
926
+ owner,
927
+ repo,
928
+ groups: buildTemplateGroups({
929
+ commits,
930
+ owner,
931
+ repo,
932
+ types,
933
+ commitAuthors: await resolveCommitAuthors(commits, githubClient)
934
+ })
935
+ };
936
+ const eta = new Eta();
937
+ const templateToUse = template || DEFAULT_CHANGELOG_TEMPLATE;
938
+ return eta.renderString(templateToUse, templateData).trim();
939
+ }
940
+ async function updateChangelog(options) {
941
+ const { version, previousVersion, commits, date, normalizedOptions, workspacePackage, githubClient } = options;
942
+ const changelogPath = join(workspacePackage.path, "CHANGELOG.md");
943
+ const changelogRelativePath = relative(normalizedOptions.workspaceRoot, join(workspacePackage.path, "CHANGELOG.md"));
944
+ const existingContent = await readFileFromGit(normalizedOptions.workspaceRoot, normalizedOptions.branch.default, changelogRelativePath);
945
+ logger.verbose("Existing content found: ", existingContent.ok && Boolean(existingContent.value));
946
+ const newEntry = await generateChangelogEntry({
947
+ packageName: workspacePackage.name,
948
+ version,
949
+ previousVersion,
950
+ date,
951
+ commits,
952
+ owner: normalizedOptions.owner,
953
+ repo: normalizedOptions.repo,
954
+ types: normalizedOptions.types,
955
+ template: normalizedOptions.changelog?.template,
956
+ githubClient
957
+ });
958
+ let updatedContent;
959
+ if (!existingContent.ok || !existingContent.value) {
960
+ updatedContent = `# ${workspacePackage.name}\n\n${newEntry}\n`;
961
+ await writeFile(changelogPath, updatedContent, "utf-8");
962
+ return;
963
+ }
964
+ const parsed = parseChangelog(existingContent.value);
965
+ const lines = existingContent.value.split("\n");
966
+ const existingVersionIndex = parsed.versions.findIndex((v) => v.version === version);
967
+ if (existingVersionIndex !== -1) {
968
+ const existingVersion = parsed.versions[existingVersionIndex];
969
+ const before = lines.slice(0, existingVersion.lineStart);
970
+ const after = lines.slice(existingVersion.lineEnd + 1);
971
+ updatedContent = [
972
+ ...before,
973
+ newEntry,
974
+ ...after
975
+ ].join("\n");
976
+ } else {
977
+ const insertAt = parsed.headerLineEnd + 1;
978
+ const before = lines.slice(0, insertAt);
979
+ const after = lines.slice(insertAt);
980
+ if (before.length > 0 && before[before.length - 1] !== "") before.push("");
981
+ updatedContent = [
982
+ ...before,
983
+ newEntry,
984
+ "",
985
+ ...after
986
+ ].join("\n");
987
+ }
988
+ await writeFile(changelogPath, updatedContent, "utf-8");
989
+ }
990
+ async function resolveCommitAuthors(commits, githubClient) {
991
+ const authorMap = /* @__PURE__ */ new Map();
992
+ const commitAuthors = /* @__PURE__ */ new Map();
993
+ for (const commit of commits) {
994
+ const authorsForCommit = [];
995
+ commit.authors.forEach((author, idx) => {
996
+ if (!author.email || !author.name) return;
997
+ if (excludeAuthors.some((re) => re.test(author.name))) return;
998
+ if (!authorMap.has(author.email)) authorMap.set(author.email, {
999
+ commits: [],
1000
+ name: author.name,
1001
+ email: author.email
1002
+ });
1003
+ const info = authorMap.get(author.email);
1004
+ if (idx === 0) info.commits.push(commit.shortHash);
1005
+ authorsForCommit.push(info);
1006
+ });
1007
+ commitAuthors.set(commit.hash, authorsForCommit);
1008
+ }
1009
+ const authors = Array.from(authorMap.values());
1010
+ await Promise.all(authors.map((info) => githubClient.resolveAuthorInfo(info)));
1011
+ return commitAuthors;
1012
+ }
1013
+ function parseChangelog(content) {
1014
+ const lines = content.split("\n");
1015
+ let packageName = null;
1016
+ let headerLineEnd = -1;
1017
+ const versions = [];
1018
+ for (let i = 0; i < lines.length; i++) {
1019
+ const line = lines[i].trim();
1020
+ if (line.startsWith("# ")) {
1021
+ packageName = line.slice(2).trim();
1022
+ headerLineEnd = i;
1023
+ break;
1024
+ }
1025
+ }
1026
+ for (let i = headerLineEnd + 1; i < lines.length; i++) {
1027
+ const line = lines[i].trim();
1028
+ if (line.startsWith("## ")) {
1029
+ const versionMatch = line.match(/##\s+(?:<small>)?\[?([^\](\s<]+)/);
1030
+ if (versionMatch) {
1031
+ const version = versionMatch[1];
1032
+ const lineStart = i;
1033
+ let lineEnd = lines.length - 1;
1034
+ for (let j = i + 1; j < lines.length; j++) if (lines[j].trim().startsWith("## ")) {
1035
+ lineEnd = j - 1;
1036
+ break;
1037
+ }
1038
+ const versionContent = lines.slice(lineStart, lineEnd + 1).join("\n");
1039
+ versions.push({
1040
+ version,
1041
+ lineStart,
1042
+ lineEnd,
1043
+ content: versionContent
1044
+ });
1045
+ }
1046
+ }
1047
+ }
1048
+ return {
1049
+ packageName,
1050
+ versions,
1051
+ headerLineEnd
1052
+ };
1053
+ }
1054
+
1055
+ //#endregion
1056
+ //#region src/operations/semver.ts
1057
+ function isValidSemver(version) {
1058
+ return semver.valid(version) != null;
1059
+ }
1060
+ function getNextVersion(currentVersion, bump) {
1061
+ if (bump === "none") return currentVersion;
1062
+ if (!isValidSemver(currentVersion)) throw new Error(`Cannot bump version for invalid semver: ${currentVersion}`);
1063
+ const next = semver.inc(currentVersion, bump);
1064
+ if (!next) throw new Error(`Failed to bump version ${currentVersion} with bump ${bump}`);
1065
+ return next;
1066
+ }
1067
+ function calculateBumpType(oldVersion, newVersion) {
1068
+ if (!isValidSemver(oldVersion) || !isValidSemver(newVersion)) throw new Error(`Cannot calculate bump type for invalid semver: ${oldVersion} or ${newVersion}`);
1069
+ const diff = semver.diff(oldVersion, newVersion);
1070
+ if (!diff) return "none";
1071
+ if (diff === "major" || diff === "premajor") return "major";
1072
+ if (diff === "minor" || diff === "preminor") return "minor";
1073
+ if (diff === "patch" || diff === "prepatch" || diff === "prerelease") return "patch";
1074
+ if (semver.gt(newVersion, oldVersion)) return "patch";
1075
+ return "none";
1076
+ }
1077
+ function getPrereleaseIdentifier(version) {
1078
+ const parsed = semver.parse(version);
1079
+ if (!parsed || parsed.prerelease.length === 0) return;
1080
+ const identifier = parsed.prerelease[0];
1081
+ return typeof identifier === "string" ? identifier : void 0;
1082
+ }
1083
+ function getNextPrereleaseVersion(currentVersion, mode, identifier) {
1084
+ if (!isValidSemver(currentVersion)) throw new Error(`Cannot bump prerelease for invalid semver: ${currentVersion}`);
1085
+ const releaseType = mode === "next" ? "prerelease" : mode;
1086
+ const next = identifier ? semver.inc(currentVersion, releaseType, identifier) : semver.inc(currentVersion, releaseType);
1087
+ if (!next) throw new Error(`Failed to compute prerelease version for ${currentVersion}`);
1088
+ return next;
1089
+ }
1090
+
1091
+ //#endregion
1092
+ //#region src/core/prompts.ts
1093
+ async function selectPackagePrompt(packages) {
1094
+ const response = await prompts({
1095
+ type: "multiselect",
1096
+ name: "selectedPackages",
1097
+ message: "Select packages to release",
1098
+ choices: packages.map((pkg) => ({
1099
+ title: `${pkg.name} (${farver.bold(pkg.version)})`,
1100
+ value: pkg.name,
1101
+ selected: true
1102
+ })),
1103
+ min: 1,
1104
+ hint: "Space to select/deselect. Return to submit.",
1105
+ instructions: false
1106
+ });
1107
+ if (!response.selectedPackages || response.selectedPackages.length === 0) return [];
1108
+ return response.selectedPackages;
1109
+ }
1110
+ async function selectVersionPrompt(workspaceRoot, pkg, currentVersion, suggestedVersion, options) {
1111
+ const defaultChoice = options?.defaultChoice ?? "auto";
1112
+ const suggestedSuffix = options?.suggestedHint ? farver.dim(` (${options.suggestedHint})`) : "";
1113
+ const prereleaseIdentifier = getPrereleaseIdentifier(currentVersion);
1114
+ const nextDefaultPrerelease = getNextPrereleaseVersion(currentVersion, "next", prereleaseIdentifier === "alpha" || prereleaseIdentifier === "beta" ? prereleaseIdentifier : "beta");
1115
+ const nextBeta = getNextPrereleaseVersion(currentVersion, "next", "beta");
1116
+ const nextAlpha = getNextPrereleaseVersion(currentVersion, "next", "alpha");
1117
+ const prePatchBeta = getNextPrereleaseVersion(currentVersion, "prepatch", "beta");
1118
+ const preMinorBeta = getNextPrereleaseVersion(currentVersion, "preminor", "beta");
1119
+ const preMajorBeta = getNextPrereleaseVersion(currentVersion, "premajor", "beta");
1120
+ const prePatchAlpha = getNextPrereleaseVersion(currentVersion, "prepatch", "alpha");
1121
+ const preMinorAlpha = getNextPrereleaseVersion(currentVersion, "preminor", "alpha");
1122
+ const preMajorAlpha = getNextPrereleaseVersion(currentVersion, "premajor", "alpha");
1123
+ const choices = [
1124
+ {
1125
+ value: "skip",
1126
+ title: `skip ${farver.dim("(no change)")}`
1127
+ },
1128
+ {
1129
+ value: "suggested",
1130
+ title: `suggested ${farver.bold(suggestedVersion)}${suggestedSuffix}`
1131
+ },
1132
+ {
1133
+ value: "as-is",
1134
+ title: `as-is ${farver.dim("(keep current version)")}`
1135
+ },
1136
+ {
1137
+ value: "patch",
1138
+ title: `patch ${farver.bold(getNextVersion(pkg.version, "patch"))}`
1139
+ },
1140
+ {
1141
+ value: "minor",
1142
+ title: `minor ${farver.bold(getNextVersion(pkg.version, "minor"))}`
1143
+ },
1144
+ {
1145
+ value: "major",
1146
+ title: `major ${farver.bold(getNextVersion(pkg.version, "major"))}`
1147
+ },
1148
+ {
1149
+ value: "prerelease",
1150
+ title: `prerelease ${farver.dim("(choose strategy)")}`
1151
+ },
1152
+ {
1153
+ value: "custom",
1154
+ title: "custom"
1155
+ }
1156
+ ];
1157
+ const initialValue = defaultChoice === "auto" ? suggestedVersion === currentVersion ? "skip" : "suggested" : defaultChoice;
1158
+ const initial = Math.max(0, choices.findIndex((choice) => choice.value === initialValue));
1159
+ const prereleaseVersionByChoice = {
1160
+ "next": nextDefaultPrerelease,
1161
+ "next-beta": nextBeta,
1162
+ "next-alpha": nextAlpha,
1163
+ "prepatch-beta": prePatchBeta,
1164
+ "preminor-beta": preMinorBeta,
1165
+ "premajor-beta": preMajorBeta,
1166
+ "prepatch-alpha": prePatchAlpha,
1167
+ "preminor-alpha": preMinorAlpha,
1168
+ "premajor-alpha": preMajorAlpha
1169
+ };
1170
+ const answers = await prompts({
1171
+ type: "autocomplete",
1172
+ name: "version",
1173
+ message: `${pkg.name}: ${farver.green(pkg.version)}`,
1174
+ choices,
1175
+ limit: choices.length,
1176
+ initial
1177
+ });
1178
+ if (!answers.version) return null;
1179
+ if (answers.version === "skip") return null;
1180
+ else if (answers.version === "suggested") return suggestedVersion;
1181
+ else if (answers.version === "custom") {
1182
+ const customAnswer = await prompts({
1183
+ type: "text",
1184
+ name: "custom",
1185
+ message: "Enter the new version number:",
1186
+ initial: suggestedVersion,
1187
+ validate: (custom) => {
1188
+ if (isValidSemver(custom)) return true;
1189
+ return "That's not a valid version number";
1190
+ }
1191
+ });
1192
+ if (!customAnswer.custom) return null;
1193
+ return customAnswer.custom;
1194
+ } else if (answers.version === "as-is") return currentVersion;
1195
+ else if (answers.version === "prerelease") {
1196
+ const prereleaseChoices = [
1197
+ {
1198
+ value: "next",
1199
+ title: `next ${farver.bold(nextDefaultPrerelease)}`
1200
+ },
1201
+ {
1202
+ value: "next-beta",
1203
+ title: `next beta ${farver.bold(nextBeta)}`
1204
+ },
1205
+ {
1206
+ value: "next-alpha",
1207
+ title: `next alpha ${farver.bold(nextAlpha)}`
1208
+ },
1209
+ {
1210
+ value: "prepatch-beta",
1211
+ title: `pre-patch (beta) ${farver.bold(prePatchBeta)}`
1212
+ },
1213
+ {
1214
+ value: "prepatch-alpha",
1215
+ title: `pre-patch (alpha) ${farver.bold(prePatchAlpha)}`
1216
+ },
1217
+ {
1218
+ value: "preminor-beta",
1219
+ title: `pre-minor (beta) ${farver.bold(preMinorBeta)}`
1220
+ },
1221
+ {
1222
+ value: "preminor-alpha",
1223
+ title: `pre-minor (alpha) ${farver.bold(preMinorAlpha)}`
1224
+ },
1225
+ {
1226
+ value: "premajor-beta",
1227
+ title: `pre-major (beta) ${farver.bold(preMajorBeta)}`
1228
+ },
1229
+ {
1230
+ value: "premajor-alpha",
1231
+ title: `pre-major (alpha) ${farver.bold(preMajorAlpha)}`
1232
+ }
1233
+ ];
1234
+ const prereleaseAnswer = await prompts({
1235
+ type: "autocomplete",
1236
+ name: "prerelease",
1237
+ message: `${pkg.name}: select prerelease strategy`,
1238
+ choices: prereleaseChoices,
1239
+ limit: prereleaseChoices.length,
1240
+ initial: 0
1241
+ });
1242
+ if (!prereleaseAnswer.prerelease) return null;
1243
+ return prereleaseVersionByChoice[prereleaseAnswer.prerelease];
1244
+ }
1245
+ const prereleaseVersion = prereleaseVersionByChoice[answers.version];
1246
+ if (prereleaseVersion) return prereleaseVersion;
1247
+ return getNextVersion(pkg.version, answers.version);
1248
+ }
1249
+
1250
+ //#endregion
1251
+ //#region src/core/workspace.ts
1252
+ function toWorkspaceError(operation, error) {
1253
+ return {
1254
+ type: "workspace",
1255
+ operation,
1256
+ message: error instanceof Error ? error.message : String(error)
1257
+ };
1258
+ }
1259
+ async function discoverWorkspacePackages(workspaceRoot, options) {
1260
+ let workspaceOptions;
1261
+ let explicitPackages;
1262
+ if (options.packages == null || options.packages === true) workspaceOptions = { excludePrivate: false };
1263
+ else if (Array.isArray(options.packages)) {
1264
+ workspaceOptions = {
1265
+ excludePrivate: false,
1266
+ include: options.packages
1267
+ };
1268
+ explicitPackages = options.packages;
1269
+ } else {
1270
+ workspaceOptions = options.packages;
1271
+ if (options.packages.include) explicitPackages = options.packages.include;
1272
+ }
1273
+ let workspacePackages;
1274
+ try {
1275
+ workspacePackages = await findWorkspacePackages(workspaceRoot, workspaceOptions);
1276
+ } catch (error) {
1277
+ return err(toWorkspaceError("discoverWorkspacePackages", error));
1278
+ }
1279
+ if (explicitPackages) {
1280
+ const foundNames = new Set(workspacePackages.map((p) => p.name));
1281
+ const missing = explicitPackages.filter((p) => !foundNames.has(p));
1282
+ if (missing.length > 0) exitWithError(`Package${missing.length > 1 ? "s" : ""} not found in workspace: ${missing.join(", ")}`, "Check your package names or run 'pnpm ls' to see available packages");
1283
+ }
1284
+ const isPackagePromptEnabled = options.prompts?.packages !== false;
1285
+ if (!isCI && isPackagePromptEnabled && !explicitPackages) {
1286
+ const selectedNames = await selectPackagePrompt(workspacePackages);
1287
+ workspacePackages = workspacePackages.filter((pkg) => selectedNames.includes(pkg.name));
1288
+ }
1289
+ return ok(workspacePackages);
1290
+ }
1291
+ async function findWorkspacePackages(workspaceRoot, options) {
1292
+ try {
1293
+ const result = await run("pnpm", [
1294
+ "-r",
1295
+ "ls",
1296
+ "--json"
1297
+ ], { nodeOptions: {
1298
+ cwd: workspaceRoot,
1299
+ stdio: "pipe"
1300
+ } });
1301
+ const rawProjects = JSON.parse(result.stdout);
1302
+ const allPackageNames = new Set(rawProjects.map((p) => p.name));
1303
+ const excludedPackages = /* @__PURE__ */ new Set();
1304
+ const promises = rawProjects.map(async (rawProject) => {
1305
+ const content = await readFile(join(rawProject.path, "package.json"), "utf-8");
1306
+ const packageJson = JSON.parse(content);
1307
+ if (!shouldIncludePackage(packageJson, options)) {
1308
+ excludedPackages.add(rawProject.name);
1309
+ return null;
1310
+ }
1311
+ return {
1312
+ name: rawProject.name,
1313
+ version: rawProject.version,
1314
+ path: rawProject.path,
1315
+ packageJson,
1316
+ workspaceDependencies: Object.keys(rawProject.dependencies || []).filter((dep) => {
1317
+ return allPackageNames.has(dep);
1318
+ }),
1319
+ workspaceDevDependencies: Object.keys(rawProject.devDependencies || []).filter((dep) => {
1320
+ return allPackageNames.has(dep);
1321
+ })
1322
+ };
1323
+ });
1324
+ const packages = await Promise.all(promises);
1325
+ if (excludedPackages.size > 0) logger.info(`Excluded packages: ${farver.green(Array.from(excludedPackages).join(", "))}`);
1326
+ return packages.filter((pkg) => pkg !== null);
1327
+ } catch (err) {
1328
+ logger.error("Error discovering workspace packages:", err);
1329
+ throw err;
1330
+ }
1331
+ }
1332
+ function shouldIncludePackage(pkg, options) {
1333
+ if (!options) return true;
1334
+ if (options.excludePrivate && pkg.private) return false;
1335
+ if (options.include && options.include.length > 0) {
1336
+ if (!options.include.includes(pkg.name)) return false;
1337
+ }
1338
+ if (options.exclude?.includes(pkg.name)) return false;
1339
+ return true;
1340
+ }
1341
+
1342
+ //#endregion
1343
+ //#region src/operations/branch.ts
1344
+ async function prepareReleaseBranch(options) {
1345
+ const { workspaceRoot, releaseBranch, defaultBranch } = options;
1346
+ const currentBranch = await getCurrentBranch(workspaceRoot);
1347
+ if (!currentBranch.ok) return currentBranch;
1348
+ if (currentBranch.value !== defaultBranch) return err({
1349
+ type: "git",
1350
+ operation: "validateBranch",
1351
+ message: `Current branch is '${currentBranch.value}'. Please switch to '${defaultBranch}'.`
1352
+ });
1353
+ const branchExists = await doesBranchExist(releaseBranch, workspaceRoot);
1354
+ if (!branchExists.ok) return branchExists;
1355
+ if (!branchExists.value) {
1356
+ const created = await createBranch(releaseBranch, defaultBranch, workspaceRoot);
1357
+ if (!created.ok) return created;
1358
+ }
1359
+ const checkedOut = await checkoutBranch(releaseBranch, workspaceRoot);
1360
+ if (!checkedOut.ok) return checkedOut;
1361
+ if (branchExists.value) {
1362
+ const pulled = await pullLatestChanges(releaseBranch, workspaceRoot);
1363
+ if (!pulled.ok) return pulled;
1364
+ if (!pulled.value) logger.warn("Failed to pull latest changes, continuing anyway.");
1365
+ }
1366
+ const rebased = await rebaseBranch(defaultBranch, workspaceRoot);
1367
+ if (!rebased.ok) return rebased;
1368
+ return ok(void 0);
1369
+ }
1370
+ async function syncReleaseChanges(options) {
1371
+ const { workspaceRoot, releaseBranch, commitMessage, hasChanges } = options;
1372
+ const committed = hasChanges ? await commitChanges(commitMessage, workspaceRoot) : ok(false);
1373
+ if (!committed.ok) return committed;
1374
+ const isAhead = await isBranchAheadOfRemote(releaseBranch, workspaceRoot);
1375
+ if (!isAhead.ok) return isAhead;
1376
+ if (!committed.value && !isAhead.value) return ok(false);
1377
+ const pushed = await pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true });
1378
+ if (!pushed.ok) return pushed;
1379
+ return ok(true);
1380
+ }
1381
+
1382
+ //#endregion
1383
+ //#region src/versioning/commits.ts
1384
+ /**
1385
+ * Get commits grouped by workspace package.
1386
+ * For each package, retrieves all commits since its last release tag that affect that package.
1387
+ *
1388
+ * @param {string} workspaceRoot - The root directory of the workspace
1389
+ * @param {WorkspacePackage[]} packages - Array of workspace packages to analyze
1390
+ * @returns {Promise<Map<string, GitCommit[]>>} A map of package names to their commits since their last release
1391
+ */
1392
+ async function getWorkspacePackageGroupedCommits(workspaceRoot, packages) {
1393
+ const changedPackages = /* @__PURE__ */ new Map();
1394
+ const promises = packages.map(async (pkg) => {
1395
+ const lastTagResult = await getMostRecentPackageTag(workspaceRoot, pkg.name);
1396
+ const lastTag = lastTagResult.ok ? lastTagResult.value : void 0;
1397
+ const allCommits = await getCommits({
1398
+ from: lastTag,
1399
+ to: "HEAD",
1400
+ cwd: workspaceRoot,
1401
+ folder: pkg.path
1402
+ });
1403
+ logger.verbose(`Found ${farver.cyan(allCommits.length)} commits for package ${farver.bold(pkg.name)} since tag ${farver.cyan(lastTag ?? "N/A")}`);
1404
+ return {
1405
+ pkgName: pkg.name,
1406
+ commits: allCommits
1407
+ };
1408
+ });
1409
+ const results = await Promise.all(promises);
1410
+ for (const { pkgName, commits } of results) changedPackages.set(pkgName, commits);
1411
+ return changedPackages;
1412
+ }
1413
+ /**
1414
+ * Check if a file path touches any package folder.
1415
+ * @param file - The file path to check
1416
+ * @param packagePaths - Set of normalized package paths
1417
+ * @param workspaceRoot - The workspace root for path normalization
1418
+ * @returns true if the file is inside a package folder
1419
+ */
1420
+ function fileMatchesPackageFolder(file, packagePaths, workspaceRoot) {
1421
+ const normalizedFile = file.startsWith("./") ? file.slice(2) : file;
1422
+ for (const pkgPath of packagePaths) {
1423
+ const normalizedPkgPath = pkgPath.startsWith(workspaceRoot) ? pkgPath.slice(workspaceRoot.length + 1) : pkgPath;
1424
+ if (normalizedFile.startsWith(`${normalizedPkgPath}/`) || normalizedFile === normalizedPkgPath) return true;
1425
+ }
1426
+ return false;
1427
+ }
1428
+ /**
1429
+ * Check if a commit is a "global" commit (doesn't touch any package folder).
1430
+ * @param workspaceRoot - The workspace root
1431
+ * @param files - Array of files changed in the commit
1432
+ * @param packagePaths - Set of normalized package paths
1433
+ * @returns true if this is a global commit
1434
+ */
1435
+ function isGlobalCommit(workspaceRoot, files, packagePaths) {
1436
+ if (!files || files.length === 0) return false;
1437
+ return !files.some((file) => fileMatchesPackageFolder(file, packagePaths, workspaceRoot));
1438
+ }
1439
+ const DEPENDENCY_FILES = [
1440
+ "package.json",
1441
+ "pnpm-lock.yaml",
1442
+ "pnpm-workspace.yaml",
1443
+ "yarn.lock",
1444
+ "package-lock.json"
1445
+ ];
1446
+ /**
1447
+ * Find the oldest and newest commits across all packages.
1448
+ * @param packageCommits - Map of package commits
1449
+ * @returns Object with oldest and newest commit SHAs, or null if no commits
1450
+ */
1451
+ function findCommitRange(packageCommits) {
1452
+ let oldestCommit = null;
1453
+ let newestCommit = null;
1454
+ for (const commits of packageCommits.values()) {
1455
+ if (commits.length === 0) continue;
1456
+ const firstCommit = commits[0].shortHash;
1457
+ const lastCommit = commits[commits.length - 1].shortHash;
1458
+ if (!newestCommit) newestCommit = firstCommit;
1459
+ oldestCommit = lastCommit;
1460
+ }
1461
+ if (!oldestCommit || !newestCommit) return null;
1462
+ return {
1463
+ oldest: oldestCommit,
1464
+ newest: newestCommit
1465
+ };
1466
+ }
1467
+ /**
1468
+ * Get global commits for each package based on their individual commit timelines.
1469
+ * This solves the problem where packages with different release histories need different global commits.
1470
+ *
1471
+ * A "global commit" is a commit that doesn't touch any package folder but may affect all packages
1472
+ * (e.g., root package.json, CI config, README).
1473
+ *
1474
+ * Performance: Makes ONE batched git call to get files for all commits across all packages.
1475
+ *
1476
+ * @param workspaceRoot - The root directory of the workspace
1477
+ * @param packageCommits - Map of package name to their commits (from getWorkspacePackageCommits)
1478
+ * @param allPackages - All workspace packages (used to identify package folders)
1479
+ * @param mode - Filter mode: false (disabled), "all" (all global commits), or "dependencies" (only dependency-related)
1480
+ * @returns Map of package name to their global commits
1481
+ */
1482
+ async function getGlobalCommitsPerPackage(workspaceRoot, packageCommits, allPackages, mode) {
1483
+ const result = /* @__PURE__ */ new Map();
1484
+ if (!mode) {
1485
+ logger.verbose("Global commits mode disabled");
1486
+ return result;
1487
+ }
1488
+ logger.verbose(`Computing global commits per-package (mode: ${farver.cyan(mode)})`);
1489
+ const commitRange = findCommitRange(packageCommits);
1490
+ if (!commitRange) {
1491
+ logger.verbose("No commits found across packages");
1492
+ return result;
1493
+ }
1494
+ logger.verbose("Fetching files for commits range", `${farver.cyan(commitRange.oldest)}..${farver.cyan(commitRange.newest)}`);
1495
+ const commitFilesMap = await getGroupedFilesByCommitSha(workspaceRoot, commitRange.oldest, commitRange.newest);
1496
+ if (!commitFilesMap.ok) {
1497
+ logger.warn("Failed to get commit file list, returning empty global commits");
1498
+ return result;
1499
+ }
1500
+ logger.verbose("Got file lists for commits", `${farver.cyan(commitFilesMap.value.size)} commits in ONE git call`);
1501
+ const packagePaths = new Set(allPackages.map((p) => p.path));
1502
+ for (const [pkgName, commits] of packageCommits) {
1503
+ const globalCommitsAffectingPackage = [];
1504
+ logger.verbose("Filtering global commits for package", `${farver.bold(pkgName)} from ${farver.cyan(commits.length)} commits`);
1505
+ for (const commit of commits) {
1506
+ const files = commitFilesMap.value.get(commit.shortHash);
1507
+ if (!files) continue;
1508
+ if (isGlobalCommit(workspaceRoot, files, packagePaths)) globalCommitsAffectingPackage.push(commit);
1509
+ }
1510
+ logger.verbose("Package global commits found", `${farver.bold(pkgName)}: ${farver.cyan(globalCommitsAffectingPackage.length)} global commits`);
1511
+ if (mode === "all") {
1512
+ result.set(pkgName, globalCommitsAffectingPackage);
1513
+ continue;
1514
+ }
1515
+ const dependencyCommits = [];
1516
+ for (const commit of globalCommitsAffectingPackage) {
1517
+ const files = commitFilesMap.value.get(commit.shortHash);
1518
+ if (!files) continue;
1519
+ if (files.some((file) => DEPENDENCY_FILES.includes(file.startsWith("./") ? file.slice(2) : file))) {
1520
+ logger.verbose("Global commit affects dependencies", `${farver.bold(pkgName)}: commit ${farver.cyan(commit.shortHash)} affects dependencies`);
1521
+ dependencyCommits.push(commit);
1522
+ }
1523
+ }
1524
+ logger.verbose("Global commits affect dependencies", `${farver.bold(pkgName)}: ${farver.cyan(dependencyCommits.length)} global commits affect dependencies`);
1525
+ result.set(pkgName, dependencyCommits);
1526
+ }
1527
+ return result;
1528
+ }
1529
+
1530
+ //#endregion
1531
+ //#region src/operations/version.ts
1532
+ function determineHighestBump(commits) {
1533
+ if (commits.length === 0) return "none";
1534
+ let highestBump = "none";
1535
+ for (const commit of commits) {
1536
+ const bump = determineBumpType(commit);
1537
+ if (bump === "major") return "major";
1538
+ if (bump === "minor") highestBump = "minor";
1539
+ else if (bump === "patch" && highestBump === "none") highestBump = "patch";
1540
+ }
1541
+ return highestBump;
1542
+ }
1543
+ function createVersionUpdate(pkg, bump, hasDirectChanges) {
1544
+ const newVersion = getNextVersion(pkg.version, bump);
1545
+ return {
1546
+ package: pkg,
1547
+ currentVersion: pkg.version,
1548
+ newVersion,
1549
+ bumpType: bump,
1550
+ hasDirectChanges,
1551
+ changeKind: "dependent"
1552
+ };
1553
+ }
1554
+ function determineBumpType(commit) {
1555
+ if (!commit.isConventional) return "none";
1556
+ if (commit.isBreaking) return "major";
1557
+ if (commit.type === "feat") return "minor";
1558
+ if (commit.type === "fix" || commit.type === "perf") return "patch";
1559
+ return "none";
480
1560
  }
481
1561
 
482
1562
  //#endregion
483
- //#region src/package.ts
1563
+ //#region src/versioning/package.ts
484
1564
  /**
485
1565
  * Build a dependency graph from workspace packages
486
1566
  *
@@ -533,6 +1613,51 @@ function getAllAffectedPackages(graph, changedPackages) {
533
1613
  return affected;
534
1614
  }
535
1615
  /**
1616
+ * Calculate the order in which packages should be published
1617
+ *
1618
+ * Performs topological sorting to ensure dependencies are published before dependents.
1619
+ * Assigns a "level" to each package based on its depth in the dependency tree.
1620
+ *
1621
+ * This is used by the publish command to publish packages in the correct order.
1622
+ *
1623
+ * @param graph - Dependency graph
1624
+ * @param packagesToPublish - Set of package names to publish
1625
+ * @returns Array of packages in publish order with their dependency level
1626
+ */
1627
+ function getPackagePublishOrder(graph, packagesToPublish) {
1628
+ const result = [];
1629
+ const visited = /* @__PURE__ */ new Set();
1630
+ const toUpdate = new Set(packagesToPublish);
1631
+ const packagesToProcess = new Set(packagesToPublish);
1632
+ for (const pkg of packagesToPublish) {
1633
+ const deps = graph.dependents.get(pkg);
1634
+ if (deps) for (const dep of deps) {
1635
+ packagesToProcess.add(dep);
1636
+ toUpdate.add(dep);
1637
+ }
1638
+ }
1639
+ function visit(pkgName, level) {
1640
+ if (visited.has(pkgName)) return;
1641
+ visited.add(pkgName);
1642
+ const pkg = graph.packages.get(pkgName);
1643
+ if (!pkg) return;
1644
+ const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
1645
+ let maxDepLevel = level;
1646
+ for (const dep of allDeps) if (toUpdate.has(dep)) {
1647
+ visit(dep, level);
1648
+ const depResult = result.find((r) => r.package.name === dep);
1649
+ if (depResult && depResult.level >= maxDepLevel) maxDepLevel = depResult.level + 1;
1650
+ }
1651
+ result.push({
1652
+ package: pkg,
1653
+ level: maxDepLevel
1654
+ });
1655
+ }
1656
+ for (const pkg of toUpdate) visit(pkg, 0);
1657
+ result.sort((a, b) => a.level - b.level);
1658
+ return result;
1659
+ }
1660
+ /**
536
1661
  * Create version updates for all packages affected by dependency changes
537
1662
  *
538
1663
  * When a package is updated, all packages that depend on it should also be updated.
@@ -543,364 +1668,810 @@ function getAllAffectedPackages(graph, changedPackages) {
543
1668
  * @param directUpdates - Packages with direct code changes
544
1669
  * @returns All updates including dependent packages that need patch bumps
545
1670
  */
546
- function createDependentUpdates(graph, workspacePackages, directUpdates) {
1671
+ function createDependentUpdates(graph, workspacePackages, directUpdates, excludedPackages = /* @__PURE__ */ new Set()) {
547
1672
  const allUpdates = [...directUpdates];
548
1673
  const directUpdateMap = new Map(directUpdates.map((u) => [u.package.name, u]));
549
1674
  const affectedPackages = getAllAffectedPackages(graph, new Set(directUpdates.map((u) => u.package.name)));
550
1675
  for (const pkgName of affectedPackages) {
551
- if (directUpdateMap.has(pkgName)) continue;
1676
+ logger.verbose(`Processing affected package: ${pkgName}`);
1677
+ if (excludedPackages.has(pkgName)) {
1678
+ logger.verbose(`Skipping ${pkgName}, explicitly excluded from dependent bumps`);
1679
+ continue;
1680
+ }
1681
+ if (directUpdateMap.has(pkgName)) {
1682
+ logger.verbose(`Skipping ${pkgName}, already has a direct update`);
1683
+ continue;
1684
+ }
552
1685
  const pkg = workspacePackages.find((p) => p.name === pkgName);
553
1686
  if (!pkg) continue;
554
1687
  allUpdates.push(createVersionUpdate(pkg, "patch", false));
555
1688
  }
556
1689
  return allUpdates;
557
1690
  }
558
- /**
559
- * Update all package.json files with new versions and dependency updates
560
- *
561
- * Updates are performed in parallel for better performance.
562
- *
563
- * @param updates - Version updates to apply
564
- */
565
- async function updateAllPackageJsonFiles(updates) {
566
- await Promise.all(updates.map(async (update) => {
567
- const depUpdates = getDependencyUpdates(update.package, updates);
568
- await updatePackageJson(update.package, update.newVersion, depUpdates);
569
- }));
570
- }
571
1691
 
572
1692
  //#endregion
573
- //#region src/prompts.ts
574
- /**
575
- * Get commits for a package grouped by conventional commit type
576
- *
577
- * @param pkg - The workspace package
578
- * @param workspaceRoot - Root directory of the workspace
579
- * @param limit - Maximum number of commits to return (default: 10)
580
- * @returns Commits grouped by type
581
- */
582
- async function getCommitsForPackage(pkg, workspaceRoot, limit = 10) {
583
- const limitedCommits = (await getPackageCommits(pkg, workspaceRoot)).slice(0, limit);
584
- const grouped = {
585
- feat: [],
586
- fix: [],
587
- perf: [],
588
- chore: [],
589
- docs: [],
590
- style: [],
591
- refactor: [],
592
- test: [],
593
- build: [],
594
- ci: [],
595
- revert: [],
596
- other: []
1693
+ //#region src/versioning/version.ts
1694
+ const messageColorMap = {
1695
+ feat: farver.green,
1696
+ feature: farver.green,
1697
+ refactor: farver.cyan,
1698
+ style: farver.cyan,
1699
+ docs: farver.blue,
1700
+ doc: farver.blue,
1701
+ types: farver.blue,
1702
+ type: farver.blue,
1703
+ chore: farver.gray,
1704
+ ci: farver.gray,
1705
+ build: farver.gray,
1706
+ deps: farver.gray,
1707
+ dev: farver.gray,
1708
+ fix: farver.yellow,
1709
+ test: farver.yellow,
1710
+ perf: farver.magenta,
1711
+ revert: farver.red,
1712
+ breaking: farver.red
1713
+ };
1714
+ function formatCommitsForDisplay(commits) {
1715
+ if (commits.length === 0) return farver.dim("No commits found");
1716
+ const maxCommitsToShow = 10;
1717
+ const commitsToShow = commits.slice(0, maxCommitsToShow);
1718
+ const hasMore = commits.length > maxCommitsToShow;
1719
+ const typeLength = commits.map(({ type }) => type.length).reduce((a, b) => Math.max(a, b), 0);
1720
+ const scopeLength = commits.map(({ scope }) => scope?.length).reduce((a, b) => Math.max(a || 0, b || 0), 0) || 0;
1721
+ const formattedCommits = commitsToShow.map((commit) => {
1722
+ let color = messageColorMap[commit.type] || ((c) => c);
1723
+ if (commit.isBreaking) color = (s) => farver.inverse.red(s);
1724
+ const paddedType = commit.type.padStart(typeLength + 1, " ");
1725
+ const paddedScope = !commit.scope ? " ".repeat(scopeLength ? scopeLength + 2 : 0) : farver.dim("(") + commit.scope + farver.dim(")") + " ".repeat(scopeLength - commit.scope.length);
1726
+ return [
1727
+ farver.dim(commit.shortHash),
1728
+ " ",
1729
+ color === farver.gray ? color(paddedType) : farver.bold(color(paddedType)),
1730
+ " ",
1731
+ paddedScope,
1732
+ farver.dim(":"),
1733
+ " ",
1734
+ color === farver.gray ? color(commit.description) : commit.description
1735
+ ].join("");
1736
+ }).join("\n");
1737
+ if (hasMore) return `${formattedCommits}\n ${farver.dim(`... and ${commits.length - maxCommitsToShow} more commits`)}`;
1738
+ return formattedCommits;
1739
+ }
1740
+ async function calculateVersionUpdates({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage, overrides: initialOverrides = {} }) {
1741
+ const versionUpdates = [];
1742
+ const processedPackages = /* @__PURE__ */ new Set();
1743
+ const newOverrides = { ...initialOverrides };
1744
+ const excludedPackages = /* @__PURE__ */ new Set();
1745
+ const bumpRanks = {
1746
+ major: 3,
1747
+ minor: 2,
1748
+ patch: 1,
1749
+ none: 0
1750
+ };
1751
+ logger.verbose(`Starting version inference for ${packageCommits.size} packages with commits`);
1752
+ for (const [pkgName, pkgCommits] of packageCommits) {
1753
+ const pkg = workspacePackages.find((p) => p.name === pkgName);
1754
+ if (!pkg) {
1755
+ logger.error(`Package ${pkgName} not found in workspace packages, skipping`);
1756
+ continue;
1757
+ }
1758
+ processedPackages.add(pkgName);
1759
+ const globalCommits = globalCommitsPerPackage.get(pkgName) || [];
1760
+ const allCommitsForPackage = [...pkgCommits, ...globalCommits];
1761
+ const determinedBump = determineHighestBump(allCommitsForPackage);
1762
+ const override = newOverrides[pkgName];
1763
+ const effectiveBump = override?.type || determinedBump;
1764
+ const canPrompt = !isCI && showPrompt;
1765
+ if (effectiveBump === "none" && !canPrompt) continue;
1766
+ let newVersion = override?.version || getNextVersion(pkg.version, effectiveBump);
1767
+ let finalBumpType = effectiveBump;
1768
+ if (canPrompt) {
1769
+ logger.clearScreen();
1770
+ logger.section(`📝 Commits for ${farver.cyan(pkg.name)}`);
1771
+ formatCommitsForDisplay(allCommitsForPackage).split("\n").forEach((line) => logger.item(line));
1772
+ logger.emptyLine();
1773
+ const selectedVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, newVersion, {
1774
+ defaultChoice: override ? "suggested" : "auto",
1775
+ suggestedHint: override ? "from override" : void 0
1776
+ });
1777
+ if (selectedVersion === null) continue;
1778
+ const userBump = calculateBumpType(pkg.version, selectedVersion);
1779
+ finalBumpType = userBump;
1780
+ if (selectedVersion === pkg.version) {
1781
+ excludedPackages.add(pkgName);
1782
+ if (determinedBump !== "none") {
1783
+ const nextOverride = {
1784
+ type: "none",
1785
+ version: pkg.version
1786
+ };
1787
+ if (!override || override.type !== nextOverride.type || override.version !== nextOverride.version) {
1788
+ newOverrides[pkgName] = nextOverride;
1789
+ logger.info(`Override set for ${pkgName}: suggested as-is (${pkg.version}) from auto ${determinedBump}`);
1790
+ }
1791
+ } else if (newOverrides[pkgName]) {
1792
+ delete newOverrides[pkgName];
1793
+ logger.info(`Override cleared for ${pkgName}.`);
1794
+ }
1795
+ versionUpdates.push({
1796
+ package: pkg,
1797
+ currentVersion: pkg.version,
1798
+ newVersion: pkg.version,
1799
+ bumpType: "none",
1800
+ hasDirectChanges: allCommitsForPackage.length > 0,
1801
+ changeKind: "as-is"
1802
+ });
1803
+ continue;
1804
+ }
1805
+ if (bumpRanks[userBump] < bumpRanks[determinedBump]) {
1806
+ const nextOverride = {
1807
+ type: userBump,
1808
+ version: selectedVersion
1809
+ };
1810
+ if (!override || override.type !== nextOverride.type || override.version !== nextOverride.version) {
1811
+ newOverrides[pkgName] = nextOverride;
1812
+ logger.info(`Override set for ${pkgName}: suggested ${userBump} (${selectedVersion}) from auto ${determinedBump}`);
1813
+ }
1814
+ } else if (newOverrides[pkgName] && bumpRanks[userBump] >= bumpRanks[determinedBump]) {
1815
+ delete newOverrides[pkgName];
1816
+ logger.info(`Override cleared for ${pkgName}.`);
1817
+ }
1818
+ newVersion = selectedVersion;
1819
+ }
1820
+ versionUpdates.push({
1821
+ package: pkg,
1822
+ currentVersion: pkg.version,
1823
+ newVersion,
1824
+ bumpType: finalBumpType,
1825
+ hasDirectChanges: allCommitsForPackage.length > 0,
1826
+ changeKind: canPrompt ? "manual" : "auto"
1827
+ });
1828
+ }
1829
+ if (!isCI && showPrompt) for (const pkg of workspacePackages) {
1830
+ if (processedPackages.has(pkg.name)) continue;
1831
+ logger.clearScreen();
1832
+ logger.section(`📦 Package: ${pkg.name}`);
1833
+ logger.item("No direct commits found");
1834
+ const newVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, pkg.version);
1835
+ if (newVersion === null) break;
1836
+ if (newVersion === pkg.version) {
1837
+ excludedPackages.add(pkg.name);
1838
+ continue;
1839
+ }
1840
+ const bumpType = calculateBumpType(pkg.version, newVersion);
1841
+ versionUpdates.push({
1842
+ package: pkg,
1843
+ currentVersion: pkg.version,
1844
+ newVersion,
1845
+ bumpType,
1846
+ hasDirectChanges: false,
1847
+ changeKind: "manual"
1848
+ });
1849
+ }
1850
+ return {
1851
+ updates: versionUpdates,
1852
+ overrides: newOverrides,
1853
+ excludedPackages
597
1854
  };
598
- for (const commit of limitedCommits) if (commit.type && commit.type in grouped) grouped[commit.type].push(commit);
599
- else grouped.other.push(commit);
600
- return grouped;
601
1855
  }
602
1856
  /**
603
- * Format grouped commits into a readable string
1857
+ * Calculate version updates and prepare dependent updates
1858
+ * Returns both the updates and a function to apply them
604
1859
  */
605
- function formatCommitGroups(grouped) {
606
- const lines = [];
607
- const typeLabels = {
608
- feat: "Features",
609
- fix: "Bug Fixes",
610
- perf: "Performance",
611
- chore: "Chores",
612
- docs: "Documentation",
613
- style: "Styling",
614
- refactor: "Refactoring",
615
- test: "Tests",
616
- build: "Build",
617
- ci: "CI",
618
- revert: "Reverts",
619
- other: "Other"
1860
+ async function calculateAndPrepareVersionUpdates({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage, overrides }) {
1861
+ const { updates: directUpdates, overrides: newOverrides, excludedPackages: promptExcludedPackages } = await calculateVersionUpdates({
1862
+ workspacePackages,
1863
+ packageCommits,
1864
+ workspaceRoot,
1865
+ showPrompt,
1866
+ globalCommitsPerPackage,
1867
+ overrides
1868
+ });
1869
+ const graph = buildPackageDependencyGraph(workspacePackages);
1870
+ const overrideExcludedPackages = new Set(Object.entries(newOverrides).filter(([, override]) => override.type === "none").map(([pkgName]) => pkgName));
1871
+ const allUpdates = createDependentUpdates(graph, workspacePackages, directUpdates, new Set([...overrideExcludedPackages, ...promptExcludedPackages]));
1872
+ const applyUpdates = async () => {
1873
+ await Promise.all(allUpdates.map(async (update) => {
1874
+ const depUpdates = getDependencyUpdates(update.package, allUpdates);
1875
+ await updatePackageJson(update.package, update.newVersion, depUpdates);
1876
+ }));
620
1877
  };
621
- for (const type of [
622
- "feat",
623
- "fix",
624
- "perf",
625
- "refactor",
626
- "test",
627
- "docs",
628
- "style",
629
- "build",
630
- "ci",
631
- "chore",
632
- "revert",
633
- "other"
634
- ]) {
635
- const commits = grouped[type];
636
- if (commits.length > 0) {
637
- lines.push(`\n${typeLabels[type]}:`);
638
- for (const commit of commits) {
639
- const scope = commit.scope ? `(${commit.scope})` : "";
640
- const breaking = commit.isBreaking ? " ⚠️ BREAKING" : "";
641
- lines.push(` • ${commit.type}${scope}: ${commit.message}${breaking}`);
642
- }
1878
+ return {
1879
+ allUpdates,
1880
+ applyUpdates,
1881
+ overrides: newOverrides
1882
+ };
1883
+ }
1884
+ async function updatePackageJson(pkg, newVersion, dependencyUpdates) {
1885
+ const packageJsonPath = join(pkg.path, "package.json");
1886
+ const content = await readFile(packageJsonPath, "utf-8");
1887
+ const packageJson = JSON.parse(content);
1888
+ packageJson.version = newVersion;
1889
+ function updateDependency(deps, depName, depVersion, isPeerDependency = false) {
1890
+ if (!deps) return;
1891
+ const oldVersion = deps[depName];
1892
+ if (!oldVersion) return;
1893
+ if (oldVersion === "workspace:*") {
1894
+ logger.verbose(` - Skipping workspace:* dependency: ${depName}`);
1895
+ return;
643
1896
  }
1897
+ if (isPeerDependency) {
1898
+ const majorVersion = depVersion.split(".")[0];
1899
+ deps[depName] = `>=${depVersion} <${Number(majorVersion) + 1}.0.0`;
1900
+ } else deps[depName] = `^${depVersion}`;
1901
+ logger.verbose(` - Updated dependency ${depName}: ${oldVersion} → ${deps[depName]}`);
1902
+ }
1903
+ for (const [depName, depVersion] of dependencyUpdates) {
1904
+ updateDependency(packageJson.dependencies, depName, depVersion);
1905
+ updateDependency(packageJson.devDependencies, depName, depVersion);
1906
+ updateDependency(packageJson.peerDependencies, depName, depVersion, true);
644
1907
  }
645
- return lines.join("\n");
1908
+ await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
1909
+ logger.verbose(` - Successfully wrote updated package.json`);
646
1910
  }
647
- async function selectPackagePrompt(packages) {
648
- const response = await prompts({
649
- type: "multiselect",
650
- name: "selectedPackages",
651
- message: "Select packages to release",
652
- choices: packages.map((pkg) => ({
653
- title: `${pkg.name} (${farver.bold(pkg.version)})`,
654
- value: pkg.name,
655
- selected: true
656
- })),
657
- min: 1,
658
- hint: "Space to select/deselect. Return to submit.",
659
- instructions: false
660
- });
661
- if (!response.selectedPackages || response.selectedPackages.length === 0) return [];
662
- return response.selectedPackages;
1911
+ /**
1912
+ * Get all dependency updates needed for a package
1913
+ */
1914
+ function getDependencyUpdates(pkg, allUpdates) {
1915
+ const updates = /* @__PURE__ */ new Map();
1916
+ const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
1917
+ for (const dep of allDeps) {
1918
+ const update = allUpdates.find((u) => u.package.name === dep);
1919
+ if (update) {
1920
+ logger.verbose(` - Dependency ${dep} will be updated: ${update.currentVersion} → ${update.newVersion} (${update.bumpType})`);
1921
+ updates.set(dep, update.newVersion);
1922
+ }
1923
+ }
1924
+ if (updates.size === 0) logger.verbose(` - No dependency updates needed`);
1925
+ return updates;
663
1926
  }
664
- async function promptVersionOverride(pkg, workspaceRoot, currentVersion, suggestedVersion, suggestedBumpType) {
665
- const commitSummary = formatCommitGroups(await getCommitsForPackage(pkg, workspaceRoot));
666
- if (commitSummary.trim()) console.log(`\nRecent changes in ${pkg.name}:${commitSummary}\n`);
667
- const choices = [{
668
- title: `Use suggested: ${suggestedVersion} (${suggestedBumpType})`,
669
- value: "suggested"
670
- }];
671
- for (const bumpType of [
672
- "patch",
673
- "minor",
674
- "major"
675
- ]) if (bumpType !== suggestedBumpType) {
676
- const version = calculateNewVersion(currentVersion, bumpType);
677
- choices.push({
678
- title: `${bumpType}: ${version}`,
679
- value: bumpType
1927
+
1928
+ //#endregion
1929
+ //#region src/operations/calculate.ts
1930
+ async function calculateUpdates(options) {
1931
+ const { workspacePackages, workspaceRoot, showPrompt, overrides, globalCommitMode } = options;
1932
+ try {
1933
+ const grouped = await getWorkspacePackageGroupedCommits(workspaceRoot, workspacePackages);
1934
+ return ok(await calculateAndPrepareVersionUpdates({
1935
+ workspacePackages,
1936
+ packageCommits: grouped,
1937
+ workspaceRoot,
1938
+ showPrompt,
1939
+ globalCommitsPerPackage: await getGlobalCommitsPerPackage(workspaceRoot, grouped, workspacePackages, globalCommitMode),
1940
+ overrides
1941
+ }));
1942
+ } catch (error) {
1943
+ return err({
1944
+ type: "git",
1945
+ operation: "calculateUpdates",
1946
+ message: error instanceof Error ? error.message : String(error)
680
1947
  });
681
1948
  }
682
- choices.push({
683
- title: "Custom version",
684
- value: "custom"
1949
+ }
1950
+ function ensureHasPackages(packages) {
1951
+ if (packages.length === 0) return err({
1952
+ type: "git",
1953
+ operation: "discoverWorkspacePackages",
1954
+ message: "No packages found to release"
685
1955
  });
686
- const response = await prompts([{
687
- type: "select",
688
- name: "choice",
689
- message: `${pkg.name} (${currentVersion}):`,
690
- choices,
691
- initial: 0
692
- }, {
693
- type: (prev) => prev === "custom" ? "text" : null,
694
- name: "customVersion",
695
- message: "Enter custom version:",
696
- initial: suggestedVersion,
697
- validate: (value) => {
698
- return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(value) || "Invalid semver version (e.g., 1.0.0)";
699
- }
700
- }]);
701
- if (response.choice === "suggested") return suggestedVersion;
702
- else if (response.choice === "custom") return response.customVersion;
703
- else return calculateNewVersion(currentVersion, response.choice);
1956
+ return ok(packages);
704
1957
  }
705
- async function promptVersionOverrides(packages, workspaceRoot) {
706
- const overrides = /* @__PURE__ */ new Map();
707
- for (const item of packages) {
708
- const newVersion = await promptVersionOverride(item.package, workspaceRoot, item.currentVersion, item.suggestedVersion, item.bumpType);
709
- overrides.set(item.package.name, newVersion);
1958
+
1959
+ //#endregion
1960
+ //#region src/operations/pr.ts
1961
+ async function syncPullRequest(options) {
1962
+ const { github, releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody, updates } = options;
1963
+ let existing = null;
1964
+ try {
1965
+ existing = await github.getExistingPullRequest(releaseBranch);
1966
+ } catch (error) {
1967
+ return {
1968
+ ok: false,
1969
+ error: toGitHubError("getExistingPullRequest", error)
1970
+ };
1971
+ }
1972
+ const doesExist = !!existing;
1973
+ const title = existing?.title || pullRequestTitle || "chore: update package versions";
1974
+ const body = generatePullRequestBody(updates, pullRequestBody);
1975
+ let pr = null;
1976
+ try {
1977
+ pr = await github.upsertPullRequest({
1978
+ pullNumber: existing?.number,
1979
+ title,
1980
+ body,
1981
+ head: releaseBranch,
1982
+ base: defaultBranch
1983
+ });
1984
+ } catch (error) {
1985
+ return {
1986
+ ok: false,
1987
+ error: toGitHubError("upsertPullRequest", error)
1988
+ };
710
1989
  }
711
- return overrides;
1990
+ return ok({
1991
+ pullRequest: pr,
1992
+ created: !doesExist
1993
+ });
712
1994
  }
713
1995
 
714
1996
  //#endregion
715
- //#region src/workspace.ts
716
- async function discoverWorkspacePackages(workspaceRoot, options) {
717
- let workspaceOptions;
718
- let explicitPackages;
719
- if (options.packages == null || options.packages === true) workspaceOptions = { excludePrivate: false };
720
- else if (Array.isArray(options.packages)) {
721
- workspaceOptions = {
722
- excludePrivate: false,
723
- included: options.packages
724
- };
725
- explicitPackages = options.packages;
726
- } else {
727
- workspaceOptions = options.packages;
728
- if (options.packages.included) explicitPackages = options.packages.included;
1997
+ //#region src/workflows/prepare.ts
1998
+ async function prepareWorkflow(options) {
1999
+ if (options.safeguards) {
2000
+ const clean = await isWorkingDirectoryClean(options.workspaceRoot);
2001
+ if (!clean.ok) exitWithError("Failed to verify working directory state.", "Ensure this is a valid git repository and try again.", clean.error);
2002
+ if (!clean.value) exitWithError("Working directory is not clean. Please commit or stash your changes before proceeding.");
729
2003
  }
730
- const workspacePackages = await findWorkspacePackages(workspaceRoot, workspaceOptions);
731
- if (explicitPackages) {
732
- const foundNames = new Set(workspacePackages.map((p) => p.name));
733
- const missing = explicitPackages.filter((p) => !foundNames.has(p));
734
- if (missing.length > 0) throw new Error(`Packages not found in workspace: ${missing.join(", ")}`);
2004
+ const discovered = await discoverWorkspacePackages(options.workspaceRoot, options);
2005
+ if (!discovered.ok) exitWithError("Failed to discover packages.", void 0, discovered.error);
2006
+ const ensured = ensureHasPackages(discovered.value);
2007
+ if (!ensured.ok) {
2008
+ logger.warn(ensured.error.message);
2009
+ return null;
735
2010
  }
736
- let packagesToAnalyze = workspacePackages;
737
- const isPackagePromptEnabled = options.prompts?.packages !== false;
738
- if (!isCI && isPackagePromptEnabled && !explicitPackages) {
739
- const selectedNames = await selectPackagePrompt(workspacePackages);
740
- packagesToAnalyze = workspacePackages.filter((pkg) => selectedNames.includes(pkg.name));
2011
+ const workspacePackages = ensured.value;
2012
+ logger.section("📦 Workspace Packages");
2013
+ logger.item(`Found ${workspacePackages.length} packages`);
2014
+ for (const pkg of workspacePackages) {
2015
+ logger.item(`${farver.cyan(pkg.name)} (${farver.bold(pkg.version)})`);
2016
+ logger.item(` ${farver.gray("→")} ${farver.gray(pkg.path)}`);
741
2017
  }
742
- return {
2018
+ logger.emptyLine();
2019
+ const prepareBranchResult = await prepareReleaseBranch({
2020
+ workspaceRoot: options.workspaceRoot,
2021
+ releaseBranch: options.branch.release,
2022
+ defaultBranch: options.branch.default
2023
+ });
2024
+ if (!prepareBranchResult.ok) exitWithError("Failed to prepare release branch.", void 0, prepareBranchResult.error);
2025
+ const overridesPath = join(options.workspaceRoot, ucdjsReleaseOverridesPath);
2026
+ let existingOverrides = {};
2027
+ try {
2028
+ const overridesContent = await readFile(overridesPath, "utf-8");
2029
+ existingOverrides = JSON.parse(overridesContent);
2030
+ logger.info("Found existing version overrides file.");
2031
+ } catch (error) {
2032
+ logger.info("No existing version overrides file found. Continuing...");
2033
+ logger.verbose(`Reading overrides file failed: ${formatUnknownError(error).message}`);
2034
+ }
2035
+ const updatesResult = await calculateUpdates({
743
2036
  workspacePackages,
744
- packagesToAnalyze
2037
+ workspaceRoot: options.workspaceRoot,
2038
+ showPrompt: options.prompts?.versions !== false,
2039
+ globalCommitMode: options.globalCommitMode === "none" ? false : options.globalCommitMode,
2040
+ overrides: existingOverrides
2041
+ });
2042
+ if (!updatesResult.ok) exitWithError("Failed to calculate package updates.", void 0, updatesResult.error);
2043
+ const { allUpdates, applyUpdates, overrides: newOverrides } = updatesResult.value;
2044
+ const hasOverrideChanges = JSON.stringify(existingOverrides) !== JSON.stringify(newOverrides);
2045
+ if (Object.keys(newOverrides).length > 0 && hasOverrideChanges) {
2046
+ logger.step("Writing version overrides file...");
2047
+ try {
2048
+ await mkdir(join(options.workspaceRoot, ".github"), { recursive: true });
2049
+ await writeFile(overridesPath, JSON.stringify(newOverrides, null, 2), "utf-8");
2050
+ logger.success("Successfully wrote version overrides file.");
2051
+ } catch (e) {
2052
+ logger.error("Failed to write version overrides file:", e);
2053
+ }
2054
+ } else if (Object.keys(newOverrides).length > 0) logger.step("Version overrides unchanged. Skipping write.");
2055
+ if (Object.keys(newOverrides).length === 0 && Object.keys(existingOverrides).length > 0) {
2056
+ let shouldRemoveOverrides = false;
2057
+ for (const update of allUpdates) {
2058
+ const overriddenVersion = existingOverrides[update.package.name];
2059
+ if (overriddenVersion) {
2060
+ if (compare(update.newVersion, overriddenVersion.version) > 0) {
2061
+ shouldRemoveOverrides = true;
2062
+ break;
2063
+ }
2064
+ }
2065
+ }
2066
+ if (shouldRemoveOverrides) {
2067
+ logger.info("Removing obsolete version overrides file...");
2068
+ try {
2069
+ await rm(overridesPath);
2070
+ logger.success("Successfully removed obsolete version overrides file.");
2071
+ } catch (e) {
2072
+ logger.error("Failed to remove obsolete version overrides file:", e);
2073
+ }
2074
+ }
2075
+ }
2076
+ if (allUpdates.filter((u) => u.hasDirectChanges).length === 0) logger.warn("No packages have changes requiring a release");
2077
+ logger.section("🔄 Version Updates");
2078
+ logger.item(`Updating ${allUpdates.length} packages (including dependents)`);
2079
+ for (const update of allUpdates) {
2080
+ const suffix = update.changeKind === "as-is" ? farver.dim(" (as-is)") : "";
2081
+ logger.item(`${update.package.name}: ${update.currentVersion} → ${update.newVersion}${suffix}`);
2082
+ }
2083
+ await applyUpdates();
2084
+ if (options.changelog?.enabled) {
2085
+ logger.step("Updating changelogs");
2086
+ const groupedPackageCommits = await getWorkspacePackageGroupedCommits(options.workspaceRoot, workspacePackages);
2087
+ const globalCommitsPerPackage = await getGlobalCommitsPerPackage(options.workspaceRoot, groupedPackageCommits, workspacePackages, options.globalCommitMode === "none" ? false : options.globalCommitMode);
2088
+ const changelogPromises = allUpdates.map((update) => {
2089
+ const pkgCommits = groupedPackageCommits.get(update.package.name) || [];
2090
+ const globalCommits = globalCommitsPerPackage.get(update.package.name) || [];
2091
+ const allCommits = [...pkgCommits, ...globalCommits];
2092
+ if (allCommits.length === 0) {
2093
+ logger.verbose(`No commits for ${update.package.name}, skipping changelog`);
2094
+ return Promise.resolve();
2095
+ }
2096
+ logger.verbose(`Updating changelog for ${farver.cyan(update.package.name)}`);
2097
+ return updateChangelog({
2098
+ normalizedOptions: {
2099
+ ...options,
2100
+ workspaceRoot: options.workspaceRoot
2101
+ },
2102
+ githubClient: options.githubClient,
2103
+ workspacePackage: update.package,
2104
+ version: update.newVersion,
2105
+ previousVersion: update.currentVersion !== "0.0.0" ? update.currentVersion : void 0,
2106
+ commits: allCommits,
2107
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
2108
+ });
2109
+ }).filter((p) => p != null);
2110
+ const updates = await Promise.all(changelogPromises);
2111
+ logger.success(`Updated ${updates.length} changelog(s)`);
2112
+ }
2113
+ const hasChangesToPush = await syncReleaseChanges({
2114
+ workspaceRoot: options.workspaceRoot,
2115
+ releaseBranch: options.branch.release,
2116
+ commitMessage: "chore: update release versions",
2117
+ hasChanges: true
2118
+ });
2119
+ if (!hasChangesToPush.ok) exitWithError("Failed to sync release changes.", void 0, hasChangesToPush.error);
2120
+ if (!hasChangesToPush.value) {
2121
+ const prResult = await syncPullRequest({
2122
+ github: options.githubClient,
2123
+ releaseBranch: options.branch.release,
2124
+ defaultBranch: options.branch.default,
2125
+ pullRequestTitle: options.pullRequest?.title,
2126
+ pullRequestBody: options.pullRequest?.body,
2127
+ updates: allUpdates
2128
+ });
2129
+ if (!prResult.ok) exitWithError("Failed to sync release pull request.", void 0, prResult.error);
2130
+ if (prResult.value.pullRequest) {
2131
+ logger.item("No updates needed, PR is already up to date");
2132
+ const checkoutResult = await checkoutBranch(options.branch.default, options.workspaceRoot);
2133
+ if (!checkoutResult.ok) exitWithError(`Failed to checkout branch: ${options.branch.default}`, void 0, checkoutResult.error);
2134
+ return {
2135
+ updates: allUpdates,
2136
+ prUrl: prResult.value.pullRequest.html_url,
2137
+ created: prResult.value.created
2138
+ };
2139
+ }
2140
+ logger.error("No changes to commit, and no existing PR. Nothing to do.");
2141
+ return null;
2142
+ }
2143
+ const prResult = await syncPullRequest({
2144
+ github: options.githubClient,
2145
+ releaseBranch: options.branch.release,
2146
+ defaultBranch: options.branch.default,
2147
+ pullRequestTitle: options.pullRequest?.title,
2148
+ pullRequestBody: options.pullRequest?.body,
2149
+ updates: allUpdates
2150
+ });
2151
+ if (!prResult.ok) exitWithError("Failed to sync release pull request.", void 0, prResult.error);
2152
+ if (prResult.value.pullRequest?.html_url) {
2153
+ logger.section("🚀 Pull Request");
2154
+ logger.success(`Pull request ${prResult.value.created ? "created" : "updated"}: ${prResult.value.pullRequest.html_url}`);
2155
+ }
2156
+ const returnToDefault = await checkoutBranch(options.branch.default, options.workspaceRoot);
2157
+ if (!returnToDefault.ok) exitWithError(`Failed to checkout branch: ${options.branch.default}`, void 0, returnToDefault.error);
2158
+ if (!returnToDefault.value) exitWithError(`Failed to checkout branch: ${options.branch.default}`);
2159
+ return {
2160
+ updates: allUpdates,
2161
+ prUrl: prResult.value.pullRequest?.html_url,
2162
+ created: prResult.value.created
745
2163
  };
746
2164
  }
747
- async function findWorkspacePackages(workspaceRoot, options) {
2165
+
2166
+ //#endregion
2167
+ //#region src/core/npm.ts
2168
+ function toNPMError(operation, error, code) {
2169
+ const formatted = formatUnknownError(error);
2170
+ return {
2171
+ type: "npm",
2172
+ operation,
2173
+ message: formatted.message,
2174
+ code: code || formatted.code,
2175
+ stderr: formatted.stderr,
2176
+ status: formatted.status
2177
+ };
2178
+ }
2179
+ /**
2180
+ * Get the NPM registry URL
2181
+ * Respects NPM_CONFIG_REGISTRY environment variable, defaults to npmjs.org
2182
+ */
2183
+ function getRegistryURL() {
2184
+ return process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org";
2185
+ }
2186
+ /**
2187
+ * Fetch package metadata from NPM registry
2188
+ * @param packageName - The package name (e.g., "lodash" or "@scope/name")
2189
+ * @returns Result with package metadata or error
2190
+ */
2191
+ async function getPackageMetadata(packageName) {
748
2192
  try {
749
- const result = await run("pnpm", [
750
- "-r",
751
- "ls",
752
- "--json"
753
- ], { nodeOptions: {
2193
+ const registry = getRegistryURL();
2194
+ const encodedName = packageName.startsWith("@") ? `@${encodeURIComponent(packageName.slice(1))}` : encodeURIComponent(packageName);
2195
+ const response = await fetch(`${registry}/${encodedName}`, { headers: { Accept: "application/json" } });
2196
+ if (!response.ok) {
2197
+ if (response.status === 404) return err(toNPMError("getPackageMetadata", `Package not found: ${packageName}`, "E404"));
2198
+ return err(toNPMError("getPackageMetadata", `HTTP ${response.status}: ${response.statusText}`));
2199
+ }
2200
+ return ok(await response.json());
2201
+ } catch (error) {
2202
+ return err(toNPMError("getPackageMetadata", error, "ENETWORK"));
2203
+ }
2204
+ }
2205
+ /**
2206
+ * Check if a specific package version exists on NPM
2207
+ * @param packageName - The package name
2208
+ * @param version - The version to check (e.g., "1.2.3")
2209
+ * @returns Result with boolean (true if version exists) or error
2210
+ */
2211
+ async function checkVersionExists(packageName, version) {
2212
+ const metadataResult = await getPackageMetadata(packageName);
2213
+ if (!metadataResult.ok) {
2214
+ if (metadataResult.error.code === "E404") return ok(false);
2215
+ return err(metadataResult.error);
2216
+ }
2217
+ return ok(version in metadataResult.value.versions);
2218
+ }
2219
+ /**
2220
+ * Publish a package to NPM
2221
+ * Uses pnpm to handle workspace protocol and catalog: resolution automatically
2222
+ * @param packageName - The package name to publish
2223
+ * @param version - The package version to publish
2224
+ * @param workspaceRoot - Path to the workspace root
2225
+ * @param options - Normalized release scripts options
2226
+ * @returns Result indicating success or failure
2227
+ */
2228
+ async function publishPackage(packageName, version, workspaceRoot, options) {
2229
+ const args = [
2230
+ "--filter",
2231
+ packageName,
2232
+ "publish",
2233
+ "--access",
2234
+ options.npm.access,
2235
+ "--no-git-checks"
2236
+ ];
2237
+ if (options.npm.otp) args.push("--otp", options.npm.otp);
2238
+ const explicitTag = process.env.NPM_CONFIG_TAG;
2239
+ const prereleaseTag = (() => {
2240
+ const prerelease = semver.prerelease(version);
2241
+ if (!prerelease || prerelease.length === 0) return;
2242
+ const identifier = prerelease[0];
2243
+ if (identifier === "alpha" || identifier === "beta") return identifier;
2244
+ return "next";
2245
+ })();
2246
+ const publishTag = explicitTag || prereleaseTag;
2247
+ if (publishTag) args.push("--tag", publishTag);
2248
+ const env = { ...process.env };
2249
+ if (options.npm.provenance) env.NPM_CONFIG_PROVENANCE = "true";
2250
+ try {
2251
+ await runIfNotDry("pnpm", args, { nodeOptions: {
754
2252
  cwd: workspaceRoot,
755
- stdio: "pipe"
2253
+ stdio: "inherit",
2254
+ env
756
2255
  } });
757
- const rawProjects = JSON.parse(result.stdout);
758
- const allPackageNames = new Set(rawProjects.map((p) => p.name));
759
- const excludedPackages = /* @__PURE__ */ new Set();
760
- const promises = rawProjects.map(async (rawProject) => {
761
- const content = await readFile(join(rawProject.path, "package.json"), "utf-8");
762
- const packageJson = JSON.parse(content);
763
- if (!shouldIncludePackage(packageJson, options)) {
764
- excludedPackages.add(rawProject.name);
765
- return null;
766
- }
767
- return {
768
- name: rawProject.name,
769
- version: rawProject.version,
770
- path: rawProject.path,
771
- packageJson,
772
- workspaceDependencies: extractWorkspaceDependencies(rawProject.dependencies, allPackageNames),
773
- workspaceDevDependencies: extractWorkspaceDependencies(rawProject.devDependencies, allPackageNames)
774
- };
775
- });
776
- const packages = await Promise.all(promises);
777
- if (excludedPackages.size > 0) console.info(`${farver.cyan("[info]:")} Excluded packages: ${farver.green(Array.from(excludedPackages).join(", "))}`);
778
- return packages.filter((pkg) => pkg !== null);
779
- } catch (err) {
780
- console.error("Error discovering workspace packages:", err);
781
- throw err;
2256
+ return ok(void 0);
2257
+ } catch (error) {
2258
+ const errorMessage = formatUnknownError(error).message;
2259
+ return err(toNPMError("publishPackage", error, errorMessage.includes("E403") ? "E403" : errorMessage.includes("EPUBLISHCONFLICT") ? "EPUBLISHCONFLICT" : errorMessage.includes("EOTP") ? "EOTP" : void 0));
782
2260
  }
783
2261
  }
784
- function shouldIncludePackage(pkg, options) {
785
- if (!options) return true;
786
- if (options.excludePrivate && pkg.private) return false;
787
- if (options.included && options.included.length > 0) {
788
- if (!options.included.includes(pkg.name)) return false;
2262
+
2263
+ //#endregion
2264
+ //#region src/workflows/publish.ts
2265
+ async function publishWorkflow(options) {
2266
+ logger.section("📦 Publishing Packages");
2267
+ const discovered = await discoverWorkspacePackages(options.workspaceRoot, options);
2268
+ if (!discovered.ok) exitWithError("Failed to discover packages.", void 0, discovered.error);
2269
+ const workspacePackages = discovered.value;
2270
+ logger.item(`Found ${workspacePackages.length} packages in workspace`);
2271
+ const graph = buildPackageDependencyGraph(workspacePackages);
2272
+ const publicPackages = workspacePackages.filter((pkg) => !pkg.packageJson.private);
2273
+ logger.item(`Publishing ${publicPackages.length} public packages (private packages excluded)`);
2274
+ if (publicPackages.length === 0) {
2275
+ logger.warn("No public packages to publish");
2276
+ return;
789
2277
  }
790
- if (options.excluded?.includes(pkg.name)) return false;
791
- return true;
792
- }
793
- function extractWorkspaceDependencies(dependencies, workspacePackages) {
794
- if (!dependencies) return [];
795
- return Object.keys(dependencies).filter((dep) => {
796
- return workspacePackages.has(dep);
797
- });
2278
+ const publishOrder = getPackagePublishOrder(graph, new Set(publicPackages.map((p) => p.name)));
2279
+ const status = {
2280
+ published: [],
2281
+ skipped: [],
2282
+ failed: []
2283
+ };
2284
+ for (const order of publishOrder) {
2285
+ const pkg = order.package;
2286
+ const version = pkg.version;
2287
+ const packageName = pkg.name;
2288
+ logger.section(`📦 ${farver.cyan(packageName)} ${farver.gray(`(level ${order.level})`)}`);
2289
+ logger.step(`Checking if ${farver.cyan(`${packageName}@${version}`)} exists on NPM...`);
2290
+ const existsResult = await checkVersionExists(packageName, version);
2291
+ if (!existsResult.ok) {
2292
+ logger.error(`Failed to check version: ${existsResult.error.message}`);
2293
+ status.failed.push(packageName);
2294
+ exitWithError(`Publishing failed for ${packageName}.`, "Check your network connection and NPM registry access", existsResult.error);
2295
+ }
2296
+ if (existsResult.value) {
2297
+ logger.info(`Version ${farver.cyan(version)} already exists on NPM, skipping`);
2298
+ status.skipped.push(packageName);
2299
+ continue;
2300
+ }
2301
+ logger.step(`Publishing ${farver.cyan(`${packageName}@${version}`)} to NPM...`);
2302
+ const publishResult = await publishPackage(packageName, version, options.workspaceRoot, options);
2303
+ if (!publishResult.ok) {
2304
+ logger.error(`Failed to publish: ${publishResult.error.message}`);
2305
+ status.failed.push(packageName);
2306
+ let hint;
2307
+ if (publishResult.error.code === "E403") hint = "Authentication failed. Ensure your NPM token or OIDC configuration is correct";
2308
+ else if (publishResult.error.code === "EPUBLISHCONFLICT") hint = "Version conflict. The version may have been published recently";
2309
+ else if (publishResult.error.code === "EOTP") hint = "2FA/OTP required. Provide the otp option or use OIDC authentication";
2310
+ exitWithError(`Publishing failed for ${packageName}`, hint, publishResult.error);
2311
+ }
2312
+ logger.success(`Published ${farver.cyan(`${packageName}@${version}`)}`);
2313
+ status.published.push(packageName);
2314
+ logger.step(`Creating git tag ${farver.cyan(`${packageName}@${version}`)}...`);
2315
+ const tagResult = await createAndPushPackageTag(packageName, version, options.workspaceRoot);
2316
+ const tagName = `${packageName}@${version}`;
2317
+ if (!tagResult.ok) {
2318
+ logger.error(`Failed to create/push tag: ${tagResult.error.message}`);
2319
+ status.failed.push(packageName);
2320
+ exitWithError(`Publishing failed for ${packageName}: could not create git tag`, "Ensure the workflow token can push tags (contents: write) and git credentials are configured", tagResult.error);
2321
+ }
2322
+ logger.success(`Created and pushed tag ${farver.cyan(tagName)}`);
2323
+ logger.step(`Creating GitHub release for ${farver.cyan(tagName)}...`);
2324
+ try {
2325
+ const releaseResult = await options.githubClient.upsertReleaseByTag({
2326
+ tagName,
2327
+ name: tagName,
2328
+ prerelease: Boolean(semver.prerelease(version))
2329
+ });
2330
+ if (releaseResult.release.htmlUrl) logger.success(`${releaseResult.created ? "Created" : "Updated"} GitHub release: ${releaseResult.release.htmlUrl}`);
2331
+ else logger.success(`${releaseResult.created ? "Created" : "Updated"} GitHub release for ${farver.cyan(tagName)}`);
2332
+ } catch (error) {
2333
+ status.failed.push(packageName);
2334
+ exitWithError(`Publishing failed for ${packageName}: could not create GitHub release`, "Ensure the workflow token can write repository contents and releases", error);
2335
+ }
2336
+ }
2337
+ logger.section("📊 Publishing Summary");
2338
+ logger.item(`${farver.green("✓")} Published: ${status.published.length} package(s)`);
2339
+ if (status.published.length > 0) for (const pkg of status.published) logger.item(` ${farver.green("•")} ${pkg}`);
2340
+ if (status.skipped.length > 0) {
2341
+ logger.item(`${farver.yellow("⚠")} Skipped (already exists): ${status.skipped.length} package(s)`);
2342
+ for (const pkg of status.skipped) logger.item(` ${farver.yellow("•")} ${pkg}`);
2343
+ }
2344
+ if (status.failed.length > 0) {
2345
+ logger.item(`${farver.red("✖")} Failed: ${status.failed.length} package(s)`);
2346
+ for (const pkg of status.failed) logger.item(` ${farver.red("•")} ${pkg}`);
2347
+ }
2348
+ if (status.failed.length > 0) exitWithError(`Publishing completed with ${status.failed.length} failure(s)`);
2349
+ logger.success("All packages published successfully!");
798
2350
  }
799
2351
 
800
2352
  //#endregion
801
- //#region src/release.ts
802
- async function release(options) {
803
- const { dryRun: dryRun$1 = false, safeguards = true, workspaceRoot = process.cwd(), releaseBranch = "release/next", githubToken } = options;
804
- globalOptions.dryRun = dryRun$1;
805
- if (githubToken.trim() === "" || githubToken == null) throw new Error("GitHub token is required");
806
- const [owner, repo] = options.repo.split("/");
807
- if (!owner || !repo) throw new Error(`Invalid repo format: ${options.repo}. Expected "owner/repo".`);
808
- if (safeguards && !await isWorkingDirectoryClean(workspaceRoot)) {
809
- console.error("Working directory is not clean. Please commit or stash your changes before proceeding.");
810
- return null;
2353
+ //#region src/workflows/verify.ts
2354
+ async function verifyWorkflow(options) {
2355
+ if (options.safeguards) {
2356
+ const clean = await isWorkingDirectoryClean(options.workspaceRoot);
2357
+ if (!clean.ok) exitWithError("Failed to verify working directory state.", "Ensure this is a valid git repository and try again.", clean.error);
2358
+ if (!clean.value) exitWithError("Working directory is not clean. Please commit or stash your changes before proceeding.");
811
2359
  }
812
- const { workspacePackages, packagesToAnalyze } = await discoverWorkspacePackages(workspaceRoot, options);
813
- if (packagesToAnalyze.length === 0) {
814
- console.log("No packages found to analyze for release.");
815
- return null;
2360
+ const releaseBranch = options.branch.release;
2361
+ const defaultBranch = options.branch.default;
2362
+ const releasePr = await options.githubClient.getExistingPullRequest(releaseBranch);
2363
+ if (!releasePr || !releasePr.head) {
2364
+ logger.warn(`No open release pull request found for branch "${releaseBranch}". Nothing to verify.`);
2365
+ return;
816
2366
  }
817
- const changedPackages = await analyzeCommits(packagesToAnalyze, workspaceRoot);
818
- if (changedPackages.size === 0) throw new Error("No packages have changes requiring a release");
819
- let versionUpdates = [];
820
- for (const [pkgName, bump] of changedPackages) {
821
- const pkg = workspacePackages.find((p) => p.name === pkgName);
822
- if (pkg) versionUpdates.push(createVersionUpdate(pkg, bump, true));
823
- }
824
- const isVersionPromptEnabled = options.prompts?.versions !== false;
825
- if (!isCI && isVersionPromptEnabled) {
826
- const versionOverrides = await promptVersionOverrides(versionUpdates.map((u) => ({
827
- package: u.package,
828
- currentVersion: u.currentVersion,
829
- suggestedVersion: u.newVersion,
830
- bumpType: u.bumpType
831
- })), workspaceRoot);
832
- versionUpdates = versionUpdates.map((update) => {
833
- const overriddenVersion = versionOverrides.get(update.package.name);
834
- if (overriddenVersion && overriddenVersion !== update.newVersion) return {
835
- ...update,
836
- newVersion: overriddenVersion
837
- };
838
- return update;
839
- });
2367
+ logger.info(`Found release PR #${releasePr.number}. Verifying against default branch "${defaultBranch}"...`);
2368
+ const originalBranch = await getCurrentBranch(options.workspaceRoot);
2369
+ if (!originalBranch.ok) exitWithError("Failed to detect current branch.", void 0, originalBranch.error);
2370
+ if (originalBranch.value !== defaultBranch) {
2371
+ const checkout = await checkoutBranch(defaultBranch, options.workspaceRoot);
2372
+ if (!checkout.ok) exitWithError(`Failed to checkout branch: ${defaultBranch}`, void 0, checkout.error);
2373
+ if (!checkout.value) exitWithError(`Failed to checkout branch: ${defaultBranch}`);
840
2374
  }
841
- const allUpdates = createDependentUpdates(buildPackageDependencyGraph(workspacePackages), workspacePackages, versionUpdates);
842
- const currentBranch = await getCurrentBranch(workspaceRoot);
843
- const existingPullRequest = await getExistingPullRequest({
844
- owner,
845
- repo,
846
- branch: releaseBranch,
847
- githubToken
848
- });
849
- const prExists = !!existingPullRequest;
850
- if (prExists) console.log("Existing pull request found:", existingPullRequest.html_url);
851
- else console.log("No existing pull request found, will create new one");
852
- const branchExists = await doesBranchExist(releaseBranch, workspaceRoot);
853
- if (!branchExists) {
854
- console.log("Creating release branch:", releaseBranch);
855
- await createBranch(releaseBranch, currentBranch, workspaceRoot);
856
- }
857
- if (!await checkoutBranch(releaseBranch, workspaceRoot)) throw new Error(`Failed to checkout branch: ${releaseBranch}`);
858
- if (branchExists) {
859
- console.log("Pulling latest changes from remote");
860
- if (!await pullLatestChanges(releaseBranch, workspaceRoot)) console.log("Warning: Failed to pull latest changes, continuing anyway");
861
- }
862
- console.log("Rebasing release branch onto", currentBranch);
863
- await rebaseBranch(currentBranch, workspaceRoot);
864
- await updateAllPackageJsonFiles(allUpdates);
865
- const hasCommitted = await commitChanges("chore: update release versions", workspaceRoot);
866
- const isBranchAhead = await isBranchAheadOfRemote(releaseBranch, workspaceRoot);
867
- if (!hasCommitted && !isBranchAhead) {
868
- console.log("No changes to commit and branch is in sync with remote");
869
- await checkoutBranch(currentBranch, workspaceRoot);
870
- if (prExists) {
871
- console.log("No updates needed, PR is already up to date");
872
- return {
873
- updates: allUpdates,
874
- prUrl: existingPullRequest.html_url,
875
- created: false
876
- };
877
- } else {
878
- console.error("No changes to commit, and no existing PR. Nothing to do.");
879
- return null;
2375
+ let existingOverrides = {};
2376
+ try {
2377
+ const overridesContent = await readFileFromGit(options.workspaceRoot, releasePr.head.sha, ucdjsReleaseOverridesPath);
2378
+ if (overridesContent.ok && overridesContent.value) {
2379
+ existingOverrides = JSON.parse(overridesContent.value);
2380
+ logger.info("Found existing version overrides file on release branch.");
880
2381
  }
2382
+ } catch (error) {
2383
+ logger.info("No version overrides file found on release branch. Continuing...");
2384
+ logger.verbose(`Reading release overrides failed: ${formatUnknownError(error).message}`);
881
2385
  }
882
- console.log("Pushing changes to remote");
883
- await pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true });
884
- const prTitle = existingPullRequest?.title || options.pullRequest?.title || "chore: update package versions";
885
- const prBody = generatePullRequestBody(allUpdates, options.pullRequest?.body);
886
- const pullRequest = await upsertPullRequest({
887
- owner,
888
- repo,
889
- pullNumber: existingPullRequest?.number,
890
- title: prTitle,
891
- body: prBody,
892
- head: releaseBranch,
893
- base: currentBranch,
894
- githubToken
2386
+ const discovered = await discoverWorkspacePackages(options.workspaceRoot, options);
2387
+ if (!discovered.ok) exitWithError("Failed to discover packages.", void 0, discovered.error);
2388
+ const ensured = ensureHasPackages(discovered.value);
2389
+ if (!ensured.ok) {
2390
+ logger.warn(ensured.error.message);
2391
+ return;
2392
+ }
2393
+ const mainPackages = ensured.value;
2394
+ const updatesResult = await calculateUpdates({
2395
+ workspacePackages: mainPackages,
2396
+ workspaceRoot: options.workspaceRoot,
2397
+ showPrompt: false,
2398
+ globalCommitMode: options.globalCommitMode === "none" ? false : options.globalCommitMode,
2399
+ overrides: existingOverrides
895
2400
  });
896
- console.log(prExists ? "Updated pull request:" : "Created pull request:", pullRequest?.html_url);
897
- await checkoutBranch(currentBranch, workspaceRoot);
2401
+ if (!updatesResult.ok) exitWithError("Failed to calculate expected package updates.", void 0, updatesResult.error);
2402
+ const expectedUpdates = updatesResult.value.allUpdates;
2403
+ const expectedVersionMap = new Map(expectedUpdates.map((u) => [u.package.name, u.newVersion]));
2404
+ const prVersionMap = /* @__PURE__ */ new Map();
2405
+ for (const pkg of mainPackages) {
2406
+ const pkgJsonPath = relative(options.workspaceRoot, join(pkg.path, "package.json"));
2407
+ const pkgJsonContent = await readFileFromGit(options.workspaceRoot, releasePr.head.sha, pkgJsonPath);
2408
+ if (pkgJsonContent.ok && pkgJsonContent.value) {
2409
+ const pkgJson = JSON.parse(pkgJsonContent.value);
2410
+ prVersionMap.set(pkg.name, pkgJson.version);
2411
+ }
2412
+ }
2413
+ if (originalBranch.value !== defaultBranch) await checkoutBranch(originalBranch.value, options.workspaceRoot);
2414
+ let isOutOfSync = false;
2415
+ for (const [pkgName, expectedVersion] of expectedVersionMap.entries()) {
2416
+ const prVersion = prVersionMap.get(pkgName);
2417
+ if (!prVersion) {
2418
+ logger.warn(`Package "${pkgName}" found in default branch but not in release branch. Skipping.`);
2419
+ continue;
2420
+ }
2421
+ if (gt(expectedVersion, prVersion)) {
2422
+ logger.error(`Package "${pkgName}" is out of sync. Expected version >= ${expectedVersion}, but PR has ${prVersion}.`);
2423
+ isOutOfSync = true;
2424
+ } else logger.success(`Package "${pkgName}" is up to date (PR version: ${prVersion}, Expected: ${expectedVersion})`);
2425
+ }
2426
+ const statusContext = "ucdjs/release-verify";
2427
+ if (isOutOfSync) {
2428
+ await options.githubClient.setCommitStatus({
2429
+ sha: releasePr.head.sha,
2430
+ state: "failure",
2431
+ context: statusContext,
2432
+ description: "Release PR is out of sync with the default branch. Please re-run the release process."
2433
+ });
2434
+ logger.error("Verification failed. Commit status set to 'failure'.");
2435
+ } else {
2436
+ await options.githubClient.setCommitStatus({
2437
+ sha: releasePr.head.sha,
2438
+ state: "success",
2439
+ context: statusContext,
2440
+ description: "Release PR is up to date.",
2441
+ targetUrl: `https://github.com/${options.owner}/${options.repo}/pull/${releasePr.number}`
2442
+ });
2443
+ logger.success("Verification successful. Commit status set to 'success'.");
2444
+ }
2445
+ }
2446
+
2447
+ //#endregion
2448
+ //#region src/index.ts
2449
+ async function createReleaseScripts(options) {
2450
+ const normalizedOptions = normalizeReleaseScriptsOptions(options);
898
2451
  return {
899
- updates: allUpdates,
900
- prUrl: pullRequest?.html_url,
901
- created: !prExists
2452
+ async verify() {
2453
+ return verifyWorkflow(normalizedOptions);
2454
+ },
2455
+ async prepare() {
2456
+ return prepareWorkflow(normalizedOptions);
2457
+ },
2458
+ async publish() {
2459
+ return publishWorkflow(normalizedOptions);
2460
+ },
2461
+ packages: {
2462
+ async list() {
2463
+ const result = await discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions);
2464
+ if (!result.ok) throw new Error(result.error.message);
2465
+ return result.value;
2466
+ },
2467
+ async get(packageName) {
2468
+ const result = await discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions);
2469
+ if (!result.ok) throw new Error(result.error.message);
2470
+ return result.value.find((p) => p.name === packageName);
2471
+ }
2472
+ }
902
2473
  };
903
2474
  }
904
2475
 
905
2476
  //#endregion
906
- export { release };
2477
+ export { createReleaseScripts };