@bensandee/tooling 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/bin.mjs +415 -94
  2. package/package.json +2 -2
package/dist/bin.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from "citty";
3
3
  import * as p from "@clack/prompts";
4
+ import { execSync } from "node:child_process";
4
5
  import path from "node:path";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
7
  import { parse } from "jsonc-parser";
7
8
  import { z } from "zod";
8
- import { execSync } from "node:child_process";
9
9
 
10
10
  //#region src/types.ts
11
11
  /** Default CI platform when not explicitly chosen. */
@@ -41,7 +41,7 @@ const TsconfigSchema = z.object({
41
41
  include: z.array(z.string()).optional(),
42
42
  exclude: z.array(z.string()).optional(),
43
43
  files: z.array(z.string()).optional(),
44
- references: z.array(z.object({ path: z.string() })).optional(),
44
+ references: z.array(z.object({ path: z.string() }).passthrough()).optional(),
45
45
  compilerOptions: z.record(z.string(), z.unknown()).optional()
46
46
  }).loose();
47
47
  const RenovateSchema = z.object({
@@ -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,25 @@ 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("For each archived file, **diff the old version against the new one** and look for features, categories, or modules that were enabled in the original but are missing from the replacement. Focus on broad capability gaps rather than individual rule strictness (in general, being stricter is fine). Examples of what to look for:");
660
+ sections.push("");
661
+ sections.push("- **Lint configs**: enabled plugin categories (e.g. `jsx-a11y`, `import`, `react`, `nextjs`), custom `plugins` or `overrides`, file-scoped rule blocks");
662
+ sections.push("- **TypeScript configs**: compiler features like `jsx`, `paths`, `baseUrl`, or `references` that affect build behavior");
663
+ sections.push("- **Other configs**: feature flags, custom presets, or integrations that go beyond the default template");
664
+ sections.push("");
665
+ sections.push("If the old config had capabilities the new one lacks, port them into the new file. Then:");
666
+ sections.push("");
667
+ sections.push("1. If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`");
668
+ sections.push("2. Delete the `.tooling-archived/` directory when migration is complete");
669
+ sections.push("");
670
+ }
629
671
  const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
630
672
  if (detected.hasLegacyOxlintJson) {
631
673
  sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
@@ -1032,7 +1074,8 @@ const STANDARD_ENTRIES = [
1032
1074
  ".env",
1033
1075
  ".env.*",
1034
1076
  "!.env.example",
1035
- ".tooling-migrate.md"
1077
+ ".tooling-migrate.md",
1078
+ ".tooling-archived/"
1036
1079
  ];
1037
1080
  /** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
1038
1081
  function normalizeEntry(entry) {
@@ -1306,6 +1349,8 @@ function buildSettings(ctx) {
1306
1349
  `Bash(${pm} view *)`,
1307
1350
  `Bash(${pm} list)`,
1308
1351
  `Bash(${pm} list *)`,
1352
+ `Bash(${pm} ls)`,
1353
+ `Bash(${pm} ls *)`,
1309
1354
  "Bash(npm view *)",
1310
1355
  "Bash(npm info *)",
1311
1356
  "Bash(npm show *)",
@@ -1345,6 +1390,8 @@ function buildSettings(ctx) {
1345
1390
  "Bash(head *)",
1346
1391
  "Bash(tail *)",
1347
1392
  "Bash(wc *)",
1393
+ "Bash(test *)",
1394
+ "Bash([ *)",
1348
1395
  "Bash(find *)",
1349
1396
  "Bash(which *)",
1350
1397
  "Bash(node -e *)",
@@ -1677,12 +1724,30 @@ async function generateReleaseCi(ctx) {
1677
1724
  }
1678
1725
 
1679
1726
  //#endregion
1680
- //#region src/generators/lint-staged.ts
1727
+ //#region src/generators/lefthook.ts
1681
1728
  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. */
1729
+ return [
1730
+ "pre-commit:",
1731
+ " commands:",
1732
+ " lint:",
1733
+ " run: pnpm exec oxlint {staged_files}",
1734
+ " format:",
1735
+ ` run: ${formatter === "prettier" ? "pnpm exec prettier --write {staged_files}" : "pnpm exec oxfmt --no-error-on-unmatched-pattern {staged_files}"}`,
1736
+ " stage_fixed: true",
1737
+ ""
1738
+ ].join("\n");
1739
+ }
1740
+ const ARCHIVE_DIR = ".tooling-archived";
1741
+ /** Common client-side git hooks that husky may have configured. */
1742
+ const HUSKY_HOOK_NAMES = [
1743
+ "pre-commit",
1744
+ "commit-msg",
1745
+ "pre-push",
1746
+ "post-merge",
1747
+ "post-checkout",
1748
+ "prepare-commit-msg"
1749
+ ];
1750
+ /** All known lint-staged config file locations to archive. */
1686
1751
  const LINT_STAGED_CONFIG_PATHS = [
1687
1752
  "lint-staged.config.mjs",
1688
1753
  "lint-staged.config.js",
@@ -1694,37 +1759,117 @@ const LINT_STAGED_CONFIG_PATHS = [
1694
1759
  ".lintstagedrc.mjs",
1695
1760
  ".lintstagedrc.cjs"
1696
1761
  ];
1697
- async function generateLintStaged(ctx) {
1698
- const filePath = "lint-staged.config.mjs";
1699
- const huskyPath = ".husky/pre-commit";
1762
+ /** All known lefthook config file locations, in priority order. */
1763
+ const LEFTHOOK_CONFIG_PATHS = ["lefthook.yml", ".lefthook.yml"];
1764
+ /** Archive all husky hook files found in .husky/ */
1765
+ function archiveHuskyHooks(ctx, results) {
1766
+ let found = false;
1767
+ for (const hook of HUSKY_HOOK_NAMES) {
1768
+ const huskyPath = `.husky/${hook}`;
1769
+ const existing = ctx.read(huskyPath);
1770
+ if (existing !== void 0) {
1771
+ ctx.write(`${ARCHIVE_DIR}/${huskyPath}`, existing);
1772
+ ctx.remove(huskyPath);
1773
+ results.push({
1774
+ filePath: huskyPath,
1775
+ action: "archived",
1776
+ description: `Moved to ${ARCHIVE_DIR}/${huskyPath}`
1777
+ });
1778
+ found = true;
1779
+ }
1780
+ }
1781
+ return found;
1782
+ }
1783
+ /** Archive all lint-staged config files. */
1784
+ function archiveLintStagedConfigs(ctx, results) {
1785
+ let found = false;
1786
+ for (const lsPath of LINT_STAGED_CONFIG_PATHS) {
1787
+ const existing = ctx.read(lsPath);
1788
+ if (existing !== void 0) {
1789
+ ctx.write(`${ARCHIVE_DIR}/${lsPath}`, existing);
1790
+ ctx.remove(lsPath);
1791
+ results.push({
1792
+ filePath: lsPath,
1793
+ action: "archived",
1794
+ description: `Moved to ${ARCHIVE_DIR}/${lsPath}`
1795
+ });
1796
+ found = true;
1797
+ }
1798
+ }
1799
+ return found;
1800
+ }
1801
+ /** Remove husky/lint-staged from package.json devDependencies and fix prepare script. */
1802
+ function cleanPackageJson(ctx, results) {
1803
+ const raw = ctx.read("package.json");
1804
+ if (!raw) return;
1805
+ const pkg = parsePackageJson(raw);
1806
+ if (!pkg) return;
1807
+ const changes = [];
1808
+ if (pkg.devDependencies) {
1809
+ if ("husky" in pkg.devDependencies) {
1810
+ delete pkg.devDependencies["husky"];
1811
+ changes.push("removed devDependency: husky");
1812
+ }
1813
+ if ("lint-staged" in pkg.devDependencies) {
1814
+ delete pkg.devDependencies["lint-staged"];
1815
+ changes.push("removed devDependency: lint-staged");
1816
+ }
1817
+ }
1818
+ if (pkg.scripts?.["prepare"] && /\bhusky\b/.test(pkg.scripts["prepare"])) {
1819
+ pkg.scripts["prepare"] = "lefthook install";
1820
+ changes.push("replaced prepare script: husky → lefthook install");
1821
+ }
1822
+ if (changes.length === 0) return;
1823
+ ctx.write("package.json", JSON.stringify(pkg, null, 2) + "\n");
1824
+ results.push({
1825
+ filePath: "package.json",
1826
+ action: "updated",
1827
+ description: changes.join(", ")
1828
+ });
1829
+ }
1830
+ async function generateLefthook(ctx) {
1831
+ const filePath = "lefthook.yml";
1700
1832
  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));
1833
+ const results = [];
1834
+ archiveHuskyHooks(ctx, results);
1835
+ archiveLintStagedConfigs(ctx, results);
1836
+ cleanPackageJson(ctx, results);
1837
+ const existingPath = LEFTHOOK_CONFIG_PATHS.find((p) => ctx.exists(p));
1703
1838
  if (existingPath === filePath) {
1704
1839
  const existing = ctx.read(filePath);
1705
1840
  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
- };
1841
+ if (existing === content) {
1842
+ results.push({
1843
+ filePath,
1844
+ action: "skipped",
1845
+ description: "Already configured"
1846
+ });
1847
+ return results;
1848
+ }
1849
+ if (await ctx.confirmOverwrite(filePath) === "skip") {
1850
+ results.push({
1851
+ filePath,
1852
+ action: "skipped",
1853
+ description: "Existing lefthook config preserved"
1854
+ });
1855
+ return results;
1856
+ }
1716
1857
  }
1717
- } else if (existingPath) return {
1718
- filePath: existingPath,
1719
- action: "skipped",
1720
- description: `Existing config found at ${existingPath}`
1721
- };
1858
+ } else if (existingPath) {
1859
+ results.push({
1860
+ filePath: existingPath,
1861
+ action: "skipped",
1862
+ description: `Existing config found at ${existingPath}`
1863
+ });
1864
+ return results;
1865
+ }
1722
1866
  ctx.write(filePath, content);
1723
- return {
1867
+ results.push({
1724
1868
  filePath,
1725
1869
  action: existingPath === filePath ? "updated" : "created",
1726
- description: "Generated lint-staged config and husky pre-commit hook"
1727
- };
1870
+ description: "Generated lefthook pre-commit config"
1871
+ });
1872
+ return results;
1728
1873
  }
1729
1874
 
1730
1875
  //#endregion
@@ -1768,7 +1913,7 @@ const initCommand = defineCommand({
1768
1913
  });
1769
1914
  async function runInit(config, options = {}) {
1770
1915
  const detected = detectProject(config.targetDir);
1771
- const ctx = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
1916
+ const { ctx, archivedFiles } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
1772
1917
  const result = await p.select({
1773
1918
  message: `${relativePath} already exists. What do you want to do?`,
1774
1919
  options: [{
@@ -1791,7 +1936,7 @@ async function runInit(config, options = {}) {
1791
1936
  results.push(await generateTsdown(ctx));
1792
1937
  results.push(await generateOxlint(ctx));
1793
1938
  results.push(await generateFormatter(ctx));
1794
- results.push(await generateLintStaged(ctx));
1939
+ results.push(...await generateLefthook(ctx));
1795
1940
  results.push(await generateGitignore(ctx));
1796
1941
  results.push(await generateKnip(ctx));
1797
1942
  results.push(await generateRenovate(ctx));
@@ -1802,13 +1947,27 @@ async function runInit(config, options = {}) {
1802
1947
  results.push(await generateReleaseCi(ctx));
1803
1948
  const vitestResults = await generateVitest(ctx);
1804
1949
  results.push(...vitestResults);
1950
+ const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
1951
+ for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
1952
+ filePath: rel,
1953
+ action: "archived",
1954
+ description: `Original saved to .tooling-archived/${rel}`
1955
+ });
1805
1956
  s.stop("Done!");
1957
+ if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
1958
+ execSync("git config --unset core.hooksPath", {
1959
+ cwd: config.targetDir,
1960
+ stdio: "ignore"
1961
+ });
1962
+ } catch (_error) {}
1806
1963
  const created = results.filter((r) => r.action === "created");
1807
1964
  const updated = results.filter((r) => r.action === "updated");
1808
1965
  const skipped = results.filter((r) => r.action === "skipped");
1966
+ const archived = results.filter((r) => r.action === "archived");
1809
1967
  const summaryLines = [];
1810
1968
  if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
1811
1969
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
1970
+ if (archived.length > 0) summaryLines.push(`Archived: ${archived.map((r) => r.filePath).join(", ")}`);
1812
1971
  if (skipped.length > 0) summaryLines.push(`Skipped: ${skipped.map((r) => r.filePath).join(", ")}`);
1813
1972
  p.note(summaryLines.join("\n"), "Summary");
1814
1973
  if (!options.noPrompt) {
@@ -1948,10 +2107,6 @@ function createRealExecutor() {
1948
2107
  }
1949
2108
  };
1950
2109
  }
1951
- /** Check whether there are pending changeset files. */
1952
- function hasChangesets(executor, cwd) {
1953
- return executor.listChangesetFiles(cwd).length > 0;
1954
- }
1955
2110
  /** Parse "New tag:" lines from changeset publish output. */
1956
2111
  function parseNewTags(output) {
1957
2112
  const tags = [];
@@ -2033,6 +2188,22 @@ async function updatePr(executor, conn, prNumber, options) {
2033
2188
  });
2034
2189
  if (!res.ok) throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
2035
2190
  }
2191
+ /** Merge a pull request by number. */
2192
+ async function mergePr(executor, conn, prNumber, options) {
2193
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}/merge`;
2194
+ const res = await executor.fetch(url, {
2195
+ method: "POST",
2196
+ headers: {
2197
+ Authorization: `token ${conn.token}`,
2198
+ "Content-Type": "application/json"
2199
+ },
2200
+ body: JSON.stringify({
2201
+ Do: options?.method ?? "merge",
2202
+ delete_branch_after_merge: options?.deleteBranch ?? true
2203
+ })
2204
+ });
2205
+ if (!res.ok) throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
2206
+ }
2036
2207
  /** Check whether a Forgejo release already exists for a given tag. */
2037
2208
  async function findRelease(executor, conn, tag) {
2038
2209
  const encodedTag = encodeURIComponent(tag);
@@ -2060,6 +2231,21 @@ async function createRelease(executor, conn, tag) {
2060
2231
  if (!res.ok) throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}`);
2061
2232
  }
2062
2233
 
2234
+ //#endregion
2235
+ //#region src/release/log.ts
2236
+ /** Log a debug message when verbose mode is enabled. */
2237
+ function debug(config, message) {
2238
+ if (config.verbose) p.log.info(`[debug] ${message}`);
2239
+ }
2240
+ /** Log the result of an exec call when verbose mode is enabled. */
2241
+ function debugExec(config, label, result) {
2242
+ if (!config.verbose) return;
2243
+ const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
2244
+ if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
2245
+ if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
2246
+ p.log.info(lines.join("\n"));
2247
+ }
2248
+
2063
2249
  //#endregion
2064
2250
  //#region src/release/version.ts
2065
2251
  const BRANCH = "changeset-release/main";
@@ -2132,11 +2318,20 @@ function buildPrContent(executor, cwd, packagesBefore) {
2132
2318
  async function runVersionMode(executor, config) {
2133
2319
  p.log.info("Changesets detected — versioning packages");
2134
2320
  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 });
2321
+ debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
2322
+ const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
2323
+ debugExec(config, "pnpm changeset version", versionResult);
2324
+ if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
2325
+ debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
2137
2326
  const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
2327
+ debug(config, `PR title: ${title}`);
2138
2328
  executor.exec("git add -A", { cwd: config.cwd });
2139
- if (executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd }).exitCode !== 0) {
2329
+ const remainingChangesets = executor.listChangesetFiles(config.cwd);
2330
+ if (remainingChangesets.length > 0) p.log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
2331
+ debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
2332
+ const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
2333
+ debugExec(config, "git commit", commitResult);
2334
+ if (commitResult.exitCode !== 0) {
2140
2335
  p.log.info("Nothing to commit after versioning");
2141
2336
  return {
2142
2337
  mode: "version",
@@ -2150,13 +2345,14 @@ async function runVersionMode(executor, config) {
2150
2345
  pr: "none"
2151
2346
  };
2152
2347
  }
2153
- executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd });
2348
+ debugExec(config, "git push", executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd }));
2154
2349
  const conn = {
2155
2350
  serverUrl: config.serverUrl,
2156
2351
  repository: config.repository,
2157
2352
  token: config.token
2158
2353
  };
2159
2354
  const existingPr = await findOpenPr(executor, conn, BRANCH);
2355
+ debug(config, `Existing open PR for ${BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
2160
2356
  if (existingPr === null) {
2161
2357
  await createPr(executor, conn, {
2162
2358
  title,
@@ -2202,12 +2398,17 @@ async function retryAsync(fn) {
2202
2398
  async function runPublishMode(executor, config) {
2203
2399
  p.log.info("No changesets — publishing packages");
2204
2400
  const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
2401
+ debugExec(config, "pnpm changeset publish", publishResult);
2205
2402
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
2206
2403
  const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
2404
+ debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
2207
2405
  const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
2406
+ debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
2208
2407
  const remoteTags = parseRemoteTags(executor.exec("git ls-remote --tags origin", { cwd: config.cwd }).stdout);
2408
+ debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
2209
2409
  const remoteSet = new Set(remoteTags);
2210
2410
  const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
2411
+ debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
2211
2412
  if (config.dryRun) {
2212
2413
  if (tagsToPush.length === 0) {
2213
2414
  p.log.info("No packages were published");
@@ -2266,6 +2467,80 @@ async function runPublishMode(executor, config) {
2266
2467
  };
2267
2468
  }
2268
2469
 
2470
+ //#endregion
2471
+ //#region src/release/connection.ts
2472
+ const RepositorySchema = z.union([z.string(), z.object({ url: z.string() })]);
2473
+ /**
2474
+ * Resolve the hosting platform and connection details.
2475
+ *
2476
+ * Priority:
2477
+ * 1. Environment variables (FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, FORGEJO_TOKEN)
2478
+ * 2. `repository` field in package.json (server URL and owner/repo parsed from the URL)
2479
+ *
2480
+ * For Forgejo, FORGEJO_TOKEN is always required (either from env or explicitly).
2481
+ * If the repository URL hostname is `github.com`, returns `{ type: "github" }`.
2482
+ */
2483
+ function resolveConnection(cwd) {
2484
+ const serverUrl = process.env["FORGEJO_SERVER_URL"];
2485
+ const repository = process.env["FORGEJO_REPOSITORY"];
2486
+ const token = process.env["FORGEJO_TOKEN"];
2487
+ if (serverUrl && repository && token) return {
2488
+ type: "forgejo",
2489
+ conn: {
2490
+ serverUrl,
2491
+ repository,
2492
+ token
2493
+ }
2494
+ };
2495
+ const parsed = parseRepositoryUrl(cwd);
2496
+ if (parsed === null) {
2497
+ if (serverUrl) {
2498
+ if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
2499
+ if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
2500
+ }
2501
+ return { type: "github" };
2502
+ }
2503
+ if (parsed.hostname === "github.com") return { type: "github" };
2504
+ const resolvedToken = token;
2505
+ if (!resolvedToken) throw new FatalError("FORGEJO_TOKEN environment variable is required (server URL and repository were resolved from package.json)");
2506
+ return {
2507
+ type: "forgejo",
2508
+ conn: {
2509
+ serverUrl: serverUrl ?? `${parsed.protocol}//${parsed.hostname}`,
2510
+ repository: repository ?? parsed.repository,
2511
+ token: resolvedToken
2512
+ }
2513
+ };
2514
+ }
2515
+ function parseRepositoryUrl(cwd) {
2516
+ const pkgPath = path.join(cwd, "package.json");
2517
+ let raw;
2518
+ try {
2519
+ raw = readFileSync(pkgPath, "utf-8");
2520
+ } catch {
2521
+ return null;
2522
+ }
2523
+ const pkg = z.object({ repository: RepositorySchema.optional() }).safeParse(JSON.parse(raw));
2524
+ if (!pkg.success) return null;
2525
+ const repo = pkg.data.repository;
2526
+ if (!repo) return null;
2527
+ return parseGitUrl(typeof repo === "string" ? repo : repo.url);
2528
+ }
2529
+ function parseGitUrl(urlStr) {
2530
+ try {
2531
+ const url = new URL(urlStr);
2532
+ const pathname = url.pathname.replace(/\.git$/, "").replace(/^\//, "");
2533
+ if (!pathname.includes("/")) return null;
2534
+ return {
2535
+ protocol: url.protocol,
2536
+ hostname: url.hostname,
2537
+ repository: pathname
2538
+ };
2539
+ } catch {
2540
+ return null;
2541
+ }
2542
+ }
2543
+
2269
2544
  //#endregion
2270
2545
  //#region src/commands/release-changesets.ts
2271
2546
  const releaseForgejoCommand = defineCommand({
@@ -2273,33 +2548,43 @@ const releaseForgejoCommand = defineCommand({
2273
2548
  name: "release:changesets",
2274
2549
  description: "Changesets version/publish for Forgejo CI"
2275
2550
  },
2276
- args: { "dry-run": {
2277
- type: "boolean",
2278
- description: "Skip push, API calls, and publishing side effects"
2279
- } },
2551
+ args: {
2552
+ "dry-run": {
2553
+ type: "boolean",
2554
+ description: "Skip push, API calls, and publishing side effects"
2555
+ },
2556
+ verbose: {
2557
+ type: "boolean",
2558
+ description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
2559
+ }
2560
+ },
2280
2561
  async run({ args }) {
2281
- if ((await runRelease(buildReleaseConfig({ dryRun: args["dry-run"] === true }), createRealExecutor())).mode === "none") process.exitCode = 0;
2562
+ if ((await runRelease(buildReleaseConfig({
2563
+ dryRun: args["dry-run"] === true,
2564
+ verbose: args.verbose === true || process.env["RELEASE_DEBUG"] === "true"
2565
+ }), createRealExecutor())).mode === "none") process.exitCode = 0;
2282
2566
  }
2283
2567
  });
2284
- /** Build release config from environment variables and CLI flags. */
2568
+ /** Build release config from environment / package.json and CLI flags. */
2285
2569
  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");
2570
+ const resolved = resolveConnection(process.cwd());
2571
+ if (resolved.type !== "forgejo") throw new FatalError("release:changesets requires a Forgejo repository");
2292
2572
  return {
2293
- serverUrl,
2294
- repository,
2295
- token,
2573
+ ...resolved.conn,
2296
2574
  cwd: process.cwd(),
2297
- dryRun: flags.dryRun ?? false
2575
+ dryRun: flags.dryRun ?? false,
2576
+ verbose: flags.verbose ?? false
2298
2577
  };
2299
2578
  }
2300
2579
  /** Core release logic — testable with a mock executor. */
2301
2580
  async function runRelease(config, executor) {
2302
- if (hasChangesets(executor, config.cwd)) return runVersionMode(executor, config);
2581
+ const changesetFiles = executor.listChangesetFiles(config.cwd);
2582
+ debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
2583
+ if (changesetFiles.length > 0) {
2584
+ debug(config, "Entering version mode");
2585
+ return runVersionMode(executor, config);
2586
+ }
2587
+ debug(config, "Entering publish mode");
2303
2588
  return runPublishMode(executor, config);
2304
2589
  }
2305
2590
 
@@ -2317,21 +2602,17 @@ const releaseTriggerCommand = defineCommand({
2317
2602
  } },
2318
2603
  async run({ args }) {
2319
2604
  const ref = args.ref ?? "main";
2320
- const serverUrl = process.env["FORGEJO_SERVER_URL"];
2321
- if (serverUrl) await triggerForgejo(serverUrl, ref);
2605
+ const resolved = resolveConnection(process.cwd());
2606
+ if (resolved.type === "forgejo") await triggerForgejo(resolved.conn, ref);
2322
2607
  else triggerGitHub(ref);
2323
2608
  }
2324
2609
  });
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`;
2610
+ async function triggerForgejo(conn, ref) {
2611
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/workflows/release.yml/dispatches`;
2331
2612
  const res = await fetch(url, {
2332
2613
  method: "POST",
2333
2614
  headers: {
2334
- Authorization: `token ${token}`,
2615
+ Authorization: `token ${conn.token}`,
2335
2616
  "Content-Type": "application/json"
2336
2617
  },
2337
2618
  body: JSON.stringify({ ref })
@@ -2357,18 +2638,10 @@ const createForgejoReleaseCommand = defineCommand({
2357
2638
  required: true
2358
2639
  } },
2359
2640
  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");
2641
+ const resolved = resolveConnection(process.cwd());
2642
+ if (resolved.type !== "forgejo") throw new FatalError("release:create-forgejo-release requires a Forgejo repository");
2366
2643
  const executor = createRealExecutor();
2367
- const conn = {
2368
- serverUrl,
2369
- repository,
2370
- token
2371
- };
2644
+ const conn = resolved.conn;
2372
2645
  if (await findRelease(executor, conn, args.tag)) {
2373
2646
  p.log.info(`Release for ${args.tag} already exists — skipping`);
2374
2647
  return;
@@ -2378,12 +2651,57 @@ const createForgejoReleaseCommand = defineCommand({
2378
2651
  }
2379
2652
  });
2380
2653
 
2654
+ //#endregion
2655
+ //#region src/commands/release-merge.ts
2656
+ const HEAD_BRANCH = "changeset-release/main";
2657
+ const releaseMergeCommand = defineCommand({
2658
+ meta: {
2659
+ name: "release:merge",
2660
+ description: "Merge the open changesets version PR"
2661
+ },
2662
+ args: { "dry-run": {
2663
+ type: "boolean",
2664
+ description: "Show what would be merged without actually merging"
2665
+ } },
2666
+ async run({ args }) {
2667
+ const dryRun = args["dry-run"] === true;
2668
+ const resolved = resolveConnection(process.cwd());
2669
+ if (resolved.type === "forgejo") await mergeForgejo(resolved.conn, dryRun);
2670
+ else mergeGitHub(dryRun);
2671
+ }
2672
+ });
2673
+ async function mergeForgejo(conn, dryRun) {
2674
+ const executor = createRealExecutor();
2675
+ const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
2676
+ if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
2677
+ if (dryRun) {
2678
+ p.log.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
2679
+ return;
2680
+ }
2681
+ await mergePr(executor, conn, prNumber, {
2682
+ method: "merge",
2683
+ deleteBranch: true
2684
+ });
2685
+ p.log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
2686
+ }
2687
+ function mergeGitHub(dryRun) {
2688
+ const executor = createRealExecutor();
2689
+ if (dryRun) {
2690
+ const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
2691
+ if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
2692
+ p.log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
2693
+ return;
2694
+ }
2695
+ executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
2696
+ p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
2697
+ }
2698
+
2381
2699
  //#endregion
2382
2700
  //#region src/bin.ts
2383
- runMain(defineCommand({
2701
+ const main = defineCommand({
2384
2702
  meta: {
2385
2703
  name: "tooling",
2386
- version: "0.1.0",
2704
+ version: "0.5.1",
2387
2705
  description: "Bootstrap and maintain standardized TypeScript project tooling"
2388
2706
  },
2389
2707
  subCommands: {
@@ -2391,9 +2709,12 @@ runMain(defineCommand({
2391
2709
  "repo:update": updateCommand,
2392
2710
  "release:changesets": releaseForgejoCommand,
2393
2711
  "release:trigger": releaseTriggerCommand,
2394
- "release:create-forgejo-release": createForgejoReleaseCommand
2712
+ "release:create-forgejo-release": createForgejoReleaseCommand,
2713
+ "release:merge": releaseMergeCommand
2395
2714
  }
2396
- }));
2715
+ });
2716
+ console.log(`@bensandee/tooling v0.5.1`);
2717
+ runMain(main);
2397
2718
 
2398
2719
  //#endregion
2399
2720
  export { };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",