@bensandee/tooling 0.6.2 → 0.7.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.
- package/dist/bin.mjs +399 -136
- package/package.json +4 -3
package/dist/bin.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
|
|
|
7
7
|
import JSON5 from "json5";
|
|
8
8
|
import { parse } from "jsonc-parser";
|
|
9
9
|
import { z } from "zod";
|
|
10
|
+
import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
|
|
10
11
|
|
|
11
12
|
//#region src/types.ts
|
|
12
13
|
/** Default CI platform when not explicitly chosen. */
|
|
@@ -63,6 +64,16 @@ function parseRenovateJson(raw) {
|
|
|
63
64
|
return {};
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
const ChangesetConfigSchema = z.object({ commit: z.union([z.boolean(), z.string()]).optional() }).loose();
|
|
68
|
+
/** Parse a JSON string as a .changeset/config.json. Returns `undefined` on failure. */
|
|
69
|
+
function parseChangesetConfig(raw) {
|
|
70
|
+
try {
|
|
71
|
+
const result = ChangesetConfigSchema.safeParse(JSON.parse(raw));
|
|
72
|
+
return result.success ? result.data : void 0;
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
66
77
|
/** Parse a JSON string as a package.json. Returns `undefined` on failure. */
|
|
67
78
|
function parsePackageJson(raw) {
|
|
68
79
|
try {
|
|
@@ -431,6 +442,32 @@ function createContext(config, confirmOverwrite) {
|
|
|
431
442
|
archivedFiles
|
|
432
443
|
};
|
|
433
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Create a read-only GeneratorContext for dry-run checks.
|
|
447
|
+
* Reads from the real filesystem but captures writes instead of applying them.
|
|
448
|
+
* confirmOverwrite always returns "overwrite" to surface all potential changes.
|
|
449
|
+
*/
|
|
450
|
+
function createDryRunContext(config) {
|
|
451
|
+
const pkgRaw = readFile(config.targetDir, "package.json");
|
|
452
|
+
const pendingWrites = /* @__PURE__ */ new Map();
|
|
453
|
+
const shadow = /* @__PURE__ */ new Map();
|
|
454
|
+
return {
|
|
455
|
+
ctx: {
|
|
456
|
+
config,
|
|
457
|
+
targetDir: config.targetDir,
|
|
458
|
+
packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
|
|
459
|
+
exists: (rel) => shadow.has(rel) || fileExists(config.targetDir, rel),
|
|
460
|
+
read: (rel) => shadow.get(rel) ?? readFile(config.targetDir, rel),
|
|
461
|
+
write: (rel, content) => {
|
|
462
|
+
pendingWrites.set(rel, content);
|
|
463
|
+
shadow.set(rel, content);
|
|
464
|
+
},
|
|
465
|
+
remove: () => {},
|
|
466
|
+
confirmOverwrite: async () => "overwrite"
|
|
467
|
+
},
|
|
468
|
+
pendingWrites
|
|
469
|
+
};
|
|
470
|
+
}
|
|
434
471
|
|
|
435
472
|
//#endregion
|
|
436
473
|
//#region src/generators/package-json.ts
|
|
@@ -492,7 +529,6 @@ function addReleaseDeps(deps, config) {
|
|
|
492
529
|
case "release-it":
|
|
493
530
|
deps["release-it"] = "18.1.2";
|
|
494
531
|
if (config.structure === "monorepo") deps["@release-it/bumper"] = "7.0.2";
|
|
495
|
-
if (config.ci === "forgejo") deps["@bensandee/release-it-forgejo"] = "0.1.1";
|
|
496
532
|
break;
|
|
497
533
|
case "commit-and-tag-version":
|
|
498
534
|
deps["commit-and-tag-version"] = "12.5.0";
|
|
@@ -555,6 +591,9 @@ async function generatePackageJson(ctx) {
|
|
|
555
591
|
for (const [key, value] of Object.entries(devDeps)) if (!(key in existingDevDeps)) {
|
|
556
592
|
existingDevDeps[key] = value;
|
|
557
593
|
changes.push(`added devDependency: ${key}`);
|
|
594
|
+
} else if (key.startsWith("@bensandee/") && value === "latest" && existingDevDeps[key] !== "latest" && existingDevDeps[key] !== "workspace:*") {
|
|
595
|
+
existingDevDeps[key] = "latest";
|
|
596
|
+
changes.push(`updated devDependency: ${key} to latest`);
|
|
558
597
|
}
|
|
559
598
|
pkg.devDependencies = existingDevDeps;
|
|
560
599
|
if (!pkg["engines"]) {
|
|
@@ -766,42 +805,81 @@ async function generateTsconfig(ctx) {
|
|
|
766
805
|
const existing = ctx.read(filePath);
|
|
767
806
|
if (ctx.config.structure === "monorepo") return [generateMonorepoRootTsconfig(ctx, existing), ...ctx.config.detectPackageTypes ? generateMonorepoPackageTsconfigs(ctx) : []];
|
|
768
807
|
const extendsValue = `@bensandee/config/tsconfig/${ctx.config.projectType}`;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const changes = [];
|
|
777
|
-
if (!parsed.extends) {
|
|
778
|
-
parsed.extends = extendsValue;
|
|
779
|
-
changes.push(`added extends: ${extendsValue}`);
|
|
780
|
-
}
|
|
781
|
-
const existingInclude = parsed.include ?? [];
|
|
782
|
-
for (const entry of config.include) if (!existingInclude.includes(entry)) {
|
|
783
|
-
existingInclude.push(entry);
|
|
784
|
-
changes.push(`added "${entry}" to include`);
|
|
785
|
-
}
|
|
786
|
-
parsed.include = existingInclude;
|
|
787
|
-
if (changes.length === 0) return [{
|
|
788
|
-
filePath,
|
|
789
|
-
action: "skipped",
|
|
790
|
-
description: "Already up to spec"
|
|
791
|
-
}];
|
|
792
|
-
ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
|
|
808
|
+
if (!existing) {
|
|
809
|
+
const config = {
|
|
810
|
+
extends: extendsValue,
|
|
811
|
+
include: ["src"],
|
|
812
|
+
exclude: ["node_modules", "dist"]
|
|
813
|
+
};
|
|
814
|
+
ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
|
|
793
815
|
return [{
|
|
794
816
|
filePath,
|
|
795
|
-
action: "
|
|
796
|
-
description:
|
|
817
|
+
action: "created",
|
|
818
|
+
description: `Generated tsconfig.json with ${extendsValue}`
|
|
797
819
|
}];
|
|
798
820
|
}
|
|
799
|
-
|
|
800
|
-
return [{
|
|
821
|
+
if (existing.includes("// @bensandee/tooling:ignore")) return [{
|
|
801
822
|
filePath,
|
|
802
|
-
action: "
|
|
803
|
-
description:
|
|
823
|
+
action: "skipped",
|
|
824
|
+
description: "Ignored via tooling:ignore comment"
|
|
804
825
|
}];
|
|
826
|
+
const parsed = parseTsconfig(existing);
|
|
827
|
+
if (isSolutionStyle(parsed)) {
|
|
828
|
+
const results = [{
|
|
829
|
+
filePath,
|
|
830
|
+
action: "skipped",
|
|
831
|
+
description: "Solution-style tsconfig — traversing references"
|
|
832
|
+
}];
|
|
833
|
+
for (const ref of parsed.references ?? []) {
|
|
834
|
+
const refPath = resolveReferencePath(ref.path);
|
|
835
|
+
results.push(mergeSingleTsconfig(ctx, refPath, extendsValue));
|
|
836
|
+
}
|
|
837
|
+
return results;
|
|
838
|
+
}
|
|
839
|
+
return [mergeSingleTsconfig(ctx, filePath, extendsValue)];
|
|
840
|
+
}
|
|
841
|
+
function isSolutionStyle(parsed) {
|
|
842
|
+
return Array.isArray(parsed.references) && parsed.references.length > 0 && Array.isArray(parsed.files) && parsed.files.length === 0;
|
|
843
|
+
}
|
|
844
|
+
function resolveReferencePath(refPath) {
|
|
845
|
+
const resolved = refPath.endsWith(".json") ? refPath : path.join(refPath, "tsconfig.json");
|
|
846
|
+
return path.normalize(resolved);
|
|
847
|
+
}
|
|
848
|
+
function mergeSingleTsconfig(ctx, filePath, extendsValue) {
|
|
849
|
+
const existing = ctx.read(filePath);
|
|
850
|
+
if (!existing) return {
|
|
851
|
+
filePath,
|
|
852
|
+
action: "skipped",
|
|
853
|
+
description: "File not found"
|
|
854
|
+
};
|
|
855
|
+
if (existing.includes("// @bensandee/tooling:ignore")) return {
|
|
856
|
+
filePath,
|
|
857
|
+
action: "skipped",
|
|
858
|
+
description: "Ignored via tooling:ignore comment"
|
|
859
|
+
};
|
|
860
|
+
const parsed = parseTsconfig(existing);
|
|
861
|
+
const changes = [];
|
|
862
|
+
if (!parsed.extends) {
|
|
863
|
+
parsed.extends = extendsValue;
|
|
864
|
+
changes.push(`added extends: ${extendsValue}`);
|
|
865
|
+
}
|
|
866
|
+
const existingInclude = parsed.include ?? [];
|
|
867
|
+
if (!existingInclude.includes("src")) {
|
|
868
|
+
existingInclude.push("src");
|
|
869
|
+
changes.push("added \"src\" to include");
|
|
870
|
+
}
|
|
871
|
+
parsed.include = existingInclude;
|
|
872
|
+
if (changes.length === 0) return {
|
|
873
|
+
filePath,
|
|
874
|
+
action: "skipped",
|
|
875
|
+
description: "Already up to spec"
|
|
876
|
+
};
|
|
877
|
+
ctx.write(filePath, JSON.stringify(parsed, null, 2) + "\n");
|
|
878
|
+
return {
|
|
879
|
+
filePath,
|
|
880
|
+
action: "updated",
|
|
881
|
+
description: changes.join(", ")
|
|
882
|
+
};
|
|
805
883
|
}
|
|
806
884
|
function generateMonorepoRootTsconfig(ctx, existing) {
|
|
807
885
|
const filePath = "tsconfig.json";
|
|
@@ -843,6 +921,14 @@ function generateMonorepoPackageTsconfigs(ctx) {
|
|
|
843
921
|
const projectType = detectProjectType(pkg.dir);
|
|
844
922
|
const extendsValue = `@bensandee/config/tsconfig/${projectType}`;
|
|
845
923
|
if (existing) {
|
|
924
|
+
if (existing.includes("// @bensandee/tooling:ignore")) {
|
|
925
|
+
results.push({
|
|
926
|
+
filePath,
|
|
927
|
+
action: "skipped",
|
|
928
|
+
description: "Ignored via tooling:ignore comment"
|
|
929
|
+
});
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
846
932
|
const parsed = parseTsconfig(existing);
|
|
847
933
|
const changes = [];
|
|
848
934
|
if (parsed.extends !== extendsValue) {
|
|
@@ -918,7 +1004,12 @@ async function generateVitest(ctx) {
|
|
|
918
1004
|
description: "Monorepo: vitest config belongs in individual packages"
|
|
919
1005
|
}];
|
|
920
1006
|
const configPath = "vitest.config.ts";
|
|
921
|
-
if (ctx.exists(configPath)) if (
|
|
1007
|
+
if (ctx.exists(configPath)) if (ctx.read(configPath) === VITEST_CONFIG) results.push({
|
|
1008
|
+
filePath: configPath,
|
|
1009
|
+
action: "skipped",
|
|
1010
|
+
description: "Config already up to date"
|
|
1011
|
+
});
|
|
1012
|
+
else if (await ctx.confirmOverwrite(configPath) === "skip") results.push({
|
|
922
1013
|
filePath: configPath,
|
|
923
1014
|
action: "skipped",
|
|
924
1015
|
description: "Existing config preserved"
|
|
@@ -972,7 +1063,7 @@ async function generateOxlint(ctx) {
|
|
|
972
1063
|
const content = ctx.config.useEslintPlugin ? CONFIG_WITH_LINT_RULES : CONFIG_PRESET_ONLY;
|
|
973
1064
|
const existing = ctx.read(filePath);
|
|
974
1065
|
if (existing) {
|
|
975
|
-
if (existing === content) return {
|
|
1066
|
+
if (existing === content || existing.includes("@bensandee/config/oxlint")) return {
|
|
976
1067
|
filePath,
|
|
977
1068
|
action: "skipped",
|
|
978
1069
|
description: "Already configured"
|
|
@@ -1008,8 +1099,13 @@ async function generateFormatter(ctx) {
|
|
|
1008
1099
|
}
|
|
1009
1100
|
async function generateOxfmt(ctx) {
|
|
1010
1101
|
const filePath = ".oxfmtrc.json";
|
|
1011
|
-
const
|
|
1012
|
-
if (
|
|
1102
|
+
const existing = ctx.read(filePath);
|
|
1103
|
+
if (existing) {
|
|
1104
|
+
if (existing === OXFMT_CONFIG) return {
|
|
1105
|
+
filePath,
|
|
1106
|
+
action: "skipped",
|
|
1107
|
+
description: "Already configured"
|
|
1108
|
+
};
|
|
1013
1109
|
if (await ctx.confirmOverwrite(filePath) === "skip") return {
|
|
1014
1110
|
filePath,
|
|
1015
1111
|
action: "skipped",
|
|
@@ -1019,7 +1115,7 @@ async function generateOxfmt(ctx) {
|
|
|
1019
1115
|
ctx.write(filePath, OXFMT_CONFIG);
|
|
1020
1116
|
return {
|
|
1021
1117
|
filePath,
|
|
1022
|
-
action:
|
|
1118
|
+
action: existing ? "updated" : "created",
|
|
1023
1119
|
description: "Generated .oxfmtrc.json"
|
|
1024
1120
|
};
|
|
1025
1121
|
}
|
|
@@ -1053,7 +1149,13 @@ async function generateTsdown(ctx) {
|
|
|
1053
1149
|
action: "skipped",
|
|
1054
1150
|
description: "Monorepo: tsdown config belongs in individual packages"
|
|
1055
1151
|
};
|
|
1056
|
-
|
|
1152
|
+
const existing = ctx.read(filePath);
|
|
1153
|
+
if (existing) {
|
|
1154
|
+
if (existing === TSDOWN_CONFIG) return {
|
|
1155
|
+
filePath,
|
|
1156
|
+
action: "skipped",
|
|
1157
|
+
description: "Already configured"
|
|
1158
|
+
};
|
|
1057
1159
|
if (await ctx.confirmOverwrite(filePath) === "skip") return {
|
|
1058
1160
|
filePath,
|
|
1059
1161
|
action: "skipped",
|
|
@@ -1063,23 +1165,25 @@ async function generateTsdown(ctx) {
|
|
|
1063
1165
|
ctx.write(filePath, TSDOWN_CONFIG);
|
|
1064
1166
|
return {
|
|
1065
1167
|
filePath,
|
|
1066
|
-
action: "created",
|
|
1168
|
+
action: existing ? "updated" : "created",
|
|
1067
1169
|
description: "Generated tsdown.config.ts"
|
|
1068
1170
|
};
|
|
1069
1171
|
}
|
|
1070
1172
|
|
|
1071
1173
|
//#endregion
|
|
1072
1174
|
//#region src/generators/gitignore.ts
|
|
1073
|
-
|
|
1175
|
+
/** Entries that every project should have — repo:check flags these as missing. */
|
|
1176
|
+
const REQUIRED_ENTRIES = [
|
|
1074
1177
|
"node_modules/",
|
|
1075
1178
|
"dist/",
|
|
1076
1179
|
"*.tsbuildinfo",
|
|
1077
1180
|
".env",
|
|
1078
1181
|
".env.*",
|
|
1079
|
-
"!.env.example"
|
|
1080
|
-
".tooling-migrate.md",
|
|
1081
|
-
".tooling-archived/"
|
|
1182
|
+
"!.env.example"
|
|
1082
1183
|
];
|
|
1184
|
+
/** Tooling-specific entries added during init/update but not required for repo:check. */
|
|
1185
|
+
const OPTIONAL_ENTRIES = [".tooling-migrate.md", ".tooling-archived/"];
|
|
1186
|
+
const ALL_ENTRIES = [...REQUIRED_ENTRIES, ...OPTIONAL_ENTRIES];
|
|
1083
1187
|
/** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
|
|
1084
1188
|
function normalizeEntry(entry) {
|
|
1085
1189
|
let s = entry.trim();
|
|
@@ -1092,21 +1196,27 @@ async function generateGitignore(ctx) {
|
|
|
1092
1196
|
const existing = ctx.read(filePath);
|
|
1093
1197
|
if (existing) {
|
|
1094
1198
|
const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
|
|
1095
|
-
const missing =
|
|
1199
|
+
const missing = ALL_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
|
|
1096
1200
|
if (missing.length === 0) return {
|
|
1097
1201
|
filePath,
|
|
1098
1202
|
action: "skipped",
|
|
1099
1203
|
description: "Already has all standard entries"
|
|
1100
1204
|
};
|
|
1205
|
+
const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
|
|
1101
1206
|
const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
|
|
1102
1207
|
ctx.write(filePath, updated);
|
|
1208
|
+
if (missingRequired.length === 0) return {
|
|
1209
|
+
filePath,
|
|
1210
|
+
action: "skipped",
|
|
1211
|
+
description: "Only optional entries missing"
|
|
1212
|
+
};
|
|
1103
1213
|
return {
|
|
1104
1214
|
filePath,
|
|
1105
1215
|
action: "updated",
|
|
1106
1216
|
description: `Appended ${String(missing.length)} missing entries`
|
|
1107
1217
|
};
|
|
1108
1218
|
}
|
|
1109
|
-
ctx.write(filePath,
|
|
1219
|
+
ctx.write(filePath, ALL_ENTRIES.join("\n") + "\n");
|
|
1110
1220
|
return {
|
|
1111
1221
|
filePath,
|
|
1112
1222
|
action: "created",
|
|
@@ -1117,7 +1227,9 @@ async function generateGitignore(ctx) {
|
|
|
1117
1227
|
//#endregion
|
|
1118
1228
|
//#region src/generators/ci.ts
|
|
1119
1229
|
function hasEnginesNode$1(ctx) {
|
|
1120
|
-
|
|
1230
|
+
const raw = ctx.read("package.json");
|
|
1231
|
+
if (!raw) return false;
|
|
1232
|
+
return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
|
|
1121
1233
|
}
|
|
1122
1234
|
function ciWorkflow(isMonorepo, nodeVersionYaml) {
|
|
1123
1235
|
return `name: CI
|
|
@@ -1144,6 +1256,7 @@ jobs:
|
|
|
1144
1256
|
- run: ${isMonorepo ? "pnpm -r test" : "pnpm test"}
|
|
1145
1257
|
- run: pnpm format --check
|
|
1146
1258
|
- run: pnpm knip
|
|
1259
|
+
- run: pnpm exec tooling repo:check
|
|
1147
1260
|
`;
|
|
1148
1261
|
}
|
|
1149
1262
|
async function generateCi(ctx) {
|
|
@@ -1158,10 +1271,20 @@ async function generateCi(ctx) {
|
|
|
1158
1271
|
const filePath = isGitHub ? ".github/workflows/check.yml" : ".forgejo/workflows/check.yml";
|
|
1159
1272
|
const content = ciWorkflow(isMonorepo, nodeVersionYaml);
|
|
1160
1273
|
if (ctx.exists(filePath)) {
|
|
1161
|
-
|
|
1274
|
+
const existing = ctx.read(filePath);
|
|
1275
|
+
if (existing && !existing.includes("repo:check")) {
|
|
1276
|
+
const patched = existing.trimEnd() + "\n - run: pnpm exec tooling repo:check\n";
|
|
1277
|
+
ctx.write(filePath, patched);
|
|
1278
|
+
return {
|
|
1279
|
+
filePath,
|
|
1280
|
+
action: "updated",
|
|
1281
|
+
description: "Added `pnpm exec tooling repo:check` step to CI workflow"
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
return {
|
|
1162
1285
|
filePath,
|
|
1163
1286
|
action: "skipped",
|
|
1164
|
-
description: "
|
|
1287
|
+
description: "CI workflow already up to date"
|
|
1165
1288
|
};
|
|
1166
1289
|
}
|
|
1167
1290
|
ctx.write(filePath, content);
|
|
@@ -1465,7 +1588,6 @@ function buildConfig$2(ci, isMonorepo) {
|
|
|
1465
1588
|
};
|
|
1466
1589
|
if (ci === "github") config["github"] = { release: true };
|
|
1467
1590
|
const plugins = {};
|
|
1468
|
-
if (ci === "forgejo") plugins["@bensandee/release-it-forgejo"] = { release: true };
|
|
1469
1591
|
if (isMonorepo) {
|
|
1470
1592
|
config["npm"] = {
|
|
1471
1593
|
publish: true,
|
|
@@ -1529,18 +1651,11 @@ async function generateChangesets(ctx) {
|
|
|
1529
1651
|
};
|
|
1530
1652
|
const content = JSON.stringify(buildConfig$1(), null, 2) + "\n";
|
|
1531
1653
|
const existing = ctx.read(filePath);
|
|
1532
|
-
if (existing) {
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
};
|
|
1538
|
-
if (await ctx.confirmOverwrite(filePath) === "skip") return {
|
|
1539
|
-
filePath,
|
|
1540
|
-
action: "skipped",
|
|
1541
|
-
description: "Existing changesets config preserved"
|
|
1542
|
-
};
|
|
1543
|
-
}
|
|
1654
|
+
if (existing) return {
|
|
1655
|
+
filePath,
|
|
1656
|
+
action: "skipped",
|
|
1657
|
+
description: "Existing changesets config preserved"
|
|
1658
|
+
};
|
|
1544
1659
|
ctx.write(filePath, content);
|
|
1545
1660
|
return {
|
|
1546
1661
|
filePath,
|
|
@@ -1706,13 +1821,11 @@ async function generateReleaseCi(ctx) {
|
|
|
1706
1821
|
action: "skipped",
|
|
1707
1822
|
description: "Release CI workflow not applicable"
|
|
1708
1823
|
};
|
|
1709
|
-
if (ctx.exists(workflowPath)) {
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
};
|
|
1715
|
-
}
|
|
1824
|
+
if (ctx.exists(workflowPath)) return {
|
|
1825
|
+
filePath: workflowPath,
|
|
1826
|
+
action: "skipped",
|
|
1827
|
+
description: "Existing release workflow preserved"
|
|
1828
|
+
};
|
|
1716
1829
|
ctx.write(workflowPath, content);
|
|
1717
1830
|
return {
|
|
1718
1831
|
filePath: workflowPath,
|
|
@@ -1833,43 +1946,127 @@ async function generateLefthook(ctx) {
|
|
|
1833
1946
|
archiveLintStagedConfigs(ctx, results);
|
|
1834
1947
|
cleanPackageJson(ctx, results);
|
|
1835
1948
|
const existingPath = LEFTHOOK_CONFIG_PATHS.find((p) => ctx.exists(p));
|
|
1836
|
-
if (existingPath
|
|
1837
|
-
const existing = ctx.read(filePath);
|
|
1838
|
-
if (existing) {
|
|
1839
|
-
if (existing === content) {
|
|
1840
|
-
results.push({
|
|
1841
|
-
filePath,
|
|
1842
|
-
action: "skipped",
|
|
1843
|
-
description: "Already configured"
|
|
1844
|
-
});
|
|
1845
|
-
return results;
|
|
1846
|
-
}
|
|
1847
|
-
if (await ctx.confirmOverwrite(filePath) === "skip") {
|
|
1848
|
-
results.push({
|
|
1849
|
-
filePath,
|
|
1850
|
-
action: "skipped",
|
|
1851
|
-
description: "Existing lefthook config preserved"
|
|
1852
|
-
});
|
|
1853
|
-
return results;
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
} else if (existingPath) {
|
|
1949
|
+
if (existingPath) {
|
|
1857
1950
|
results.push({
|
|
1858
1951
|
filePath: existingPath,
|
|
1859
1952
|
action: "skipped",
|
|
1860
|
-
description:
|
|
1953
|
+
description: "Existing lefthook config preserved"
|
|
1861
1954
|
});
|
|
1862
1955
|
return results;
|
|
1863
1956
|
}
|
|
1864
1957
|
ctx.write(filePath, content);
|
|
1865
1958
|
results.push({
|
|
1866
1959
|
filePath,
|
|
1867
|
-
action:
|
|
1960
|
+
action: "created",
|
|
1868
1961
|
description: "Generated lefthook pre-commit config"
|
|
1869
1962
|
});
|
|
1870
1963
|
return results;
|
|
1871
1964
|
}
|
|
1872
1965
|
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region src/generators/pipeline.ts
|
|
1968
|
+
/** Run all generators sequentially and return their results. */
|
|
1969
|
+
async function runGenerators(ctx) {
|
|
1970
|
+
const results = [];
|
|
1971
|
+
results.push(await generatePackageJson(ctx));
|
|
1972
|
+
results.push(await generatePnpmWorkspace(ctx));
|
|
1973
|
+
results.push(...await generateTsconfig(ctx));
|
|
1974
|
+
results.push(await generateTsdown(ctx));
|
|
1975
|
+
results.push(await generateOxlint(ctx));
|
|
1976
|
+
results.push(await generateFormatter(ctx));
|
|
1977
|
+
results.push(...await generateLefthook(ctx));
|
|
1978
|
+
results.push(await generateGitignore(ctx));
|
|
1979
|
+
results.push(await generateKnip(ctx));
|
|
1980
|
+
results.push(await generateRenovate(ctx));
|
|
1981
|
+
results.push(await generateCi(ctx));
|
|
1982
|
+
results.push(await generateClaudeSettings(ctx));
|
|
1983
|
+
results.push(await generateReleaseIt(ctx));
|
|
1984
|
+
results.push(await generateChangesets(ctx));
|
|
1985
|
+
results.push(await generateReleaseCi(ctx));
|
|
1986
|
+
results.push(...await generateVitest(ctx));
|
|
1987
|
+
return results;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
//#endregion
|
|
1991
|
+
//#region src/utils/tooling-config.ts
|
|
1992
|
+
const CONFIG_FILE = ".tooling.json";
|
|
1993
|
+
const ToolingConfigSchema = z.object({
|
|
1994
|
+
structure: z.enum(["single", "monorepo"]).optional(),
|
|
1995
|
+
useEslintPlugin: z.boolean().optional(),
|
|
1996
|
+
formatter: z.enum(["oxfmt", "prettier"]).optional(),
|
|
1997
|
+
setupVitest: z.boolean().optional(),
|
|
1998
|
+
ci: z.enum([
|
|
1999
|
+
"github",
|
|
2000
|
+
"forgejo",
|
|
2001
|
+
"none"
|
|
2002
|
+
]).optional(),
|
|
2003
|
+
setupRenovate: z.boolean().optional(),
|
|
2004
|
+
releaseStrategy: z.enum([
|
|
2005
|
+
"release-it",
|
|
2006
|
+
"commit-and-tag-version",
|
|
2007
|
+
"changesets",
|
|
2008
|
+
"none"
|
|
2009
|
+
]).optional(),
|
|
2010
|
+
projectType: z.enum([
|
|
2011
|
+
"default",
|
|
2012
|
+
"node",
|
|
2013
|
+
"react",
|
|
2014
|
+
"library"
|
|
2015
|
+
]).optional(),
|
|
2016
|
+
detectPackageTypes: z.boolean().optional()
|
|
2017
|
+
});
|
|
2018
|
+
/** Load saved tooling config from the target directory. Returns undefined if missing or invalid. */
|
|
2019
|
+
function loadToolingConfig(targetDir) {
|
|
2020
|
+
const fullPath = path.join(targetDir, CONFIG_FILE);
|
|
2021
|
+
if (!existsSync(fullPath)) return void 0;
|
|
2022
|
+
try {
|
|
2023
|
+
const raw = readFileSync(fullPath, "utf-8");
|
|
2024
|
+
const result = ToolingConfigSchema.safeParse(JSON.parse(raw));
|
|
2025
|
+
return result.success ? result.data : void 0;
|
|
2026
|
+
} catch {
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
/** Save the user's config choices to .tooling.json via the generator context. */
|
|
2031
|
+
function saveToolingConfig(ctx, config) {
|
|
2032
|
+
const saved = {
|
|
2033
|
+
structure: config.structure,
|
|
2034
|
+
useEslintPlugin: config.useEslintPlugin,
|
|
2035
|
+
formatter: config.formatter,
|
|
2036
|
+
setupVitest: config.setupVitest,
|
|
2037
|
+
ci: config.ci,
|
|
2038
|
+
setupRenovate: config.setupRenovate,
|
|
2039
|
+
releaseStrategy: config.releaseStrategy,
|
|
2040
|
+
projectType: config.projectType,
|
|
2041
|
+
detectPackageTypes: config.detectPackageTypes
|
|
2042
|
+
};
|
|
2043
|
+
const content = JSON.stringify(saved, null, 2) + "\n";
|
|
2044
|
+
const existed = ctx.exists(CONFIG_FILE);
|
|
2045
|
+
ctx.write(CONFIG_FILE, content);
|
|
2046
|
+
return {
|
|
2047
|
+
filePath: CONFIG_FILE,
|
|
2048
|
+
action: existed ? "updated" : "created",
|
|
2049
|
+
description: "Saved tooling configuration"
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
/** Merge saved config over detected defaults. Saved values win when present. */
|
|
2053
|
+
function mergeWithSavedConfig(detected, saved) {
|
|
2054
|
+
return {
|
|
2055
|
+
name: detected.name,
|
|
2056
|
+
isNew: detected.isNew,
|
|
2057
|
+
targetDir: detected.targetDir,
|
|
2058
|
+
structure: saved.structure ?? detected.structure,
|
|
2059
|
+
useEslintPlugin: saved.useEslintPlugin ?? detected.useEslintPlugin,
|
|
2060
|
+
formatter: saved.formatter ?? detected.formatter,
|
|
2061
|
+
setupVitest: saved.setupVitest ?? detected.setupVitest,
|
|
2062
|
+
ci: saved.ci ?? detected.ci,
|
|
2063
|
+
setupRenovate: saved.setupRenovate ?? detected.setupRenovate,
|
|
2064
|
+
releaseStrategy: saved.releaseStrategy ?? detected.releaseStrategy,
|
|
2065
|
+
projectType: saved.projectType ?? detected.projectType,
|
|
2066
|
+
detectPackageTypes: saved.detectPackageTypes ?? detected.detectPackageTypes
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
|
|
1873
2070
|
//#endregion
|
|
1874
2071
|
//#region src/commands/repo-init.ts
|
|
1875
2072
|
const initCommand = defineCommand({
|
|
@@ -1903,10 +2100,14 @@ const initCommand = defineCommand({
|
|
|
1903
2100
|
},
|
|
1904
2101
|
async run({ args }) {
|
|
1905
2102
|
const targetDir = path.resolve(args.dir ?? ".");
|
|
1906
|
-
await runInit(args.yes ?
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2103
|
+
await runInit(args.yes ? (() => {
|
|
2104
|
+
const saved = loadToolingConfig(targetDir);
|
|
2105
|
+
const detected = buildDefaultConfig(targetDir, {
|
|
2106
|
+
eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
|
|
2107
|
+
noCi: args["no-ci"] === true ? true : void 0
|
|
2108
|
+
});
|
|
2109
|
+
return saved ? mergeWithSavedConfig(detected, saved) : detected;
|
|
2110
|
+
})() : await runInitPrompts(targetDir), args["no-prompt"] === true ? { noPrompt: true } : {});
|
|
1910
2111
|
}
|
|
1911
2112
|
});
|
|
1912
2113
|
async function runInit(config, options = {}) {
|
|
@@ -1928,25 +2129,9 @@ async function runInit(config, options = {}) {
|
|
|
1928
2129
|
if (p.isCancel(result)) return "skip";
|
|
1929
2130
|
return result;
|
|
1930
2131
|
}));
|
|
1931
|
-
const results = [];
|
|
1932
2132
|
s.start("Generating configuration files...");
|
|
1933
|
-
results
|
|
1934
|
-
results.push(
|
|
1935
|
-
results.push(...await generateTsconfig(ctx));
|
|
1936
|
-
results.push(await generateTsdown(ctx));
|
|
1937
|
-
results.push(await generateOxlint(ctx));
|
|
1938
|
-
results.push(await generateFormatter(ctx));
|
|
1939
|
-
results.push(...await generateLefthook(ctx));
|
|
1940
|
-
results.push(await generateGitignore(ctx));
|
|
1941
|
-
results.push(await generateKnip(ctx));
|
|
1942
|
-
results.push(await generateRenovate(ctx));
|
|
1943
|
-
results.push(await generateCi(ctx));
|
|
1944
|
-
results.push(await generateClaudeSettings(ctx));
|
|
1945
|
-
results.push(await generateReleaseIt(ctx));
|
|
1946
|
-
results.push(await generateChangesets(ctx));
|
|
1947
|
-
results.push(await generateReleaseCi(ctx));
|
|
1948
|
-
const vitestResults = await generateVitest(ctx);
|
|
1949
|
-
results.push(...vitestResults);
|
|
2133
|
+
const results = await runGenerators(ctx);
|
|
2134
|
+
results.push(saveToolingConfig(ctx, config));
|
|
1950
2135
|
const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
|
|
1951
2136
|
for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
|
|
1952
2137
|
filePath: rel,
|
|
@@ -1977,14 +2162,26 @@ async function runInit(config, options = {}) {
|
|
|
1977
2162
|
p.log.info(`Migration prompt written to ${promptPath}`);
|
|
1978
2163
|
p.log.info("Paste its contents into Claude Code to finish the migration.");
|
|
1979
2164
|
}
|
|
1980
|
-
const
|
|
2165
|
+
const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
|
|
2166
|
+
const hasLockfile = ctx.exists("pnpm-lock.yaml");
|
|
2167
|
+
if (bensandeeDeps.length > 0 && hasLockfile) {
|
|
2168
|
+
s.start("Updating @bensandee/* packages...");
|
|
2169
|
+
try {
|
|
2170
|
+
execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
|
|
2171
|
+
cwd: config.targetDir,
|
|
2172
|
+
stdio: "ignore"
|
|
2173
|
+
});
|
|
2174
|
+
s.stop("Updated @bensandee/* packages");
|
|
2175
|
+
} catch (_error) {
|
|
2176
|
+
s.stop("Could not update @bensandee/* packages — run pnpm install first");
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
1981
2179
|
p.note([
|
|
1982
2180
|
"1. Run: pnpm install",
|
|
1983
|
-
|
|
1984
|
-
"3. Run: pnpm
|
|
1985
|
-
"4. Run: pnpm
|
|
1986
|
-
"5.
|
|
1987
|
-
...options.noPrompt ? [] : ["6. Paste .tooling-migrate.md into Claude Code for cleanup"]
|
|
2181
|
+
"2. Run: pnpm typecheck",
|
|
2182
|
+
"3. Run: pnpm build",
|
|
2183
|
+
"4. Run: pnpm test",
|
|
2184
|
+
...options.noPrompt ? [] : ["5. Paste .tooling-migrate.md into Claude Code for cleanup"]
|
|
1988
2185
|
].join("\n"), "Next steps");
|
|
1989
2186
|
return results;
|
|
1990
2187
|
}
|
|
@@ -1994,7 +2191,7 @@ async function runInit(config, options = {}) {
|
|
|
1994
2191
|
const updateCommand = defineCommand({
|
|
1995
2192
|
meta: {
|
|
1996
2193
|
name: "repo:update",
|
|
1997
|
-
description: "
|
|
2194
|
+
description: "Update managed config and add missing files"
|
|
1998
2195
|
},
|
|
1999
2196
|
args: { dir: {
|
|
2000
2197
|
type: "positional",
|
|
@@ -2006,26 +2203,73 @@ const updateCommand = defineCommand({
|
|
|
2006
2203
|
}
|
|
2007
2204
|
});
|
|
2008
2205
|
async function runUpdate(targetDir) {
|
|
2009
|
-
|
|
2206
|
+
const saved = loadToolingConfig(targetDir);
|
|
2207
|
+
const detected = buildDefaultConfig(targetDir, {});
|
|
2208
|
+
if (!saved) p.log.warn("No .tooling.json found — using detected defaults. Run `tooling repo:init` to save your preferences.");
|
|
2209
|
+
return runInit(saved ? mergeWithSavedConfig(detected, saved) : detected, {
|
|
2010
2210
|
noPrompt: true,
|
|
2011
|
-
confirmOverwrite: async () => "
|
|
2211
|
+
confirmOverwrite: async () => "overwrite"
|
|
2012
2212
|
});
|
|
2013
2213
|
}
|
|
2014
2214
|
|
|
2015
2215
|
//#endregion
|
|
2016
|
-
//#region src/
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2216
|
+
//#region src/commands/repo-check.ts
|
|
2217
|
+
const checkCommand = defineCommand({
|
|
2218
|
+
meta: {
|
|
2219
|
+
name: "repo:check",
|
|
2220
|
+
description: "Check repo for tooling drift (dry-run, CI-friendly)"
|
|
2221
|
+
},
|
|
2222
|
+
args: { dir: {
|
|
2223
|
+
type: "positional",
|
|
2224
|
+
description: "Target directory (default: current directory)",
|
|
2225
|
+
required: false
|
|
2226
|
+
} },
|
|
2227
|
+
async run({ args }) {
|
|
2228
|
+
const exitCode = await runCheck(path.resolve(args.dir ?? "."));
|
|
2229
|
+
process.exitCode = exitCode;
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
async function runCheck(targetDir) {
|
|
2233
|
+
const saved = loadToolingConfig(targetDir);
|
|
2234
|
+
const detected = buildDefaultConfig(targetDir, {});
|
|
2235
|
+
if (!saved) p.log.warn("No .tooling.json found — using detected defaults. Run `tooling repo:init` to save your preferences.");
|
|
2236
|
+
const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
|
|
2237
|
+
const actionable = (await runGenerators(ctx)).filter((r) => r.action === "created" || r.action === "updated");
|
|
2238
|
+
if (actionable.length === 0) {
|
|
2239
|
+
p.log.success("Repository is up to date.");
|
|
2240
|
+
return 0;
|
|
2241
|
+
}
|
|
2242
|
+
p.log.warn(`${actionable.length} file(s) would be changed by repo:update`);
|
|
2243
|
+
for (const r of actionable) {
|
|
2244
|
+
p.log.info(` ${r.action}: ${r.filePath} — ${r.description}`);
|
|
2245
|
+
const newContent = pendingWrites.get(r.filePath);
|
|
2246
|
+
if (!newContent) continue;
|
|
2247
|
+
const existingPath = path.join(targetDir, r.filePath);
|
|
2248
|
+
const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
|
|
2249
|
+
if (!existing) {
|
|
2250
|
+
const lineCount = newContent.split("\n").length - 1;
|
|
2251
|
+
p.log.info(` + ${lineCount} new lines`);
|
|
2252
|
+
} else {
|
|
2253
|
+
const diff = lineDiff(existing, newContent);
|
|
2254
|
+
if (diff.length > 0) for (const line of diff) p.log.info(` ${line}`);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
return 1;
|
|
2258
|
+
}
|
|
2259
|
+
const normalize = (line) => line.trimEnd();
|
|
2260
|
+
/** Produce a compact line-level diff summary, ignoring whitespace-only differences. */
|
|
2261
|
+
function lineDiff(oldText, newText) {
|
|
2262
|
+
const oldLines = oldText.split("\n").map(normalize);
|
|
2263
|
+
const newLines = newText.split("\n").map(normalize);
|
|
2264
|
+
const oldSet = new Set(oldLines);
|
|
2265
|
+
const newSet = new Set(newLines);
|
|
2266
|
+
const removed = oldLines.filter((l) => l.trim() !== "" && !newSet.has(l));
|
|
2267
|
+
const added = newLines.filter((l) => l.trim() !== "" && !oldSet.has(l));
|
|
2268
|
+
const lines = [];
|
|
2269
|
+
for (const l of removed) lines.push(`- ${l.trim()}`);
|
|
2270
|
+
for (const l of added) lines.push(`+ ${l.trim()}`);
|
|
2271
|
+
return lines;
|
|
2272
|
+
}
|
|
2029
2273
|
|
|
2030
2274
|
//#endregion
|
|
2031
2275
|
//#region src/utils/exec.ts
|
|
@@ -2104,6 +2348,10 @@ function createRealExecutor() {
|
|
|
2104
2348
|
} catch {
|
|
2105
2349
|
return null;
|
|
2106
2350
|
}
|
|
2351
|
+
},
|
|
2352
|
+
writeFile(filePath, content) {
|
|
2353
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
2354
|
+
writeFileSync(filePath, content);
|
|
2107
2355
|
}
|
|
2108
2356
|
};
|
|
2109
2357
|
}
|
|
@@ -2319,8 +2567,22 @@ async function runVersionMode(executor, config) {
|
|
|
2319
2567
|
p.log.info("Changesets detected — versioning packages");
|
|
2320
2568
|
const packagesBefore = executor.listWorkspacePackages(config.cwd);
|
|
2321
2569
|
debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
|
|
2570
|
+
const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
|
|
2571
|
+
const originalConfig = executor.readFile(changesetConfigPath);
|
|
2572
|
+
if (originalConfig) {
|
|
2573
|
+
const parsed = parseChangesetConfig(originalConfig);
|
|
2574
|
+
if (parsed?.commit) {
|
|
2575
|
+
const patched = {
|
|
2576
|
+
...parsed,
|
|
2577
|
+
commit: false
|
|
2578
|
+
};
|
|
2579
|
+
executor.writeFile(changesetConfigPath, JSON.stringify(patched, null, 2) + "\n");
|
|
2580
|
+
debug(config, "Temporarily disabled changeset commit:true");
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2322
2583
|
const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
|
|
2323
2584
|
debugExec(config, "pnpm changeset version", versionResult);
|
|
2585
|
+
if (originalConfig) executor.writeFile(changesetConfigPath, originalConfig);
|
|
2324
2586
|
if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
|
|
2325
2587
|
debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
|
|
2326
2588
|
const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
|
|
@@ -2701,19 +2963,20 @@ function mergeGitHub(dryRun) {
|
|
|
2701
2963
|
const main = defineCommand({
|
|
2702
2964
|
meta: {
|
|
2703
2965
|
name: "tooling",
|
|
2704
|
-
version: "0.
|
|
2966
|
+
version: "0.7.1",
|
|
2705
2967
|
description: "Bootstrap and maintain standardized TypeScript project tooling"
|
|
2706
2968
|
},
|
|
2707
2969
|
subCommands: {
|
|
2708
2970
|
"repo:init": initCommand,
|
|
2709
2971
|
"repo:update": updateCommand,
|
|
2972
|
+
"repo:check": checkCommand,
|
|
2710
2973
|
"release:changesets": releaseForgejoCommand,
|
|
2711
2974
|
"release:trigger": releaseTriggerCommand,
|
|
2712
2975
|
"release:create-forgejo-release": createForgejoReleaseCommand,
|
|
2713
2976
|
"release:merge": releaseMergeCommand
|
|
2714
2977
|
}
|
|
2715
2978
|
});
|
|
2716
|
-
console.log(`@bensandee/tooling v0.
|
|
2979
|
+
console.log(`@bensandee/tooling v0.7.1`);
|
|
2717
2980
|
runMain(main);
|
|
2718
2981
|
|
|
2719
2982
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bensandee/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tooling": "./dist/bin.mjs"
|
|
@@ -25,14 +25,15 @@
|
|
|
25
25
|
"citty": "^0.2.1",
|
|
26
26
|
"json5": "^2.2.3",
|
|
27
27
|
"jsonc-parser": "^3.3.1",
|
|
28
|
-
"zod": "^4.3.6"
|
|
28
|
+
"zod": "^4.3.6",
|
|
29
|
+
"@bensandee/common": "0.1.0"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"@types/node": "24.10.11",
|
|
32
33
|
"tsdown": "0.20.3",
|
|
33
34
|
"typescript": "5.9.3",
|
|
34
35
|
"vitest": "4.0.18",
|
|
35
|
-
"@bensandee/config": "0.6.
|
|
36
|
+
"@bensandee/config": "0.6.3"
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsdown",
|