@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.
- package/dist/bin.mjs +340 -89
- 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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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/
|
|
1720
|
+
//#region src/generators/lefthook.ts
|
|
1681
1721
|
function buildConfig(formatter) {
|
|
1682
|
-
return
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
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
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
1702
|
-
const
|
|
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)
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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)
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
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
|
-
|
|
1805
|
+
results.push({
|
|
1724
1806
|
filePath,
|
|
1725
1807
|
action: existingPath === filePath ? "updated" : "created",
|
|
1726
|
-
description: "Generated
|
|
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
|
|
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
|
-
|
|
2136
|
-
executor.exec("pnpm
|
|
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
|
-
|
|
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: {
|
|
2277
|
-
|
|
2278
|
-
|
|
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({
|
|
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
|
|
2500
|
+
/** Build release config from environment / package.json and CLI flags. */
|
|
2285
2501
|
function buildReleaseConfig(flags) {
|
|
2286
|
-
const
|
|
2287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2321
|
-
if (
|
|
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(
|
|
2326
|
-
const
|
|
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
|
|
2361
|
-
|
|
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.
|
|
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.
|
|
34
|
+
"@bensandee/config": "0.5.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsdown",
|