@ucdjs/release-scripts 0.1.0-beta.11 → 0.1.0-beta.13

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,43 +1,66 @@
1
1
  import { t as Eta } from "./eta-Boh7yPZi.mjs";
2
- import farver from "farver";
3
- import { getCommits } from "commit-parser";
4
2
  import process from "node:process";
3
+ import farver from "farver";
4
+ import mri from "mri";
5
5
  import { exec } from "tinyexec";
6
6
  import { dedent } from "@luxass/utils";
7
7
  import { join } from "node:path";
8
8
  import { readFile, writeFile } from "node:fs/promises";
9
+ import { getCommits } from "commit-parser";
9
10
  import prompts from "prompts";
10
11
 
11
12
  //#region src/publish.ts
12
13
  function publish(_options) {}
13
14
 
14
15
  //#endregion
15
- //#region src/utils.ts
16
- const globalOptions = {
17
- dryRun: false,
18
- verbose: false
19
- };
16
+ //#region src/shared/utils.ts
17
+ const args = mri(process.argv.slice(2));
18
+ const isDryRun = !!args.dry;
19
+ const isVerbose = !!args.verbose;
20
+ const isForce = !!args.force;
20
21
  const isCI = typeof process.env.CI === "string" && process.env.CI !== "" && process.env.CI.toLowerCase() !== "false";
21
22
  const logger = {
22
- info: (...args) => {
23
- console.info(farver.cyan("[info]:"), ...args);
23
+ info: (...args$1) => {
24
+ console.info(...args$1);
25
+ },
26
+ warn: (...args$1) => {
27
+ console.warn(` ${farver.yellow("⚠")}`, ...args$1);
28
+ },
29
+ error: (...args$1) => {
30
+ console.error(` ${farver.red("✖")}`, ...args$1);
31
+ },
32
+ verbose: (...args$1) => {
33
+ if (!isVerbose) return;
34
+ if (args$1.length === 0) {
35
+ console.log();
36
+ return;
37
+ }
38
+ if (args$1.length > 1 && typeof args$1[0] === "string") {
39
+ console.log(farver.dim(args$1[0]), ...args$1.slice(1));
40
+ return;
41
+ }
42
+ console.log(...args$1);
24
43
  },
25
- debug: (...args) => {
26
- console.debug(farver.gray("[debug]:"), ...args);
44
+ section: (title) => {
45
+ console.log();
46
+ console.log(` ${farver.bold(title)}`);
47
+ console.log(` ${farver.gray("─".repeat(title.length + 2))}`);
27
48
  },
28
- warn: (...args) => {
29
- console.warn(farver.yellow("[warn]:"), ...args);
49
+ emptyLine: () => {
50
+ console.log();
30
51
  },
31
- error: (...args) => {
32
- console.error(farver.red("[error]:"), ...args);
52
+ item: (message) => {
53
+ console.log(` ${message}`);
33
54
  },
34
- log: (...args) => {
35
- if (!globalOptions.verbose) return;
36
- console.log(...args);
55
+ step: (message) => {
56
+ console.log(` ${farver.blue("→")} ${message}`);
57
+ },
58
+ success: (message) => {
59
+ console.log(` ${farver.green("✓")} ${message}`);
37
60
  }
38
61
  };
39
- async function run(bin, args, opts = {}) {
40
- return exec(bin, args, {
62
+ async function run(bin, args$1, opts = {}) {
63
+ return exec(bin, args$1, {
41
64
  throwOnError: true,
42
65
  ...opts,
43
66
  nodeOptions: {
@@ -46,21 +69,20 @@ async function run(bin, args, opts = {}) {
46
69
  }
47
70
  });
48
71
  }
49
- async function dryRun(bin, args, opts) {
50
- return logger.log(farver.blue(`[dryrun] ${bin} ${args.join(" ")}`), opts || "");
72
+ async function dryRun(bin, args$1, opts) {
73
+ return logger.verbose(farver.blue(`[dryrun] ${bin} ${args$1.join(" ")}`), opts || "");
51
74
  }
52
- const runIfNotDry = globalOptions.dryRun ? dryRun : run;
75
+ const runIfNotDry = isDryRun ? dryRun : run;
53
76
  function exitWithError(message, hint) {
54
77
  logger.error(farver.bold(message));
55
78
  if (hint) console.error(farver.gray(` ${hint}`));
56
79
  process.exit(1);
57
80
  }
58
81
  function normalizeSharedOptions(options) {
59
- const { workspaceRoot = process.cwd(), githubToken = "", verbose = false, repo: fullRepo, packages = true, prompts: prompts$1 = {
82
+ const { workspaceRoot = process.cwd(), githubToken = "", repo: fullRepo, packages = true, prompts: prompts$1 = {
60
83
  packages: true,
61
84
  versions: true
62
85
  },...rest } = options;
63
- globalOptions.verbose = verbose;
64
86
  if (!githubToken.trim()) exitWithError("GitHub token is required", "Set GITHUB_TOKEN environment variable or pass it in options");
65
87
  if (!fullRepo || !fullRepo.trim() || !fullRepo.includes("/")) exitWithError("Repository (repo) is required", "Specify the repository in 'owner/repo' format (e.g., 'octocat/hello-world')");
66
88
  const [owner, repo] = fullRepo.split("/");
@@ -68,99 +90,28 @@ function normalizeSharedOptions(options) {
68
90
  return {
69
91
  ...rest,
70
92
  packages,
71
- prompts: prompts$1,
93
+ prompts: {
94
+ packages: prompts$1?.packages ?? true,
95
+ versions: prompts$1?.versions ?? true
96
+ },
72
97
  workspaceRoot,
73
98
  githubToken,
74
99
  owner,
75
- repo,
76
- verbose
100
+ repo
77
101
  };
78
102
  }
79
-
80
- //#endregion
81
- //#region src/commits.ts
82
- async function getLastPackageTag(packageName, workspaceRoot) {
83
- try {
84
- const { stdout } = await run("git", ["tag", "--list"], { nodeOptions: {
85
- cwd: workspaceRoot,
86
- stdio: "pipe"
87
- } });
88
- return stdout.split("\n").map((tag) => tag.trim()).filter(Boolean).reverse().find((tag) => tag.startsWith(`${packageName}@`));
89
- } catch (err) {
90
- logger.warn(`Failed to get tags for package ${packageName}: ${err.message}`);
91
- return;
92
- }
93
- }
94
- function determineHighestBump(commits) {
95
- if (commits.length === 0) return "none";
96
- let highestBump = "none";
97
- for (const commit of commits) {
98
- const bump = determineBumpType(commit);
99
- if (bump === "major") return "major";
100
- if (bump === "minor") highestBump = "minor";
101
- else if (bump === "patch" && highestBump === "none") highestBump = "patch";
102
- }
103
- return highestBump;
104
- }
105
- /**
106
- * Retrieves commits that affect a specific workspace package since its last tag.
107
- *
108
- * @param {string} workspaceRoot - The root directory of the workspace.
109
- * @param {WorkspacePackage} pkg - The workspace package to analyze.
110
- * @returns {Promise<GitCommit[]>} A promise that resolves to an array of GitCommit objects affecting the package.
111
- */
112
- async function getCommitsForWorkspacePackage(workspaceRoot, pkg) {
113
- const lastTag = await getLastPackageTag(pkg.name, workspaceRoot);
114
- const allCommits = getCommits({
115
- from: lastTag,
116
- to: "HEAD",
117
- cwd: workspaceRoot
118
- });
119
- logger.log(`Found ${allCommits.length} commits for ${pkg.name} since ${lastTag || "beginning"}`);
120
- const touchedCommitHashes = getCommits({
121
- from: lastTag,
122
- to: "HEAD",
123
- cwd: workspaceRoot,
124
- folder: pkg.path
125
- });
126
- const touchedSet = new Set(touchedCommitHashes);
127
- const packageCommits = allCommits.filter((commit) => touchedSet.has(commit));
128
- logger.log(`${packageCommits.length} commits affect ${pkg.name}`);
129
- return packageCommits;
130
- }
131
- async function getWorkspacePackageCommits(workspaceRoot, packages) {
132
- const changedPackages = /* @__PURE__ */ new Map();
133
- const promises = packages.map(async (pkg) => {
134
- return {
135
- pkgName: pkg.name,
136
- commits: await getCommitsForWorkspacePackage(workspaceRoot, pkg)
137
- };
103
+ if (isDryRun || isVerbose || isForce) {
104
+ logger.verbose(farver.inverse(farver.yellow(" Running with special flags ")));
105
+ logger.verbose({
106
+ isDryRun,
107
+ isVerbose,
108
+ isForce
138
109
  });
139
- const results = await Promise.all(promises);
140
- for (const { pkgName, commits } of results) changedPackages.set(pkgName, commits);
141
- return changedPackages;
142
- }
143
- function determineBumpType(commit) {
144
- if (commit.isBreaking) return "major";
145
- if (!commit.isConventional || !commit.type) return "none";
146
- switch (commit.type) {
147
- case "feat": return "minor";
148
- case "fix":
149
- case "perf": return "patch";
150
- case "docs":
151
- case "style":
152
- case "refactor":
153
- case "test":
154
- case "build":
155
- case "ci":
156
- case "chore":
157
- case "revert": return "none";
158
- default: return "none";
159
- }
110
+ logger.verbose();
160
111
  }
161
112
 
162
113
  //#endregion
163
- //#region src/git.ts
114
+ //#region src/core/git.ts
164
115
  /**
165
116
  * Check if the working directory is clean (no uncommitted changes)
166
117
  * @param {string} workspaceRoot - The root directory of the workspace
@@ -200,31 +151,64 @@ async function doesBranchExist(branch, workspaceRoot) {
200
151
  }
201
152
  }
202
153
  /**
203
- * Pull latest changes from remote branch
204
- * @param branch - The branch name to pull from
205
- * @param workspaceRoot - The root directory of the workspace
206
- * @returns Promise resolving to true if pull succeeded, false otherwise
154
+ * Retrieves the default branch name from the remote repository.
155
+ * Falls back to "main" if the default branch cannot be determined.
156
+ * @returns {Promise<string>} A Promise resolving to the default branch name as a string.
207
157
  */
208
- async function pullLatestChanges(branch, workspaceRoot) {
158
+ async function getDefaultBranch(workspaceRoot) {
209
159
  try {
210
- await run("git", [
211
- "pull",
212
- "origin",
213
- branch
214
- ], { nodeOptions: {
160
+ const match = (await run("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { nodeOptions: {
215
161
  cwd: workspaceRoot,
216
162
  stdio: "pipe"
217
- } });
218
- return true;
163
+ } })).stdout.trim().match(/^refs\/remotes\/origin\/(.+)$/);
164
+ if (match && match[1]) return match[1];
165
+ return "main";
219
166
  } catch {
220
- return false;
167
+ return "main";
221
168
  }
222
169
  }
223
170
  /**
224
- * Create a new git branch
225
- * @param branch - The new branch name
226
- * @param base - The base branch to create from
227
- * @param workspaceRoot - The root directory of the workspace
171
+ * Retrieves the name of the current branch in the repository.
172
+ * @param {string} workspaceRoot - The root directory of the workspace
173
+ * @returns {Promise<string>} A Promise resolving to the current branch name as a string
174
+ */
175
+ async function getCurrentBranch(workspaceRoot) {
176
+ try {
177
+ return (await run("git", [
178
+ "rev-parse",
179
+ "--abbrev-ref",
180
+ "HEAD"
181
+ ], { nodeOptions: {
182
+ cwd: workspaceRoot,
183
+ stdio: "pipe"
184
+ } })).stdout.trim();
185
+ } catch (err) {
186
+ logger.error("Error getting current branch:", err);
187
+ throw err;
188
+ }
189
+ }
190
+ /**
191
+ * Retrieves the list of available branches in the repository.
192
+ * @param {string} workspaceRoot - The root directory of the workspace
193
+ * @returns {Promise<string[]>} A Promise resolving to an array of branch names
194
+ */
195
+ async function getAvailableBranches(workspaceRoot) {
196
+ try {
197
+ return (await run("git", ["branch", "--list"], { nodeOptions: {
198
+ cwd: workspaceRoot,
199
+ stdio: "pipe"
200
+ } })).stdout.split("\n").map((line) => line.replace("*", "").trim()).filter((line) => line.length > 0);
201
+ } catch (err) {
202
+ logger.error("Error getting available branches:", err);
203
+ throw err;
204
+ }
205
+ }
206
+ /**
207
+ * Creates a new branch from the specified base branch.
208
+ * @param {string} branch - The name of the new branch to create
209
+ * @param {string} base - The base branch to create the new branch from
210
+ * @param {string} workspaceRoot - The root directory of the workspace
211
+ * @returns {Promise<void>} A Promise that resolves when the branch is created
228
212
  */
229
213
  async function createBranch(branch, base, workspaceRoot) {
230
214
  try {
@@ -242,16 +226,29 @@ async function createBranch(branch, base, workspaceRoot) {
242
226
  exitWithError(`Failed to create branch: ${branch}`, `Make sure the branch doesn't already exist and you have a clean working directory`);
243
227
  }
244
228
  }
245
- /**
246
- * Checkout a git branch
247
- * @param branch - The branch name to checkout
248
- * @param workspaceRoot - The root directory of the workspace
249
- * @returns Promise resolving to true if checkout succeeded, false otherwise
250
- */
251
229
  async function checkoutBranch(branch, workspaceRoot) {
252
230
  try {
253
231
  logger.info(`Switching to branch: ${farver.green(branch)}`);
254
- await run("git", ["checkout", branch], { nodeOptions: {
232
+ const match = (await run("git", ["checkout", branch], { nodeOptions: {
233
+ cwd: workspaceRoot,
234
+ stdio: "pipe"
235
+ } })).stderr.trim().match(/Switched to branch '(.+)'/);
236
+ if (match && match[1] === branch) {
237
+ logger.info(`Successfully switched to branch: ${farver.green(branch)}`);
238
+ return true;
239
+ }
240
+ return false;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+ async function pullLatestChanges(branch, workspaceRoot) {
246
+ try {
247
+ await run("git", [
248
+ "pull",
249
+ "origin",
250
+ branch
251
+ ], { nodeOptions: {
255
252
  cwd: workspaceRoot,
256
253
  stdio: "pipe"
257
254
  } });
@@ -260,43 +257,18 @@ async function checkoutBranch(branch, workspaceRoot) {
260
257
  return false;
261
258
  }
262
259
  }
263
- /**
264
- * Get the current branch name
265
- * @param workspaceRoot - The root directory of the workspace
266
- * @returns Promise resolving to the current branch name
267
- */
268
- async function getCurrentBranch(workspaceRoot) {
269
- return (await run("git", [
270
- "rev-parse",
271
- "--abbrev-ref",
272
- "HEAD"
273
- ], { nodeOptions: {
274
- cwd: workspaceRoot,
275
- stdio: "pipe"
276
- } })).stdout.trim();
277
- }
278
- /**
279
- * Rebase current branch onto another branch
280
- * @param ontoBranch - The target branch to rebase onto
281
- * @param workspaceRoot - The root directory of the workspace
282
- */
283
260
  async function rebaseBranch(ontoBranch, workspaceRoot) {
284
261
  try {
285
262
  logger.info(`Rebasing onto: ${farver.cyan(ontoBranch)}`);
286
- await run("git", ["rebase", ontoBranch], { nodeOptions: {
263
+ await runIfNotDry("git", ["rebase", ontoBranch], { nodeOptions: {
287
264
  cwd: workspaceRoot,
288
265
  stdio: "pipe"
289
266
  } });
267
+ return true;
290
268
  } catch {
291
269
  exitWithError(`Failed to rebase onto: ${ontoBranch}`, `You may have merge conflicts. Run 'git rebase --abort' to undo the rebase`);
292
270
  }
293
271
  }
294
- /**
295
- * Check if local branch is ahead of remote (has commits to push)
296
- * @param branch - The branch name to check
297
- * @param workspaceRoot - The root directory of the workspace
298
- * @returns Promise resolving to true if local is ahead, false otherwise
299
- */
300
272
  async function isBranchAheadOfRemote(branch, workspaceRoot) {
301
273
  try {
302
274
  const result = await run("git", [
@@ -312,23 +284,12 @@ async function isBranchAheadOfRemote(branch, workspaceRoot) {
312
284
  return true;
313
285
  }
314
286
  }
315
- /**
316
- * Check if there are any changes to commit (staged or unstaged)
317
- * @param workspaceRoot - The root directory of the workspace
318
- * @returns Promise resolving to true if there are changes, false otherwise
319
- */
320
287
  async function hasChangesToCommit(workspaceRoot) {
321
288
  return (await run("git", ["status", "--porcelain"], { nodeOptions: {
322
289
  cwd: workspaceRoot,
323
290
  stdio: "pipe"
324
291
  } })).stdout.trim() !== "";
325
292
  }
326
- /**
327
- * Commit changes with a message
328
- * @param message - The commit message
329
- * @param workspaceRoot - The root directory of the workspace
330
- * @returns Promise resolving to true if commit was made, false if there were no changes
331
- */
332
293
  async function commitChanges(message, workspaceRoot) {
333
294
  try {
334
295
  await run("git", ["add", "."], { nodeOptions: {
@@ -350,51 +311,35 @@ async function commitChanges(message, workspaceRoot) {
350
311
  exitWithError(`Failed to commit changes`, `Make sure you have git configured properly with user.name and user.email`);
351
312
  }
352
313
  }
353
- /**
354
- * Push branch to remote
355
- * @param branch - The branch name to push
356
- * @param workspaceRoot - The root directory of the workspace
357
- * @param options - Push options
358
- * @param options.force - Force push (overwrite remote)
359
- * @param options.forceWithLease - Force push with safety check (won't overwrite unexpected changes)
360
- */
361
314
  async function pushBranch(branch, workspaceRoot, options) {
362
315
  try {
363
- const args = [
316
+ const args$1 = [
364
317
  "push",
365
318
  "origin",
366
319
  branch
367
320
  ];
368
321
  if (options?.forceWithLease) {
369
- args.push("--force-with-lease");
322
+ args$1.push("--force-with-lease");
370
323
  logger.info(`Pushing branch: ${farver.green(branch)} ${farver.dim("(with lease)")}`);
371
324
  } else if (options?.force) {
372
- args.push("--force");
325
+ args$1.push("--force");
373
326
  logger.info(`Force pushing branch: ${farver.green(branch)}`);
374
327
  } else logger.info(`Pushing branch: ${farver.green(branch)}`);
375
- await run("git", args, { nodeOptions: {
328
+ await runIfNotDry("git", args$1, { nodeOptions: {
376
329
  cwd: workspaceRoot,
377
330
  stdio: "pipe"
378
331
  } });
332
+ return true;
379
333
  } catch {
380
334
  exitWithError(`Failed to push branch: ${branch}`, `Make sure you have permission to push to the remote repository`);
381
335
  }
382
336
  }
383
- async function getDefaultBranch() {
384
- try {
385
- const match = (await run("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { nodeOptions: { stdio: "pipe" } })).stdout.trim().match(/^refs\/remotes\/origin\/(.+)$/);
386
- if (match && match[1]) return match[1];
387
- return "main";
388
- } catch {
389
- return "main";
390
- }
391
- }
392
337
 
393
338
  //#endregion
394
- //#region src/github.ts
339
+ //#region src/core/github.ts
395
340
  async function getExistingPullRequest({ owner, repo, branch, githubToken }) {
396
341
  try {
397
- logger.debug(`Requesting pull request for branch: ${branch} (url: https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch})`);
342
+ logger.verbose(`Requesting pull request for branch: ${branch} (url: https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch})`);
398
343
  const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch}`, { headers: {
399
344
  Accept: "application/vnd.github.v3+json",
400
345
  Authorization: `token ${githubToken}`
@@ -430,9 +375,10 @@ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNum
430
375
  title,
431
376
  body,
432
377
  head,
433
- base
378
+ base,
379
+ draft: true
434
380
  };
435
- logger.debug(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${url})`);
381
+ logger.verbose(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${url})`);
436
382
  const res = await fetch(url, {
437
383
  method,
438
384
  headers: {
@@ -492,178 +438,226 @@ function generatePullRequestBody(updates, body) {
492
438
  }
493
439
 
494
440
  //#endregion
495
- //#region src/prompts.ts
496
- async function selectPackagePrompt(packages) {
497
- const response = await prompts({
498
- type: "multiselect",
499
- name: "selectedPackages",
500
- message: "Select packages to release",
501
- choices: packages.map((pkg) => ({
502
- title: `${pkg.name} (${farver.bold(pkg.version)})`,
503
- value: pkg.name,
504
- selected: true
505
- })),
506
- min: 1,
507
- hint: "Space to select/deselect. Return to submit.",
508
- instructions: false
509
- });
510
- if (!response.selectedPackages || response.selectedPackages.length === 0) return [];
511
- return response.selectedPackages;
512
- }
513
- async function promptVersionOverride(pkg, workspaceRoot, currentVersion, suggestedVersion, suggestedBumpType) {
514
- const choices = [{
515
- title: `Use suggested: ${suggestedVersion} (${suggestedBumpType})`,
516
- value: "suggested"
517
- }];
518
- for (const bumpType of [
519
- "patch",
520
- "minor",
521
- "major"
522
- ]) if (bumpType !== suggestedBumpType) {
523
- const version = getNextVersion(currentVersion, bumpType);
524
- choices.push({
525
- title: `${bumpType}: ${version}`,
526
- value: bumpType
527
- });
441
+ //#region src/versioning/commits.ts
442
+ async function getLastPackageTag(packageName, workspaceRoot) {
443
+ try {
444
+ const { stdout } = await run("git", [
445
+ "tag",
446
+ "--list",
447
+ `${packageName}@*`
448
+ ], { nodeOptions: {
449
+ cwd: workspaceRoot,
450
+ stdio: "pipe"
451
+ } });
452
+ return stdout.split("\n").map((tag) => tag.trim()).filter(Boolean).reverse()[0];
453
+ } catch (err) {
454
+ logger.warn(`Failed to get tags for package ${packageName}: ${err.message}`);
455
+ return;
528
456
  }
529
- choices.push({
530
- title: "Custom version",
531
- value: "custom"
532
- });
533
- const response = await prompts([{
534
- type: "select",
535
- name: "choice",
536
- message: `${pkg.name} (${currentVersion}):`,
537
- choices,
538
- initial: 0
539
- }, {
540
- type: (prev) => prev === "custom" ? "text" : null,
541
- name: "customVersion",
542
- message: "Enter custom version:",
543
- initial: suggestedVersion,
544
- validate: (value) => {
545
- return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(value) || "Invalid semver version (e.g., 1.0.0)";
546
- }
547
- }]);
548
- if (response.choice === "suggested") return suggestedVersion;
549
- else if (response.choice === "custom") return response.customVersion;
550
- else return getNextVersion(currentVersion, response.choice);
551
- }
552
-
553
- //#endregion
554
- //#region src/version.ts
555
- function isValidSemver(version) {
556
- return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(version);
557
457
  }
558
- function validateSemver(version) {
559
- if (!isValidSemver(version)) throw new Error(`Invalid semver version: ${version}`);
560
- }
561
- function getNextVersion(currentVersion, bump) {
562
- if (bump === "none") return currentVersion;
563
- validateSemver(currentVersion);
564
- const match = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
565
- if (!match) throw new Error(`Invalid semver version: ${currentVersion}`);
566
- const [, major, minor, patch] = match;
567
- let newMajor = Number.parseInt(major, 10);
568
- let newMinor = Number.parseInt(minor, 10);
569
- let newPatch = Number.parseInt(patch, 10);
570
- switch (bump) {
571
- case "major":
572
- newMajor += 1;
573
- newMinor = 0;
574
- newPatch = 0;
575
- break;
576
- case "minor":
577
- newMinor += 1;
578
- newPatch = 0;
579
- break;
580
- case "patch":
581
- newPatch += 1;
582
- break;
458
+ function determineHighestBump(commits) {
459
+ if (commits.length === 0) return "none";
460
+ let highestBump = "none";
461
+ for (const commit of commits) {
462
+ const bump = determineBumpType(commit);
463
+ if (bump === "major") return "major";
464
+ if (bump === "minor") highestBump = "minor";
465
+ else if (bump === "patch" && highestBump === "none") highestBump = "patch";
583
466
  }
584
- return `${newMajor}.${newMinor}.${newPatch}`;
585
- }
586
- /**
587
- * Create a version update object
588
- */
589
- function createVersionUpdate(pkg, bump, hasDirectChanges) {
590
- const newVersion = getNextVersion(pkg.version, bump);
591
- return {
592
- package: pkg,
593
- currentVersion: pkg.version,
594
- newVersion,
595
- bumpType: bump,
596
- hasDirectChanges
597
- };
467
+ return highestBump;
598
468
  }
599
469
  /**
600
- * Infer version updates from package commits with optional interactive overrides
470
+ * Retrieves commits that affect a specific workspace package since its last tag.
601
471
  *
602
- * @param workspacePackages - All workspace packages
603
- * @param packageCommits - Map of package names to their commits
604
- * @param workspaceRoot - Root directory for prompts
605
- * @param showPrompt - Whether to show prompts for version overrides
606
- * @returns Version updates for packages with changes
472
+ * @param {string} workspaceRoot - The root directory of the workspace.
473
+ * @param {WorkspacePackage} pkg - The workspace package to analyze.
474
+ * @returns {Promise<GitCommit[]>} A promise that resolves to an array of GitCommit objects affecting the package.
607
475
  */
608
- async function inferVersionUpdates(workspacePackages, packageCommits, workspaceRoot, showPrompt) {
609
- const versionUpdates = [];
610
- for (const [pkgName, commits] of packageCommits) {
611
- if (commits.length === 0) continue;
612
- const pkg = workspacePackages.find((p) => p.name === pkgName);
613
- if (!pkg) continue;
614
- const bump = determineHighestBump(commits);
615
- if (bump === "none") {
616
- logger.info(`No version bump needed for package ${pkg.name}`);
617
- continue;
476
+ async function getCommitsForWorkspacePackage(workspaceRoot, pkg) {
477
+ const lastTag = await getLastPackageTag(pkg.name, workspaceRoot);
478
+ const allCommits = getCommits({
479
+ from: lastTag,
480
+ to: "HEAD",
481
+ cwd: workspaceRoot
482
+ });
483
+ logger.verbose("Found commits for package", `${farver.cyan(allCommits.length)} for ${farver.bold(pkg.name)} since ${lastTag || "beginning"}`);
484
+ const commitsAffectingPackage = getCommits({
485
+ from: lastTag,
486
+ to: "HEAD",
487
+ cwd: workspaceRoot,
488
+ folder: pkg.path
489
+ });
490
+ const affectingCommitShas = /* @__PURE__ */ new Set();
491
+ for (const commit of commitsAffectingPackage) affectingCommitShas.add(commit.shortHash);
492
+ const packageCommits = allCommits.filter((commit) => {
493
+ return affectingCommitShas.has(commit.shortHash);
494
+ });
495
+ logger.verbose("Commits affect package", `${farver.cyan(packageCommits.length)} affect ${farver.bold(pkg.name)}`);
496
+ return packageCommits;
497
+ }
498
+ async function getWorkspacePackageCommits(workspaceRoot, packages) {
499
+ const changedPackages = /* @__PURE__ */ new Map();
500
+ const promises = packages.map(async (pkg) => {
501
+ return {
502
+ pkgName: pkg.name,
503
+ commits: await getCommitsForWorkspacePackage(workspaceRoot, pkg)
504
+ };
505
+ });
506
+ const results = await Promise.all(promises);
507
+ for (const { pkgName, commits } of results) changedPackages.set(pkgName, commits);
508
+ return changedPackages;
509
+ }
510
+ async function getCommitFileList(workspaceRoot, from, to) {
511
+ const map = /* @__PURE__ */ new Map();
512
+ try {
513
+ const { stdout } = await run("git", [
514
+ "log",
515
+ "--name-only",
516
+ "--format=%H",
517
+ `${from}^..${to}`
518
+ ], { nodeOptions: {
519
+ cwd: workspaceRoot,
520
+ stdio: "pipe"
521
+ } });
522
+ const lines = stdout.trim().split("\n");
523
+ let currentSha = null;
524
+ for (const line of lines) {
525
+ const trimmedLine = line.trim();
526
+ if (trimmedLine === "") {
527
+ currentSha = null;
528
+ continue;
529
+ }
530
+ if (currentSha === null) {
531
+ currentSha = trimmedLine;
532
+ map.set(currentSha, []);
533
+ continue;
534
+ }
535
+ map.get(currentSha).push(trimmedLine);
618
536
  }
619
- let newVersion = getNextVersion(pkg.version, bump);
620
- if (!isCI && showPrompt) newVersion = await promptVersionOverride(pkg, workspaceRoot, pkg.version, newVersion, bump);
621
- versionUpdates.push({
622
- package: pkg,
623
- currentVersion: pkg.version,
624
- newVersion,
625
- bumpType: bump,
626
- hasDirectChanges: true
627
- });
537
+ return map;
538
+ } catch {
539
+ return null;
628
540
  }
629
- return versionUpdates;
630
541
  }
631
- async function updatePackageJson(pkg, newVersion, dependencyUpdates) {
632
- const packageJsonPath = join(pkg.path, "package.json");
633
- const content = await readFile(packageJsonPath, "utf-8");
634
- const packageJson = JSON.parse(content);
635
- packageJson.version = newVersion;
636
- for (const [depName, depVersion] of dependencyUpdates) {
637
- if (packageJson.dependencies?.[depName]) {
638
- if (packageJson.dependencies[depName] === "workspace:*") continue;
639
- packageJson.dependencies[depName] = `^${depVersion}`;
640
- }
641
- if (packageJson.devDependencies?.[depName]) {
642
- if (packageJson.devDependencies[depName] === "workspace:*") continue;
643
- packageJson.devDependencies[depName] = `^${depVersion}`;
644
- }
645
- if (packageJson.peerDependencies?.[depName]) {
646
- if (packageJson.peerDependencies[depName] === "workspace:*") continue;
647
- packageJson.peerDependencies[depName] = `^${depVersion}`;
648
- }
542
+ /**
543
+ * Check if a file path touches any package folder.
544
+ * @param file - The file path to check
545
+ * @param packagePaths - Set of normalized package paths
546
+ * @param workspaceRoot - The workspace root for path normalization
547
+ * @returns true if the file is inside a package folder
548
+ */
549
+ function fileMatchesPackageFolder(file, packagePaths, workspaceRoot) {
550
+ const normalizedFile = file.startsWith("./") ? file.slice(2) : file;
551
+ for (const pkgPath of packagePaths) {
552
+ const normalizedPkgPath = pkgPath.startsWith(workspaceRoot) ? pkgPath.slice(workspaceRoot.length + 1) : pkgPath;
553
+ if (normalizedFile.startsWith(`${normalizedPkgPath}/`) || normalizedFile === normalizedPkgPath) return true;
649
554
  }
650
- await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
555
+ return false;
651
556
  }
652
557
  /**
653
- * Get all dependency updates needed for a package
558
+ * Check if a commit is a "global" commit (doesn't touch any package folder).
559
+ * @param files - Array of files changed in the commit
560
+ * @param packagePaths - Set of normalized package paths
561
+ * @param workspaceRoot - The workspace root
562
+ * @returns true if this is a global commit
654
563
  */
655
- function getDependencyUpdates(pkg, allUpdates) {
656
- const updates = /* @__PURE__ */ new Map();
657
- const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
658
- for (const dep of allDeps) {
659
- const update = allUpdates.find((u) => u.package.name === dep);
660
- if (update) updates.set(dep, update.newVersion);
564
+ function isGlobalCommit(files, packagePaths, workspaceRoot) {
565
+ if (!files || files.length === 0) return false;
566
+ return !files.some((file) => fileMatchesPackageFolder(file, packagePaths, workspaceRoot));
567
+ }
568
+ /**
569
+ * Get global commits for each package based on their individual commit timelines.
570
+ * This solves the problem where packages with different release histories need different global commits.
571
+ *
572
+ * A "global commit" is a commit that doesn't touch any package folder but may affect all packages
573
+ * (e.g., root package.json, CI config, README).
574
+ *
575
+ * Performance: Makes ONE batched git call to get files for all commits across all packages.
576
+ *
577
+ * @param workspaceRoot - The root directory of the workspace
578
+ * @param packageCommits - Map of package name to their commits (from getWorkspacePackageCommits)
579
+ * @param allPackages - All workspace packages (used to identify package folders)
580
+ * @param mode - Filter mode: false (disabled), "all" (all global commits), or "dependencies" (only dependency-related)
581
+ * @returns Map of package name to their global commits
582
+ */
583
+ async function getGlobalCommitsPerPackage(workspaceRoot, packageCommits, allPackages, mode) {
584
+ const result = /* @__PURE__ */ new Map();
585
+ if (!mode) {
586
+ logger.verbose("Global commits mode disabled");
587
+ return result;
588
+ }
589
+ logger.verbose(`Computing global commits per-package (mode: ${farver.cyan(mode)})`);
590
+ let oldestCommit = null;
591
+ let newestCommit = null;
592
+ for (const commits of packageCommits.values()) if (commits.length > 0) {
593
+ if (!newestCommit) newestCommit = commits[0].shortHash;
594
+ oldestCommit = commits[commits.length - 1].shortHash;
595
+ }
596
+ if (!oldestCommit || !newestCommit) {
597
+ logger.verbose("No commits found across packages");
598
+ return result;
599
+ }
600
+ logger.verbose("Fetching files for commits range", `${farver.cyan(oldestCommit)}..${farver.cyan(newestCommit)}`);
601
+ const commitFilesMap = await getCommitFileList(workspaceRoot, oldestCommit, newestCommit);
602
+ if (!commitFilesMap) {
603
+ logger.warn("Failed to get commit file list, returning empty global commits");
604
+ return result;
605
+ }
606
+ logger.verbose("Got file lists for commits", `${farver.cyan(commitFilesMap.size)} commits in ONE git call`);
607
+ const packagePaths = new Set(allPackages.map((p) => p.path));
608
+ for (const [pkgName, commits] of packageCommits) {
609
+ const globalCommitsForPackage = [];
610
+ logger.verbose("Filtering global commits for package", `${farver.bold(pkgName)} from ${farver.cyan(commits.length)} commits`);
611
+ for (const commit of commits) if (isGlobalCommit(commitFilesMap.get(commit.shortHash), packagePaths, workspaceRoot)) globalCommitsForPackage.push(commit);
612
+ logger.verbose("Package global commits found", `${farver.bold(pkgName)}: ${farver.cyan(globalCommitsForPackage.length)} global commits`);
613
+ if (mode === "all") result.set(pkgName, globalCommitsForPackage);
614
+ else if (mode === "dependencies") {
615
+ const dependencyCommits = [];
616
+ const dependencyFiles = [
617
+ "package.json",
618
+ "pnpm-lock.yaml",
619
+ "pnpm-workspace.yaml",
620
+ "yarn.lock",
621
+ "package-lock.json"
622
+ ];
623
+ for (const commit of globalCommitsForPackage) {
624
+ const files = commitFilesMap.get(commit.shortHash);
625
+ if (!files) continue;
626
+ if (files.some((file) => {
627
+ const normalizedFile = file.startsWith("./") ? file.slice(2) : file;
628
+ return dependencyFiles.includes(normalizedFile);
629
+ })) {
630
+ logger.verbose("Global commit affects dependencies", `${farver.bold(pkgName)}: commit ${farver.cyan(commit.shortHash)} affects dependencies`);
631
+ dependencyCommits.push(commit);
632
+ }
633
+ }
634
+ logger.verbose("Global commits affect dependencies", `${farver.bold(pkgName)}: ${farver.cyan(dependencyCommits.length)} global commits affect dependencies`);
635
+ result.set(pkgName, dependencyCommits);
636
+ }
637
+ }
638
+ return result;
639
+ }
640
+ function determineBumpType(commit) {
641
+ if (commit.isBreaking) return "major";
642
+ if (!commit.isConventional || !commit.type) return "none";
643
+ switch (commit.type) {
644
+ case "feat": return "minor";
645
+ case "fix":
646
+ case "perf": return "patch";
647
+ case "docs":
648
+ case "style":
649
+ case "refactor":
650
+ case "test":
651
+ case "build":
652
+ case "ci":
653
+ case "chore":
654
+ case "revert": return "none";
655
+ default: return "none";
661
656
  }
662
- return updates;
663
657
  }
664
658
 
665
659
  //#endregion
666
- //#region src/package.ts
660
+ //#region src/versioning/package.ts
667
661
  /**
668
662
  * Build a dependency graph from workspace packages
669
663
  *
@@ -731,29 +725,326 @@ function createDependentUpdates(graph, workspacePackages, directUpdates) {
731
725
  const directUpdateMap = new Map(directUpdates.map((u) => [u.package.name, u]));
732
726
  const affectedPackages = getAllAffectedPackages(graph, new Set(directUpdates.map((u) => u.package.name)));
733
727
  for (const pkgName of affectedPackages) {
734
- if (directUpdateMap.has(pkgName)) continue;
728
+ logger.verbose(`Processing affected package: ${pkgName}`);
729
+ if (directUpdateMap.has(pkgName)) {
730
+ logger.verbose(`Skipping ${pkgName}, already has a direct update`);
731
+ continue;
732
+ }
735
733
  const pkg = workspacePackages.find((p) => p.name === pkgName);
736
734
  if (!pkg) continue;
737
735
  allUpdates.push(createVersionUpdate(pkg, "patch", false));
738
736
  }
739
737
  return allUpdates;
740
738
  }
739
+
740
+ //#endregion
741
+ //#region src/versioning/version.ts
742
+ function isValidSemver(version) {
743
+ return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(version);
744
+ }
745
+ function getNextVersion(currentVersion, bump) {
746
+ if (bump === "none") {
747
+ logger.verbose(`No version bump needed, keeping version ${currentVersion}`);
748
+ return currentVersion;
749
+ }
750
+ if (!isValidSemver(currentVersion)) throw new Error(`Cannot bump version for invalid semver: ${currentVersion}`);
751
+ const match = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
752
+ if (!match) throw new Error(`Invalid semver version: ${currentVersion}`);
753
+ const [, major, minor, patch] = match;
754
+ let newMajor = Number.parseInt(major, 10);
755
+ let newMinor = Number.parseInt(minor, 10);
756
+ let newPatch = Number.parseInt(patch, 10);
757
+ switch (bump) {
758
+ case "major":
759
+ newMajor += 1;
760
+ newMinor = 0;
761
+ newPatch = 0;
762
+ break;
763
+ case "minor":
764
+ newMinor += 1;
765
+ newPatch = 0;
766
+ break;
767
+ case "patch":
768
+ newPatch += 1;
769
+ break;
770
+ }
771
+ return `${newMajor}.${newMinor}.${newPatch}`;
772
+ }
741
773
  /**
742
- * Update all package.json files with new versions and dependency updates
743
- *
744
- * Updates are performed in parallel for better performance.
745
- *
746
- * @param updates - Version updates to apply
774
+ * Create a version update object
775
+ */
776
+ function createVersionUpdate(pkg, bump, hasDirectChanges) {
777
+ const newVersion = getNextVersion(pkg.version, bump);
778
+ return {
779
+ package: pkg,
780
+ currentVersion: pkg.version,
781
+ newVersion,
782
+ bumpType: bump,
783
+ hasDirectChanges
784
+ };
785
+ }
786
+ function _calculateBumpType(oldVersion, newVersion) {
787
+ if (!isValidSemver(oldVersion) || !isValidSemver(newVersion)) throw new Error(`Cannot calculate bump type for invalid semver: ${oldVersion} or ${newVersion}`);
788
+ const oldParts = oldVersion.split(".").map(Number);
789
+ const newParts = newVersion.split(".").map(Number);
790
+ if (newParts[0] > oldParts[0]) return "major";
791
+ if (newParts[1] > oldParts[1]) return "minor";
792
+ if (newParts[2] > oldParts[2]) return "patch";
793
+ return "none";
794
+ }
795
+ const messageColorMap = {
796
+ feat: farver.green,
797
+ feature: farver.green,
798
+ refactor: farver.cyan,
799
+ style: farver.cyan,
800
+ docs: farver.blue,
801
+ doc: farver.blue,
802
+ types: farver.blue,
803
+ type: farver.blue,
804
+ chore: farver.gray,
805
+ ci: farver.gray,
806
+ build: farver.gray,
807
+ deps: farver.gray,
808
+ dev: farver.gray,
809
+ fix: farver.yellow,
810
+ test: farver.yellow,
811
+ perf: farver.magenta,
812
+ revert: farver.red,
813
+ breaking: farver.red
814
+ };
815
+ function formatCommitsForDisplay(commits) {
816
+ if (commits.length === 0) return farver.dim("No commits found");
817
+ const maxCommitsToShow = 10;
818
+ const commitsToShow = commits.slice(0, maxCommitsToShow);
819
+ const hasMore = commits.length > maxCommitsToShow;
820
+ const typeLength = commits.map(({ type }) => type.length).reduce((a, b) => Math.max(a, b), 0);
821
+ const scopeLength = commits.map(({ scope }) => scope.length).reduce((a, b) => Math.max(a, b), 0);
822
+ const formattedCommits = commitsToShow.map((commit) => {
823
+ let color = messageColorMap[commit.type] || ((c) => c);
824
+ if (commit.isBreaking) color = (s) => farver.inverse.red(s);
825
+ const paddedType = commit.type.padStart(typeLength + 1, " ");
826
+ const paddedScope = !commit.scope ? " ".repeat(scopeLength ? scopeLength + 2 : 0) : farver.dim("(") + commit.scope + farver.dim(")") + " ".repeat(scopeLength - commit.scope.length);
827
+ return [
828
+ farver.dim(commit.shortHash),
829
+ " ",
830
+ color === farver.gray ? color(paddedType) : farver.bold(color(paddedType)),
831
+ " ",
832
+ paddedScope,
833
+ farver.dim(":"),
834
+ " ",
835
+ color === farver.gray ? color(commit.description) : commit.description
836
+ ].join("");
837
+ }).join("\n");
838
+ if (hasMore) return `${formattedCommits}\n ${farver.dim(`... and ${commits.length - maxCommitsToShow} more commits`)}`;
839
+ return formattedCommits;
840
+ }
841
+ /**
842
+ * Calculate version updates for packages based on their commits
843
+ */
844
+ async function calculateVersionUpdates({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage }) {
845
+ const versionUpdates = [];
846
+ const processedPackages = /* @__PURE__ */ new Set();
847
+ logger.verbose(`Starting version inference for ${packageCommits.size} packages with commits`);
848
+ for (const [pkgName, pkgCommits] of packageCommits) {
849
+ const pkg = workspacePackages.find((p) => p.name === pkgName);
850
+ if (!pkg) {
851
+ logger.error(`Package ${pkgName} not found in workspace packages, skipping`);
852
+ continue;
853
+ }
854
+ processedPackages.add(pkgName);
855
+ const globalCommits = globalCommitsPerPackage.get(pkgName) || [];
856
+ if (globalCommits.length > 0) logger.verbose(` - Global commits for this package: ${globalCommits.length}`);
857
+ const allCommitsForPackage = [...pkgCommits, ...globalCommits];
858
+ const bump = determineHighestBump(allCommitsForPackage);
859
+ if (bump === "none") continue;
860
+ let newVersion = getNextVersion(pkg.version, bump);
861
+ if (!isCI && showPrompt) {
862
+ logger.section("📝 Commits affecting this package");
863
+ formatCommitsForDisplay(allCommitsForPackage).split("\n").forEach((line) => logger.item(line));
864
+ logger.emptyLine();
865
+ const selectedVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, newVersion);
866
+ if (selectedVersion === null) continue;
867
+ newVersion = selectedVersion;
868
+ }
869
+ logger.item(`Version update: ${pkg.version} → ${newVersion}`);
870
+ versionUpdates.push({
871
+ package: pkg,
872
+ currentVersion: pkg.version,
873
+ newVersion,
874
+ bumpType: bump,
875
+ hasDirectChanges: true
876
+ });
877
+ }
878
+ if (!isCI && showPrompt) for (const pkg of workspacePackages) {
879
+ if (processedPackages.has(pkg.name)) continue;
880
+ logger.section(`📦 Package: ${pkg.name}`);
881
+ logger.item("No direct commits found");
882
+ const newVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, pkg.version);
883
+ if (newVersion === null) break;
884
+ if (newVersion !== pkg.version) {
885
+ const bumpType = _calculateBumpType(pkg.version, newVersion);
886
+ versionUpdates.push({
887
+ package: pkg,
888
+ currentVersion: pkg.version,
889
+ newVersion,
890
+ bumpType,
891
+ hasDirectChanges: false
892
+ });
893
+ }
894
+ }
895
+ return versionUpdates;
896
+ }
897
+ /**
898
+ * Calculate version updates and prepare dependent updates
899
+ * Returns both the updates and a function to apply them
747
900
  */
748
- async function updateAllPackageJsonFiles(updates) {
749
- await Promise.all(updates.map(async (update) => {
750
- const depUpdates = getDependencyUpdates(update.package, updates);
751
- await updatePackageJson(update.package, update.newVersion, depUpdates);
752
- }));
901
+ async function calculateAndPrepareVersionUpdates({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage }) {
902
+ const directUpdates = await calculateVersionUpdates({
903
+ workspacePackages,
904
+ packageCommits,
905
+ workspaceRoot,
906
+ showPrompt,
907
+ globalCommitsPerPackage
908
+ });
909
+ const allUpdates = createDependentUpdates(buildPackageDependencyGraph(workspacePackages), workspacePackages, directUpdates);
910
+ const applyUpdates = async () => {
911
+ await Promise.all(allUpdates.map(async (update) => {
912
+ const depUpdates = getDependencyUpdates(update.package, allUpdates);
913
+ await updatePackageJson(update.package, update.newVersion, depUpdates);
914
+ }));
915
+ };
916
+ return {
917
+ allUpdates,
918
+ applyUpdates
919
+ };
920
+ }
921
+ async function updatePackageJson(pkg, newVersion, dependencyUpdates) {
922
+ const packageJsonPath = join(pkg.path, "package.json");
923
+ const content = await readFile(packageJsonPath, "utf-8");
924
+ const packageJson = JSON.parse(content);
925
+ packageJson.version = newVersion;
926
+ for (const [depName, depVersion] of dependencyUpdates) {
927
+ if (packageJson.dependencies?.[depName]) {
928
+ const oldVersion = packageJson.dependencies[depName];
929
+ if (oldVersion === "workspace:*") {
930
+ logger.verbose(` - Skipping workspace:* dependency: ${depName}`);
931
+ continue;
932
+ }
933
+ packageJson.dependencies[depName] = `^${depVersion}`;
934
+ logger.verbose(` - Updated dependency ${depName}: ${oldVersion} → ^${depVersion}`);
935
+ }
936
+ if (packageJson.devDependencies?.[depName]) {
937
+ const oldVersion = packageJson.devDependencies[depName];
938
+ if (oldVersion === "workspace:*") {
939
+ logger.verbose(` - Skipping workspace:* devDependency: ${depName}`);
940
+ continue;
941
+ }
942
+ packageJson.devDependencies[depName] = `^${depVersion}`;
943
+ logger.verbose(` - Updated devDependency ${depName}: ${oldVersion} → ^${depVersion}`);
944
+ }
945
+ if (packageJson.peerDependencies?.[depName]) {
946
+ const oldVersion = packageJson.peerDependencies[depName];
947
+ if (oldVersion === "workspace:*") {
948
+ logger.verbose(` - Skipping workspace:* peerDependency: ${depName}`);
949
+ continue;
950
+ }
951
+ const majorVersion = depVersion.split(".")[0];
952
+ packageJson.peerDependencies[depName] = `>=${depVersion} <${Number(majorVersion) + 1}.0.0`;
953
+ logger.verbose(` - Updated peerDependency ${depName}: ${oldVersion} → ^${depVersion}`);
954
+ }
955
+ }
956
+ await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
957
+ logger.verbose(` - Successfully wrote updated package.json`);
958
+ }
959
+ /**
960
+ * Get all dependency updates needed for a package
961
+ */
962
+ function getDependencyUpdates(pkg, allUpdates) {
963
+ const updates = /* @__PURE__ */ new Map();
964
+ const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
965
+ for (const dep of allDeps) {
966
+ const update = allUpdates.find((u) => u.package.name === dep);
967
+ if (update) {
968
+ logger.verbose(` - Dependency ${dep} will be updated: ${update.currentVersion} → ${update.newVersion} (${update.bumpType})`);
969
+ updates.set(dep, update.newVersion);
970
+ }
971
+ }
972
+ if (updates.size === 0) logger.verbose(` - No dependency updates needed`);
973
+ return updates;
753
974
  }
754
975
 
755
976
  //#endregion
756
- //#region src/workspace.ts
977
+ //#region src/core/prompts.ts
978
+ async function selectPackagePrompt(packages) {
979
+ const response = await prompts({
980
+ type: "multiselect",
981
+ name: "selectedPackages",
982
+ message: "Select packages to release",
983
+ choices: packages.map((pkg) => ({
984
+ title: `${pkg.name} (${farver.bold(pkg.version)})`,
985
+ value: pkg.name,
986
+ selected: true
987
+ })),
988
+ min: 1,
989
+ hint: "Space to select/deselect. Return to submit.",
990
+ instructions: false
991
+ });
992
+ if (!response.selectedPackages || response.selectedPackages.length === 0) return [];
993
+ return response.selectedPackages;
994
+ }
995
+ async function selectVersionPrompt(workspaceRoot, pkg, currentVersion, suggestedVersion) {
996
+ const answers = await prompts([{
997
+ type: "autocomplete",
998
+ name: "version",
999
+ message: `${pkg.name}: ${farver.green(pkg.version)}`,
1000
+ choices: [
1001
+ {
1002
+ value: "skip",
1003
+ title: `skip ${farver.dim("(no change)")}`
1004
+ },
1005
+ {
1006
+ value: "major",
1007
+ title: `major ${farver.bold(getNextVersion(pkg.version, "major"))}`
1008
+ },
1009
+ {
1010
+ value: "minor",
1011
+ title: `minor ${farver.bold(getNextVersion(pkg.version, "minor"))}`
1012
+ },
1013
+ {
1014
+ value: "patch",
1015
+ title: `patch ${farver.bold(getNextVersion(pkg.version, "patch"))}`
1016
+ },
1017
+ {
1018
+ value: "suggested",
1019
+ title: `suggested ${farver.bold(suggestedVersion)}`
1020
+ },
1021
+ {
1022
+ value: "custom",
1023
+ title: "custom"
1024
+ }
1025
+ ],
1026
+ initial: suggestedVersion === currentVersion ? 0 : 4
1027
+ }, {
1028
+ type: (prev) => prev === "custom" ? "text" : null,
1029
+ name: "custom",
1030
+ message: "Enter the new version number:",
1031
+ initial: suggestedVersion,
1032
+ validate: (custom) => {
1033
+ if (isValidSemver(custom)) return true;
1034
+ return "That's not a valid version number";
1035
+ }
1036
+ }]);
1037
+ if (!answers.version) return null;
1038
+ if (answers.version === "skip") return null;
1039
+ else if (answers.version === "suggested") return suggestedVersion;
1040
+ else if (answers.version === "custom") {
1041
+ if (!answers.custom) return null;
1042
+ return answers.custom;
1043
+ } else return getNextVersion(pkg.version, answers.version);
1044
+ }
1045
+
1046
+ //#endregion
1047
+ //#region src/core/workspace.ts
757
1048
  async function discoverWorkspacePackages(workspaceRoot, options) {
758
1049
  let workspaceOptions;
759
1050
  let explicitPackages;
@@ -835,88 +1126,153 @@ function shouldIncludePackage(pkg, options) {
835
1126
  //#endregion
836
1127
  //#region src/release.ts
837
1128
  async function release(options) {
838
- const normalizedOptions = normalizeSharedOptions(options);
839
- normalizedOptions.dryRun ??= false;
840
- normalizedOptions.branch ??= {};
841
- normalizedOptions.branch.release ??= "release/next";
842
- normalizedOptions.branch.default = await getDefaultBranch();
843
- normalizedOptions.safeguards ??= true;
844
- globalOptions.dryRun = normalizedOptions.dryRun;
845
- const workspaceRoot = normalizedOptions.workspaceRoot;
1129
+ const { workspaceRoot,...normalizedOptions } = await normalizeReleaseOptions(options);
846
1130
  if (normalizedOptions.safeguards && !await isWorkingDirectoryClean(workspaceRoot)) exitWithError("Working directory is not clean. Please commit or stash your changes before proceeding.");
847
1131
  const workspacePackages = await discoverWorkspacePackages(workspaceRoot, options);
848
1132
  if (workspacePackages.length === 0) {
849
- logger.log("No packages found to release.");
1133
+ logger.warn("No packages found to release");
850
1134
  return null;
851
1135
  }
852
- const versionUpdates = await inferVersionUpdates(workspacePackages, await getWorkspacePackageCommits(workspaceRoot, workspacePackages), workspaceRoot, options.prompts?.versions !== false);
853
- if (versionUpdates.length === 0) logger.warn("No packages have changes requiring a release");
854
- const allUpdates = createDependentUpdates(buildPackageDependencyGraph(workspacePackages), workspacePackages, versionUpdates);
855
- const currentBranch = await getCurrentBranch(workspaceRoot);
856
- if (currentBranch !== normalizedOptions.branch.default) exitWithError(`Current branch is '${currentBranch}'. Please switch to the default branch '${normalizedOptions.branch.default}' before proceeding.`, `git checkout ${normalizedOptions.branch.default}`);
857
- const existingPullRequest = await getExistingPullRequest({
858
- owner: normalizedOptions.owner,
859
- repo: normalizedOptions.repo,
860
- branch: normalizedOptions.branch.release,
861
- githubToken: normalizedOptions.githubToken
862
- });
863
- const prExists = !!existingPullRequest;
864
- if (prExists) logger.log("Existing pull request found:", existingPullRequest.html_url);
865
- else logger.log("No existing pull request found, will create new one");
866
- const branchExists = await doesBranchExist(normalizedOptions.branch.release, workspaceRoot);
867
- if (!branchExists) {
868
- logger.log("Creating release branch:", normalizedOptions.branch.release);
869
- await createBranch(normalizedOptions.branch.release, normalizedOptions.branch.default, workspaceRoot);
870
- }
871
- if (!await checkoutBranch(normalizedOptions.branch.release, workspaceRoot)) throw new Error(`Failed to checkout branch: ${normalizedOptions.branch.release}`);
872
- if (branchExists) {
873
- logger.log("Pulling latest changes from remote");
874
- if (!await pullLatestChanges(normalizedOptions.branch.release, workspaceRoot)) logger.log("Warning: Failed to pull latest changes, continuing anyway");
875
- }
876
- logger.log("Rebasing release branch onto", normalizedOptions.branch.default);
877
- await rebaseBranch(normalizedOptions.branch.default, workspaceRoot);
878
- await updateAllPackageJsonFiles(allUpdates);
879
- const hasCommitted = await commitChanges("chore: update release versions", workspaceRoot);
880
- const isBranchAhead = await isBranchAheadOfRemote(normalizedOptions.branch.release, workspaceRoot);
881
- if (!hasCommitted && !isBranchAhead) {
882
- logger.log("No changes to commit and branch is in sync with remote");
883
- await checkoutBranch(normalizedOptions.branch.default, workspaceRoot);
884
- if (prExists) {
885
- logger.log("No updates needed, PR is already up to date");
886
- return {
887
- updates: allUpdates,
888
- prUrl: existingPullRequest.html_url,
889
- created: false
890
- };
891
- } else {
892
- logger.error("No changes to commit, and no existing PR. Nothing to do.");
893
- return null;
894
- }
1136
+ logger.section("📦 Workspace Packages");
1137
+ logger.item(`Found ${workspacePackages.length} packages`);
1138
+ for (const pkg of workspacePackages) {
1139
+ logger.item(`${farver.cyan(pkg.name)} (${farver.bold(pkg.version)})`);
1140
+ logger.item(` ${farver.gray("→")} ${farver.gray(pkg.path)}`);
895
1141
  }
896
- logger.log("Pushing changes to remote");
897
- await pushBranch(normalizedOptions.branch.release, workspaceRoot, { forceWithLease: true });
898
- const prTitle = existingPullRequest?.title || options.pullRequest?.title || "chore: update package versions";
899
- const prBody = generatePullRequestBody(allUpdates, options.pullRequest?.body);
900
- const pullRequest = await upsertPullRequest({
1142
+ logger.emptyLine();
1143
+ const packageCommits = await getWorkspacePackageCommits(workspaceRoot, workspacePackages);
1144
+ const globalCommitsPerPackage = await getGlobalCommitsPerPackage(workspaceRoot, packageCommits, workspacePackages, normalizedOptions.globalCommitMode);
1145
+ const { allUpdates, applyUpdates } = await calculateAndPrepareVersionUpdates({
1146
+ workspacePackages,
1147
+ packageCommits,
1148
+ workspaceRoot,
1149
+ showPrompt: options.prompts?.versions !== false,
1150
+ globalCommitsPerPackage
1151
+ });
1152
+ if (allUpdates.filter((u) => u.hasDirectChanges).length === 0) logger.warn("No packages have changes requiring a release");
1153
+ logger.section("🔄 Version Updates");
1154
+ logger.item(`Updating ${allUpdates.length} packages (including dependents)`);
1155
+ for (const update of allUpdates) logger.item(`${update.package.name}: ${update.currentVersion} → ${update.newVersion}`);
1156
+ const prOps = await orchestrateReleasePullRequest({
1157
+ workspaceRoot,
901
1158
  owner: normalizedOptions.owner,
902
1159
  repo: normalizedOptions.repo,
903
- pullNumber: existingPullRequest?.number,
904
- title: prTitle,
905
- body: prBody,
906
- head: normalizedOptions.branch.release,
907
- base: normalizedOptions.branch.default,
908
- githubToken: normalizedOptions.githubToken
1160
+ githubToken: normalizedOptions.githubToken,
1161
+ releaseBranch: normalizedOptions.branch.release,
1162
+ defaultBranch: normalizedOptions.branch.default,
1163
+ pullRequestTitle: options.pullRequest?.title,
1164
+ pullRequestBody: options.pullRequest?.body
909
1165
  });
910
- logger.log(prExists ? "Updated pull request:" : "Created pull request:", pullRequest?.html_url);
911
- await checkoutBranch(normalizedOptions.branch.default, workspaceRoot);
1166
+ await prOps.prepareBranch();
1167
+ await applyUpdates();
1168
+ if (!await prOps.commitAndPush(true)) if (prOps.doesReleasePRExist && prOps.existingPullRequest) {
1169
+ logger.item("No updates needed, PR is already up to date");
1170
+ return {
1171
+ updates: allUpdates,
1172
+ prUrl: prOps.existingPullRequest.html_url,
1173
+ created: false
1174
+ };
1175
+ } else {
1176
+ logger.error("No changes to commit, and no existing PR. Nothing to do.");
1177
+ return null;
1178
+ }
1179
+ const { pullRequest, created } = await prOps.createOrUpdatePullRequest(allUpdates);
1180
+ await prOps.checkoutDefaultBranch();
912
1181
  if (pullRequest?.html_url) {
913
- logger.info();
914
- logger.info(`${farver.green("✓")} Pull request ${prExists ? "updated" : "created"}: ${farver.cyan(pullRequest.html_url)}`);
1182
+ logger.section("🚀 Pull Request");
1183
+ logger.success(`Pull request ${created ? "created" : "updated"}: ${pullRequest.html_url}`);
915
1184
  }
916
1185
  return {
917
1186
  updates: allUpdates,
918
1187
  prUrl: pullRequest?.html_url,
919
- created: !prExists
1188
+ created
1189
+ };
1190
+ }
1191
+ async function normalizeReleaseOptions(options) {
1192
+ const normalized = normalizeSharedOptions(options);
1193
+ let defaultBranch = options.branch?.default?.trim();
1194
+ const releaseBranch = options.branch?.release?.trim() ?? "release/next";
1195
+ if (defaultBranch == null || defaultBranch === "") {
1196
+ defaultBranch = await getDefaultBranch(normalized.workspaceRoot);
1197
+ if (!defaultBranch) exitWithError("Could not determine default branch", "Please specify the default branch in options");
1198
+ }
1199
+ if (defaultBranch === releaseBranch) exitWithError(`Default branch and release branch cannot be the same: "${defaultBranch}"`, "Specify different branches for default and release");
1200
+ const availableBranches = await getAvailableBranches(normalized.workspaceRoot);
1201
+ if (!availableBranches.includes(defaultBranch)) exitWithError(`Default branch "${defaultBranch}" does not exist in the repository`, `Available branches: ${availableBranches.join(", ")}`);
1202
+ logger.verbose(`Using default branch: ${farver.green(defaultBranch)}`);
1203
+ return {
1204
+ ...normalized,
1205
+ branch: {
1206
+ release: releaseBranch,
1207
+ default: defaultBranch
1208
+ },
1209
+ safeguards: options.safeguards ?? true,
1210
+ globalCommitMode: options.globalCommitMode ?? "dependencies",
1211
+ pullRequest: options.pullRequest,
1212
+ changelog: { enabled: options.changelog?.enabled ?? true }
1213
+ };
1214
+ }
1215
+ async function orchestrateReleasePullRequest({ workspaceRoot, owner, repo, githubToken, releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody }) {
1216
+ const currentBranch = await getCurrentBranch(workspaceRoot);
1217
+ if (currentBranch !== defaultBranch) exitWithError(`Current branch is '${currentBranch}'. Please switch to the default branch '${defaultBranch}' before proceeding.`, `git checkout ${defaultBranch}`);
1218
+ const existingPullRequest = await getExistingPullRequest({
1219
+ owner,
1220
+ repo,
1221
+ branch: releaseBranch,
1222
+ githubToken
1223
+ });
1224
+ const doesReleasePRExist = !!existingPullRequest;
1225
+ if (doesReleasePRExist) logger.item("Found existing release pull request");
1226
+ else logger.item("Will create new pull request");
1227
+ const branchExists = await doesBranchExist(releaseBranch, workspaceRoot);
1228
+ return {
1229
+ existingPullRequest,
1230
+ doesReleasePRExist,
1231
+ prepareBranch: async () => {
1232
+ if (!branchExists) await createBranch(releaseBranch, defaultBranch, workspaceRoot);
1233
+ logger.step(`Checking out release branch: ${releaseBranch}`);
1234
+ if (!await checkoutBranch(releaseBranch, workspaceRoot)) throw new Error(`Failed to checkout branch: ${releaseBranch}`);
1235
+ if (branchExists) {
1236
+ logger.step("Pulling latest changes from remote");
1237
+ if (!await pullLatestChanges(releaseBranch, workspaceRoot)) logger.warn("Failed to pull latest changes, continuing anyway");
1238
+ }
1239
+ logger.step(`Rebasing onto ${defaultBranch}`);
1240
+ if (!await rebaseBranch(defaultBranch, workspaceRoot)) throw new Error(`Failed to rebase onto ${defaultBranch}. Please resolve conflicts manually.`);
1241
+ },
1242
+ commitAndPush: async (hasChanges) => {
1243
+ const hasCommitted = hasChanges ? await commitChanges("chore: update release versions", workspaceRoot) : false;
1244
+ const isBranchAhead = await isBranchAheadOfRemote(releaseBranch, workspaceRoot);
1245
+ if (!hasCommitted && !isBranchAhead) {
1246
+ logger.item("No changes to commit and branch is in sync with remote");
1247
+ await checkoutBranch(defaultBranch, workspaceRoot);
1248
+ return false;
1249
+ }
1250
+ logger.step("Pushing changes to remote");
1251
+ if (!await pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true })) throw new Error(`Failed to push changes to ${releaseBranch}. Remote may have been updated.`);
1252
+ return true;
1253
+ },
1254
+ createOrUpdatePullRequest: async (updates) => {
1255
+ const prTitle = existingPullRequest?.title || pullRequestTitle || "chore: update package versions";
1256
+ const prBody = pullRequestBody || generatePullRequestBody(updates);
1257
+ const pullRequest = await upsertPullRequest({
1258
+ owner,
1259
+ repo,
1260
+ pullNumber: existingPullRequest?.number,
1261
+ title: prTitle,
1262
+ body: prBody,
1263
+ head: releaseBranch,
1264
+ base: defaultBranch,
1265
+ githubToken
1266
+ });
1267
+ logger.success(`${doesReleasePRExist ? "Updated" : "Created"} pull request: ${pullRequest?.html_url}`);
1268
+ return {
1269
+ pullRequest,
1270
+ created: !doesReleasePRExist
1271
+ };
1272
+ },
1273
+ checkoutDefaultBranch: async () => {
1274
+ await checkoutBranch(defaultBranch, workspaceRoot);
1275
+ }
920
1276
  };
921
1277
  }
922
1278