@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.
- package/dist/bin.mjs +435 -129
- 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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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.
|
|
566
|
-
deps["@bensandee/tooling"] = "0.
|
|
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.
|
|
587
|
-
devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
|
1293
|
-
const
|
|
1294
|
-
if (
|
|
1295
|
-
ctx.write(filePath,
|
|
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
|
|
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
|
|
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))
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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
|
|
2104
|
-
|
|
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
|
|
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("
|
|
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.
|
|
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
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
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
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
37
|
+
"@bensandee/config": "0.7.1"
|
|
37
38
|
},
|
|
38
39
|
"scripts": {
|
|
39
40
|
"build": "tsdown",
|