@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.
Files changed (2) hide show
  1. package/dist/bin.mjs +399 -136
  2. 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
- const config = {
770
- extends: extendsValue,
771
- include: ["src"],
772
- exclude: ["node_modules", "dist"]
773
- };
774
- if (existing) {
775
- const parsed = parseTsconfig(existing);
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: "updated",
796
- description: changes.join(", ")
817
+ action: "created",
818
+ description: `Generated tsconfig.json with ${extendsValue}`
797
819
  }];
798
820
  }
799
- ctx.write(filePath, JSON.stringify(config, null, 2) + "\n");
800
- return [{
821
+ if (existing.includes("// @bensandee/tooling:ignore")) return [{
801
822
  filePath,
802
- action: "created",
803
- description: `Generated tsconfig.json with @bensandee/config/tsconfig/${ctx.config.projectType}`
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 (await ctx.confirmOverwrite(configPath) === "skip") results.push({
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 existed = ctx.exists(filePath);
1012
- if (existed) {
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: existed ? "updated" : "created",
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
- if (ctx.exists(filePath)) {
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
- const STANDARD_ENTRIES = [
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 = STANDARD_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
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, STANDARD_ENTRIES.join("\n") + "\n");
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
- return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
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
- if (await ctx.confirmOverwrite(filePath) === "skip") return {
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: "Existing CI workflow preserved"
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
- if (existing === content) return {
1534
- filePath,
1535
- action: "skipped",
1536
- description: "Already configured"
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
- if (await ctx.confirmOverwrite(workflowPath) === "skip") return {
1711
- filePath: workflowPath,
1712
- action: "skipped",
1713
- description: "Existing release workflow preserved"
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 === filePath) {
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: `Existing config found at ${existingPath}`
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: existingPath === filePath ? "updated" : "created",
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 ? buildDefaultConfig(targetDir, {
1907
- eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
1908
- noCi: args["no-ci"] === true ? true : void 0
1909
- }) : await runInitPrompts(targetDir), args["no-prompt"] === true ? { noPrompt: true } : {});
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.push(await generatePackageJson(ctx));
1934
- results.push(await generatePnpmWorkspace(ctx));
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 updateCmd = `pnpm update --latest ${getAddedDevDepNames(config).join(" ")}`;
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
- `2. Run: ${updateCmd}`,
1984
- "3. Run: pnpm typecheck",
1985
- "4. Run: pnpm build",
1986
- "5. Run: pnpm test",
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: "Add missing config (never overwrites)"
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
- return runInit(buildDefaultConfig(targetDir, {}), {
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 () => "skip"
2211
+ confirmOverwrite: async () => "overwrite"
2012
2212
  });
2013
2213
  }
2014
2214
 
2015
2215
  //#endregion
2016
- //#region src/utils/errors.ts
2017
- /** An error caused by an external transient condition (network, API rate limit, etc). */
2018
- var TransientError = class extends Error {
2019
- name = "TransientError";
2020
- };
2021
- /** An error indicating a fatal misconfiguration or invariant violation. */
2022
- var FatalError = class extends Error {
2023
- name = "FatalError";
2024
- };
2025
- /** An error for conditions that should be unreachable. */
2026
- var UnexpectedError = class extends Error {
2027
- name = "UnexpectedError";
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.6.2",
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.6.2`);
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.6.2",
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.1"
36
+ "@bensandee/config": "0.6.3"
36
37
  },
37
38
  "scripts": {
38
39
  "build": "tsdown",