@bensandee/tooling 0.9.0 → 0.10.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 +310 -89
  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.1";
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.1";
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,106 @@ 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
+ const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
1248
+ /** Returns a yaml-language-server schema comment for Forgejo workflows, empty string otherwise. */
1249
+ function workflowSchemaComment(ci) {
1250
+ return ci === "forgejo" ? FORGEJO_SCHEMA_COMMENT : "";
1251
+ }
1252
+ /** Check if a YAML file has an opt-out comment in the first 10 lines. */
1253
+ function isToolingIgnored(content) {
1254
+ return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
1255
+ }
1256
+ /**
1257
+ * Ensure required commands exist under `pre-commit.commands` in a lefthook config.
1258
+ * Only adds missing commands — never modifies existing ones.
1259
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1260
+ */
1261
+ function mergeLefthookCommands(existing, requiredCommands) {
1262
+ if (isToolingIgnored(existing)) return {
1263
+ content: existing,
1264
+ changed: false
1265
+ };
1266
+ try {
1267
+ const doc = parseDocument(existing);
1268
+ let changed = false;
1269
+ if (!doc.hasIn(["pre-commit", "commands"])) {
1270
+ doc.setIn(["pre-commit", "commands"], requiredCommands);
1271
+ return {
1272
+ content: doc.toString(),
1273
+ changed: true
1274
+ };
1275
+ }
1276
+ const commands = doc.getIn(["pre-commit", "commands"]);
1277
+ if (!isMap(commands)) return {
1278
+ content: existing,
1279
+ changed: false
1280
+ };
1281
+ for (const [name, config] of Object.entries(requiredCommands)) if (!commands.has(name)) {
1282
+ commands.set(name, config);
1283
+ changed = true;
1284
+ }
1285
+ return {
1286
+ content: changed ? doc.toString() : existing,
1287
+ changed
1288
+ };
1289
+ } catch {
1290
+ return {
1291
+ content: existing,
1292
+ changed: false
1293
+ };
1294
+ }
1295
+ }
1296
+ /**
1297
+ * Ensure required steps exist in a workflow job's steps array.
1298
+ * Only adds missing steps at the end — never modifies existing ones.
1299
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1300
+ */
1301
+ function mergeWorkflowSteps(existing, jobName, requiredSteps) {
1302
+ if (isToolingIgnored(existing)) return {
1303
+ content: existing,
1304
+ changed: false
1305
+ };
1306
+ try {
1307
+ const doc = parseDocument(existing);
1308
+ const steps = doc.getIn([
1309
+ "jobs",
1310
+ jobName,
1311
+ "steps"
1312
+ ]);
1313
+ if (!isSeq(steps)) return {
1314
+ content: existing,
1315
+ changed: false
1316
+ };
1317
+ let changed = false;
1318
+ for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
1319
+ if (!isMap(item)) return false;
1320
+ if (match.run) {
1321
+ const run = item.get("run");
1322
+ return typeof run === "string" && run.includes(match.run);
1323
+ }
1324
+ if (match.uses) {
1325
+ const uses = item.get("uses");
1326
+ return typeof uses === "string" && uses.startsWith(match.uses);
1327
+ }
1328
+ return false;
1329
+ })) {
1330
+ steps.add(doc.createNode(step));
1331
+ changed = true;
1332
+ }
1333
+ return {
1334
+ content: changed ? doc.toString() : existing,
1335
+ changed
1336
+ };
1337
+ } catch {
1338
+ return {
1339
+ content: existing,
1340
+ changed: false
1341
+ };
1342
+ }
1343
+ }
1344
+ //#endregion
1231
1345
  //#region src/generators/ci.ts
1232
1346
  function hasEnginesNode$1(ctx) {
1233
1347
  const raw = ctx.read("package.json");
@@ -1235,8 +1349,12 @@ function hasEnginesNode$1(ctx) {
1235
1349
  return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
1236
1350
  }
1237
1351
  function ciWorkflow(isMonorepo, nodeVersionYaml, isForgejo) {
1238
- return `name: CI
1239
- ${isForgejo ? "\nenable-email-notifications: true\n" : ""}on:
1352
+ const buildCmd = isMonorepo ? "pnpm -r build" : "pnpm build";
1353
+ const testCmd = isMonorepo ? "pnpm -r test" : "pnpm test";
1354
+ const typecheckCmd = isMonorepo ? "pnpm -r --parallel run typecheck" : "pnpm typecheck";
1355
+ const emailNotifications = isForgejo ? "\nenable-email-notifications: true\n" : "";
1356
+ return `${workflowSchemaComment(isForgejo ? "forgejo" : "github")}name: CI
1357
+ ${emailNotifications}on:
1240
1358
  push:
1241
1359
  branches: [main]
1242
1360
  pull_request:
@@ -1253,32 +1371,71 @@ jobs:
1253
1371
  ${nodeVersionYaml}
1254
1372
  cache: pnpm
1255
1373
  - run: pnpm install --frozen-lockfile
1256
- - run: ${isMonorepo ? "pnpm -r --parallel run typecheck" : "pnpm typecheck"}
1374
+ - run: ${typecheckCmd}
1257
1375
  - run: pnpm lint
1258
- - run: ${isMonorepo ? "pnpm -r build" : "pnpm build"}
1259
- - run: ${isMonorepo ? "pnpm -r test" : "pnpm test"}
1376
+ - run: ${buildCmd}
1377
+ - run: ${testCmd}
1260
1378
  - run: pnpm format --check
1261
1379
  - run: pnpm knip
1262
1380
  - run: pnpm exec tooling repo:check
1263
1381
  `;
1264
1382
  }
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");
1383
+ function requiredCheckSteps(isMonorepo, nodeVersionYaml) {
1384
+ const buildCmd = isMonorepo ? "pnpm -r build" : "pnpm build";
1385
+ const testCmd = isMonorepo ? "pnpm -r test" : "pnpm test";
1386
+ const typecheckCmd = isMonorepo ? "pnpm -r --parallel run typecheck" : "pnpm typecheck";
1387
+ return [
1388
+ {
1389
+ match: { uses: "actions/checkout" },
1390
+ step: { uses: "actions/checkout@v4" }
1391
+ },
1392
+ {
1393
+ match: { uses: "pnpm/action-setup" },
1394
+ step: { uses: "pnpm/action-setup@v4" }
1395
+ },
1396
+ {
1397
+ match: { uses: "actions/setup-node" },
1398
+ step: {
1399
+ uses: "actions/setup-node@v4",
1400
+ with: {
1401
+ ...nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" },
1402
+ cache: "pnpm"
1403
+ }
1404
+ }
1405
+ },
1406
+ {
1407
+ match: { run: "pnpm install" },
1408
+ step: { run: "pnpm install --frozen-lockfile" }
1409
+ },
1410
+ {
1411
+ match: { run: "typecheck" },
1412
+ step: { run: typecheckCmd }
1413
+ },
1414
+ {
1415
+ match: { run: "lint" },
1416
+ step: { run: "pnpm lint" }
1417
+ },
1418
+ {
1419
+ match: { run: "build" },
1420
+ step: { run: buildCmd }
1421
+ },
1422
+ {
1423
+ match: { run: "test" },
1424
+ step: { run: testCmd }
1425
+ },
1426
+ {
1427
+ match: { run: "format" },
1428
+ step: { run: "pnpm format --check" }
1429
+ },
1430
+ {
1431
+ match: { run: "knip" },
1432
+ step: { run: "pnpm knip" }
1433
+ },
1434
+ {
1435
+ match: { run: "repo:check" },
1436
+ step: { run: "pnpm exec tooling repo:check" }
1437
+ }
1438
+ ];
1282
1439
  }
1283
1440
  async function generateCi(ctx) {
1284
1441
  if (ctx.config.ci === "none") return {
@@ -1293,14 +1450,14 @@ async function generateCi(ctx) {
1293
1450
  const content = ciWorkflow(isMonorepo, nodeVersionYaml, !isGitHub);
1294
1451
  if (ctx.exists(filePath)) {
1295
1452
  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);
1453
+ if (existing) {
1454
+ const merged = mergeWorkflowSteps(existing, "check", requiredCheckSteps(isMonorepo, nodeVersionYaml));
1455
+ if (merged.changed) {
1456
+ ctx.write(filePath, merged.content);
1300
1457
  return {
1301
1458
  filePath,
1302
1459
  action: "updated",
1303
- description: "Added `pnpm exec tooling repo:check` step to CI workflow"
1460
+ description: "Added missing steps to CI workflow"
1304
1461
  };
1305
1462
  }
1306
1463
  }
@@ -1489,6 +1646,7 @@ function buildSettings(ctx) {
1489
1646
  `Bash(${pm} add *)`,
1490
1647
  `Bash(${pm} update *)`,
1491
1648
  `Bash(${pm} view *)`,
1649
+ `Bash(${pm} why *)`,
1492
1650
  `Bash(${pm} list)`,
1493
1651
  `Bash(${pm} list *)`,
1494
1652
  `Bash(${pm} ls)`,
@@ -1706,7 +1864,7 @@ async function generateReleaseIt(ctx) {
1706
1864
  const content = JSON.stringify(buildConfig$2(ctx.config.ci, ctx.config.structure === "monorepo"), null, 2) + "\n";
1707
1865
  const existing = ctx.read(filePath);
1708
1866
  if (existing) {
1709
- if (existing === content) return {
1867
+ if (contentEqual(filePath, existing, content)) return {
1710
1868
  filePath,
1711
1869
  action: "skipped",
1712
1870
  description: "Already configured"
@@ -1785,7 +1943,7 @@ permissions:
1785
1943
  contents: write
1786
1944
  ` : "";
1787
1945
  const tokenEnv = isGitHub ? `GITHUB_TOKEN: \${{ github.token }}` : `FORGEJO_TOKEN: \${{ secrets.FORGEJO_TOKEN }}`;
1788
- return `name: Release
1946
+ return `${workflowSchemaComment(ci)}name: Release
1789
1947
  on:
1790
1948
  workflow_dispatch:
1791
1949
  ${permissions}
@@ -1834,7 +1992,7 @@ permissions:
1834
1992
  TAG=$(git describe --tags --abbrev=0)
1835
1993
  pnpm publish --no-git-checks
1836
1994
  pnpm exec tooling release:create-forgejo-release --tag "$TAG"`;
1837
- return `name: Release
1995
+ return `${workflowSchemaComment(ci)}name: Release
1838
1996
  on:
1839
1997
  workflow_dispatch:
1840
1998
  ${permissions}
@@ -1846,7 +2004,7 @@ ${commonSteps(nodeVersionYaml)}${gitConfigStep}${releaseStep}
1846
2004
  `;
1847
2005
  }
1848
2006
  function changesetsWorkflow(ci, nodeVersionYaml) {
1849
- if (ci === "github") return `name: Release
2007
+ if (ci === "github") return `${workflowSchemaComment(ci)}name: Release
1850
2008
  on:
1851
2009
  push:
1852
2010
  branches:
@@ -1869,7 +2027,7 @@ ${commonSteps(nodeVersionYaml)}
1869
2027
  GITHUB_TOKEN: \${{ github.token }}
1870
2028
  NPM_TOKEN: \${{ secrets.NPM_TOKEN }}
1871
2029
  `;
1872
- return `name: Release
2030
+ return `${workflowSchemaComment(ci)}name: Release
1873
2031
  on:
1874
2032
  push:
1875
2033
  branches:
@@ -1893,6 +2051,62 @@ ${commonSteps(nodeVersionYaml)}
1893
2051
  run: pnpm exec tooling release:changesets
1894
2052
  `;
1895
2053
  }
2054
+ function requiredReleaseSteps(strategy, nodeVersionYaml) {
2055
+ const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2056
+ const steps = [
2057
+ {
2058
+ match: { uses: "actions/checkout" },
2059
+ step: {
2060
+ uses: "actions/checkout@v4",
2061
+ with: { "fetch-depth": 0 }
2062
+ }
2063
+ },
2064
+ {
2065
+ match: { uses: "pnpm/action-setup" },
2066
+ step: { uses: "pnpm/action-setup@v4" }
2067
+ },
2068
+ {
2069
+ match: { uses: "actions/setup-node" },
2070
+ step: {
2071
+ uses: "actions/setup-node@v4",
2072
+ with: {
2073
+ ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2074
+ cache: "pnpm",
2075
+ "registry-url": "https://registry.npmjs.org"
2076
+ }
2077
+ }
2078
+ },
2079
+ {
2080
+ match: { run: "pnpm install" },
2081
+ step: { run: "pnpm install --frozen-lockfile" }
2082
+ },
2083
+ {
2084
+ match: { run: "build" },
2085
+ step: { run: "pnpm build" }
2086
+ }
2087
+ ];
2088
+ switch (strategy) {
2089
+ case "release-it":
2090
+ steps.push({
2091
+ match: { run: "release-it" },
2092
+ step: { run: "pnpm release-it --ci" }
2093
+ });
2094
+ break;
2095
+ case "commit-and-tag-version":
2096
+ steps.push({
2097
+ match: { run: "commit-and-tag-version" },
2098
+ step: { run: "pnpm exec commit-and-tag-version" }
2099
+ });
2100
+ break;
2101
+ case "changesets":
2102
+ steps.push({
2103
+ match: { run: "changeset" },
2104
+ step: { run: "pnpm exec tooling release:changesets" }
2105
+ });
2106
+ break;
2107
+ }
2108
+ return steps;
2109
+ }
1896
2110
  function buildWorkflow(strategy, ci, nodeVersionYaml) {
1897
2111
  switch (strategy) {
1898
2112
  case "release-it": return releaseItWorkflow(ci, nodeVersionYaml);
@@ -1917,11 +2131,25 @@ async function generateReleaseCi(ctx) {
1917
2131
  action: "skipped",
1918
2132
  description: "Release CI workflow not applicable"
1919
2133
  };
1920
- if (ctx.exists(workflowPath)) return {
1921
- filePath: workflowPath,
1922
- action: "skipped",
1923
- description: "Existing release workflow preserved"
1924
- };
2134
+ if (ctx.exists(workflowPath)) {
2135
+ const existing = ctx.read(workflowPath);
2136
+ if (existing) {
2137
+ const merged = mergeWorkflowSteps(existing, "release", requiredReleaseSteps(ctx.config.releaseStrategy, nodeVersionYaml));
2138
+ if (merged.changed) {
2139
+ ctx.write(workflowPath, merged.content);
2140
+ return {
2141
+ filePath: workflowPath,
2142
+ action: "updated",
2143
+ description: "Added missing steps to release workflow"
2144
+ };
2145
+ }
2146
+ }
2147
+ return {
2148
+ filePath: workflowPath,
2149
+ action: "skipped",
2150
+ description: "Release workflow already up to date"
2151
+ };
2152
+ }
1925
2153
  ctx.write(workflowPath, content);
1926
2154
  return {
1927
2155
  filePath: workflowPath,
@@ -1931,6 +2159,15 @@ async function generateReleaseCi(ctx) {
1931
2159
  }
1932
2160
  //#endregion
1933
2161
  //#region src/generators/lefthook.ts
2162
+ function requiredCommands(formatter) {
2163
+ return {
2164
+ lint: { run: "pnpm exec oxlint {staged_files}" },
2165
+ format: {
2166
+ run: formatter === "prettier" ? "pnpm exec prettier --write {staged_files}" : "pnpm exec oxfmt --no-error-on-unmatched-pattern {staged_files}",
2167
+ stage_fixed: true
2168
+ }
2169
+ };
2170
+ }
1934
2171
  function buildConfig(formatter) {
1935
2172
  return [
1936
2173
  "pre-commit:",
@@ -2042,10 +2279,25 @@ async function generateLefthook(ctx) {
2042
2279
  cleanPackageJson(ctx, results);
2043
2280
  const existingPath = LEFTHOOK_CONFIG_PATHS.find((p) => ctx.exists(p));
2044
2281
  if (existingPath) {
2045
- results.push({
2282
+ const existing = ctx.read(existingPath);
2283
+ if (existing) {
2284
+ const merged = mergeLefthookCommands(existing, requiredCommands(ctx.config.formatter));
2285
+ if (merged.changed) {
2286
+ ctx.write(existingPath, merged.content);
2287
+ results.push({
2288
+ filePath: existingPath,
2289
+ action: "updated",
2290
+ description: "Added missing pre-commit commands"
2291
+ });
2292
+ } else results.push({
2293
+ filePath: existingPath,
2294
+ action: "skipped",
2295
+ description: "Lefthook config already up to date"
2296
+ });
2297
+ } else results.push({
2046
2298
  filePath: existingPath,
2047
2299
  action: "skipped",
2048
- description: "Existing lefthook config preserved"
2300
+ description: "Could not read existing lefthook config"
2049
2301
  });
2050
2302
  return results;
2051
2303
  }
@@ -2062,22 +2314,13 @@ async function generateLefthook(ctx) {
2062
2314
  const SCHEMA_NPM_PATH = "@bensandee/config/schemas/forgejo-workflow.schema.json";
2063
2315
  const SCHEMA_LOCAL_PATH = ".vscode/forgejo-workflow.schema.json";
2064
2316
  const SETTINGS_PATH = ".vscode/settings.json";
2065
- const SCHEMA_GLOB = ".forgejo/workflows/*.yml";
2317
+ const SCHEMA_GLOB = ".forgejo/workflows/*.{yml,yaml}";
2066
2318
  const VscodeSettingsSchema = z.object({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) }).passthrough();
2067
- const FullWorkspaceFileSchema = z.object({ settings: z.record(z.string(), z.unknown()).default({}) }).passthrough();
2068
2319
  function readSchemaFromNodeModules(targetDir) {
2069
2320
  const candidate = path.join(targetDir, "node_modules", SCHEMA_NPM_PATH);
2070
2321
  if (!existsSync(candidate)) return void 0;
2071
2322
  return readFileSync(candidate, "utf-8");
2072
2323
  }
2073
- /** Find a *.code-workspace file in the target directory. */
2074
- function findWorkspaceFile(targetDir) {
2075
- try {
2076
- return readdirSync(targetDir).find((e) => e.endsWith(".code-workspace"));
2077
- } catch {
2078
- return;
2079
- }
2080
- }
2081
2324
  function serializeSettings(settings) {
2082
2325
  return JSON.stringify(settings, null, 2) + "\n";
2083
2326
  }
@@ -2099,36 +2342,6 @@ function mergeYamlSchemas(settings) {
2099
2342
  changed: true
2100
2343
  };
2101
2344
  }
2102
- function writeSchemaToWorkspaceFile(ctx, wsFileName) {
2103
- const raw = ctx.read(wsFileName);
2104
- if (!raw) return {
2105
- filePath: wsFileName,
2106
- action: "skipped",
2107
- description: "Could not read workspace file"
2108
- };
2109
- const fullParsed = FullWorkspaceFileSchema.safeParse(parse(raw));
2110
- if (!fullParsed.success) return {
2111
- filePath: wsFileName,
2112
- action: "skipped",
2113
- description: "Could not parse workspace file"
2114
- };
2115
- const { merged, changed } = mergeYamlSchemas(fullParsed.data.settings);
2116
- if (!changed) return {
2117
- filePath: wsFileName,
2118
- action: "skipped",
2119
- description: "Already has Forgejo schema mapping"
2120
- };
2121
- const updated = {
2122
- ...fullParsed.data,
2123
- settings: merged
2124
- };
2125
- ctx.write(wsFileName, JSON.stringify(updated, null, 2) + "\n");
2126
- return {
2127
- filePath: wsFileName,
2128
- action: "updated",
2129
- description: "Added Forgejo workflow schema mapping to workspace settings"
2130
- };
2131
- }
2132
2345
  function writeSchemaToSettings(ctx) {
2133
2346
  if (ctx.exists(SETTINGS_PATH)) {
2134
2347
  const raw = ctx.read(SETTINGS_PATH);
@@ -2183,7 +2396,7 @@ async function generateVscodeSettings(ctx) {
2183
2396
  return results;
2184
2397
  }
2185
2398
  const existingSchema = ctx.read(SCHEMA_LOCAL_PATH);
2186
- if (existingSchema === schemaContent) results.push({
2399
+ if (existingSchema !== void 0 && contentEqual(SCHEMA_LOCAL_PATH, existingSchema, schemaContent)) results.push({
2187
2400
  filePath: SCHEMA_LOCAL_PATH,
2188
2401
  action: "skipped",
2189
2402
  description: "Schema already up to date"
@@ -2196,8 +2409,7 @@ async function generateVscodeSettings(ctx) {
2196
2409
  description: "Copied Forgejo workflow schema from @bensandee/config"
2197
2410
  });
2198
2411
  }
2199
- const wsFile = findWorkspaceFile(ctx.targetDir);
2200
- results.push(wsFile ? writeSchemaToWorkspaceFile(ctx, wsFile) : writeSchemaToSettings(ctx));
2412
+ results.push(writeSchemaToSettings(ctx));
2201
2413
  return results;
2202
2414
  }
2203
2415
  //#endregion
@@ -2279,7 +2491,7 @@ function saveToolingConfig(ctx, config) {
2279
2491
  };
2280
2492
  const content = JSON.stringify(saved, null, 2) + "\n";
2281
2493
  const existing = ctx.exists(CONFIG_FILE) ? ctx.read(CONFIG_FILE) : void 0;
2282
- if (existing === content) return {
2494
+ if (existing !== void 0 && contentEqual(CONFIG_FILE, existing, content)) return {
2283
2495
  filePath: CONFIG_FILE,
2284
2496
  action: "skipped",
2285
2497
  description: "Already up to date"
@@ -2482,7 +2694,16 @@ async function runCheck(targetDir) {
2482
2694
  return 1;
2483
2695
  }
2484
2696
  const { ctx, pendingWrites } = createDryRunContext(mergeWithSavedConfig(buildDefaultConfig(targetDir, {}), saved));
2485
- const actionable = (await runGenerators(ctx)).filter((r) => r.action === "created" || r.action === "updated");
2697
+ const actionable = (await runGenerators(ctx)).filter((r) => {
2698
+ if (r.action !== "created" && r.action !== "updated") return false;
2699
+ const newContent = pendingWrites.get(r.filePath);
2700
+ if (newContent && r.action === "updated") {
2701
+ const existingPath = path.join(targetDir, r.filePath);
2702
+ const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
2703
+ if (existing && contentEqual(r.filePath, existing, newContent)) return false;
2704
+ }
2705
+ return true;
2706
+ });
2486
2707
  if (actionable.length === 0) {
2487
2708
  p.log.success("Repository is up to date.");
2488
2709
  return 0;
@@ -3207,7 +3428,7 @@ function mergeGitHub(dryRun) {
3207
3428
  const main = defineCommand({
3208
3429
  meta: {
3209
3430
  name: "tooling",
3210
- version: "0.9.0",
3431
+ version: "0.10.1",
3211
3432
  description: "Bootstrap and maintain standardized TypeScript project tooling"
3212
3433
  },
3213
3434
  subCommands: {
@@ -3220,7 +3441,7 @@ const main = defineCommand({
3220
3441
  "release:merge": releaseMergeCommand
3221
3442
  }
3222
3443
  });
3223
- console.log(`@bensandee/tooling v0.9.0`);
3444
+ console.log(`@bensandee/tooling v0.10.1`);
3224
3445
  runMain(main);
3225
3446
  //#endregion
3226
3447
  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.1",
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
  },