@bensandee/tooling 0.6.1 → 0.7.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 +304 -99
- 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. */
|
|
@@ -431,6 +432,32 @@ function createContext(config, confirmOverwrite) {
|
|
|
431
432
|
archivedFiles
|
|
432
433
|
};
|
|
433
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Create a read-only GeneratorContext for dry-run checks.
|
|
437
|
+
* Reads from the real filesystem but captures writes instead of applying them.
|
|
438
|
+
* confirmOverwrite always returns "overwrite" to surface all potential changes.
|
|
439
|
+
*/
|
|
440
|
+
function createDryRunContext(config) {
|
|
441
|
+
const pkgRaw = readFile(config.targetDir, "package.json");
|
|
442
|
+
const pendingWrites = /* @__PURE__ */ new Map();
|
|
443
|
+
const shadow = /* @__PURE__ */ new Map();
|
|
444
|
+
return {
|
|
445
|
+
ctx: {
|
|
446
|
+
config,
|
|
447
|
+
targetDir: config.targetDir,
|
|
448
|
+
packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
|
|
449
|
+
exists: (rel) => shadow.has(rel) || fileExists(config.targetDir, rel),
|
|
450
|
+
read: (rel) => shadow.get(rel) ?? readFile(config.targetDir, rel),
|
|
451
|
+
write: (rel, content) => {
|
|
452
|
+
pendingWrites.set(rel, content);
|
|
453
|
+
shadow.set(rel, content);
|
|
454
|
+
},
|
|
455
|
+
remove: () => {},
|
|
456
|
+
confirmOverwrite: async () => "overwrite"
|
|
457
|
+
},
|
|
458
|
+
pendingWrites
|
|
459
|
+
};
|
|
460
|
+
}
|
|
434
461
|
|
|
435
462
|
//#endregion
|
|
436
463
|
//#region src/generators/package-json.ts
|
|
@@ -772,6 +799,11 @@ async function generateTsconfig(ctx) {
|
|
|
772
799
|
exclude: ["node_modules", "dist"]
|
|
773
800
|
};
|
|
774
801
|
if (existing) {
|
|
802
|
+
if (existing.includes("// @bensandee/tooling:ignore")) return [{
|
|
803
|
+
filePath,
|
|
804
|
+
action: "skipped",
|
|
805
|
+
description: "Ignored via tooling:ignore comment"
|
|
806
|
+
}];
|
|
775
807
|
const parsed = parseTsconfig(existing);
|
|
776
808
|
const changes = [];
|
|
777
809
|
if (!parsed.extends) {
|
|
@@ -843,6 +875,14 @@ function generateMonorepoPackageTsconfigs(ctx) {
|
|
|
843
875
|
const projectType = detectProjectType(pkg.dir);
|
|
844
876
|
const extendsValue = `@bensandee/config/tsconfig/${projectType}`;
|
|
845
877
|
if (existing) {
|
|
878
|
+
if (existing.includes("// @bensandee/tooling:ignore")) {
|
|
879
|
+
results.push({
|
|
880
|
+
filePath,
|
|
881
|
+
action: "skipped",
|
|
882
|
+
description: "Ignored via tooling:ignore comment"
|
|
883
|
+
});
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
846
886
|
const parsed = parseTsconfig(existing);
|
|
847
887
|
const changes = [];
|
|
848
888
|
if (parsed.extends !== extendsValue) {
|
|
@@ -918,7 +958,12 @@ async function generateVitest(ctx) {
|
|
|
918
958
|
description: "Monorepo: vitest config belongs in individual packages"
|
|
919
959
|
}];
|
|
920
960
|
const configPath = "vitest.config.ts";
|
|
921
|
-
if (ctx.exists(configPath)) if (
|
|
961
|
+
if (ctx.exists(configPath)) if (ctx.read(configPath) === VITEST_CONFIG) results.push({
|
|
962
|
+
filePath: configPath,
|
|
963
|
+
action: "skipped",
|
|
964
|
+
description: "Config already up to date"
|
|
965
|
+
});
|
|
966
|
+
else if (await ctx.confirmOverwrite(configPath) === "skip") results.push({
|
|
922
967
|
filePath: configPath,
|
|
923
968
|
action: "skipped",
|
|
924
969
|
description: "Existing config preserved"
|
|
@@ -972,7 +1017,7 @@ async function generateOxlint(ctx) {
|
|
|
972
1017
|
const content = ctx.config.useEslintPlugin ? CONFIG_WITH_LINT_RULES : CONFIG_PRESET_ONLY;
|
|
973
1018
|
const existing = ctx.read(filePath);
|
|
974
1019
|
if (existing) {
|
|
975
|
-
if (existing === content) return {
|
|
1020
|
+
if (existing === content || existing.includes("@bensandee/config/oxlint")) return {
|
|
976
1021
|
filePath,
|
|
977
1022
|
action: "skipped",
|
|
978
1023
|
description: "Already configured"
|
|
@@ -1008,8 +1053,13 @@ async function generateFormatter(ctx) {
|
|
|
1008
1053
|
}
|
|
1009
1054
|
async function generateOxfmt(ctx) {
|
|
1010
1055
|
const filePath = ".oxfmtrc.json";
|
|
1011
|
-
const
|
|
1012
|
-
if (
|
|
1056
|
+
const existing = ctx.read(filePath);
|
|
1057
|
+
if (existing) {
|
|
1058
|
+
if (existing === OXFMT_CONFIG) return {
|
|
1059
|
+
filePath,
|
|
1060
|
+
action: "skipped",
|
|
1061
|
+
description: "Already configured"
|
|
1062
|
+
};
|
|
1013
1063
|
if (await ctx.confirmOverwrite(filePath) === "skip") return {
|
|
1014
1064
|
filePath,
|
|
1015
1065
|
action: "skipped",
|
|
@@ -1019,7 +1069,7 @@ async function generateOxfmt(ctx) {
|
|
|
1019
1069
|
ctx.write(filePath, OXFMT_CONFIG);
|
|
1020
1070
|
return {
|
|
1021
1071
|
filePath,
|
|
1022
|
-
action:
|
|
1072
|
+
action: existing ? "updated" : "created",
|
|
1023
1073
|
description: "Generated .oxfmtrc.json"
|
|
1024
1074
|
};
|
|
1025
1075
|
}
|
|
@@ -1053,7 +1103,13 @@ async function generateTsdown(ctx) {
|
|
|
1053
1103
|
action: "skipped",
|
|
1054
1104
|
description: "Monorepo: tsdown config belongs in individual packages"
|
|
1055
1105
|
};
|
|
1056
|
-
|
|
1106
|
+
const existing = ctx.read(filePath);
|
|
1107
|
+
if (existing) {
|
|
1108
|
+
if (existing === TSDOWN_CONFIG) return {
|
|
1109
|
+
filePath,
|
|
1110
|
+
action: "skipped",
|
|
1111
|
+
description: "Already configured"
|
|
1112
|
+
};
|
|
1057
1113
|
if (await ctx.confirmOverwrite(filePath) === "skip") return {
|
|
1058
1114
|
filePath,
|
|
1059
1115
|
action: "skipped",
|
|
@@ -1063,23 +1119,25 @@ async function generateTsdown(ctx) {
|
|
|
1063
1119
|
ctx.write(filePath, TSDOWN_CONFIG);
|
|
1064
1120
|
return {
|
|
1065
1121
|
filePath,
|
|
1066
|
-
action: "created",
|
|
1122
|
+
action: existing ? "updated" : "created",
|
|
1067
1123
|
description: "Generated tsdown.config.ts"
|
|
1068
1124
|
};
|
|
1069
1125
|
}
|
|
1070
1126
|
|
|
1071
1127
|
//#endregion
|
|
1072
1128
|
//#region src/generators/gitignore.ts
|
|
1073
|
-
|
|
1129
|
+
/** Entries that every project should have — repo:check flags these as missing. */
|
|
1130
|
+
const REQUIRED_ENTRIES = [
|
|
1074
1131
|
"node_modules/",
|
|
1075
1132
|
"dist/",
|
|
1076
1133
|
"*.tsbuildinfo",
|
|
1077
1134
|
".env",
|
|
1078
1135
|
".env.*",
|
|
1079
|
-
"!.env.example"
|
|
1080
|
-
".tooling-migrate.md",
|
|
1081
|
-
".tooling-archived/"
|
|
1136
|
+
"!.env.example"
|
|
1082
1137
|
];
|
|
1138
|
+
/** Tooling-specific entries added during init/update but not required for repo:check. */
|
|
1139
|
+
const OPTIONAL_ENTRIES = [".tooling-migrate.md", ".tooling-archived/"];
|
|
1140
|
+
const ALL_ENTRIES = [...REQUIRED_ENTRIES, ...OPTIONAL_ENTRIES];
|
|
1083
1141
|
/** Normalize a gitignore entry for comparison: strip leading `/` and trailing `/`. */
|
|
1084
1142
|
function normalizeEntry(entry) {
|
|
1085
1143
|
let s = entry.trim();
|
|
@@ -1092,21 +1150,27 @@ async function generateGitignore(ctx) {
|
|
|
1092
1150
|
const existing = ctx.read(filePath);
|
|
1093
1151
|
if (existing) {
|
|
1094
1152
|
const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
|
|
1095
|
-
const missing =
|
|
1153
|
+
const missing = ALL_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
|
|
1096
1154
|
if (missing.length === 0) return {
|
|
1097
1155
|
filePath,
|
|
1098
1156
|
action: "skipped",
|
|
1099
1157
|
description: "Already has all standard entries"
|
|
1100
1158
|
};
|
|
1159
|
+
const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
|
|
1101
1160
|
const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
|
|
1102
1161
|
ctx.write(filePath, updated);
|
|
1162
|
+
if (missingRequired.length === 0) return {
|
|
1163
|
+
filePath,
|
|
1164
|
+
action: "skipped",
|
|
1165
|
+
description: "Only optional entries missing"
|
|
1166
|
+
};
|
|
1103
1167
|
return {
|
|
1104
1168
|
filePath,
|
|
1105
1169
|
action: "updated",
|
|
1106
1170
|
description: `Appended ${String(missing.length)} missing entries`
|
|
1107
1171
|
};
|
|
1108
1172
|
}
|
|
1109
|
-
ctx.write(filePath,
|
|
1173
|
+
ctx.write(filePath, ALL_ENTRIES.join("\n") + "\n");
|
|
1110
1174
|
return {
|
|
1111
1175
|
filePath,
|
|
1112
1176
|
action: "created",
|
|
@@ -1117,7 +1181,9 @@ async function generateGitignore(ctx) {
|
|
|
1117
1181
|
//#endregion
|
|
1118
1182
|
//#region src/generators/ci.ts
|
|
1119
1183
|
function hasEnginesNode$1(ctx) {
|
|
1120
|
-
|
|
1184
|
+
const raw = ctx.read("package.json");
|
|
1185
|
+
if (!raw) return false;
|
|
1186
|
+
return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
|
|
1121
1187
|
}
|
|
1122
1188
|
function ciWorkflow(isMonorepo, nodeVersionYaml) {
|
|
1123
1189
|
return `name: CI
|
|
@@ -1144,6 +1210,7 @@ jobs:
|
|
|
1144
1210
|
- run: ${isMonorepo ? "pnpm -r test" : "pnpm test"}
|
|
1145
1211
|
- run: pnpm format --check
|
|
1146
1212
|
- run: pnpm knip
|
|
1213
|
+
- run: pnpm exec tooling repo:check
|
|
1147
1214
|
`;
|
|
1148
1215
|
}
|
|
1149
1216
|
async function generateCi(ctx) {
|
|
@@ -1158,10 +1225,20 @@ async function generateCi(ctx) {
|
|
|
1158
1225
|
const filePath = isGitHub ? ".github/workflows/check.yml" : ".forgejo/workflows/check.yml";
|
|
1159
1226
|
const content = ciWorkflow(isMonorepo, nodeVersionYaml);
|
|
1160
1227
|
if (ctx.exists(filePath)) {
|
|
1161
|
-
|
|
1228
|
+
const existing = ctx.read(filePath);
|
|
1229
|
+
if (existing && !existing.includes("repo:check")) {
|
|
1230
|
+
const patched = existing.trimEnd() + "\n - run: pnpm exec tooling repo:check\n";
|
|
1231
|
+
ctx.write(filePath, patched);
|
|
1232
|
+
return {
|
|
1233
|
+
filePath,
|
|
1234
|
+
action: "updated",
|
|
1235
|
+
description: "Added `pnpm exec tooling repo:check` step to CI workflow"
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
return {
|
|
1162
1239
|
filePath,
|
|
1163
1240
|
action: "skipped",
|
|
1164
|
-
description: "
|
|
1241
|
+
description: "CI workflow already up to date"
|
|
1165
1242
|
};
|
|
1166
1243
|
}
|
|
1167
1244
|
ctx.write(filePath, content);
|
|
@@ -1529,18 +1606,11 @@ async function generateChangesets(ctx) {
|
|
|
1529
1606
|
};
|
|
1530
1607
|
const content = JSON.stringify(buildConfig$1(), null, 2) + "\n";
|
|
1531
1608
|
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
|
-
}
|
|
1609
|
+
if (existing) return {
|
|
1610
|
+
filePath,
|
|
1611
|
+
action: "skipped",
|
|
1612
|
+
description: "Existing changesets config preserved"
|
|
1613
|
+
};
|
|
1544
1614
|
ctx.write(filePath, content);
|
|
1545
1615
|
return {
|
|
1546
1616
|
filePath,
|
|
@@ -1706,13 +1776,11 @@ async function generateReleaseCi(ctx) {
|
|
|
1706
1776
|
action: "skipped",
|
|
1707
1777
|
description: "Release CI workflow not applicable"
|
|
1708
1778
|
};
|
|
1709
|
-
if (ctx.exists(workflowPath)) {
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
};
|
|
1715
|
-
}
|
|
1779
|
+
if (ctx.exists(workflowPath)) return {
|
|
1780
|
+
filePath: workflowPath,
|
|
1781
|
+
action: "skipped",
|
|
1782
|
+
description: "Existing release workflow preserved"
|
|
1783
|
+
};
|
|
1716
1784
|
ctx.write(workflowPath, content);
|
|
1717
1785
|
return {
|
|
1718
1786
|
filePath: workflowPath,
|
|
@@ -1833,43 +1901,127 @@ async function generateLefthook(ctx) {
|
|
|
1833
1901
|
archiveLintStagedConfigs(ctx, results);
|
|
1834
1902
|
cleanPackageJson(ctx, results);
|
|
1835
1903
|
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) {
|
|
1904
|
+
if (existingPath) {
|
|
1857
1905
|
results.push({
|
|
1858
1906
|
filePath: existingPath,
|
|
1859
1907
|
action: "skipped",
|
|
1860
|
-
description:
|
|
1908
|
+
description: "Existing lefthook config preserved"
|
|
1861
1909
|
});
|
|
1862
1910
|
return results;
|
|
1863
1911
|
}
|
|
1864
1912
|
ctx.write(filePath, content);
|
|
1865
1913
|
results.push({
|
|
1866
1914
|
filePath,
|
|
1867
|
-
action:
|
|
1915
|
+
action: "created",
|
|
1868
1916
|
description: "Generated lefthook pre-commit config"
|
|
1869
1917
|
});
|
|
1870
1918
|
return results;
|
|
1871
1919
|
}
|
|
1872
1920
|
|
|
1921
|
+
//#endregion
|
|
1922
|
+
//#region src/generators/pipeline.ts
|
|
1923
|
+
/** Run all generators sequentially and return their results. */
|
|
1924
|
+
async function runGenerators(ctx) {
|
|
1925
|
+
const results = [];
|
|
1926
|
+
results.push(await generatePackageJson(ctx));
|
|
1927
|
+
results.push(await generatePnpmWorkspace(ctx));
|
|
1928
|
+
results.push(...await generateTsconfig(ctx));
|
|
1929
|
+
results.push(await generateTsdown(ctx));
|
|
1930
|
+
results.push(await generateOxlint(ctx));
|
|
1931
|
+
results.push(await generateFormatter(ctx));
|
|
1932
|
+
results.push(...await generateLefthook(ctx));
|
|
1933
|
+
results.push(await generateGitignore(ctx));
|
|
1934
|
+
results.push(await generateKnip(ctx));
|
|
1935
|
+
results.push(await generateRenovate(ctx));
|
|
1936
|
+
results.push(await generateCi(ctx));
|
|
1937
|
+
results.push(await generateClaudeSettings(ctx));
|
|
1938
|
+
results.push(await generateReleaseIt(ctx));
|
|
1939
|
+
results.push(await generateChangesets(ctx));
|
|
1940
|
+
results.push(await generateReleaseCi(ctx));
|
|
1941
|
+
results.push(...await generateVitest(ctx));
|
|
1942
|
+
return results;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
//#endregion
|
|
1946
|
+
//#region src/utils/tooling-config.ts
|
|
1947
|
+
const CONFIG_FILE = ".tooling.json";
|
|
1948
|
+
const ToolingConfigSchema = z.object({
|
|
1949
|
+
structure: z.enum(["single", "monorepo"]).optional(),
|
|
1950
|
+
useEslintPlugin: z.boolean().optional(),
|
|
1951
|
+
formatter: z.enum(["oxfmt", "prettier"]).optional(),
|
|
1952
|
+
setupVitest: z.boolean().optional(),
|
|
1953
|
+
ci: z.enum([
|
|
1954
|
+
"github",
|
|
1955
|
+
"forgejo",
|
|
1956
|
+
"none"
|
|
1957
|
+
]).optional(),
|
|
1958
|
+
setupRenovate: z.boolean().optional(),
|
|
1959
|
+
releaseStrategy: z.enum([
|
|
1960
|
+
"release-it",
|
|
1961
|
+
"commit-and-tag-version",
|
|
1962
|
+
"changesets",
|
|
1963
|
+
"none"
|
|
1964
|
+
]).optional(),
|
|
1965
|
+
projectType: z.enum([
|
|
1966
|
+
"default",
|
|
1967
|
+
"node",
|
|
1968
|
+
"react",
|
|
1969
|
+
"library"
|
|
1970
|
+
]).optional(),
|
|
1971
|
+
detectPackageTypes: z.boolean().optional()
|
|
1972
|
+
});
|
|
1973
|
+
/** Load saved tooling config from the target directory. Returns undefined if missing or invalid. */
|
|
1974
|
+
function loadToolingConfig(targetDir) {
|
|
1975
|
+
const fullPath = path.join(targetDir, CONFIG_FILE);
|
|
1976
|
+
if (!existsSync(fullPath)) return void 0;
|
|
1977
|
+
try {
|
|
1978
|
+
const raw = readFileSync(fullPath, "utf-8");
|
|
1979
|
+
const result = ToolingConfigSchema.safeParse(JSON.parse(raw));
|
|
1980
|
+
return result.success ? result.data : void 0;
|
|
1981
|
+
} catch {
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
/** Save the user's config choices to .tooling.json via the generator context. */
|
|
1986
|
+
function saveToolingConfig(ctx, config) {
|
|
1987
|
+
const saved = {
|
|
1988
|
+
structure: config.structure,
|
|
1989
|
+
useEslintPlugin: config.useEslintPlugin,
|
|
1990
|
+
formatter: config.formatter,
|
|
1991
|
+
setupVitest: config.setupVitest,
|
|
1992
|
+
ci: config.ci,
|
|
1993
|
+
setupRenovate: config.setupRenovate,
|
|
1994
|
+
releaseStrategy: config.releaseStrategy,
|
|
1995
|
+
projectType: config.projectType,
|
|
1996
|
+
detectPackageTypes: config.detectPackageTypes
|
|
1997
|
+
};
|
|
1998
|
+
const content = JSON.stringify(saved, null, 2) + "\n";
|
|
1999
|
+
const existed = ctx.exists(CONFIG_FILE);
|
|
2000
|
+
ctx.write(CONFIG_FILE, content);
|
|
2001
|
+
return {
|
|
2002
|
+
filePath: CONFIG_FILE,
|
|
2003
|
+
action: existed ? "updated" : "created",
|
|
2004
|
+
description: "Saved tooling configuration"
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
/** Merge saved config over detected defaults. Saved values win when present. */
|
|
2008
|
+
function mergeWithSavedConfig(detected, saved) {
|
|
2009
|
+
return {
|
|
2010
|
+
name: detected.name,
|
|
2011
|
+
isNew: detected.isNew,
|
|
2012
|
+
targetDir: detected.targetDir,
|
|
2013
|
+
structure: saved.structure ?? detected.structure,
|
|
2014
|
+
useEslintPlugin: saved.useEslintPlugin ?? detected.useEslintPlugin,
|
|
2015
|
+
formatter: saved.formatter ?? detected.formatter,
|
|
2016
|
+
setupVitest: saved.setupVitest ?? detected.setupVitest,
|
|
2017
|
+
ci: saved.ci ?? detected.ci,
|
|
2018
|
+
setupRenovate: saved.setupRenovate ?? detected.setupRenovate,
|
|
2019
|
+
releaseStrategy: saved.releaseStrategy ?? detected.releaseStrategy,
|
|
2020
|
+
projectType: saved.projectType ?? detected.projectType,
|
|
2021
|
+
detectPackageTypes: saved.detectPackageTypes ?? detected.detectPackageTypes
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
|
|
1873
2025
|
//#endregion
|
|
1874
2026
|
//#region src/commands/repo-init.ts
|
|
1875
2027
|
const initCommand = defineCommand({
|
|
@@ -1903,15 +2055,21 @@ const initCommand = defineCommand({
|
|
|
1903
2055
|
},
|
|
1904
2056
|
async run({ args }) {
|
|
1905
2057
|
const targetDir = path.resolve(args.dir ?? ".");
|
|
1906
|
-
await runInit(args.yes ?
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2058
|
+
await runInit(args.yes ? (() => {
|
|
2059
|
+
const saved = loadToolingConfig(targetDir);
|
|
2060
|
+
const detected = buildDefaultConfig(targetDir, {
|
|
2061
|
+
eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
|
|
2062
|
+
noCi: args["no-ci"] === true ? true : void 0
|
|
2063
|
+
});
|
|
2064
|
+
return saved ? mergeWithSavedConfig(detected, saved) : detected;
|
|
2065
|
+
})() : await runInitPrompts(targetDir), args["no-prompt"] === true ? { noPrompt: true } : {});
|
|
1910
2066
|
}
|
|
1911
2067
|
});
|
|
1912
2068
|
async function runInit(config, options = {}) {
|
|
1913
2069
|
const detected = detectProject(config.targetDir);
|
|
2070
|
+
const s = p.spinner();
|
|
1914
2071
|
const { ctx, archivedFiles } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
|
|
2072
|
+
s.stop("Paused");
|
|
1915
2073
|
const result = await p.select({
|
|
1916
2074
|
message: `${relativePath} already exists. What do you want to do?`,
|
|
1917
2075
|
options: [{
|
|
@@ -1922,29 +2080,13 @@ async function runInit(config, options = {}) {
|
|
|
1922
2080
|
label: "Skip"
|
|
1923
2081
|
}]
|
|
1924
2082
|
});
|
|
2083
|
+
s.start("Generating configuration files...");
|
|
1925
2084
|
if (p.isCancel(result)) return "skip";
|
|
1926
2085
|
return result;
|
|
1927
2086
|
}));
|
|
1928
|
-
const results = [];
|
|
1929
|
-
const s = p.spinner();
|
|
1930
2087
|
s.start("Generating configuration files...");
|
|
1931
|
-
results
|
|
1932
|
-
results.push(
|
|
1933
|
-
results.push(...await generateTsconfig(ctx));
|
|
1934
|
-
results.push(await generateTsdown(ctx));
|
|
1935
|
-
results.push(await generateOxlint(ctx));
|
|
1936
|
-
results.push(await generateFormatter(ctx));
|
|
1937
|
-
results.push(...await generateLefthook(ctx));
|
|
1938
|
-
results.push(await generateGitignore(ctx));
|
|
1939
|
-
results.push(await generateKnip(ctx));
|
|
1940
|
-
results.push(await generateRenovate(ctx));
|
|
1941
|
-
results.push(await generateCi(ctx));
|
|
1942
|
-
results.push(await generateClaudeSettings(ctx));
|
|
1943
|
-
results.push(await generateReleaseIt(ctx));
|
|
1944
|
-
results.push(await generateChangesets(ctx));
|
|
1945
|
-
results.push(await generateReleaseCi(ctx));
|
|
1946
|
-
const vitestResults = await generateVitest(ctx);
|
|
1947
|
-
results.push(...vitestResults);
|
|
2088
|
+
const results = await runGenerators(ctx);
|
|
2089
|
+
results.push(saveToolingConfig(ctx, config));
|
|
1948
2090
|
const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
|
|
1949
2091
|
for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
|
|
1950
2092
|
filePath: rel,
|
|
@@ -1992,7 +2134,7 @@ async function runInit(config, options = {}) {
|
|
|
1992
2134
|
const updateCommand = defineCommand({
|
|
1993
2135
|
meta: {
|
|
1994
2136
|
name: "repo:update",
|
|
1995
|
-
description: "
|
|
2137
|
+
description: "Update managed config and add missing files"
|
|
1996
2138
|
},
|
|
1997
2139
|
args: { dir: {
|
|
1998
2140
|
type: "positional",
|
|
@@ -2004,26 +2146,73 @@ const updateCommand = defineCommand({
|
|
|
2004
2146
|
}
|
|
2005
2147
|
});
|
|
2006
2148
|
async function runUpdate(targetDir) {
|
|
2007
|
-
|
|
2149
|
+
const saved = loadToolingConfig(targetDir);
|
|
2150
|
+
const detected = buildDefaultConfig(targetDir, {});
|
|
2151
|
+
if (!saved) p.log.warn("No .tooling.json found — using detected defaults. Run `tooling repo:init` to save your preferences.");
|
|
2152
|
+
return runInit(saved ? mergeWithSavedConfig(detected, saved) : detected, {
|
|
2008
2153
|
noPrompt: true,
|
|
2009
|
-
confirmOverwrite: async () => "
|
|
2154
|
+
confirmOverwrite: async () => "overwrite"
|
|
2010
2155
|
});
|
|
2011
2156
|
}
|
|
2012
2157
|
|
|
2013
2158
|
//#endregion
|
|
2014
|
-
//#region src/
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2159
|
+
//#region src/commands/repo-check.ts
|
|
2160
|
+
const checkCommand = defineCommand({
|
|
2161
|
+
meta: {
|
|
2162
|
+
name: "repo:check",
|
|
2163
|
+
description: "Check repo for tooling drift (dry-run, CI-friendly)"
|
|
2164
|
+
},
|
|
2165
|
+
args: { dir: {
|
|
2166
|
+
type: "positional",
|
|
2167
|
+
description: "Target directory (default: current directory)",
|
|
2168
|
+
required: false
|
|
2169
|
+
} },
|
|
2170
|
+
async run({ args }) {
|
|
2171
|
+
const exitCode = await runCheck(path.resolve(args.dir ?? "."));
|
|
2172
|
+
process.exitCode = exitCode;
|
|
2173
|
+
}
|
|
2174
|
+
});
|
|
2175
|
+
async function runCheck(targetDir) {
|
|
2176
|
+
const saved = loadToolingConfig(targetDir);
|
|
2177
|
+
const detected = buildDefaultConfig(targetDir, {});
|
|
2178
|
+
if (!saved) p.log.warn("No .tooling.json found — using detected defaults. Run `tooling repo:init` to save your preferences.");
|
|
2179
|
+
const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
|
|
2180
|
+
const actionable = (await runGenerators(ctx)).filter((r) => r.action === "created" || r.action === "updated");
|
|
2181
|
+
if (actionable.length === 0) {
|
|
2182
|
+
p.log.success("Repository is up to date.");
|
|
2183
|
+
return 0;
|
|
2184
|
+
}
|
|
2185
|
+
p.log.warn(`${actionable.length} file(s) would be changed by repo:update`);
|
|
2186
|
+
for (const r of actionable) {
|
|
2187
|
+
p.log.info(` ${r.action}: ${r.filePath} — ${r.description}`);
|
|
2188
|
+
const newContent = pendingWrites.get(r.filePath);
|
|
2189
|
+
if (!newContent) continue;
|
|
2190
|
+
const existingPath = path.join(targetDir, r.filePath);
|
|
2191
|
+
const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
|
|
2192
|
+
if (!existing) {
|
|
2193
|
+
const lineCount = newContent.split("\n").length - 1;
|
|
2194
|
+
p.log.info(` + ${lineCount} new lines`);
|
|
2195
|
+
} else {
|
|
2196
|
+
const diff = lineDiff(existing, newContent);
|
|
2197
|
+
if (diff.length > 0) for (const line of diff) p.log.info(` ${line}`);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
return 1;
|
|
2201
|
+
}
|
|
2202
|
+
const normalize = (line) => line.trimEnd();
|
|
2203
|
+
/** Produce a compact line-level diff summary, ignoring whitespace-only differences. */
|
|
2204
|
+
function lineDiff(oldText, newText) {
|
|
2205
|
+
const oldLines = oldText.split("\n").map(normalize);
|
|
2206
|
+
const newLines = newText.split("\n").map(normalize);
|
|
2207
|
+
const oldSet = new Set(oldLines);
|
|
2208
|
+
const newSet = new Set(newLines);
|
|
2209
|
+
const removed = oldLines.filter((l) => l.trim() !== "" && !newSet.has(l));
|
|
2210
|
+
const added = newLines.filter((l) => l.trim() !== "" && !oldSet.has(l));
|
|
2211
|
+
const lines = [];
|
|
2212
|
+
for (const l of removed) lines.push(`- ${l.trim()}`);
|
|
2213
|
+
for (const l of added) lines.push(`+ ${l.trim()}`);
|
|
2214
|
+
return lines;
|
|
2215
|
+
}
|
|
2027
2216
|
|
|
2028
2217
|
//#endregion
|
|
2029
2218
|
//#region src/utils/exec.ts
|
|
@@ -2102,6 +2291,10 @@ function createRealExecutor() {
|
|
|
2102
2291
|
} catch {
|
|
2103
2292
|
return null;
|
|
2104
2293
|
}
|
|
2294
|
+
},
|
|
2295
|
+
writeFile(filePath, content) {
|
|
2296
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
2297
|
+
writeFileSync(filePath, content);
|
|
2105
2298
|
}
|
|
2106
2299
|
};
|
|
2107
2300
|
}
|
|
@@ -2317,8 +2510,19 @@ async function runVersionMode(executor, config) {
|
|
|
2317
2510
|
p.log.info("Changesets detected — versioning packages");
|
|
2318
2511
|
const packagesBefore = executor.listWorkspacePackages(config.cwd);
|
|
2319
2512
|
debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
|
|
2513
|
+
const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
|
|
2514
|
+
const originalConfig = executor.readFile(changesetConfigPath);
|
|
2515
|
+
if (originalConfig) {
|
|
2516
|
+
const parsed = JSON.parse(originalConfig);
|
|
2517
|
+
if (parsed.commit) {
|
|
2518
|
+
parsed.commit = false;
|
|
2519
|
+
executor.writeFile(changesetConfigPath, JSON.stringify(parsed, null, 2) + "\n");
|
|
2520
|
+
debug(config, "Temporarily disabled changeset commit:true");
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2320
2523
|
const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
|
|
2321
2524
|
debugExec(config, "pnpm changeset version", versionResult);
|
|
2525
|
+
if (originalConfig) executor.writeFile(changesetConfigPath, originalConfig);
|
|
2322
2526
|
if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
|
|
2323
2527
|
debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
|
|
2324
2528
|
const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
|
|
@@ -2699,19 +2903,20 @@ function mergeGitHub(dryRun) {
|
|
|
2699
2903
|
const main = defineCommand({
|
|
2700
2904
|
meta: {
|
|
2701
2905
|
name: "tooling",
|
|
2702
|
-
version: "0.
|
|
2906
|
+
version: "0.7.0",
|
|
2703
2907
|
description: "Bootstrap and maintain standardized TypeScript project tooling"
|
|
2704
2908
|
},
|
|
2705
2909
|
subCommands: {
|
|
2706
2910
|
"repo:init": initCommand,
|
|
2707
2911
|
"repo:update": updateCommand,
|
|
2912
|
+
"repo:check": checkCommand,
|
|
2708
2913
|
"release:changesets": releaseForgejoCommand,
|
|
2709
2914
|
"release:trigger": releaseTriggerCommand,
|
|
2710
2915
|
"release:create-forgejo-release": createForgejoReleaseCommand,
|
|
2711
2916
|
"release:merge": releaseMergeCommand
|
|
2712
2917
|
}
|
|
2713
2918
|
});
|
|
2714
|
-
console.log(`@bensandee/tooling v0.
|
|
2919
|
+
console.log(`@bensandee/tooling v0.7.0`);
|
|
2715
2920
|
runMain(main);
|
|
2716
2921
|
|
|
2717
2922
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bensandee/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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.2"
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsdown",
|