@bensandee/tooling 0.8.1 → 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 +435 -129
  2. package/package.json +3 -2
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));
@@ -437,29 +451,31 @@ function writeFile(targetDir, relativePath, content) {
437
451
  function createContext(config, confirmOverwrite) {
438
452
  const archivedFiles = [];
439
453
  const pkgRaw = readFile(config.targetDir, "package.json");
440
- return {
441
- ctx: {
442
- config,
443
- targetDir: config.targetDir,
444
- packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
445
- exists: (rel) => fileExists(config.targetDir, rel),
446
- read: (rel) => readFile(config.targetDir, rel),
447
- write: (rel, content) => {
448
- if (!rel.startsWith(".tooling-archived/")) {
449
- const existing = readFile(config.targetDir, rel);
450
- if (existing !== void 0 && existing !== content) {
451
- writeFile(config.targetDir, `.tooling-archived/${rel}`, existing);
452
- archivedFiles.push(rel);
453
- }
454
+ const ctx = {
455
+ config,
456
+ targetDir: config.targetDir,
457
+ packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
458
+ exists: (rel) => fileExists(config.targetDir, rel),
459
+ read: (rel) => readFile(config.targetDir, rel),
460
+ write: (rel, content) => {
461
+ if (!rel.startsWith(".tooling-archived/")) {
462
+ const existing = readFile(config.targetDir, rel);
463
+ if (existing !== void 0 && !contentEqual(rel, existing, content)) {
464
+ writeFile(config.targetDir, `.tooling-archived/${rel}`, existing);
465
+ archivedFiles.push(rel);
454
466
  }
455
- writeFile(config.targetDir, rel, content);
456
- },
457
- remove: (rel) => {
458
- const fullPath = path.join(config.targetDir, rel);
459
- if (existsSync(fullPath)) rmSync(fullPath);
460
- },
461
- confirmOverwrite
467
+ }
468
+ writeFile(config.targetDir, rel, content);
469
+ if (rel === "package.json") ctx.packageJson = parsePackageJson(content);
470
+ },
471
+ remove: (rel) => {
472
+ const fullPath = path.join(config.targetDir, rel);
473
+ if (existsSync(fullPath)) rmSync(fullPath);
462
474
  },
475
+ confirmOverwrite
476
+ };
477
+ return {
478
+ ctx,
463
479
  archivedFiles
464
480
  };
465
481
  }
@@ -472,20 +488,22 @@ function createDryRunContext(config) {
472
488
  const pkgRaw = readFile(config.targetDir, "package.json");
473
489
  const pendingWrites = /* @__PURE__ */ new Map();
474
490
  const shadow = /* @__PURE__ */ new Map();
475
- return {
476
- ctx: {
477
- config,
478
- targetDir: config.targetDir,
479
- packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
480
- exists: (rel) => shadow.has(rel) || fileExists(config.targetDir, rel),
481
- read: (rel) => shadow.get(rel) ?? readFile(config.targetDir, rel),
482
- write: (rel, content) => {
483
- pendingWrites.set(rel, content);
484
- shadow.set(rel, content);
485
- },
486
- remove: () => {},
487
- confirmOverwrite: async () => "overwrite"
491
+ const ctx = {
492
+ config,
493
+ targetDir: config.targetDir,
494
+ packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
495
+ exists: (rel) => shadow.has(rel) || fileExists(config.targetDir, rel),
496
+ read: (rel) => shadow.get(rel) ?? readFile(config.targetDir, rel),
497
+ write: (rel, content) => {
498
+ pendingWrites.set(rel, content);
499
+ shadow.set(rel, content);
500
+ if (rel === "package.json") ctx.packageJson = parsePackageJson(content);
488
501
  },
502
+ remove: () => {},
503
+ confirmOverwrite: async () => "overwrite"
504
+ };
505
+ return {
506
+ ctx,
489
507
  pendingWrites
490
508
  };
491
509
  }
@@ -562,8 +580,8 @@ function addReleaseDeps(deps, config) {
562
580
  function getAddedDevDepNames(config) {
563
581
  const deps = { ...ROOT_DEV_DEPS };
564
582
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
565
- deps["@bensandee/config"] = "0.7.0";
566
- deps["@bensandee/tooling"] = "0.8.1";
583
+ deps["@bensandee/config"] = "0.7.1";
584
+ deps["@bensandee/tooling"] = "0.10.0";
567
585
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
568
586
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
569
587
  addReleaseDeps(deps, config);
@@ -583,8 +601,8 @@ async function generatePackageJson(ctx) {
583
601
  if (ctx.config.releaseStrategy !== "none") allScripts["trigger-release"] = "pnpm exec tooling release:trigger";
584
602
  const devDeps = { ...ROOT_DEV_DEPS };
585
603
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
586
- devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.7.0";
587
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.8.1";
604
+ devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.7.1";
605
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.10.0";
588
606
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.0";
589
607
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
590
608
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1224,6 +1242,101 @@ async function generateGitignore(ctx) {
1224
1242
  };
1225
1243
  }
1226
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
1227
1340
  //#region src/generators/ci.ts
1228
1341
  function hasEnginesNode$1(ctx) {
1229
1342
  const raw = ctx.read("package.json");
@@ -1258,23 +1371,62 @@ jobs:
1258
1371
  - run: pnpm exec tooling repo:check
1259
1372
  `;
1260
1373
  }
1261
- /**
1262
- * Insert a step at the end of the `check` job's steps, even if other jobs
1263
- * follow. Returns null if we can't find the right insertion point.
1264
- */
1265
- function insertStepIntoCheckJob(yaml, step) {
1266
- const lines = yaml.split("\n");
1267
- const checkJobIdx = lines.findIndex((l) => /^ {2}check:\s*$/.test(l));
1268
- if (checkJobIdx === -1) return null;
1269
- let lastStepIdx = -1;
1270
- for (const [i, line] of lines.entries()) {
1271
- if (i <= checkJobIdx) continue;
1272
- if (/^ {2}\S/.test(line)) break;
1273
- if (/^ {6}/.test(line)) lastStepIdx = i;
1274
- }
1275
- if (lastStepIdx === -1) return null;
1276
- lines.splice(lastStepIdx + 1, 0, step.trimEnd());
1277
- 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
+ ];
1278
1430
  }
1279
1431
  async function generateCi(ctx) {
1280
1432
  if (ctx.config.ci === "none") return {
@@ -1289,14 +1441,14 @@ async function generateCi(ctx) {
1289
1441
  const content = ciWorkflow(isMonorepo, nodeVersionYaml, !isGitHub);
1290
1442
  if (ctx.exists(filePath)) {
1291
1443
  const existing = ctx.read(filePath);
1292
- if (existing && !existing.includes("repo:check")) {
1293
- const patched = insertStepIntoCheckJob(existing, " - run: pnpm exec tooling repo:check\n");
1294
- if (patched) {
1295
- 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);
1296
1448
  return {
1297
1449
  filePath,
1298
1450
  action: "updated",
1299
- description: "Added `pnpm exec tooling repo:check` step to CI workflow"
1451
+ description: "Added missing steps to CI workflow"
1300
1452
  };
1301
1453
  }
1302
1454
  }
@@ -1702,7 +1854,7 @@ async function generateReleaseIt(ctx) {
1702
1854
  const content = JSON.stringify(buildConfig$2(ctx.config.ci, ctx.config.structure === "monorepo"), null, 2) + "\n";
1703
1855
  const existing = ctx.read(filePath);
1704
1856
  if (existing) {
1705
- if (existing === content) return {
1857
+ if (contentEqual(filePath, existing, content)) return {
1706
1858
  filePath,
1707
1859
  action: "skipped",
1708
1860
  description: "Already configured"
@@ -1889,6 +2041,62 @@ ${commonSteps(nodeVersionYaml)}
1889
2041
  run: pnpm exec tooling release:changesets
1890
2042
  `;
1891
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
+ }
1892
2100
  function buildWorkflow(strategy, ci, nodeVersionYaml) {
1893
2101
  switch (strategy) {
1894
2102
  case "release-it": return releaseItWorkflow(ci, nodeVersionYaml);
@@ -1913,11 +2121,25 @@ async function generateReleaseCi(ctx) {
1913
2121
  action: "skipped",
1914
2122
  description: "Release CI workflow not applicable"
1915
2123
  };
1916
- if (ctx.exists(workflowPath)) return {
1917
- filePath: workflowPath,
1918
- action: "skipped",
1919
- description: "Existing release workflow preserved"
1920
- };
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
+ }
1921
2143
  ctx.write(workflowPath, content);
1922
2144
  return {
1923
2145
  filePath: workflowPath,
@@ -1927,6 +2149,15 @@ async function generateReleaseCi(ctx) {
1927
2149
  }
1928
2150
  //#endregion
1929
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
+ }
1930
2161
  function buildConfig(formatter) {
1931
2162
  return [
1932
2163
  "pre-commit:",
@@ -2038,10 +2269,25 @@ async function generateLefthook(ctx) {
2038
2269
  cleanPackageJson(ctx, results);
2039
2270
  const existingPath = LEFTHOOK_CONFIG_PATHS.find((p) => ctx.exists(p));
2040
2271
  if (existingPath) {
2041
- 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({
2042
2288
  filePath: existingPath,
2043
2289
  action: "skipped",
2044
- description: "Existing lefthook config preserved"
2290
+ description: "Could not read existing lefthook config"
2045
2291
  });
2046
2292
  return results;
2047
2293
  }
@@ -2058,15 +2304,107 @@ async function generateLefthook(ctx) {
2058
2304
  const SCHEMA_NPM_PATH = "@bensandee/config/schemas/forgejo-workflow.schema.json";
2059
2305
  const SCHEMA_LOCAL_PATH = ".vscode/forgejo-workflow.schema.json";
2060
2306
  const SETTINGS_PATH = ".vscode/settings.json";
2307
+ const SCHEMA_GLOB = ".forgejo/workflows/*.yml";
2061
2308
  const VscodeSettingsSchema = z.object({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) }).passthrough();
2309
+ const FullWorkspaceFileSchema = z.object({ settings: z.record(z.string(), z.unknown()).default({}) }).passthrough();
2062
2310
  function readSchemaFromNodeModules(targetDir) {
2063
2311
  const candidate = path.join(targetDir, "node_modules", SCHEMA_NPM_PATH);
2064
2312
  if (!existsSync(candidate)) return void 0;
2065
2313
  return readFileSync(candidate, "utf-8");
2066
2314
  }
2315
+ /** Find a *.code-workspace file in the target directory. */
2316
+ function findWorkspaceFile(targetDir) {
2317
+ try {
2318
+ return readdirSync(targetDir).find((e) => e.endsWith(".code-workspace"));
2319
+ } catch {
2320
+ return;
2321
+ }
2322
+ }
2067
2323
  function serializeSettings(settings) {
2068
2324
  return JSON.stringify(settings, null, 2) + "\n";
2069
2325
  }
2326
+ const YamlSchemasSchema = z.record(z.string(), z.unknown());
2327
+ /** Merge yaml.schemas into a settings object. Returns the result and whether anything changed. */
2328
+ function mergeYamlSchemas(settings) {
2329
+ const parsed = YamlSchemasSchema.safeParse(settings["yaml.schemas"]);
2330
+ const yamlSchemas = parsed.success ? { ...parsed.data } : {};
2331
+ if (SCHEMA_LOCAL_PATH in yamlSchemas) return {
2332
+ merged: settings,
2333
+ changed: false
2334
+ };
2335
+ yamlSchemas[SCHEMA_LOCAL_PATH] = SCHEMA_GLOB;
2336
+ return {
2337
+ merged: {
2338
+ ...settings,
2339
+ "yaml.schemas": yamlSchemas
2340
+ },
2341
+ changed: true
2342
+ };
2343
+ }
2344
+ function writeSchemaToWorkspaceFile(ctx, wsFileName) {
2345
+ const raw = ctx.read(wsFileName);
2346
+ if (!raw) return {
2347
+ filePath: wsFileName,
2348
+ action: "skipped",
2349
+ description: "Could not read workspace file"
2350
+ };
2351
+ const fullParsed = FullWorkspaceFileSchema.safeParse(parse(raw));
2352
+ if (!fullParsed.success) return {
2353
+ filePath: wsFileName,
2354
+ action: "skipped",
2355
+ description: "Could not parse workspace file"
2356
+ };
2357
+ const { merged, changed } = mergeYamlSchemas(fullParsed.data.settings);
2358
+ if (!changed) return {
2359
+ filePath: wsFileName,
2360
+ action: "skipped",
2361
+ description: "Already has Forgejo schema mapping"
2362
+ };
2363
+ const updated = {
2364
+ ...fullParsed.data,
2365
+ settings: merged
2366
+ };
2367
+ ctx.write(wsFileName, JSON.stringify(updated, null, 2) + "\n");
2368
+ return {
2369
+ filePath: wsFileName,
2370
+ action: "updated",
2371
+ description: "Added Forgejo workflow schema mapping to workspace settings"
2372
+ };
2373
+ }
2374
+ function writeSchemaToSettings(ctx) {
2375
+ if (ctx.exists(SETTINGS_PATH)) {
2376
+ const raw = ctx.read(SETTINGS_PATH);
2377
+ if (!raw) return {
2378
+ filePath: SETTINGS_PATH,
2379
+ action: "skipped",
2380
+ description: "Could not read existing settings"
2381
+ };
2382
+ const parsed = VscodeSettingsSchema.safeParse(parse(raw));
2383
+ if (!parsed.success) return {
2384
+ filePath: SETTINGS_PATH,
2385
+ action: "skipped",
2386
+ description: "Could not parse existing settings"
2387
+ };
2388
+ const { merged, changed } = mergeYamlSchemas(parsed.data);
2389
+ if (!changed) return {
2390
+ filePath: SETTINGS_PATH,
2391
+ action: "skipped",
2392
+ description: "Already has Forgejo schema mapping"
2393
+ };
2394
+ ctx.write(SETTINGS_PATH, serializeSettings(merged));
2395
+ return {
2396
+ filePath: SETTINGS_PATH,
2397
+ action: "updated",
2398
+ description: "Added Forgejo workflow schema mapping"
2399
+ };
2400
+ }
2401
+ ctx.write(SETTINGS_PATH, serializeSettings({ "yaml.schemas": { [SCHEMA_LOCAL_PATH]: SCHEMA_GLOB } }));
2402
+ return {
2403
+ filePath: SETTINGS_PATH,
2404
+ action: "created",
2405
+ description: "Generated .vscode/settings.json with Forgejo workflow schema"
2406
+ };
2407
+ }
2070
2408
  async function generateVscodeSettings(ctx) {
2071
2409
  const results = [];
2072
2410
  if (ctx.config.ci !== "forgejo") {
@@ -2087,7 +2425,7 @@ async function generateVscodeSettings(ctx) {
2087
2425
  return results;
2088
2426
  }
2089
2427
  const existingSchema = ctx.read(SCHEMA_LOCAL_PATH);
2090
- if (existingSchema === schemaContent) results.push({
2428
+ if (existingSchema !== void 0 && contentEqual(SCHEMA_LOCAL_PATH, existingSchema, schemaContent)) results.push({
2091
2429
  filePath: SCHEMA_LOCAL_PATH,
2092
2430
  action: "skipped",
2093
2431
  description: "Schema already up to date"
@@ -2100,54 +2438,8 @@ async function generateVscodeSettings(ctx) {
2100
2438
  description: "Copied Forgejo workflow schema from @bensandee/config"
2101
2439
  });
2102
2440
  }
2103
- const schemaGlob = ".forgejo/workflows/*.yml";
2104
- if (ctx.exists(SETTINGS_PATH)) {
2105
- const raw = ctx.read(SETTINGS_PATH);
2106
- if (!raw) {
2107
- results.push({
2108
- filePath: SETTINGS_PATH,
2109
- action: "skipped",
2110
- description: "Could not read existing settings"
2111
- });
2112
- return results;
2113
- }
2114
- const parsed = VscodeSettingsSchema.safeParse(JSON.parse(raw));
2115
- if (!parsed.success) {
2116
- results.push({
2117
- filePath: SETTINGS_PATH,
2118
- action: "skipped",
2119
- description: "Could not parse existing settings"
2120
- });
2121
- return results;
2122
- }
2123
- const existing = parsed.data;
2124
- const yamlSchemas = existing["yaml.schemas"];
2125
- if (SCHEMA_LOCAL_PATH in yamlSchemas) {
2126
- results.push({
2127
- filePath: SETTINGS_PATH,
2128
- action: "skipped",
2129
- description: "Already has Forgejo schema mapping"
2130
- });
2131
- return results;
2132
- }
2133
- yamlSchemas[SCHEMA_LOCAL_PATH] = schemaGlob;
2134
- ctx.write(SETTINGS_PATH, serializeSettings({
2135
- ...existing,
2136
- "yaml.schemas": yamlSchemas
2137
- }));
2138
- results.push({
2139
- filePath: SETTINGS_PATH,
2140
- action: "updated",
2141
- description: "Added Forgejo workflow schema mapping"
2142
- });
2143
- } else {
2144
- ctx.write(SETTINGS_PATH, serializeSettings({ "yaml.schemas": { [SCHEMA_LOCAL_PATH]: schemaGlob } }));
2145
- results.push({
2146
- filePath: SETTINGS_PATH,
2147
- action: "created",
2148
- description: "Generated .vscode/settings.json with Forgejo workflow schema"
2149
- });
2150
- }
2441
+ const wsFile = findWorkspaceFile(ctx.targetDir);
2442
+ results.push(wsFile ? writeSchemaToWorkspaceFile(ctx, wsFile) : writeSchemaToSettings(ctx));
2151
2443
  return results;
2152
2444
  }
2153
2445
  //#endregion
@@ -2229,7 +2521,7 @@ function saveToolingConfig(ctx, config) {
2229
2521
  };
2230
2522
  const content = JSON.stringify(saved, null, 2) + "\n";
2231
2523
  const existing = ctx.exists(CONFIG_FILE) ? ctx.read(CONFIG_FILE) : void 0;
2232
- if (existing === content) return {
2524
+ if (existing !== void 0 && contentEqual(CONFIG_FILE, existing, content)) return {
2233
2525
  filePath: CONFIG_FILE,
2234
2526
  action: "skipped",
2235
2527
  description: "Already up to date"
@@ -2355,7 +2647,7 @@ async function runInit(config, options = {}) {
2355
2647
  const promptPath = ".tooling-migrate.md";
2356
2648
  ctx.write(promptPath, prompt);
2357
2649
  p.log.info(`Migration prompt written to ${promptPath}`);
2358
- p.log.info("Paste its contents into Claude Code to finish the migration.");
2650
+ p.log.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
2359
2651
  }
2360
2652
  const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
2361
2653
  const hasLockfile = ctx.exists("pnpm-lock.yaml");
@@ -2376,7 +2668,7 @@ async function runInit(config, options = {}) {
2376
2668
  "2. Run: pnpm typecheck",
2377
2669
  "3. Run: pnpm build",
2378
2670
  "4. Run: pnpm test",
2379
- ...options.noPrompt ? [] : ["5. Paste .tooling-migrate.md into Claude Code for cleanup"]
2671
+ ...options.noPrompt ? [] : ["5. In Claude Code, run: \"Execute the steps in .tooling-migrate.md\""]
2380
2672
  ].join("\n"), "Next steps");
2381
2673
  return results;
2382
2674
  }
@@ -2398,9 +2690,12 @@ const updateCommand = defineCommand({
2398
2690
  });
2399
2691
  async function runUpdate(targetDir) {
2400
2692
  const saved = loadToolingConfig(targetDir);
2401
- const detected = buildDefaultConfig(targetDir, {});
2402
- if (!saved) p.log.warn("No .tooling.json found — using detected defaults. Run `tooling repo:init` to save your preferences.");
2403
- return runInit(saved ? mergeWithSavedConfig(detected, saved) : detected, {
2693
+ if (!saved) {
2694
+ p.log.error("No .tooling.json found. Run `tooling repo:init` first to initialize the project.");
2695
+ process.exitCode = 1;
2696
+ return [];
2697
+ }
2698
+ return runInit(mergeWithSavedConfig(buildDefaultConfig(targetDir, {}), saved), {
2404
2699
  noPrompt: true,
2405
2700
  confirmOverwrite: async () => "overwrite"
2406
2701
  });
@@ -2424,10 +2719,21 @@ const checkCommand = defineCommand({
2424
2719
  });
2425
2720
  async function runCheck(targetDir) {
2426
2721
  const saved = loadToolingConfig(targetDir);
2427
- const detected = buildDefaultConfig(targetDir, {});
2428
- if (!saved) p.log.warn("No .tooling.json found — using detected defaults. Run `tooling repo:init` to save your preferences.");
2429
- const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
2430
- const actionable = (await runGenerators(ctx)).filter((r) => r.action === "created" || r.action === "updated");
2722
+ if (!saved) {
2723
+ p.log.error("No .tooling.json found. Run `tooling repo:init` first to initialize the project.");
2724
+ return 1;
2725
+ }
2726
+ const { ctx, pendingWrites } = createDryRunContext(mergeWithSavedConfig(buildDefaultConfig(targetDir, {}), saved));
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
+ });
2431
2737
  if (actionable.length === 0) {
2432
2738
  p.log.success("Repository is up to date.");
2433
2739
  return 0;
@@ -3152,7 +3458,7 @@ function mergeGitHub(dryRun) {
3152
3458
  const main = defineCommand({
3153
3459
  meta: {
3154
3460
  name: "tooling",
3155
- version: "0.8.1",
3461
+ version: "0.10.0",
3156
3462
  description: "Bootstrap and maintain standardized TypeScript project tooling"
3157
3463
  },
3158
3464
  subCommands: {
@@ -3165,7 +3471,7 @@ const main = defineCommand({
3165
3471
  "release:merge": releaseMergeCommand
3166
3472
  }
3167
3473
  });
3168
- console.log(`@bensandee/tooling v0.8.1`);
3474
+ console.log(`@bensandee/tooling v0.10.0`);
3169
3475
  runMain(main);
3170
3476
  //#endregion
3171
3477
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.8.1",
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
  },
@@ -33,7 +34,7 @@
33
34
  "tsdown": "0.21.0",
34
35
  "typescript": "5.9.3",
35
36
  "vitest": "4.0.18",
36
- "@bensandee/config": "0.7.0"
37
+ "@bensandee/config": "0.7.1"
37
38
  },
38
39
  "scripts": {
39
40
  "build": "tsdown",