@bensandee/tooling 0.9.0 → 0.10.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 +289 -38
  2. package/package.json +2 -1
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 { isMap, isSeq, parseDocument } from "yaml";
10
11
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
11
12
  //#region src/types.ts
12
13
  /** Default CI platform when not explicitly chosen. */
@@ -414,6 +415,19 @@ function buildDefaultConfig(targetDir, flags) {
414
415
  }
415
416
  //#endregion
416
417
  //#region src/utils/fs.ts
418
+ /**
419
+ * Compare file content, using parsed comparison for JSON files to ignore
420
+ * formatting differences. Falls back to exact string comparison for JSONC
421
+ * files (with comments/trailing commas) that fail JSON.parse.
422
+ */
423
+ function contentEqual(relativePath, a, b) {
424
+ if (relativePath.endsWith(".json")) try {
425
+ return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
426
+ } catch {
427
+ return a === b;
428
+ }
429
+ return a === b;
430
+ }
417
431
  /** Check whether a file exists at the given path. */
418
432
  function fileExists(targetDir, relativePath) {
419
433
  return existsSync(path.join(targetDir, relativePath));
@@ -446,7 +460,7 @@ function createContext(config, confirmOverwrite) {
446
460
  write: (rel, content) => {
447
461
  if (!rel.startsWith(".tooling-archived/")) {
448
462
  const existing = readFile(config.targetDir, rel);
449
- if (existing !== void 0 && existing !== content) {
463
+ if (existing !== void 0 && !contentEqual(rel, existing, content)) {
450
464
  writeFile(config.targetDir, `.tooling-archived/${rel}`, existing);
451
465
  archivedFiles.push(rel);
452
466
  }
@@ -567,7 +581,7 @@ function getAddedDevDepNames(config) {
567
581
  const deps = { ...ROOT_DEV_DEPS };
568
582
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
569
583
  deps["@bensandee/config"] = "0.7.1";
570
- deps["@bensandee/tooling"] = "0.9.0";
584
+ deps["@bensandee/tooling"] = "0.10.0";
571
585
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
572
586
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
573
587
  addReleaseDeps(deps, config);
@@ -588,7 +602,7 @@ async function generatePackageJson(ctx) {
588
602
  const devDeps = { ...ROOT_DEV_DEPS };
589
603
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
590
604
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.7.1";
591
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.9.0";
605
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.10.0";
592
606
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.0";
593
607
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
594
608
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1228,6 +1242,101 @@ async function generateGitignore(ctx) {
1228
1242
  };
1229
1243
  }
1230
1244
  //#endregion
1245
+ //#region src/utils/yaml-merge.ts
1246
+ const IGNORE_PATTERN = "@bensandee/tooling:ignore";
1247
+ /** Check if a YAML file has an opt-out comment in the first 10 lines. */
1248
+ function isToolingIgnored(content) {
1249
+ return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
1250
+ }
1251
+ /**
1252
+ * Ensure required commands exist under `pre-commit.commands` in a lefthook config.
1253
+ * Only adds missing commands — never modifies existing ones.
1254
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1255
+ */
1256
+ function mergeLefthookCommands(existing, requiredCommands) {
1257
+ if (isToolingIgnored(existing)) return {
1258
+ content: existing,
1259
+ changed: false
1260
+ };
1261
+ try {
1262
+ const doc = parseDocument(existing);
1263
+ let changed = false;
1264
+ if (!doc.hasIn(["pre-commit", "commands"])) {
1265
+ doc.setIn(["pre-commit", "commands"], requiredCommands);
1266
+ return {
1267
+ content: doc.toString(),
1268
+ changed: true
1269
+ };
1270
+ }
1271
+ const commands = doc.getIn(["pre-commit", "commands"]);
1272
+ if (!isMap(commands)) return {
1273
+ content: existing,
1274
+ changed: false
1275
+ };
1276
+ for (const [name, config] of Object.entries(requiredCommands)) if (!commands.has(name)) {
1277
+ commands.set(name, config);
1278
+ changed = true;
1279
+ }
1280
+ return {
1281
+ content: changed ? doc.toString() : existing,
1282
+ changed
1283
+ };
1284
+ } catch {
1285
+ return {
1286
+ content: existing,
1287
+ changed: false
1288
+ };
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Ensure required steps exist in a workflow job's steps array.
1293
+ * Only adds missing steps at the end — never modifies existing ones.
1294
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1295
+ */
1296
+ function mergeWorkflowSteps(existing, jobName, requiredSteps) {
1297
+ if (isToolingIgnored(existing)) return {
1298
+ content: existing,
1299
+ changed: false
1300
+ };
1301
+ try {
1302
+ const doc = parseDocument(existing);
1303
+ const steps = doc.getIn([
1304
+ "jobs",
1305
+ jobName,
1306
+ "steps"
1307
+ ]);
1308
+ if (!isSeq(steps)) return {
1309
+ content: existing,
1310
+ changed: false
1311
+ };
1312
+ let changed = false;
1313
+ for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
1314
+ if (!isMap(item)) return false;
1315
+ if (match.run) {
1316
+ const run = item.get("run");
1317
+ return typeof run === "string" && run.includes(match.run);
1318
+ }
1319
+ if (match.uses) {
1320
+ const uses = item.get("uses");
1321
+ return typeof uses === "string" && uses.startsWith(match.uses);
1322
+ }
1323
+ return false;
1324
+ })) {
1325
+ steps.add(doc.createNode(step));
1326
+ changed = true;
1327
+ }
1328
+ return {
1329
+ content: changed ? doc.toString() : existing,
1330
+ changed
1331
+ };
1332
+ } catch {
1333
+ return {
1334
+ content: existing,
1335
+ changed: false
1336
+ };
1337
+ }
1338
+ }
1339
+ //#endregion
1231
1340
  //#region src/generators/ci.ts
1232
1341
  function hasEnginesNode$1(ctx) {
1233
1342
  const raw = ctx.read("package.json");
@@ -1262,23 +1371,62 @@ jobs:
1262
1371
  - run: pnpm exec tooling repo:check
1263
1372
  `;
1264
1373
  }
1265
- /**
1266
- * Insert a step at the end of the `check` job's steps, even if other jobs
1267
- * follow. Returns null if we can't find the right insertion point.
1268
- */
1269
- function insertStepIntoCheckJob(yaml, step) {
1270
- const lines = yaml.split("\n");
1271
- const checkJobIdx = lines.findIndex((l) => /^ {2}check:\s*$/.test(l));
1272
- if (checkJobIdx === -1) return null;
1273
- let lastStepIdx = -1;
1274
- for (const [i, line] of lines.entries()) {
1275
- if (i <= checkJobIdx) continue;
1276
- if (/^ {2}\S/.test(line)) break;
1277
- if (/^ {6}/.test(line)) lastStepIdx = i;
1278
- }
1279
- if (lastStepIdx === -1) return null;
1280
- lines.splice(lastStepIdx + 1, 0, step.trimEnd());
1281
- return lines.join("\n");
1374
+ function requiredCheckSteps(isMonorepo, nodeVersionYaml) {
1375
+ const buildCmd = isMonorepo ? "pnpm -r build" : "pnpm build";
1376
+ const testCmd = isMonorepo ? "pnpm -r test" : "pnpm test";
1377
+ const typecheckCmd = isMonorepo ? "pnpm -r --parallel run typecheck" : "pnpm typecheck";
1378
+ return [
1379
+ {
1380
+ match: { uses: "actions/checkout" },
1381
+ step: { uses: "actions/checkout@v4" }
1382
+ },
1383
+ {
1384
+ match: { uses: "pnpm/action-setup" },
1385
+ step: { uses: "pnpm/action-setup@v4" }
1386
+ },
1387
+ {
1388
+ match: { uses: "actions/setup-node" },
1389
+ step: {
1390
+ uses: "actions/setup-node@v4",
1391
+ with: {
1392
+ ...nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" },
1393
+ cache: "pnpm"
1394
+ }
1395
+ }
1396
+ },
1397
+ {
1398
+ match: { run: "pnpm install" },
1399
+ step: { run: "pnpm install --frozen-lockfile" }
1400
+ },
1401
+ {
1402
+ match: { run: "typecheck" },
1403
+ step: { run: typecheckCmd }
1404
+ },
1405
+ {
1406
+ match: { run: "lint" },
1407
+ step: { run: "pnpm lint" }
1408
+ },
1409
+ {
1410
+ match: { run: "build" },
1411
+ step: { run: buildCmd }
1412
+ },
1413
+ {
1414
+ match: { run: "test" },
1415
+ step: { run: testCmd }
1416
+ },
1417
+ {
1418
+ match: { run: "format" },
1419
+ step: { run: "pnpm format --check" }
1420
+ },
1421
+ {
1422
+ match: { run: "knip" },
1423
+ step: { run: "pnpm knip" }
1424
+ },
1425
+ {
1426
+ match: { run: "repo:check" },
1427
+ step: { run: "pnpm exec tooling repo:check" }
1428
+ }
1429
+ ];
1282
1430
  }
1283
1431
  async function generateCi(ctx) {
1284
1432
  if (ctx.config.ci === "none") return {
@@ -1293,14 +1441,14 @@ async function generateCi(ctx) {
1293
1441
  const content = ciWorkflow(isMonorepo, nodeVersionYaml, !isGitHub);
1294
1442
  if (ctx.exists(filePath)) {
1295
1443
  const existing = ctx.read(filePath);
1296
- if (existing && !existing.includes("repo:check")) {
1297
- const patched = insertStepIntoCheckJob(existing, " - run: pnpm exec tooling repo:check\n");
1298
- if (patched) {
1299
- ctx.write(filePath, patched);
1444
+ if (existing) {
1445
+ const merged = mergeWorkflowSteps(existing, "check", requiredCheckSteps(isMonorepo, nodeVersionYaml));
1446
+ if (merged.changed) {
1447
+ ctx.write(filePath, merged.content);
1300
1448
  return {
1301
1449
  filePath,
1302
1450
  action: "updated",
1303
- description: "Added `pnpm exec tooling repo:check` step to CI workflow"
1451
+ description: "Added missing steps to CI workflow"
1304
1452
  };
1305
1453
  }
1306
1454
  }
@@ -1706,7 +1854,7 @@ async function generateReleaseIt(ctx) {
1706
1854
  const content = JSON.stringify(buildConfig$2(ctx.config.ci, ctx.config.structure === "monorepo"), null, 2) + "\n";
1707
1855
  const existing = ctx.read(filePath);
1708
1856
  if (existing) {
1709
- if (existing === content) return {
1857
+ if (contentEqual(filePath, existing, content)) return {
1710
1858
  filePath,
1711
1859
  action: "skipped",
1712
1860
  description: "Already configured"
@@ -1893,6 +2041,62 @@ ${commonSteps(nodeVersionYaml)}
1893
2041
  run: pnpm exec tooling release:changesets
1894
2042
  `;
1895
2043
  }
2044
+ function requiredReleaseSteps(strategy, nodeVersionYaml) {
2045
+ const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2046
+ const steps = [
2047
+ {
2048
+ match: { uses: "actions/checkout" },
2049
+ step: {
2050
+ uses: "actions/checkout@v4",
2051
+ with: { "fetch-depth": 0 }
2052
+ }
2053
+ },
2054
+ {
2055
+ match: { uses: "pnpm/action-setup" },
2056
+ step: { uses: "pnpm/action-setup@v4" }
2057
+ },
2058
+ {
2059
+ match: { uses: "actions/setup-node" },
2060
+ step: {
2061
+ uses: "actions/setup-node@v4",
2062
+ with: {
2063
+ ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2064
+ cache: "pnpm",
2065
+ "registry-url": "https://registry.npmjs.org"
2066
+ }
2067
+ }
2068
+ },
2069
+ {
2070
+ match: { run: "pnpm install" },
2071
+ step: { run: "pnpm install --frozen-lockfile" }
2072
+ },
2073
+ {
2074
+ match: { run: "build" },
2075
+ step: { run: "pnpm build" }
2076
+ }
2077
+ ];
2078
+ switch (strategy) {
2079
+ case "release-it":
2080
+ steps.push({
2081
+ match: { run: "release-it" },
2082
+ step: { run: "pnpm release-it --ci" }
2083
+ });
2084
+ break;
2085
+ case "commit-and-tag-version":
2086
+ steps.push({
2087
+ match: { run: "commit-and-tag-version" },
2088
+ step: { run: "pnpm exec commit-and-tag-version" }
2089
+ });
2090
+ break;
2091
+ case "changesets":
2092
+ steps.push({
2093
+ match: { run: "changeset" },
2094
+ step: { run: "pnpm exec tooling release:changesets" }
2095
+ });
2096
+ break;
2097
+ }
2098
+ return steps;
2099
+ }
1896
2100
  function buildWorkflow(strategy, ci, nodeVersionYaml) {
1897
2101
  switch (strategy) {
1898
2102
  case "release-it": return releaseItWorkflow(ci, nodeVersionYaml);
@@ -1917,11 +2121,25 @@ async function generateReleaseCi(ctx) {
1917
2121
  action: "skipped",
1918
2122
  description: "Release CI workflow not applicable"
1919
2123
  };
1920
- if (ctx.exists(workflowPath)) return {
1921
- filePath: workflowPath,
1922
- action: "skipped",
1923
- description: "Existing release workflow preserved"
1924
- };
2124
+ if (ctx.exists(workflowPath)) {
2125
+ const existing = ctx.read(workflowPath);
2126
+ if (existing) {
2127
+ const merged = mergeWorkflowSteps(existing, "release", requiredReleaseSteps(ctx.config.releaseStrategy, nodeVersionYaml));
2128
+ if (merged.changed) {
2129
+ ctx.write(workflowPath, merged.content);
2130
+ return {
2131
+ filePath: workflowPath,
2132
+ action: "updated",
2133
+ description: "Added missing steps to release workflow"
2134
+ };
2135
+ }
2136
+ }
2137
+ return {
2138
+ filePath: workflowPath,
2139
+ action: "skipped",
2140
+ description: "Release workflow already up to date"
2141
+ };
2142
+ }
1925
2143
  ctx.write(workflowPath, content);
1926
2144
  return {
1927
2145
  filePath: workflowPath,
@@ -1931,6 +2149,15 @@ async function generateReleaseCi(ctx) {
1931
2149
  }
1932
2150
  //#endregion
1933
2151
  //#region src/generators/lefthook.ts
2152
+ function requiredCommands(formatter) {
2153
+ return {
2154
+ lint: { run: "pnpm exec oxlint {staged_files}" },
2155
+ format: {
2156
+ run: formatter === "prettier" ? "pnpm exec prettier --write {staged_files}" : "pnpm exec oxfmt --no-error-on-unmatched-pattern {staged_files}",
2157
+ stage_fixed: true
2158
+ }
2159
+ };
2160
+ }
1934
2161
  function buildConfig(formatter) {
1935
2162
  return [
1936
2163
  "pre-commit:",
@@ -2042,10 +2269,25 @@ async function generateLefthook(ctx) {
2042
2269
  cleanPackageJson(ctx, results);
2043
2270
  const existingPath = LEFTHOOK_CONFIG_PATHS.find((p) => ctx.exists(p));
2044
2271
  if (existingPath) {
2045
- results.push({
2272
+ const existing = ctx.read(existingPath);
2273
+ if (existing) {
2274
+ const merged = mergeLefthookCommands(existing, requiredCommands(ctx.config.formatter));
2275
+ if (merged.changed) {
2276
+ ctx.write(existingPath, merged.content);
2277
+ results.push({
2278
+ filePath: existingPath,
2279
+ action: "updated",
2280
+ description: "Added missing pre-commit commands"
2281
+ });
2282
+ } else results.push({
2283
+ filePath: existingPath,
2284
+ action: "skipped",
2285
+ description: "Lefthook config already up to date"
2286
+ });
2287
+ } else results.push({
2046
2288
  filePath: existingPath,
2047
2289
  action: "skipped",
2048
- description: "Existing lefthook config preserved"
2290
+ description: "Could not read existing lefthook config"
2049
2291
  });
2050
2292
  return results;
2051
2293
  }
@@ -2183,7 +2425,7 @@ async function generateVscodeSettings(ctx) {
2183
2425
  return results;
2184
2426
  }
2185
2427
  const existingSchema = ctx.read(SCHEMA_LOCAL_PATH);
2186
- if (existingSchema === schemaContent) results.push({
2428
+ if (existingSchema !== void 0 && contentEqual(SCHEMA_LOCAL_PATH, existingSchema, schemaContent)) results.push({
2187
2429
  filePath: SCHEMA_LOCAL_PATH,
2188
2430
  action: "skipped",
2189
2431
  description: "Schema already up to date"
@@ -2279,7 +2521,7 @@ function saveToolingConfig(ctx, config) {
2279
2521
  };
2280
2522
  const content = JSON.stringify(saved, null, 2) + "\n";
2281
2523
  const existing = ctx.exists(CONFIG_FILE) ? ctx.read(CONFIG_FILE) : void 0;
2282
- if (existing === content) return {
2524
+ if (existing !== void 0 && contentEqual(CONFIG_FILE, existing, content)) return {
2283
2525
  filePath: CONFIG_FILE,
2284
2526
  action: "skipped",
2285
2527
  description: "Already up to date"
@@ -2482,7 +2724,16 @@ async function runCheck(targetDir) {
2482
2724
  return 1;
2483
2725
  }
2484
2726
  const { ctx, pendingWrites } = createDryRunContext(mergeWithSavedConfig(buildDefaultConfig(targetDir, {}), saved));
2485
- const actionable = (await runGenerators(ctx)).filter((r) => r.action === "created" || r.action === "updated");
2727
+ const actionable = (await runGenerators(ctx)).filter((r) => {
2728
+ if (r.action !== "created" && r.action !== "updated") return false;
2729
+ const newContent = pendingWrites.get(r.filePath);
2730
+ if (newContent && r.action === "updated") {
2731
+ const existingPath = path.join(targetDir, r.filePath);
2732
+ const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
2733
+ if (existing && contentEqual(r.filePath, existing, newContent)) return false;
2734
+ }
2735
+ return true;
2736
+ });
2486
2737
  if (actionable.length === 0) {
2487
2738
  p.log.success("Repository is up to date.");
2488
2739
  return 0;
@@ -3207,7 +3458,7 @@ function mergeGitHub(dryRun) {
3207
3458
  const main = defineCommand({
3208
3459
  meta: {
3209
3460
  name: "tooling",
3210
- version: "0.9.0",
3461
+ version: "0.10.0",
3211
3462
  description: "Bootstrap and maintain standardized TypeScript project tooling"
3212
3463
  },
3213
3464
  subCommands: {
@@ -3220,7 +3471,7 @@ const main = defineCommand({
3220
3471
  "release:merge": releaseMergeCommand
3221
3472
  }
3222
3473
  });
3223
- console.log(`@bensandee/tooling v0.9.0`);
3474
+ console.log(`@bensandee/tooling v0.10.0`);
3224
3475
  runMain(main);
3225
3476
  //#endregion
3226
3477
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "tooling": "./dist/bin.mjs"
@@ -25,6 +25,7 @@
25
25
  "citty": "^0.2.1",
26
26
  "json5": "^2.2.3",
27
27
  "jsonc-parser": "^3.3.1",
28
+ "yaml": "^2.8.2",
28
29
  "zod": "^4.3.6",
29
30
  "@bensandee/common": "0.1.0"
30
31
  },