@bensandee/tooling 0.6.2 → 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.
Files changed (2) hide show
  1. package/dist/bin.mjs +301 -98
  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. */
@@ -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 (await ctx.confirmOverwrite(configPath) === "skip") results.push({
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 existed = ctx.exists(filePath);
1012
- if (existed) {
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: existed ? "updated" : "created",
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
- if (ctx.exists(filePath)) {
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
- const STANDARD_ENTRIES = [
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 = STANDARD_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
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, STANDARD_ENTRIES.join("\n") + "\n");
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
- return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
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
- if (await ctx.confirmOverwrite(filePath) === "skip") return {
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: "Existing CI workflow preserved"
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
- 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
- }
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
- if (await ctx.confirmOverwrite(workflowPath) === "skip") return {
1711
- filePath: workflowPath,
1712
- action: "skipped",
1713
- description: "Existing release workflow preserved"
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 === 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) {
1904
+ if (existingPath) {
1857
1905
  results.push({
1858
1906
  filePath: existingPath,
1859
1907
  action: "skipped",
1860
- description: `Existing config found at ${existingPath}`
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: existingPath === filePath ? "updated" : "created",
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,10 +2055,14 @@ const initCommand = defineCommand({
1903
2055
  },
1904
2056
  async run({ args }) {
1905
2057
  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 } : {});
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 = {}) {
@@ -1928,25 +2084,9 @@ async function runInit(config, options = {}) {
1928
2084
  if (p.isCancel(result)) return "skip";
1929
2085
  return result;
1930
2086
  }));
1931
- const results = [];
1932
2087
  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);
2088
+ const results = await runGenerators(ctx);
2089
+ results.push(saveToolingConfig(ctx, config));
1950
2090
  const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
1951
2091
  for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
1952
2092
  filePath: rel,
@@ -1994,7 +2134,7 @@ async function runInit(config, options = {}) {
1994
2134
  const updateCommand = defineCommand({
1995
2135
  meta: {
1996
2136
  name: "repo:update",
1997
- description: "Add missing config (never overwrites)"
2137
+ description: "Update managed config and add missing files"
1998
2138
  },
1999
2139
  args: { dir: {
2000
2140
  type: "positional",
@@ -2006,26 +2146,73 @@ const updateCommand = defineCommand({
2006
2146
  }
2007
2147
  });
2008
2148
  async function runUpdate(targetDir) {
2009
- return runInit(buildDefaultConfig(targetDir, {}), {
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, {
2010
2153
  noPrompt: true,
2011
- confirmOverwrite: async () => "skip"
2154
+ confirmOverwrite: async () => "overwrite"
2012
2155
  });
2013
2156
  }
2014
2157
 
2015
2158
  //#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
- };
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
+ }
2029
2216
 
2030
2217
  //#endregion
2031
2218
  //#region src/utils/exec.ts
@@ -2104,6 +2291,10 @@ function createRealExecutor() {
2104
2291
  } catch {
2105
2292
  return null;
2106
2293
  }
2294
+ },
2295
+ writeFile(filePath, content) {
2296
+ mkdirSync(path.dirname(filePath), { recursive: true });
2297
+ writeFileSync(filePath, content);
2107
2298
  }
2108
2299
  };
2109
2300
  }
@@ -2319,8 +2510,19 @@ async function runVersionMode(executor, config) {
2319
2510
  p.log.info("Changesets detected — versioning packages");
2320
2511
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
2321
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
+ }
2322
2523
  const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
2323
2524
  debugExec(config, "pnpm changeset version", versionResult);
2525
+ if (originalConfig) executor.writeFile(changesetConfigPath, originalConfig);
2324
2526
  if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
2325
2527
  debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
2326
2528
  const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
@@ -2701,19 +2903,20 @@ function mergeGitHub(dryRun) {
2701
2903
  const main = defineCommand({
2702
2904
  meta: {
2703
2905
  name: "tooling",
2704
- version: "0.6.2",
2906
+ version: "0.7.0",
2705
2907
  description: "Bootstrap and maintain standardized TypeScript project tooling"
2706
2908
  },
2707
2909
  subCommands: {
2708
2910
  "repo:init": initCommand,
2709
2911
  "repo:update": updateCommand,
2912
+ "repo:check": checkCommand,
2710
2913
  "release:changesets": releaseForgejoCommand,
2711
2914
  "release:trigger": releaseTriggerCommand,
2712
2915
  "release:create-forgejo-release": createForgejoReleaseCommand,
2713
2916
  "release:merge": releaseMergeCommand
2714
2917
  }
2715
2918
  });
2716
- console.log(`@bensandee/tooling v0.6.2`);
2919
+ console.log(`@bensandee/tooling v0.7.0`);
2717
2920
  runMain(main);
2718
2921
 
2719
2922
  //#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.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.1"
36
+ "@bensandee/config": "0.6.2"
36
37
  },
37
38
  "scripts": {
38
39
  "build": "tsdown",