@bensandee/tooling 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/bin.mjs +340 -89
  2. package/package.json +2 -2
package/dist/bin.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { defineCommand, runMain } from "citty";
3
3
  import * as p from "@clack/prompts";
4
4
  import path from "node:path";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { parse } from "jsonc-parser";
7
7
  import { z } from "zod";
8
8
  import { execSync } from "node:child_process";
@@ -395,17 +395,35 @@ function writeFile(targetDir, relativePath, content) {
395
395
  }
396
396
  /**
397
397
  * Create a GeneratorContext from a ProjectConfig and a conflict resolution handler.
398
+ * Returns the context and a list of files that were auto-archived before overwriting.
398
399
  */
399
400
  function createContext(config, confirmOverwrite) {
401
+ const archivedFiles = [];
400
402
  const pkgRaw = readFile(config.targetDir, "package.json");
401
403
  return {
402
- config,
403
- targetDir: config.targetDir,
404
- packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
405
- exists: (rel) => fileExists(config.targetDir, rel),
406
- read: (rel) => readFile(config.targetDir, rel),
407
- write: (rel, content) => writeFile(config.targetDir, rel, content),
408
- confirmOverwrite
404
+ ctx: {
405
+ config,
406
+ targetDir: config.targetDir,
407
+ packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
408
+ exists: (rel) => fileExists(config.targetDir, rel),
409
+ read: (rel) => readFile(config.targetDir, rel),
410
+ write: (rel, content) => {
411
+ if (!rel.startsWith(".tooling-archived/")) {
412
+ const existing = readFile(config.targetDir, rel);
413
+ if (existing !== void 0 && existing !== content) {
414
+ writeFile(config.targetDir, `.tooling-archived/${rel}`, existing);
415
+ archivedFiles.push(rel);
416
+ }
417
+ }
418
+ writeFile(config.targetDir, rel, content);
419
+ },
420
+ remove: (rel) => {
421
+ const fullPath = path.join(config.targetDir, rel);
422
+ if (existsSync(fullPath)) rmSync(fullPath);
423
+ },
424
+ confirmOverwrite
425
+ },
426
+ archivedFiles
409
427
  };
410
428
  }
411
429
 
@@ -419,7 +437,7 @@ const STANDARD_SCRIPTS_SINGLE = {
419
437
  lint: "oxlint",
420
438
  knip: "knip",
421
439
  check: "pnpm typecheck && pnpm build && pnpm lint && pnpm knip",
422
- prepare: "husky"
440
+ prepare: "lefthook install"
423
441
  };
424
442
  const STANDARD_SCRIPTS_MONOREPO = {
425
443
  build: "pnpm -r build",
@@ -428,7 +446,7 @@ const STANDARD_SCRIPTS_MONOREPO = {
428
446
  lint: "oxlint",
429
447
  knip: "knip",
430
448
  check: "pnpm typecheck && pnpm build && pnpm lint && pnpm knip",
431
- prepare: "husky"
449
+ prepare: "lefthook install"
432
450
  };
433
451
  /** DevDeps that belong in every project (single repo) or per-package (monorepo). */
434
452
  const PER_PACKAGE_DEV_DEPS = {
@@ -439,9 +457,8 @@ const PER_PACKAGE_DEV_DEPS = {
439
457
  };
440
458
  /** DevDeps that belong at the root regardless of structure. */
441
459
  const ROOT_DEV_DEPS = {
442
- husky: "9.1.7",
443
460
  knip: "5.85.0",
444
- "lint-staged": "16.3.1",
461
+ lefthook: "2.1.2",
445
462
  oxlint: "1.50.0"
446
463
  };
447
464
  /**
@@ -590,6 +607,7 @@ function generateMigratePrompt(results, config, detected) {
590
607
  const created = results.filter((r) => r.action === "created");
591
608
  const updated = results.filter((r) => r.action === "updated");
592
609
  const skipped = results.filter((r) => r.action === "skipped");
610
+ const archived = results.filter((r) => r.action === "archived");
593
611
  if (created.length > 0) {
594
612
  sections.push("**Created:**");
595
613
  for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
@@ -600,6 +618,11 @@ function generateMigratePrompt(results, config, detected) {
600
618
  for (const r of updated) sections.push(`- \`${r.filePath}\` — ${r.description}`);
601
619
  sections.push("");
602
620
  }
621
+ if (archived.length > 0) {
622
+ sections.push("**Archived:**");
623
+ for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
624
+ sections.push("");
625
+ }
603
626
  if (skipped.length > 0) {
604
627
  sections.push("**Skipped (review these):**");
605
628
  for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
@@ -626,6 +649,18 @@ function generateMigratePrompt(results, config, detected) {
626
649
  }
627
650
  sections.push("");
628
651
  }
652
+ if (archived.length > 0) {
653
+ sections.push("### Review archived files");
654
+ sections.push("");
655
+ sections.push("The following files were modified or replaced. The originals have been saved to `.tooling-archived/`:");
656
+ sections.push("");
657
+ for (const r of archived) sections.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
658
+ sections.push("");
659
+ sections.push("1. Review the archived files for any custom configuration that should be preserved in the new files");
660
+ sections.push("2. If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`");
661
+ sections.push("3. Delete the `.tooling-archived/` directory when migration is complete");
662
+ sections.push("");
663
+ }
629
664
  const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
630
665
  if (detected.hasLegacyOxlintJson) {
631
666
  sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
@@ -1032,7 +1067,8 @@ const STANDARD_ENTRIES = [
1032
1067
  ".env",
1033
1068
  ".env.*",
1034
1069
  "!.env.example",
1035
- ".tooling-migrate.md"
1070
+ ".tooling-migrate.md",
1071
+ ".tooling-archived/"
1036
1072
  ];
1037
1073
  /** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
1038
1074
  function normalizeEntry(entry) {
@@ -1306,6 +1342,8 @@ function buildSettings(ctx) {
1306
1342
  `Bash(${pm} view *)`,
1307
1343
  `Bash(${pm} list)`,
1308
1344
  `Bash(${pm} list *)`,
1345
+ `Bash(${pm} ls)`,
1346
+ `Bash(${pm} ls *)`,
1309
1347
  "Bash(npm view *)",
1310
1348
  "Bash(npm info *)",
1311
1349
  "Bash(npm show *)",
@@ -1345,6 +1383,8 @@ function buildSettings(ctx) {
1345
1383
  "Bash(head *)",
1346
1384
  "Bash(tail *)",
1347
1385
  "Bash(wc *)",
1386
+ "Bash(test *)",
1387
+ "Bash([ *)",
1348
1388
  "Bash(find *)",
1349
1389
  "Bash(which *)",
1350
1390
  "Bash(node -e *)",
@@ -1677,12 +1717,21 @@ async function generateReleaseCi(ctx) {
1677
1717
  }
1678
1718
 
1679
1719
  //#endregion
1680
- //#region src/generators/lint-staged.ts
1720
+ //#region src/generators/lefthook.ts
1681
1721
  function buildConfig(formatter) {
1682
- return `export default {\n "*": "${formatter === "prettier" ? "prettier --write" : "oxfmt --no-error-on-unmatched-pattern"}",\n};\n`;
1683
- }
1684
- const HUSKY_PRE_COMMIT = "pnpm exec lint-staged\n";
1685
- /** All known lint-staged config file locations, in priority order. */
1722
+ return [
1723
+ "pre-commit:",
1724
+ " commands:",
1725
+ " lint:",
1726
+ " run: pnpm exec oxlint {staged_files}",
1727
+ " format:",
1728
+ ` run: ${formatter === "prettier" ? "pnpm exec prettier --write {staged_files}" : "pnpm exec oxfmt --no-error-on-unmatched-pattern {staged_files}"}`,
1729
+ " stage_fixed: true",
1730
+ ""
1731
+ ].join("\n");
1732
+ }
1733
+ const ARCHIVE_DIR = ".tooling-archived";
1734
+ /** All known lint-staged config file locations to archive. */
1686
1735
  const LINT_STAGED_CONFIG_PATHS = [
1687
1736
  "lint-staged.config.mjs",
1688
1737
  "lint-staged.config.js",
@@ -1694,37 +1743,71 @@ const LINT_STAGED_CONFIG_PATHS = [
1694
1743
  ".lintstagedrc.mjs",
1695
1744
  ".lintstagedrc.cjs"
1696
1745
  ];
1697
- async function generateLintStaged(ctx) {
1698
- const filePath = "lint-staged.config.mjs";
1699
- const huskyPath = ".husky/pre-commit";
1746
+ /** All known lefthook config file locations, in priority order. */
1747
+ const LEFTHOOK_CONFIG_PATHS = ["lefthook.yml", ".lefthook.yml"];
1748
+ async function generateLefthook(ctx) {
1749
+ const filePath = "lefthook.yml";
1700
1750
  const content = buildConfig(ctx.config.formatter);
1701
- if (ctx.read(huskyPath) !== HUSKY_PRE_COMMIT) ctx.write(huskyPath, HUSKY_PRE_COMMIT);
1702
- const existingPath = LINT_STAGED_CONFIG_PATHS.find((p) => ctx.exists(p));
1751
+ const results = [];
1752
+ const huskyPath = ".husky/pre-commit";
1753
+ const existingHusky = ctx.read(huskyPath);
1754
+ if (existingHusky !== void 0) {
1755
+ ctx.write(`${ARCHIVE_DIR}/${huskyPath}`, existingHusky);
1756
+ ctx.remove(huskyPath);
1757
+ results.push({
1758
+ filePath: huskyPath,
1759
+ action: "archived",
1760
+ description: `Moved to ${ARCHIVE_DIR}/${huskyPath}`
1761
+ });
1762
+ }
1763
+ for (const lsPath of LINT_STAGED_CONFIG_PATHS) {
1764
+ const existing = ctx.read(lsPath);
1765
+ if (existing !== void 0) {
1766
+ ctx.write(`${ARCHIVE_DIR}/${lsPath}`, existing);
1767
+ ctx.remove(lsPath);
1768
+ results.push({
1769
+ filePath: lsPath,
1770
+ action: "archived",
1771
+ description: `Moved to ${ARCHIVE_DIR}/${lsPath}`
1772
+ });
1773
+ }
1774
+ }
1775
+ const existingPath = LEFTHOOK_CONFIG_PATHS.find((p) => ctx.exists(p));
1703
1776
  if (existingPath === filePath) {
1704
1777
  const existing = ctx.read(filePath);
1705
1778
  if (existing) {
1706
- if (existing === content) return {
1707
- filePath,
1708
- action: "skipped",
1709
- description: "Already configured"
1710
- };
1711
- if (await ctx.confirmOverwrite(filePath) === "skip") return {
1712
- filePath,
1713
- action: "skipped",
1714
- description: "Existing lint-staged config preserved"
1715
- };
1779
+ if (existing === content) {
1780
+ results.push({
1781
+ filePath,
1782
+ action: "skipped",
1783
+ description: "Already configured"
1784
+ });
1785
+ return results;
1786
+ }
1787
+ if (await ctx.confirmOverwrite(filePath) === "skip") {
1788
+ results.push({
1789
+ filePath,
1790
+ action: "skipped",
1791
+ description: "Existing lefthook config preserved"
1792
+ });
1793
+ return results;
1794
+ }
1716
1795
  }
1717
- } else if (existingPath) return {
1718
- filePath: existingPath,
1719
- action: "skipped",
1720
- description: `Existing config found at ${existingPath}`
1721
- };
1796
+ } else if (existingPath) {
1797
+ results.push({
1798
+ filePath: existingPath,
1799
+ action: "skipped",
1800
+ description: `Existing config found at ${existingPath}`
1801
+ });
1802
+ return results;
1803
+ }
1722
1804
  ctx.write(filePath, content);
1723
- return {
1805
+ results.push({
1724
1806
  filePath,
1725
1807
  action: existingPath === filePath ? "updated" : "created",
1726
- description: "Generated lint-staged config and husky pre-commit hook"
1727
- };
1808
+ description: "Generated lefthook pre-commit config"
1809
+ });
1810
+ return results;
1728
1811
  }
1729
1812
 
1730
1813
  //#endregion
@@ -1768,7 +1851,7 @@ const initCommand = defineCommand({
1768
1851
  });
1769
1852
  async function runInit(config, options = {}) {
1770
1853
  const detected = detectProject(config.targetDir);
1771
- const ctx = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
1854
+ const { ctx, archivedFiles } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
1772
1855
  const result = await p.select({
1773
1856
  message: `${relativePath} already exists. What do you want to do?`,
1774
1857
  options: [{
@@ -1791,7 +1874,7 @@ async function runInit(config, options = {}) {
1791
1874
  results.push(await generateTsdown(ctx));
1792
1875
  results.push(await generateOxlint(ctx));
1793
1876
  results.push(await generateFormatter(ctx));
1794
- results.push(await generateLintStaged(ctx));
1877
+ results.push(...await generateLefthook(ctx));
1795
1878
  results.push(await generateGitignore(ctx));
1796
1879
  results.push(await generateKnip(ctx));
1797
1880
  results.push(await generateRenovate(ctx));
@@ -1802,13 +1885,21 @@ async function runInit(config, options = {}) {
1802
1885
  results.push(await generateReleaseCi(ctx));
1803
1886
  const vitestResults = await generateVitest(ctx);
1804
1887
  results.push(...vitestResults);
1888
+ const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
1889
+ for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
1890
+ filePath: rel,
1891
+ action: "archived",
1892
+ description: `Original saved to .tooling-archived/${rel}`
1893
+ });
1805
1894
  s.stop("Done!");
1806
1895
  const created = results.filter((r) => r.action === "created");
1807
1896
  const updated = results.filter((r) => r.action === "updated");
1808
1897
  const skipped = results.filter((r) => r.action === "skipped");
1898
+ const archived = results.filter((r) => r.action === "archived");
1809
1899
  const summaryLines = [];
1810
1900
  if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
1811
1901
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
1902
+ if (archived.length > 0) summaryLines.push(`Archived: ${archived.map((r) => r.filePath).join(", ")}`);
1812
1903
  if (skipped.length > 0) summaryLines.push(`Skipped: ${skipped.map((r) => r.filePath).join(", ")}`);
1813
1904
  p.note(summaryLines.join("\n"), "Summary");
1814
1905
  if (!options.noPrompt) {
@@ -1948,10 +2039,6 @@ function createRealExecutor() {
1948
2039
  }
1949
2040
  };
1950
2041
  }
1951
- /** Check whether there are pending changeset files. */
1952
- function hasChangesets(executor, cwd) {
1953
- return executor.listChangesetFiles(cwd).length > 0;
1954
- }
1955
2042
  /** Parse "New tag:" lines from changeset publish output. */
1956
2043
  function parseNewTags(output) {
1957
2044
  const tags = [];
@@ -2033,6 +2120,22 @@ async function updatePr(executor, conn, prNumber, options) {
2033
2120
  });
2034
2121
  if (!res.ok) throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
2035
2122
  }
2123
+ /** Merge a pull request by number. */
2124
+ async function mergePr(executor, conn, prNumber, options) {
2125
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}/merge`;
2126
+ const res = await executor.fetch(url, {
2127
+ method: "POST",
2128
+ headers: {
2129
+ Authorization: `token ${conn.token}`,
2130
+ "Content-Type": "application/json"
2131
+ },
2132
+ body: JSON.stringify({
2133
+ Do: options?.method ?? "merge",
2134
+ delete_branch_after_merge: options?.deleteBranch ?? true
2135
+ })
2136
+ });
2137
+ if (!res.ok) throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
2138
+ }
2036
2139
  /** Check whether a Forgejo release already exists for a given tag. */
2037
2140
  async function findRelease(executor, conn, tag) {
2038
2141
  const encodedTag = encodeURIComponent(tag);
@@ -2060,6 +2163,21 @@ async function createRelease(executor, conn, tag) {
2060
2163
  if (!res.ok) throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}`);
2061
2164
  }
2062
2165
 
2166
+ //#endregion
2167
+ //#region src/release/log.ts
2168
+ /** Log a debug message when verbose mode is enabled. */
2169
+ function debug(config, message) {
2170
+ if (config.verbose) p.log.info(`[debug] ${message}`);
2171
+ }
2172
+ /** Log the result of an exec call when verbose mode is enabled. */
2173
+ function debugExec(config, label, result) {
2174
+ if (!config.verbose) return;
2175
+ const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
2176
+ if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
2177
+ if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
2178
+ p.log.info(lines.join("\n"));
2179
+ }
2180
+
2063
2181
  //#endregion
2064
2182
  //#region src/release/version.ts
2065
2183
  const BRANCH = "changeset-release/main";
@@ -2132,11 +2250,20 @@ function buildPrContent(executor, cwd, packagesBefore) {
2132
2250
  async function runVersionMode(executor, config) {
2133
2251
  p.log.info("Changesets detected — versioning packages");
2134
2252
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
2135
- executor.exec("pnpm changeset version", { cwd: config.cwd });
2136
- executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd });
2253
+ debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
2254
+ const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
2255
+ debugExec(config, "pnpm changeset version", versionResult);
2256
+ if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
2257
+ debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
2137
2258
  const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
2259
+ debug(config, `PR title: ${title}`);
2138
2260
  executor.exec("git add -A", { cwd: config.cwd });
2139
- if (executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd }).exitCode !== 0) {
2261
+ const remainingChangesets = executor.listChangesetFiles(config.cwd);
2262
+ if (remainingChangesets.length > 0) p.log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
2263
+ debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
2264
+ const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
2265
+ debugExec(config, "git commit", commitResult);
2266
+ if (commitResult.exitCode !== 0) {
2140
2267
  p.log.info("Nothing to commit after versioning");
2141
2268
  return {
2142
2269
  mode: "version",
@@ -2150,13 +2277,14 @@ async function runVersionMode(executor, config) {
2150
2277
  pr: "none"
2151
2278
  };
2152
2279
  }
2153
- executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd });
2280
+ debugExec(config, "git push", executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd }));
2154
2281
  const conn = {
2155
2282
  serverUrl: config.serverUrl,
2156
2283
  repository: config.repository,
2157
2284
  token: config.token
2158
2285
  };
2159
2286
  const existingPr = await findOpenPr(executor, conn, BRANCH);
2287
+ debug(config, `Existing open PR for ${BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
2160
2288
  if (existingPr === null) {
2161
2289
  await createPr(executor, conn, {
2162
2290
  title,
@@ -2202,12 +2330,17 @@ async function retryAsync(fn) {
2202
2330
  async function runPublishMode(executor, config) {
2203
2331
  p.log.info("No changesets — publishing packages");
2204
2332
  const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
2333
+ debugExec(config, "pnpm changeset publish", publishResult);
2205
2334
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
2206
2335
  const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
2336
+ debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
2207
2337
  const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
2338
+ debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
2208
2339
  const remoteTags = parseRemoteTags(executor.exec("git ls-remote --tags origin", { cwd: config.cwd }).stdout);
2340
+ debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
2209
2341
  const remoteSet = new Set(remoteTags);
2210
2342
  const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
2343
+ debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
2211
2344
  if (config.dryRun) {
2212
2345
  if (tagsToPush.length === 0) {
2213
2346
  p.log.info("No packages were published");
@@ -2266,6 +2399,80 @@ async function runPublishMode(executor, config) {
2266
2399
  };
2267
2400
  }
2268
2401
 
2402
+ //#endregion
2403
+ //#region src/release/connection.ts
2404
+ const RepositorySchema = z.union([z.string(), z.object({ url: z.string() })]);
2405
+ /**
2406
+ * Resolve the hosting platform and connection details.
2407
+ *
2408
+ * Priority:
2409
+ * 1. Environment variables (FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, FORGEJO_TOKEN)
2410
+ * 2. `repository` field in package.json (server URL and owner/repo parsed from the URL)
2411
+ *
2412
+ * For Forgejo, FORGEJO_TOKEN is always required (either from env or explicitly).
2413
+ * If the repository URL hostname is `github.com`, returns `{ type: "github" }`.
2414
+ */
2415
+ function resolveConnection(cwd) {
2416
+ const serverUrl = process.env["FORGEJO_SERVER_URL"];
2417
+ const repository = process.env["FORGEJO_REPOSITORY"];
2418
+ const token = process.env["FORGEJO_TOKEN"];
2419
+ if (serverUrl && repository && token) return {
2420
+ type: "forgejo",
2421
+ conn: {
2422
+ serverUrl,
2423
+ repository,
2424
+ token
2425
+ }
2426
+ };
2427
+ const parsed = parseRepositoryUrl(cwd);
2428
+ if (parsed === null) {
2429
+ if (serverUrl) {
2430
+ if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2431
+ if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2432
+ }
2433
+ return { type: "github" };
2434
+ }
2435
+ if (parsed.hostname === "github.com") return { type: "github" };
2436
+ const resolvedToken = token;
2437
+ if (!resolvedToken) throw new FatalError("FORGEJO_TOKEN environment variable is required (server URL and repository were resolved from package.json)");
2438
+ return {
2439
+ type: "forgejo",
2440
+ conn: {
2441
+ serverUrl: serverUrl ?? `${parsed.protocol}//${parsed.hostname}`,
2442
+ repository: repository ?? parsed.repository,
2443
+ token: resolvedToken
2444
+ }
2445
+ };
2446
+ }
2447
+ function parseRepositoryUrl(cwd) {
2448
+ const pkgPath = path.join(cwd, "package.json");
2449
+ let raw;
2450
+ try {
2451
+ raw = readFileSync(pkgPath, "utf-8");
2452
+ } catch {
2453
+ return null;
2454
+ }
2455
+ const pkg = z.object({ repository: RepositorySchema.optional() }).safeParse(JSON.parse(raw));
2456
+ if (!pkg.success) return null;
2457
+ const repo = pkg.data.repository;
2458
+ if (!repo) return null;
2459
+ return parseGitUrl(typeof repo === "string" ? repo : repo.url);
2460
+ }
2461
+ function parseGitUrl(urlStr) {
2462
+ try {
2463
+ const url = new URL(urlStr);
2464
+ const pathname = url.pathname.replace(/\.git$/, "").replace(/^\//, "");
2465
+ if (!pathname.includes("/")) return null;
2466
+ return {
2467
+ protocol: url.protocol,
2468
+ hostname: url.hostname,
2469
+ repository: pathname
2470
+ };
2471
+ } catch {
2472
+ return null;
2473
+ }
2474
+ }
2475
+
2269
2476
  //#endregion
2270
2477
  //#region src/commands/release-changesets.ts
2271
2478
  const releaseForgejoCommand = defineCommand({
@@ -2273,33 +2480,43 @@ const releaseForgejoCommand = defineCommand({
2273
2480
  name: "release:changesets",
2274
2481
  description: "Changesets version/publish for Forgejo CI"
2275
2482
  },
2276
- args: { "dry-run": {
2277
- type: "boolean",
2278
- description: "Skip push, API calls, and publishing side effects"
2279
- } },
2483
+ args: {
2484
+ "dry-run": {
2485
+ type: "boolean",
2486
+ description: "Skip push, API calls, and publishing side effects"
2487
+ },
2488
+ verbose: {
2489
+ type: "boolean",
2490
+ description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
2491
+ }
2492
+ },
2280
2493
  async run({ args }) {
2281
- if ((await runRelease(buildReleaseConfig({ dryRun: args["dry-run"] === true }), createRealExecutor())).mode === "none") process.exitCode = 0;
2494
+ if ((await runRelease(buildReleaseConfig({
2495
+ dryRun: args["dry-run"] === true,
2496
+ verbose: args.verbose === true || process.env["RELEASE_DEBUG"] === "true"
2497
+ }), createRealExecutor())).mode === "none") process.exitCode = 0;
2282
2498
  }
2283
2499
  });
2284
- /** Build release config from environment variables and CLI flags. */
2500
+ /** Build release config from environment / package.json and CLI flags. */
2285
2501
  function buildReleaseConfig(flags) {
2286
- const serverUrl = process.env["FORGEJO_SERVER_URL"];
2287
- const repository = process.env["FORGEJO_REPOSITORY"];
2288
- const token = process.env["FORGEJO_TOKEN"];
2289
- if (!serverUrl) throw new FatalError("FORGEJO_SERVER_URL environment variable is required");
2290
- if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2291
- if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2502
+ const resolved = resolveConnection(process.cwd());
2503
+ if (resolved.type !== "forgejo") throw new FatalError("release:changesets requires a Forgejo repository");
2292
2504
  return {
2293
- serverUrl,
2294
- repository,
2295
- token,
2505
+ ...resolved.conn,
2296
2506
  cwd: process.cwd(),
2297
- dryRun: flags.dryRun ?? false
2507
+ dryRun: flags.dryRun ?? false,
2508
+ verbose: flags.verbose ?? false
2298
2509
  };
2299
2510
  }
2300
2511
  /** Core release logic — testable with a mock executor. */
2301
2512
  async function runRelease(config, executor) {
2302
- if (hasChangesets(executor, config.cwd)) return runVersionMode(executor, config);
2513
+ const changesetFiles = executor.listChangesetFiles(config.cwd);
2514
+ debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
2515
+ if (changesetFiles.length > 0) {
2516
+ debug(config, "Entering version mode");
2517
+ return runVersionMode(executor, config);
2518
+ }
2519
+ debug(config, "Entering publish mode");
2303
2520
  return runPublishMode(executor, config);
2304
2521
  }
2305
2522
 
@@ -2317,21 +2534,17 @@ const releaseTriggerCommand = defineCommand({
2317
2534
  } },
2318
2535
  async run({ args }) {
2319
2536
  const ref = args.ref ?? "main";
2320
- const serverUrl = process.env["FORGEJO_SERVER_URL"];
2321
- if (serverUrl) await triggerForgejo(serverUrl, ref);
2537
+ const resolved = resolveConnection(process.cwd());
2538
+ if (resolved.type === "forgejo") await triggerForgejo(resolved.conn, ref);
2322
2539
  else triggerGitHub(ref);
2323
2540
  }
2324
2541
  });
2325
- async function triggerForgejo(serverUrl, ref) {
2326
- const repository = process.env["FORGEJO_REPOSITORY"];
2327
- const token = process.env["FORGEJO_TOKEN"];
2328
- if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2329
- if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2330
- const url = `${serverUrl}/api/v1/repos/${repository}/actions/workflows/release.yml/dispatches`;
2542
+ async function triggerForgejo(conn, ref) {
2543
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/workflows/release.yml/dispatches`;
2331
2544
  const res = await fetch(url, {
2332
2545
  method: "POST",
2333
2546
  headers: {
2334
- Authorization: `token ${token}`,
2547
+ Authorization: `token ${conn.token}`,
2335
2548
  "Content-Type": "application/json"
2336
2549
  },
2337
2550
  body: JSON.stringify({ ref })
@@ -2357,18 +2570,10 @@ const createForgejoReleaseCommand = defineCommand({
2357
2570
  required: true
2358
2571
  } },
2359
2572
  async run({ args }) {
2360
- const serverUrl = process.env["FORGEJO_SERVER_URL"];
2361
- const repository = process.env["FORGEJO_REPOSITORY"];
2362
- const token = process.env["FORGEJO_TOKEN"];
2363
- if (!serverUrl) throw new FatalError("FORGEJO_SERVER_URL environment variable is required");
2364
- if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2365
- if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2573
+ const resolved = resolveConnection(process.cwd());
2574
+ if (resolved.type !== "forgejo") throw new FatalError("release:create-forgejo-release requires a Forgejo repository");
2366
2575
  const executor = createRealExecutor();
2367
- const conn = {
2368
- serverUrl,
2369
- repository,
2370
- token
2371
- };
2576
+ const conn = resolved.conn;
2372
2577
  if (await findRelease(executor, conn, args.tag)) {
2373
2578
  p.log.info(`Release for ${args.tag} already exists — skipping`);
2374
2579
  return;
@@ -2378,6 +2583,51 @@ const createForgejoReleaseCommand = defineCommand({
2378
2583
  }
2379
2584
  });
2380
2585
 
2586
+ //#endregion
2587
+ //#region src/commands/release-merge.ts
2588
+ const HEAD_BRANCH = "changeset-release/main";
2589
+ const releaseMergeCommand = defineCommand({
2590
+ meta: {
2591
+ name: "release:merge",
2592
+ description: "Merge the open changesets version PR"
2593
+ },
2594
+ args: { "dry-run": {
2595
+ type: "boolean",
2596
+ description: "Show what would be merged without actually merging"
2597
+ } },
2598
+ async run({ args }) {
2599
+ const dryRun = args["dry-run"] === true;
2600
+ const resolved = resolveConnection(process.cwd());
2601
+ if (resolved.type === "forgejo") await mergeForgejo(resolved.conn, dryRun);
2602
+ else mergeGitHub(dryRun);
2603
+ }
2604
+ });
2605
+ async function mergeForgejo(conn, dryRun) {
2606
+ const executor = createRealExecutor();
2607
+ const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
2608
+ if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
2609
+ if (dryRun) {
2610
+ p.log.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
2611
+ return;
2612
+ }
2613
+ await mergePr(executor, conn, prNumber, {
2614
+ method: "merge",
2615
+ deleteBranch: true
2616
+ });
2617
+ p.log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
2618
+ }
2619
+ function mergeGitHub(dryRun) {
2620
+ const executor = createRealExecutor();
2621
+ if (dryRun) {
2622
+ const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
2623
+ if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
2624
+ p.log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
2625
+ return;
2626
+ }
2627
+ executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
2628
+ p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
2629
+ }
2630
+
2381
2631
  //#endregion
2382
2632
  //#region src/bin.ts
2383
2633
  runMain(defineCommand({
@@ -2391,7 +2641,8 @@ runMain(defineCommand({
2391
2641
  "repo:update": updateCommand,
2392
2642
  "release:changesets": releaseForgejoCommand,
2393
2643
  "release:trigger": releaseTriggerCommand,
2394
- "release:create-forgejo-release": createForgejoReleaseCommand
2644
+ "release:create-forgejo-release": createForgejoReleaseCommand,
2645
+ "release:merge": releaseMergeCommand
2395
2646
  }
2396
2647
  }));
2397
2648
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "tooling": "./dist/bin.mjs"
@@ -31,7 +31,7 @@
31
31
  "tsdown": "0.20.3",
32
32
  "typescript": "5.9.3",
33
33
  "vitest": "4.0.18",
34
- "@bensandee/config": "0.4.0"
34
+ "@bensandee/config": "0.5.0"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsdown",