@bensandee/tooling 0.3.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 +396 -178
- package/package.json +5 -9
- package/dist/bin.d.mts +0 -1
- package/dist/index.d.mts +0 -102
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";
|
|
@@ -48,18 +48,6 @@ const RenovateSchema = z.object({
|
|
|
48
48
|
$schema: z.string().optional(),
|
|
49
49
|
extends: z.array(z.string()).optional()
|
|
50
50
|
}).loose();
|
|
51
|
-
const KnipWorkspaceConfig = z.object({
|
|
52
|
-
entry: z.array(z.string()).optional(),
|
|
53
|
-
project: z.array(z.string()).optional(),
|
|
54
|
-
ignore: z.array(z.string()).optional()
|
|
55
|
-
});
|
|
56
|
-
const KnipSchema = z.object({
|
|
57
|
-
$schema: z.string().optional(),
|
|
58
|
-
entry: z.array(z.string()).optional(),
|
|
59
|
-
project: z.array(z.string()).optional(),
|
|
60
|
-
ignore: z.array(z.string()).optional(),
|
|
61
|
-
workspaces: z.record(z.string(), KnipWorkspaceConfig).optional()
|
|
62
|
-
}).loose();
|
|
63
51
|
/** Parse a JSONC string as a tsconfig.json. Returns a typed object with `{}` fallback on failure. */
|
|
64
52
|
function parseTsconfig(raw) {
|
|
65
53
|
const result = TsconfigSchema.safeParse(parse(raw));
|
|
@@ -70,11 +58,6 @@ function parseRenovateJson(raw) {
|
|
|
70
58
|
const result = RenovateSchema.safeParse(parse(raw));
|
|
71
59
|
return result.success ? result.data : {};
|
|
72
60
|
}
|
|
73
|
-
/** Parse a JSONC string as a knip.json. Returns a typed object with `{}` fallback on failure. */
|
|
74
|
-
function parseKnipJson(raw) {
|
|
75
|
-
const result = KnipSchema.safeParse(parse(raw));
|
|
76
|
-
return result.success ? result.data : {};
|
|
77
|
-
}
|
|
78
61
|
/** Parse a JSON string as a package.json. Returns `undefined` on failure. */
|
|
79
62
|
function parsePackageJson(raw) {
|
|
80
63
|
try {
|
|
@@ -208,11 +191,11 @@ async function runInitPrompts(targetDir) {
|
|
|
208
191
|
p.cancel("Cancelled.");
|
|
209
192
|
process.exit(0);
|
|
210
193
|
}
|
|
211
|
-
const
|
|
212
|
-
message: "Include @bensandee/
|
|
194
|
+
const useEslintPlugin = await p.confirm({
|
|
195
|
+
message: "Include @bensandee/eslint-plugin?",
|
|
213
196
|
initialValue: true
|
|
214
197
|
});
|
|
215
|
-
if (isCancelled(
|
|
198
|
+
if (isCancelled(useEslintPlugin)) {
|
|
216
199
|
p.cancel("Cancelled.");
|
|
217
200
|
process.exit(0);
|
|
218
201
|
}
|
|
@@ -361,7 +344,7 @@ async function runInitPrompts(targetDir) {
|
|
|
361
344
|
name,
|
|
362
345
|
isNew: !isExisting,
|
|
363
346
|
structure,
|
|
364
|
-
|
|
347
|
+
useEslintPlugin,
|
|
365
348
|
formatter,
|
|
366
349
|
setupVitest,
|
|
367
350
|
ci,
|
|
@@ -380,7 +363,7 @@ function buildDefaultConfig(targetDir, flags) {
|
|
|
380
363
|
name: existingPkg?.name ?? path.basename(targetDir),
|
|
381
364
|
isNew: !detected.hasPackageJson,
|
|
382
365
|
structure: detected.hasPnpmWorkspace ? "monorepo" : "single",
|
|
383
|
-
|
|
366
|
+
useEslintPlugin: flags.eslintPlugin ?? true,
|
|
384
367
|
formatter: detected.legacyConfigs.some((l) => l.tool === "prettier") ? "prettier" : "oxfmt",
|
|
385
368
|
setupVitest: !detected.hasVitestConfig,
|
|
386
369
|
ci: flags.noCi ? "none" : DEFAULT_CI,
|
|
@@ -412,17 +395,35 @@ function writeFile(targetDir, relativePath, content) {
|
|
|
412
395
|
}
|
|
413
396
|
/**
|
|
414
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.
|
|
415
399
|
*/
|
|
416
400
|
function createContext(config, confirmOverwrite) {
|
|
401
|
+
const archivedFiles = [];
|
|
417
402
|
const pkgRaw = readFile(config.targetDir, "package.json");
|
|
418
403
|
return {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
426
427
|
};
|
|
427
428
|
}
|
|
428
429
|
|
|
@@ -436,7 +437,7 @@ const STANDARD_SCRIPTS_SINGLE = {
|
|
|
436
437
|
lint: "oxlint",
|
|
437
438
|
knip: "knip",
|
|
438
439
|
check: "pnpm typecheck && pnpm build && pnpm lint && pnpm knip",
|
|
439
|
-
prepare: "
|
|
440
|
+
prepare: "lefthook install"
|
|
440
441
|
};
|
|
441
442
|
const STANDARD_SCRIPTS_MONOREPO = {
|
|
442
443
|
build: "pnpm -r build",
|
|
@@ -445,11 +446,10 @@ const STANDARD_SCRIPTS_MONOREPO = {
|
|
|
445
446
|
lint: "oxlint",
|
|
446
447
|
knip: "knip",
|
|
447
448
|
check: "pnpm typecheck && pnpm build && pnpm lint && pnpm knip",
|
|
448
|
-
prepare: "
|
|
449
|
+
prepare: "lefthook install"
|
|
449
450
|
};
|
|
450
451
|
/** DevDeps that belong in every project (single repo) or per-package (monorepo). */
|
|
451
452
|
const PER_PACKAGE_DEV_DEPS = {
|
|
452
|
-
"@tsconfig/strictest": "2.0.8",
|
|
453
453
|
"@types/node": "25.3.2",
|
|
454
454
|
tsdown: "0.20.3",
|
|
455
455
|
typescript: "5.9.3",
|
|
@@ -457,9 +457,8 @@ const PER_PACKAGE_DEV_DEPS = {
|
|
|
457
457
|
};
|
|
458
458
|
/** DevDeps that belong at the root regardless of structure. */
|
|
459
459
|
const ROOT_DEV_DEPS = {
|
|
460
|
-
husky: "9.1.7",
|
|
461
460
|
knip: "5.85.0",
|
|
462
|
-
|
|
461
|
+
lefthook: "2.1.2",
|
|
463
462
|
oxlint: "1.50.0"
|
|
464
463
|
};
|
|
465
464
|
/**
|
|
@@ -527,7 +526,7 @@ async function generatePackageJson(ctx) {
|
|
|
527
526
|
if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
|
|
528
527
|
devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "latest";
|
|
529
528
|
devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "latest";
|
|
530
|
-
if (ctx.config.
|
|
529
|
+
if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "latest";
|
|
531
530
|
if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
|
|
532
531
|
if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
|
|
533
532
|
addReleaseDeps(devDeps, ctx.config);
|
|
@@ -608,6 +607,7 @@ function generateMigratePrompt(results, config, detected) {
|
|
|
608
607
|
const created = results.filter((r) => r.action === "created");
|
|
609
608
|
const updated = results.filter((r) => r.action === "updated");
|
|
610
609
|
const skipped = results.filter((r) => r.action === "skipped");
|
|
610
|
+
const archived = results.filter((r) => r.action === "archived");
|
|
611
611
|
if (created.length > 0) {
|
|
612
612
|
sections.push("**Created:**");
|
|
613
613
|
for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
@@ -618,6 +618,11 @@ function generateMigratePrompt(results, config, detected) {
|
|
|
618
618
|
for (const r of updated) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
619
619
|
sections.push("");
|
|
620
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
|
+
}
|
|
621
626
|
if (skipped.length > 0) {
|
|
622
627
|
sections.push("**Skipped (review these):**");
|
|
623
628
|
for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
|
|
@@ -644,6 +649,18 @@ function generateMigratePrompt(results, config, detected) {
|
|
|
644
649
|
}
|
|
645
650
|
sections.push("");
|
|
646
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
|
+
}
|
|
647
664
|
const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
|
|
648
665
|
if (detected.hasLegacyOxlintJson) {
|
|
649
666
|
sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
|
|
@@ -710,7 +727,7 @@ function generateMigratePrompt(results, config, detected) {
|
|
|
710
727
|
sections.push("");
|
|
711
728
|
sections.push("- **Lint errors**: fix the code rather than adding disable comments or rule exceptions");
|
|
712
729
|
sections.push("- **Test failures**: update the test or fix the underlying bug rather than skipping or deleting the test");
|
|
713
|
-
sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.
|
|
730
|
+
sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.config.ts`");
|
|
714
731
|
sections.push("- **Type errors**: add proper types rather than using `any` or `@ts-expect-error`");
|
|
715
732
|
sections.push("");
|
|
716
733
|
sections.push("Only suppress an issue if there is a clear, documented reason why the fix is not feasible (e.g. a third-party type mismatch). Leave a comment explaining why.");
|
|
@@ -942,7 +959,7 @@ export default defineConfig({
|
|
|
942
959
|
`;
|
|
943
960
|
async function generateOxlint(ctx) {
|
|
944
961
|
const filePath = "oxlint.config.ts";
|
|
945
|
-
const content = ctx.config.
|
|
962
|
+
const content = ctx.config.useEslintPlugin ? CONFIG_WITH_LINT_RULES : CONFIG_PRESET_ONLY;
|
|
946
963
|
const existing = ctx.read(filePath);
|
|
947
964
|
if (existing) {
|
|
948
965
|
if (existing === content) return {
|
|
@@ -1050,7 +1067,8 @@ const STANDARD_ENTRIES = [
|
|
|
1050
1067
|
".env",
|
|
1051
1068
|
".env.*",
|
|
1052
1069
|
"!.env.example",
|
|
1053
|
-
".tooling-migrate.md"
|
|
1070
|
+
".tooling-migrate.md",
|
|
1071
|
+
".tooling-archived/"
|
|
1054
1072
|
];
|
|
1055
1073
|
/** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
|
|
1056
1074
|
function normalizeEntry(entry) {
|
|
@@ -1146,88 +1164,62 @@ async function generateCi(ctx) {
|
|
|
1146
1164
|
|
|
1147
1165
|
//#endregion
|
|
1148
1166
|
//#region src/generators/knip.ts
|
|
1149
|
-
const KNIP_CONFIG_SINGLE = {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1167
|
+
const KNIP_CONFIG_SINGLE = `import type { KnipConfig } from "knip";
|
|
1168
|
+
|
|
1169
|
+
export default {
|
|
1170
|
+
entry: ["src/index.ts"],
|
|
1171
|
+
project: ["src/**/*.ts"],
|
|
1172
|
+
ignore: ["dist/**"],
|
|
1173
|
+
} satisfies KnipConfig;
|
|
1174
|
+
`;
|
|
1175
|
+
const KNIP_CONFIG_MONOREPO = `import type { KnipConfig } from "knip";
|
|
1176
|
+
|
|
1177
|
+
export default {
|
|
1178
|
+
workspaces: {
|
|
1179
|
+
".": {
|
|
1180
|
+
entry: [],
|
|
1181
|
+
project: [],
|
|
1182
|
+
},
|
|
1183
|
+
"packages/*": {
|
|
1184
|
+
entry: ["src/index.ts", "src/bin.ts"],
|
|
1185
|
+
project: ["src/**/*.ts"],
|
|
1186
|
+
ignore: ["dist/**"],
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
} satisfies KnipConfig;
|
|
1190
|
+
`;
|
|
1169
1191
|
/** All known knip config file locations, in priority order. */
|
|
1170
1192
|
const KNIP_CONFIG_PATHS = [
|
|
1193
|
+
"knip.config.ts",
|
|
1194
|
+
"knip.config.mts",
|
|
1171
1195
|
"knip.json",
|
|
1172
1196
|
"knip.jsonc",
|
|
1173
1197
|
"knip.ts",
|
|
1174
|
-
"knip.mts"
|
|
1175
|
-
"knip.config.ts",
|
|
1176
|
-
"knip.config.mts"
|
|
1198
|
+
"knip.mts"
|
|
1177
1199
|
];
|
|
1178
1200
|
async function generateKnip(ctx) {
|
|
1179
|
-
const filePath = "knip.
|
|
1201
|
+
const filePath = "knip.config.ts";
|
|
1180
1202
|
const isMonorepo = ctx.config.structure === "monorepo";
|
|
1181
1203
|
const existingPath = KNIP_CONFIG_PATHS.find((p) => ctx.exists(p));
|
|
1182
|
-
if (existingPath === filePath) {
|
|
1183
|
-
const existing = ctx.read(filePath);
|
|
1184
|
-
if (existing) {
|
|
1185
|
-
const parsed = parseKnipJson(existing);
|
|
1186
|
-
const changes = [];
|
|
1187
|
-
if (isMonorepo && !parsed.workspaces) {
|
|
1188
|
-
parsed.workspaces = KNIP_CONFIG_MONOREPO.workspaces;
|
|
1189
|
-
changes.push("added monorepo workspaces config");
|
|
1190
|
-
}
|
|
1191
|
-
if (!isMonorepo) {
|
|
1192
|
-
if (!parsed.entry) {
|
|
1193
|
-
parsed.entry = KNIP_CONFIG_SINGLE.entry;
|
|
1194
|
-
changes.push("added entry patterns");
|
|
1195
|
-
}
|
|
1196
|
-
if (!parsed.project) {
|
|
1197
|
-
parsed.project = KNIP_CONFIG_SINGLE.project;
|
|
1198
|
-
changes.push("added project patterns");
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
if (changes.length === 0) return {
|
|
1202
|
-
filePath,
|
|
1203
|
-
action: "skipped",
|
|
1204
|
-
description: "Already configured"
|
|
1205
|
-
};
|
|
1206
|
-
ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
|
|
1207
|
-
return {
|
|
1208
|
-
filePath,
|
|
1209
|
-
action: "updated",
|
|
1210
|
-
description: changes.join(", ")
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
1204
|
if (existingPath) return {
|
|
1215
1205
|
filePath: existingPath,
|
|
1216
1206
|
action: "skipped",
|
|
1217
|
-
description: `Existing config found at ${existingPath}`
|
|
1207
|
+
description: existingPath === filePath ? "Already configured" : `Existing config found at ${existingPath}`
|
|
1218
1208
|
};
|
|
1219
1209
|
const config = isMonorepo ? KNIP_CONFIG_MONOREPO : KNIP_CONFIG_SINGLE;
|
|
1220
|
-
ctx.write(filePath,
|
|
1210
|
+
ctx.write(filePath, config);
|
|
1221
1211
|
return {
|
|
1222
1212
|
filePath,
|
|
1223
1213
|
action: "created",
|
|
1224
|
-
description: "Generated knip.
|
|
1214
|
+
description: "Generated knip.config.ts for dead code analysis"
|
|
1225
1215
|
};
|
|
1226
1216
|
}
|
|
1227
1217
|
|
|
1228
1218
|
//#endregion
|
|
1229
1219
|
//#region src/generators/renovate.ts
|
|
1230
|
-
const SHARED_PRESET = "
|
|
1220
|
+
const SHARED_PRESET = "local>bensandee/tooling";
|
|
1221
|
+
/** Deprecated npm-based preset to migrate away from. */
|
|
1222
|
+
const LEGACY_PRESET = "@bensandee/config";
|
|
1231
1223
|
/** All known renovate config file locations, in priority order. */
|
|
1232
1224
|
const RENOVATE_CONFIG_PATHS = [
|
|
1233
1225
|
"renovate.json",
|
|
@@ -1250,6 +1242,17 @@ async function generateRenovate(ctx) {
|
|
|
1250
1242
|
if (existing) {
|
|
1251
1243
|
const parsed = parseRenovateJson(existing);
|
|
1252
1244
|
const existingExtends = parsed.extends ?? [];
|
|
1245
|
+
const legacyIndex = existingExtends.indexOf(LEGACY_PRESET);
|
|
1246
|
+
if (legacyIndex !== -1) {
|
|
1247
|
+
existingExtends[legacyIndex] = SHARED_PRESET;
|
|
1248
|
+
parsed.extends = existingExtends;
|
|
1249
|
+
ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
|
|
1250
|
+
return {
|
|
1251
|
+
filePath,
|
|
1252
|
+
action: "updated",
|
|
1253
|
+
description: `Migrated extends: ${LEGACY_PRESET} → ${SHARED_PRESET}`
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1253
1256
|
if (!existingExtends.includes(SHARED_PRESET)) {
|
|
1254
1257
|
existingExtends.unshift(SHARED_PRESET);
|
|
1255
1258
|
parsed.extends = existingExtends;
|
|
@@ -1339,6 +1342,8 @@ function buildSettings(ctx) {
|
|
|
1339
1342
|
`Bash(${pm} view *)`,
|
|
1340
1343
|
`Bash(${pm} list)`,
|
|
1341
1344
|
`Bash(${pm} list *)`,
|
|
1345
|
+
`Bash(${pm} ls)`,
|
|
1346
|
+
`Bash(${pm} ls *)`,
|
|
1342
1347
|
"Bash(npm view *)",
|
|
1343
1348
|
"Bash(npm info *)",
|
|
1344
1349
|
"Bash(npm show *)",
|
|
@@ -1378,6 +1383,8 @@ function buildSettings(ctx) {
|
|
|
1378
1383
|
"Bash(head *)",
|
|
1379
1384
|
"Bash(tail *)",
|
|
1380
1385
|
"Bash(wc *)",
|
|
1386
|
+
"Bash(test *)",
|
|
1387
|
+
"Bash([ *)",
|
|
1381
1388
|
"Bash(find *)",
|
|
1382
1389
|
"Bash(which *)",
|
|
1383
1390
|
"Bash(node -e *)",
|
|
@@ -1710,12 +1717,21 @@ async function generateReleaseCi(ctx) {
|
|
|
1710
1717
|
}
|
|
1711
1718
|
|
|
1712
1719
|
//#endregion
|
|
1713
|
-
//#region src/generators/
|
|
1720
|
+
//#region src/generators/lefthook.ts
|
|
1714
1721
|
function buildConfig(formatter) {
|
|
1715
|
-
return
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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. */
|
|
1719
1735
|
const LINT_STAGED_CONFIG_PATHS = [
|
|
1720
1736
|
"lint-staged.config.mjs",
|
|
1721
1737
|
"lint-staged.config.js",
|
|
@@ -1727,37 +1743,71 @@ const LINT_STAGED_CONFIG_PATHS = [
|
|
|
1727
1743
|
".lintstagedrc.mjs",
|
|
1728
1744
|
".lintstagedrc.cjs"
|
|
1729
1745
|
];
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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";
|
|
1733
1750
|
const content = buildConfig(ctx.config.formatter);
|
|
1734
|
-
|
|
1735
|
-
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));
|
|
1736
1776
|
if (existingPath === filePath) {
|
|
1737
1777
|
const existing = ctx.read(filePath);
|
|
1738
1778
|
if (existing) {
|
|
1739
|
-
if (existing === content)
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
+
}
|
|
1749
1795
|
}
|
|
1750
|
-
} else if (existingPath)
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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
|
+
}
|
|
1755
1804
|
ctx.write(filePath, content);
|
|
1756
|
-
|
|
1805
|
+
results.push({
|
|
1757
1806
|
filePath,
|
|
1758
1807
|
action: existingPath === filePath ? "updated" : "created",
|
|
1759
|
-
description: "Generated
|
|
1760
|
-
};
|
|
1808
|
+
description: "Generated lefthook pre-commit config"
|
|
1809
|
+
});
|
|
1810
|
+
return results;
|
|
1761
1811
|
}
|
|
1762
1812
|
|
|
1763
1813
|
//#endregion
|
|
@@ -1778,9 +1828,9 @@ const initCommand = defineCommand({
|
|
|
1778
1828
|
alias: "y",
|
|
1779
1829
|
description: "Accept all defaults (non-interactive)"
|
|
1780
1830
|
},
|
|
1781
|
-
"
|
|
1831
|
+
"eslint-plugin": {
|
|
1782
1832
|
type: "boolean",
|
|
1783
|
-
description: "Include @bensandee/
|
|
1833
|
+
description: "Include @bensandee/eslint-plugin (default: true)"
|
|
1784
1834
|
},
|
|
1785
1835
|
"no-ci": {
|
|
1786
1836
|
type: "boolean",
|
|
@@ -1794,14 +1844,14 @@ const initCommand = defineCommand({
|
|
|
1794
1844
|
async run({ args }) {
|
|
1795
1845
|
const targetDir = path.resolve(args.dir ?? ".");
|
|
1796
1846
|
await runInit(args.yes ? buildDefaultConfig(targetDir, {
|
|
1797
|
-
|
|
1847
|
+
eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
|
|
1798
1848
|
noCi: args["no-ci"] === true ? true : void 0
|
|
1799
1849
|
}) : await runInitPrompts(targetDir), args["no-prompt"] === true ? { noPrompt: true } : {});
|
|
1800
1850
|
}
|
|
1801
1851
|
});
|
|
1802
1852
|
async function runInit(config, options = {}) {
|
|
1803
1853
|
const detected = detectProject(config.targetDir);
|
|
1804
|
-
const ctx = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
|
|
1854
|
+
const { ctx, archivedFiles } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
|
|
1805
1855
|
const result = await p.select({
|
|
1806
1856
|
message: `${relativePath} already exists. What do you want to do?`,
|
|
1807
1857
|
options: [{
|
|
@@ -1824,7 +1874,7 @@ async function runInit(config, options = {}) {
|
|
|
1824
1874
|
results.push(await generateTsdown(ctx));
|
|
1825
1875
|
results.push(await generateOxlint(ctx));
|
|
1826
1876
|
results.push(await generateFormatter(ctx));
|
|
1827
|
-
results.push(await
|
|
1877
|
+
results.push(...await generateLefthook(ctx));
|
|
1828
1878
|
results.push(await generateGitignore(ctx));
|
|
1829
1879
|
results.push(await generateKnip(ctx));
|
|
1830
1880
|
results.push(await generateRenovate(ctx));
|
|
@@ -1835,13 +1885,21 @@ async function runInit(config, options = {}) {
|
|
|
1835
1885
|
results.push(await generateReleaseCi(ctx));
|
|
1836
1886
|
const vitestResults = await generateVitest(ctx);
|
|
1837
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
|
+
});
|
|
1838
1894
|
s.stop("Done!");
|
|
1839
1895
|
const created = results.filter((r) => r.action === "created");
|
|
1840
1896
|
const updated = results.filter((r) => r.action === "updated");
|
|
1841
1897
|
const skipped = results.filter((r) => r.action === "skipped");
|
|
1898
|
+
const archived = results.filter((r) => r.action === "archived");
|
|
1842
1899
|
const summaryLines = [];
|
|
1843
1900
|
if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
|
|
1844
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(", ")}`);
|
|
1845
1903
|
if (skipped.length > 0) summaryLines.push(`Skipped: ${skipped.map((r) => r.filePath).join(", ")}`);
|
|
1846
1904
|
p.note(summaryLines.join("\n"), "Summary");
|
|
1847
1905
|
if (!options.noPrompt) {
|
|
@@ -1981,10 +2039,6 @@ function createRealExecutor() {
|
|
|
1981
2039
|
}
|
|
1982
2040
|
};
|
|
1983
2041
|
}
|
|
1984
|
-
/** Check whether there are pending changeset files. */
|
|
1985
|
-
function hasChangesets(executor, cwd) {
|
|
1986
|
-
return executor.listChangesetFiles(cwd).length > 0;
|
|
1987
|
-
}
|
|
1988
2042
|
/** Parse "New tag:" lines from changeset publish output. */
|
|
1989
2043
|
function parseNewTags(output) {
|
|
1990
2044
|
const tags = [];
|
|
@@ -2066,6 +2120,22 @@ async function updatePr(executor, conn, prNumber, options) {
|
|
|
2066
2120
|
});
|
|
2067
2121
|
if (!res.ok) throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
|
|
2068
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
|
+
}
|
|
2069
2139
|
/** Check whether a Forgejo release already exists for a given tag. */
|
|
2070
2140
|
async function findRelease(executor, conn, tag) {
|
|
2071
2141
|
const encodedTag = encodeURIComponent(tag);
|
|
@@ -2093,6 +2163,21 @@ async function createRelease(executor, conn, tag) {
|
|
|
2093
2163
|
if (!res.ok) throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}`);
|
|
2094
2164
|
}
|
|
2095
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
|
+
|
|
2096
2181
|
//#endregion
|
|
2097
2182
|
//#region src/release/version.ts
|
|
2098
2183
|
const BRANCH = "changeset-release/main";
|
|
@@ -2165,11 +2250,20 @@ function buildPrContent(executor, cwd, packagesBefore) {
|
|
|
2165
2250
|
async function runVersionMode(executor, config) {
|
|
2166
2251
|
p.log.info("Changesets detected — versioning packages");
|
|
2167
2252
|
const packagesBefore = executor.listWorkspacePackages(config.cwd);
|
|
2168
|
-
|
|
2169
|
-
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 }));
|
|
2170
2258
|
const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
|
|
2259
|
+
debug(config, `PR title: ${title}`);
|
|
2171
2260
|
executor.exec("git add -A", { cwd: config.cwd });
|
|
2172
|
-
|
|
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) {
|
|
2173
2267
|
p.log.info("Nothing to commit after versioning");
|
|
2174
2268
|
return {
|
|
2175
2269
|
mode: "version",
|
|
@@ -2183,13 +2277,14 @@ async function runVersionMode(executor, config) {
|
|
|
2183
2277
|
pr: "none"
|
|
2184
2278
|
};
|
|
2185
2279
|
}
|
|
2186
|
-
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 }));
|
|
2187
2281
|
const conn = {
|
|
2188
2282
|
serverUrl: config.serverUrl,
|
|
2189
2283
|
repository: config.repository,
|
|
2190
2284
|
token: config.token
|
|
2191
2285
|
};
|
|
2192
2286
|
const existingPr = await findOpenPr(executor, conn, BRANCH);
|
|
2287
|
+
debug(config, `Existing open PR for ${BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
|
|
2193
2288
|
if (existingPr === null) {
|
|
2194
2289
|
await createPr(executor, conn, {
|
|
2195
2290
|
title,
|
|
@@ -2235,12 +2330,17 @@ async function retryAsync(fn) {
|
|
|
2235
2330
|
async function runPublishMode(executor, config) {
|
|
2236
2331
|
p.log.info("No changesets — publishing packages");
|
|
2237
2332
|
const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
|
|
2333
|
+
debugExec(config, "pnpm changeset publish", publishResult);
|
|
2238
2334
|
if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
|
|
2239
2335
|
const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
|
|
2336
|
+
debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
|
|
2240
2337
|
const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
|
|
2338
|
+
debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
|
|
2241
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)"}`);
|
|
2242
2341
|
const remoteSet = new Set(remoteTags);
|
|
2243
2342
|
const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
|
|
2343
|
+
debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
|
|
2244
2344
|
if (config.dryRun) {
|
|
2245
2345
|
if (tagsToPush.length === 0) {
|
|
2246
2346
|
p.log.info("No packages were published");
|
|
@@ -2299,6 +2399,80 @@ async function runPublishMode(executor, config) {
|
|
|
2299
2399
|
};
|
|
2300
2400
|
}
|
|
2301
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
|
+
|
|
2302
2476
|
//#endregion
|
|
2303
2477
|
//#region src/commands/release-changesets.ts
|
|
2304
2478
|
const releaseForgejoCommand = defineCommand({
|
|
@@ -2306,33 +2480,43 @@ const releaseForgejoCommand = defineCommand({
|
|
|
2306
2480
|
name: "release:changesets",
|
|
2307
2481
|
description: "Changesets version/publish for Forgejo CI"
|
|
2308
2482
|
},
|
|
2309
|
-
args: {
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
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
|
+
},
|
|
2313
2493
|
async run({ args }) {
|
|
2314
|
-
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;
|
|
2315
2498
|
}
|
|
2316
2499
|
});
|
|
2317
|
-
/** Build release config from environment
|
|
2500
|
+
/** Build release config from environment / package.json and CLI flags. */
|
|
2318
2501
|
function buildReleaseConfig(flags) {
|
|
2319
|
-
const
|
|
2320
|
-
|
|
2321
|
-
const token = process.env["FORGEJO_TOKEN"];
|
|
2322
|
-
if (!serverUrl) throw new FatalError("FORGEJO_SERVER_URL environment variable is required");
|
|
2323
|
-
if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
|
|
2324
|
-
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");
|
|
2325
2504
|
return {
|
|
2326
|
-
|
|
2327
|
-
repository,
|
|
2328
|
-
token,
|
|
2505
|
+
...resolved.conn,
|
|
2329
2506
|
cwd: process.cwd(),
|
|
2330
|
-
dryRun: flags.dryRun ?? false
|
|
2507
|
+
dryRun: flags.dryRun ?? false,
|
|
2508
|
+
verbose: flags.verbose ?? false
|
|
2331
2509
|
};
|
|
2332
2510
|
}
|
|
2333
2511
|
/** Core release logic — testable with a mock executor. */
|
|
2334
2512
|
async function runRelease(config, executor) {
|
|
2335
|
-
|
|
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");
|
|
2336
2520
|
return runPublishMode(executor, config);
|
|
2337
2521
|
}
|
|
2338
2522
|
|
|
@@ -2350,21 +2534,17 @@ const releaseTriggerCommand = defineCommand({
|
|
|
2350
2534
|
} },
|
|
2351
2535
|
async run({ args }) {
|
|
2352
2536
|
const ref = args.ref ?? "main";
|
|
2353
|
-
const
|
|
2354
|
-
if (
|
|
2537
|
+
const resolved = resolveConnection(process.cwd());
|
|
2538
|
+
if (resolved.type === "forgejo") await triggerForgejo(resolved.conn, ref);
|
|
2355
2539
|
else triggerGitHub(ref);
|
|
2356
2540
|
}
|
|
2357
2541
|
});
|
|
2358
|
-
async function triggerForgejo(
|
|
2359
|
-
const
|
|
2360
|
-
const token = process.env["FORGEJO_TOKEN"];
|
|
2361
|
-
if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
|
|
2362
|
-
if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
|
|
2363
|
-
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`;
|
|
2364
2544
|
const res = await fetch(url, {
|
|
2365
2545
|
method: "POST",
|
|
2366
2546
|
headers: {
|
|
2367
|
-
Authorization: `token ${token}`,
|
|
2547
|
+
Authorization: `token ${conn.token}`,
|
|
2368
2548
|
"Content-Type": "application/json"
|
|
2369
2549
|
},
|
|
2370
2550
|
body: JSON.stringify({ ref })
|
|
@@ -2390,18 +2570,10 @@ const createForgejoReleaseCommand = defineCommand({
|
|
|
2390
2570
|
required: true
|
|
2391
2571
|
} },
|
|
2392
2572
|
async run({ args }) {
|
|
2393
|
-
const
|
|
2394
|
-
|
|
2395
|
-
const token = process.env["FORGEJO_TOKEN"];
|
|
2396
|
-
if (!serverUrl) throw new FatalError("FORGEJO_SERVER_URL environment variable is required");
|
|
2397
|
-
if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
|
|
2398
|
-
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");
|
|
2399
2575
|
const executor = createRealExecutor();
|
|
2400
|
-
const conn =
|
|
2401
|
-
serverUrl,
|
|
2402
|
-
repository,
|
|
2403
|
-
token
|
|
2404
|
-
};
|
|
2576
|
+
const conn = resolved.conn;
|
|
2405
2577
|
if (await findRelease(executor, conn, args.tag)) {
|
|
2406
2578
|
p.log.info(`Release for ${args.tag} already exists — skipping`);
|
|
2407
2579
|
return;
|
|
@@ -2411,6 +2583,51 @@ const createForgejoReleaseCommand = defineCommand({
|
|
|
2411
2583
|
}
|
|
2412
2584
|
});
|
|
2413
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
|
+
|
|
2414
2631
|
//#endregion
|
|
2415
2632
|
//#region src/bin.ts
|
|
2416
2633
|
runMain(defineCommand({
|
|
@@ -2424,7 +2641,8 @@ runMain(defineCommand({
|
|
|
2424
2641
|
"repo:update": updateCommand,
|
|
2425
2642
|
"release:changesets": releaseForgejoCommand,
|
|
2426
2643
|
"release:trigger": releaseTriggerCommand,
|
|
2427
|
-
"release:create-forgejo-release": createForgejoReleaseCommand
|
|
2644
|
+
"release:create-forgejo-release": createForgejoReleaseCommand,
|
|
2645
|
+
"release:merge": releaseMergeCommand
|
|
2428
2646
|
}
|
|
2429
2647
|
}));
|
|
2430
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"
|
|
@@ -9,17 +9,13 @@
|
|
|
9
9
|
"dist"
|
|
10
10
|
],
|
|
11
11
|
"type": "module",
|
|
12
|
-
"main": "./dist/index.mjs",
|
|
13
|
-
"module": "./dist/index.mjs",
|
|
14
|
-
"types": "./dist/index.d.mts",
|
|
15
12
|
"imports": {
|
|
16
13
|
"#src/*": "./src/*.ts"
|
|
17
14
|
},
|
|
18
15
|
"exports": {
|
|
19
|
-
".":
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
16
|
+
".": "./dist/index.mjs",
|
|
17
|
+
"./bin": "./dist/bin.mjs",
|
|
18
|
+
"./package.json": "./package.json"
|
|
23
19
|
},
|
|
24
20
|
"publishConfig": {
|
|
25
21
|
"access": "public"
|
|
@@ -35,7 +31,7 @@
|
|
|
35
31
|
"tsdown": "0.20.3",
|
|
36
32
|
"typescript": "5.9.3",
|
|
37
33
|
"vitest": "4.0.18",
|
|
38
|
-
"@bensandee/config": "0.
|
|
34
|
+
"@bensandee/config": "0.5.0"
|
|
39
35
|
},
|
|
40
36
|
"scripts": {
|
|
41
37
|
"build": "tsdown",
|
package/dist/bin.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { };
|
package/dist/index.d.mts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
//#region src/utils/json.d.ts
|
|
4
|
-
declare const PackageJsonSchema: z.ZodObject<{
|
|
5
|
-
name: z.ZodOptional<z.ZodString>;
|
|
6
|
-
version: z.ZodOptional<z.ZodString>;
|
|
7
|
-
private: z.ZodOptional<z.ZodBoolean>;
|
|
8
|
-
type: z.ZodOptional<z.ZodString>;
|
|
9
|
-
scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
10
|
-
dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
11
|
-
devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
12
|
-
bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
|
|
13
|
-
exports: z.ZodOptional<z.ZodUnknown>;
|
|
14
|
-
main: z.ZodOptional<z.ZodString>;
|
|
15
|
-
types: z.ZodOptional<z.ZodString>;
|
|
16
|
-
typings: z.ZodOptional<z.ZodString>;
|
|
17
|
-
engines: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
18
|
-
}, z.core.$loose>;
|
|
19
|
-
type PackageJson = z.infer<typeof PackageJsonSchema>;
|
|
20
|
-
//#endregion
|
|
21
|
-
//#region src/types.d.ts
|
|
22
|
-
type CiPlatform = "github" | "forgejo" | "none";
|
|
23
|
-
type ReleaseStrategy = "release-it" | "commit-and-tag-version" | "changesets" | "none";
|
|
24
|
-
/** User's answers from the interactive prompt or CLI flags. */
|
|
25
|
-
interface ProjectConfig {
|
|
26
|
-
/** Project name (from package.json name or user input) */
|
|
27
|
-
name: string;
|
|
28
|
-
/** Whether this is a new project or existing */
|
|
29
|
-
isNew: boolean;
|
|
30
|
-
/** Project structure */
|
|
31
|
-
structure: "single" | "monorepo";
|
|
32
|
-
/** Include @bensandee/lint-rules oxlint plugin */
|
|
33
|
-
useLintRules: boolean;
|
|
34
|
-
/** Formatter choice */
|
|
35
|
-
formatter: "oxfmt" | "prettier";
|
|
36
|
-
/** Set up vitest with a starter test */
|
|
37
|
-
setupVitest: boolean;
|
|
38
|
-
/** CI platform choice */
|
|
39
|
-
ci: CiPlatform;
|
|
40
|
-
/** Set up Renovate for automated dependency updates */
|
|
41
|
-
setupRenovate: boolean;
|
|
42
|
-
/** Release management strategy */
|
|
43
|
-
releaseStrategy: ReleaseStrategy;
|
|
44
|
-
/** Project type determines tsconfig base configuration */
|
|
45
|
-
projectType: "default" | "node" | "react" | "library";
|
|
46
|
-
/** Auto-detect and configure tsconfig bases for monorepo packages */
|
|
47
|
-
detectPackageTypes: boolean;
|
|
48
|
-
/** Target directory (default: cwd) */
|
|
49
|
-
targetDir: string;
|
|
50
|
-
}
|
|
51
|
-
/** Result from a single generator: what file was written and how. */
|
|
52
|
-
interface GeneratorResult {
|
|
53
|
-
filePath: string;
|
|
54
|
-
action: "created" | "updated" | "skipped";
|
|
55
|
-
/** Human-readable description of what changed */
|
|
56
|
-
description: string;
|
|
57
|
-
}
|
|
58
|
-
/** Context passed to each generator function. */
|
|
59
|
-
interface GeneratorContext {
|
|
60
|
-
config: ProjectConfig;
|
|
61
|
-
/** Absolute path to target directory */
|
|
62
|
-
targetDir: string;
|
|
63
|
-
/** Pre-parsed package.json from the target directory, or undefined if missing/invalid */
|
|
64
|
-
packageJson: PackageJson | undefined;
|
|
65
|
-
/** Check whether a file exists in the target directory */
|
|
66
|
-
exists: (relativePath: string) => boolean;
|
|
67
|
-
/** Read an existing file from the target directory, returns undefined if not found */
|
|
68
|
-
read: (relativePath: string) => string | undefined;
|
|
69
|
-
/** Write a file to the target directory (creating directories as needed) */
|
|
70
|
-
write: (relativePath: string, content: string) => void;
|
|
71
|
-
/** Prompt user for conflict resolution on non-mergeable files */
|
|
72
|
-
confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
|
|
73
|
-
}
|
|
74
|
-
/** Generator function signature. */
|
|
75
|
-
type Generator = (ctx: GeneratorContext) => Promise<GeneratorResult>;
|
|
76
|
-
/** State detected from an existing project directory. */
|
|
77
|
-
interface DetectedProjectState {
|
|
78
|
-
hasPackageJson: boolean;
|
|
79
|
-
hasTsconfig: boolean;
|
|
80
|
-
hasOxlintConfig: boolean;
|
|
81
|
-
/** Legacy .oxlintrc.json found (should be migrated to oxlint.config.ts) */
|
|
82
|
-
hasLegacyOxlintJson: boolean;
|
|
83
|
-
hasGitignore: boolean;
|
|
84
|
-
hasVitestConfig: boolean;
|
|
85
|
-
hasTsdownConfig: boolean;
|
|
86
|
-
hasPnpmWorkspace: boolean;
|
|
87
|
-
hasKnipConfig: boolean;
|
|
88
|
-
hasRenovateConfig: boolean;
|
|
89
|
-
hasReleaseItConfig: boolean;
|
|
90
|
-
hasCommitAndTagVersionConfig: boolean;
|
|
91
|
-
hasChangesetsConfig: boolean;
|
|
92
|
-
/** Legacy tooling configs found */
|
|
93
|
-
legacyConfigs: LegacyConfig[];
|
|
94
|
-
}
|
|
95
|
-
declare const LEGACY_TOOLS: readonly ["eslint", "prettier", "jest", "webpack", "rollup"];
|
|
96
|
-
type LegacyTool = (typeof LEGACY_TOOLS)[number];
|
|
97
|
-
interface LegacyConfig {
|
|
98
|
-
tool: LegacyTool;
|
|
99
|
-
files: string[];
|
|
100
|
-
}
|
|
101
|
-
//#endregion
|
|
102
|
-
export { type DetectedProjectState, type Generator, type GeneratorContext, type GeneratorResult, type LegacyConfig, type ProjectConfig };
|