@bensandee/tooling 0.37.0 → 0.38.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 CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { d as debug, f as debugExec, h as note, l as createRealExecutor$1, m as log, p as isEnvVerbose, t as runDockerCheck, u as isExecSyncError } from "./check-Ceom_OgJ.mjs";
2
+ import { d as debug, f as debugExec, g as note, h as log, l as createRealExecutor$1, m as redact, p as isEnvVerbose, t as runDockerCheck, u as isExecSyncError } from "./check-hutCTIXe.mjs";
3
3
  import { defineCommand, runMain } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import { isCancel, select } from "@clack/prompts";
6
6
  import path from "node:path";
7
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
8
- import { execSync } from "node:child_process";
7
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { execFileSync, execSync } from "node:child_process";
9
9
  import JSON5 from "json5";
10
10
  import { parse } from "jsonc-parser";
11
11
  import { z } from "zod";
@@ -102,6 +102,7 @@ function detectProject(targetDir) {
102
102
  hasPackageJson: exists("package.json"),
103
103
  hasTsconfig: exists("tsconfig.json"),
104
104
  hasOxlintConfig: exists("oxlint.config.ts"),
105
+ oxlintExtendsRecommended: fileContains(path.join(targetDir, "oxlint.config.ts"), "@bensandee/config/oxlint/recommended"),
105
106
  hasLegacyOxlintJson: exists(".oxlintrc.json"),
106
107
  hasGitignore: exists(".gitignore"),
107
108
  hasVitestConfig: exists("vitest.config.ts") || exists("vitest.config.mts"),
@@ -170,6 +171,15 @@ function detectLegacyConfigs(targetDir) {
170
171
  }
171
172
  return results;
172
173
  }
174
+ /** Check whether a file exists and contains a given substring. */
175
+ function fileContains(filePath, substring) {
176
+ if (!existsSync(filePath)) return false;
177
+ try {
178
+ return readFileSync(filePath, "utf-8").includes(substring);
179
+ } catch (_error) {
180
+ return false;
181
+ }
182
+ }
173
183
  /** Read and parse existing package.json, or return undefined. */
174
184
  function readPackageJson(targetDir) {
175
185
  const pkgPath = path.join(targetDir, "package.json");
@@ -455,6 +465,11 @@ function buildDefaultConfig(targetDir, flags) {
455
465
  }
456
466
  //#endregion
457
467
  //#region src/utils/fs.ts
468
+ /** Verify that a relative path resolves within the target directory. */
469
+ function assertContained(targetDir, rel) {
470
+ const resolved = path.resolve(targetDir, rel);
471
+ if (!resolved.startsWith(targetDir + path.sep) && resolved !== targetDir) throw new FatalError(`Path traversal detected: ${rel} resolves outside target directory`);
472
+ }
458
473
  /**
459
474
  * Compare file content, using parsed comparison for JSON files to ignore
460
475
  * formatting differences. Falls back to exact string comparison for JSONC
@@ -495,9 +510,18 @@ function createContext(config, confirmOverwrite) {
495
510
  config,
496
511
  targetDir: config.targetDir,
497
512
  packageJson: pkgRaw ? parsePackageJson(pkgRaw) : void 0,
498
- exists: (rel) => fileExists(config.targetDir, rel),
499
- read: (rel) => readFile(config.targetDir, rel),
513
+ exists: (rel) => {
514
+ const resolved = path.resolve(config.targetDir, rel);
515
+ if (!resolved.startsWith(config.targetDir + path.sep) && resolved !== config.targetDir) return false;
516
+ return fileExists(config.targetDir, rel);
517
+ },
518
+ read: (rel) => {
519
+ const resolved = path.resolve(config.targetDir, rel);
520
+ if (!resolved.startsWith(config.targetDir + path.sep) && resolved !== config.targetDir) return;
521
+ return readFile(config.targetDir, rel);
522
+ },
500
523
  write: (rel, content) => {
524
+ assertContained(config.targetDir, rel);
501
525
  if (!rel.startsWith(".tooling-archived/")) {
502
526
  const existing = readFile(config.targetDir, rel);
503
527
  if (existing !== void 0 && !contentEqual(rel, existing, content)) {
@@ -509,6 +533,7 @@ function createContext(config, confirmOverwrite) {
509
533
  if (rel === "package.json") ctx.packageJson = parsePackageJson(content);
510
534
  },
511
535
  remove: (rel) => {
536
+ assertContained(config.targetDir, rel);
512
537
  const fullPath = path.join(config.targetDir, rel);
513
538
  if (existsSync(fullPath)) rmSync(fullPath);
514
539
  },
@@ -560,7 +585,7 @@ const DockerCheckConfigSchema = z.object({
560
585
  envFile: z.string().optional().meta({ description: "Environment file for compose" }),
561
586
  services: z.array(z.string()).optional().meta({ description: "Services to check (default: all)" }),
562
587
  healthChecks: z.array(DeclarativeHealthCheckSchema).optional().meta({ description: "Health check definitions" }),
563
- buildCommand: z.string().optional().meta({ description: "Command to build images before checking" }),
588
+ buildCommand: z.array(z.string()).optional().meta({ description: "Command to build images before checking (e.g. [\"pnpm\", \"image:build\"])" }),
564
589
  buildCwd: z.string().optional().meta({ description: "Working directory for build command" }),
565
590
  timeoutMs: z.number().int().positive().optional().meta({ description: "Overall timeout in milliseconds" }),
566
591
  pollIntervalMs: z.number().int().positive().optional().meta({ description: "Poll interval in milliseconds" })
@@ -596,7 +621,9 @@ const ToolingConfigSchema = z.strictObject({
596
621
  dockerfile: z.string().meta({ description: "Path to Dockerfile relative to package" }),
597
622
  context: z.string().default(".").meta({ description: "Docker build context relative to package" })
598
623
  })).optional().meta({ description: "Docker package overrides (package name → config)" }),
599
- dockerCheck: z.union([z.literal(false), DockerCheckConfigSchema]).optional().meta({ description: "Docker health check configuration or false to disable" })
624
+ dockerCheck: z.union([z.literal(false), DockerCheckConfigSchema]).optional().meta({ description: "Docker health check configuration or false to disable" }),
625
+ registryHost: z.string().optional().meta({ description: "Docker registry hostname (e.g. ghcr.io, code.example.com)" }),
626
+ registryNamespace: z.string().optional().meta({ description: "Docker registry namespace (e.g. ghcr.io/myorg, code.example.com/myorg)" })
600
627
  });
601
628
  /** Load saved tooling config from the target directory. Returns undefined if missing, throws on invalid. */
602
629
  function loadToolingConfig(targetDir) {
@@ -855,14 +882,15 @@ function readRepoName(executor, cwd) {
855
882
  function buildImage(executor, pkg, cwd, extraArgs) {
856
883
  const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
857
884
  const contextPath = path.resolve(cwd, pkg.docker.context);
858
- const command = [
859
- "docker build",
860
- `-f ${dockerfilePath}`,
861
- `-t ${pkg.imageName}:latest`,
885
+ executor.execInherit("docker", [
886
+ "build",
887
+ "-f",
888
+ dockerfilePath,
889
+ "-t",
890
+ `${pkg.imageName}:latest`,
862
891
  ...extraArgs,
863
892
  contextPath
864
- ].join(" ");
865
- executor.execInherit(command);
893
+ ]);
866
894
  }
867
895
  /**
868
896
  * Detect packages with docker config in .tooling.json and build each one.
@@ -917,7 +945,13 @@ function runDockerPublish(executor, config) {
917
945
  for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
918
946
  if (!config.dryRun) {
919
947
  log.info(`Logging in to ${config.registryHost}...`);
920
- const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
948
+ const loginResult = executor.exec("docker", [
949
+ "login",
950
+ config.registryHost,
951
+ "-u",
952
+ config.username,
953
+ "--password-stdin"
954
+ ], { input: config.password });
921
955
  debugExec(config, "docker login", loginResult);
922
956
  if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
923
957
  } else log.info("[dry-run] Skipping docker login");
@@ -930,12 +964,16 @@ function runDockerPublish(executor, config) {
930
964
  const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
931
965
  allTags.push(ref);
932
966
  log.info(`Tagging ${pkg.imageName} → ${ref}`);
933
- const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
967
+ const tagResult = executor.exec("docker", [
968
+ "tag",
969
+ pkg.imageName,
970
+ ref
971
+ ]);
934
972
  debugExec(config, `docker tag ${pkg.imageName} ${ref}`, tagResult);
935
973
  if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
936
974
  if (!config.dryRun) {
937
975
  log.info(`Pushing ${ref}...`);
938
- const pushResult = executor.exec(`docker push ${ref}`);
976
+ const pushResult = executor.exec("docker", ["push", ref]);
939
977
  debugExec(config, `docker push ${ref}`, pushResult);
940
978
  if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
941
979
  } else log.info(`[dry-run] Skipping push for ${ref}`);
@@ -944,7 +982,7 @@ function runDockerPublish(executor, config) {
944
982
  } finally {
945
983
  if (!config.dryRun) {
946
984
  log.info(`Logging out from ${config.registryHost}...`);
947
- executor.exec(`docker logout ${config.registryHost}`);
985
+ executor.exec("docker", ["logout", config.registryHost]);
948
986
  }
949
987
  }
950
988
  log.info(`Published ${String(allTags.length)} image tag(s)`);
@@ -1143,7 +1181,29 @@ function computeNodeVersionYaml(ctx) {
1143
1181
  }
1144
1182
  //#endregion
1145
1183
  //#region src/generators/publish-ci.ts
1146
- function publishSteps(nodeVersionYaml) {
1184
+ /**
1185
+ * Check whether the project has a repository URL in package.json.
1186
+ * When true, the CLI can derive DOCKER_REGISTRY_HOST and DOCKER_REGISTRY_NAMESPACE
1187
+ * at runtime, so workflows can omit those env vars.
1188
+ */
1189
+ function hasRepositoryUrl(ctx) {
1190
+ const raw = ctx.read("package.json");
1191
+ if (!raw) return false;
1192
+ return parsePackageJson(raw)?.repository !== void 0;
1193
+ }
1194
+ /** Build the docker publish env block, omitting registry vars when derivable at runtime. */
1195
+ function dockerPublishEnv$1(registryDerivable) {
1196
+ return {
1197
+ ...registryDerivable ? {} : {
1198
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1199
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
1200
+ },
1201
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1202
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
1203
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
1204
+ };
1205
+ }
1206
+ function publishSteps(nodeVersionYaml, registryDerivable) {
1147
1207
  return [
1148
1208
  { step: { uses: "actions/checkout@v6" } },
1149
1209
  { step: { uses: "pnpm/action-setup@v5" } },
@@ -1154,13 +1214,7 @@ function publishSteps(nodeVersionYaml) {
1154
1214
  { step: { run: "pnpm install --frozen-lockfile" } },
1155
1215
  { step: {
1156
1216
  name: "Publish Docker images",
1157
- env: {
1158
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1159
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
1160
- DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1161
- DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
1162
- RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
1163
- },
1217
+ env: dockerPublishEnv$1(registryDerivable),
1164
1218
  run: "pnpm exec bst docker:publish"
1165
1219
  } }
1166
1220
  ];
@@ -1203,7 +1257,7 @@ async function generateDeployCi(ctx) {
1203
1257
  };
1204
1258
  const isGitHub = ctx.config.ci === "github";
1205
1259
  const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
1206
- const steps = publishSteps(computeNodeVersionYaml(ctx));
1260
+ const steps = publishSteps(computeNodeVersionYaml(ctx), hasRepositoryUrl(ctx));
1207
1261
  const content = buildWorkflowYaml({
1208
1262
  ci: ctx.config.ci,
1209
1263
  name: "Publish",
@@ -1284,8 +1338,8 @@ const PER_PACKAGE_DEV_DEPS = {
1284
1338
  "@changesets/cli": "2.30.0",
1285
1339
  "@release-it/bumper": "7.0.5",
1286
1340
  "@types/node": "24.12.0",
1287
- "commit-and-tag-version": "12.7.0",
1288
- "knip": "5.87.0",
1341
+ "commit-and-tag-version": "12.7.1",
1342
+ "knip": "6.0.3",
1289
1343
  "lefthook": "2.1.4",
1290
1344
  "oxfmt": "0.41.0",
1291
1345
  "oxlint": "1.56.0",
@@ -1300,8 +1354,8 @@ const PER_PACKAGE_DEV_DEPS = {
1300
1354
  "@changesets/cli": "2.30.0",
1301
1355
  "@release-it/bumper": "7.0.5",
1302
1356
  "@types/node": "24.12.0",
1303
- "commit-and-tag-version": "12.7.0",
1304
- "knip": "5.87.0",
1357
+ "commit-and-tag-version": "12.7.1",
1358
+ "knip": "6.0.3",
1305
1359
  "lefthook": "2.1.4",
1306
1360
  "oxfmt": "0.41.0",
1307
1361
  "oxlint": "1.56.0",
@@ -1316,8 +1370,8 @@ const PER_PACKAGE_DEV_DEPS = {
1316
1370
  "@changesets/cli": "2.30.0",
1317
1371
  "@release-it/bumper": "7.0.5",
1318
1372
  "@types/node": "24.12.0",
1319
- "commit-and-tag-version": "12.7.0",
1320
- "knip": "5.87.0",
1373
+ "commit-and-tag-version": "12.7.1",
1374
+ "knip": "6.0.3",
1321
1375
  "lefthook": "2.1.4",
1322
1376
  "oxfmt": "0.41.0",
1323
1377
  "oxlint": "1.56.0",
@@ -1332,8 +1386,8 @@ const PER_PACKAGE_DEV_DEPS = {
1332
1386
  "@changesets/cli": "2.30.0",
1333
1387
  "@release-it/bumper": "7.0.5",
1334
1388
  "@types/node": "24.12.0",
1335
- "commit-and-tag-version": "12.7.0",
1336
- "knip": "5.87.0",
1389
+ "commit-and-tag-version": "12.7.1",
1390
+ "knip": "6.0.3",
1337
1391
  "lefthook": "2.1.4",
1338
1392
  "oxfmt": "0.41.0",
1339
1393
  "oxlint": "1.56.0",
@@ -1351,8 +1405,8 @@ const ROOT_DEV_DEPS = {
1351
1405
  "@changesets/cli": "2.30.0",
1352
1406
  "@release-it/bumper": "7.0.5",
1353
1407
  "@types/node": "24.12.0",
1354
- "commit-and-tag-version": "12.7.0",
1355
- "knip": "5.87.0",
1408
+ "commit-and-tag-version": "12.7.1",
1409
+ "knip": "6.0.3",
1356
1410
  "lefthook": "2.1.4",
1357
1411
  "oxfmt": "0.41.0",
1358
1412
  "oxlint": "1.56.0",
@@ -1367,8 +1421,8 @@ const ROOT_DEV_DEPS = {
1367
1421
  "@changesets/cli": "2.30.0",
1368
1422
  "@release-it/bumper": "7.0.5",
1369
1423
  "@types/node": "24.12.0",
1370
- "commit-and-tag-version": "12.7.0",
1371
- "knip": "5.87.0",
1424
+ "commit-and-tag-version": "12.7.1",
1425
+ "knip": "6.0.3",
1372
1426
  "lefthook": "2.1.4",
1373
1427
  "oxfmt": "0.41.0",
1374
1428
  "oxlint": "1.56.0",
@@ -1383,8 +1437,8 @@ const ROOT_DEV_DEPS = {
1383
1437
  "@changesets/cli": "2.30.0",
1384
1438
  "@release-it/bumper": "7.0.5",
1385
1439
  "@types/node": "24.12.0",
1386
- "commit-and-tag-version": "12.7.0",
1387
- "knip": "5.87.0",
1440
+ "commit-and-tag-version": "12.7.1",
1441
+ "knip": "6.0.3",
1388
1442
  "lefthook": "2.1.4",
1389
1443
  "oxfmt": "0.41.0",
1390
1444
  "oxlint": "1.56.0",
@@ -1424,8 +1478,8 @@ function addReleaseDeps(deps, config) {
1424
1478
  "@changesets/cli": "2.30.0",
1425
1479
  "@release-it/bumper": "7.0.5",
1426
1480
  "@types/node": "24.12.0",
1427
- "commit-and-tag-version": "12.7.0",
1428
- "knip": "5.87.0",
1481
+ "commit-and-tag-version": "12.7.1",
1482
+ "knip": "6.0.3",
1429
1483
  "lefthook": "2.1.4",
1430
1484
  "oxfmt": "0.41.0",
1431
1485
  "oxlint": "1.56.0",
@@ -1440,8 +1494,8 @@ function addReleaseDeps(deps, config) {
1440
1494
  "@changesets/cli": "2.30.0",
1441
1495
  "@release-it/bumper": "7.0.5",
1442
1496
  "@types/node": "24.12.0",
1443
- "commit-and-tag-version": "12.7.0",
1444
- "knip": "5.87.0",
1497
+ "commit-and-tag-version": "12.7.1",
1498
+ "knip": "6.0.3",
1445
1499
  "lefthook": "2.1.4",
1446
1500
  "oxfmt": "0.41.0",
1447
1501
  "oxlint": "1.56.0",
@@ -1458,8 +1512,8 @@ function addReleaseDeps(deps, config) {
1458
1512
  "@changesets/cli": "2.30.0",
1459
1513
  "@release-it/bumper": "7.0.5",
1460
1514
  "@types/node": "24.12.0",
1461
- "commit-and-tag-version": "12.7.0",
1462
- "knip": "5.87.0",
1515
+ "commit-and-tag-version": "12.7.1",
1516
+ "knip": "6.0.3",
1463
1517
  "lefthook": "2.1.4",
1464
1518
  "oxfmt": "0.41.0",
1465
1519
  "oxlint": "1.56.0",
@@ -1476,8 +1530,8 @@ function addReleaseDeps(deps, config) {
1476
1530
  "@changesets/cli": "2.30.0",
1477
1531
  "@release-it/bumper": "7.0.5",
1478
1532
  "@types/node": "24.12.0",
1479
- "commit-and-tag-version": "12.7.0",
1480
- "knip": "5.87.0",
1533
+ "commit-and-tag-version": "12.7.1",
1534
+ "knip": "6.0.3",
1481
1535
  "lefthook": "2.1.4",
1482
1536
  "oxfmt": "0.41.0",
1483
1537
  "oxlint": "1.56.0",
@@ -1509,15 +1563,15 @@ async function generatePackageJson(ctx) {
1509
1563
  }
1510
1564
  const devDeps = { ...ROOT_DEV_DEPS };
1511
1565
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1512
- devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1513
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.37.0";
1514
- if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1566
+ devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.2";
1567
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.38.0";
1568
+ if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.10.0";
1515
1569
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1516
1570
  "@changesets/cli": "2.30.0",
1517
1571
  "@release-it/bumper": "7.0.5",
1518
1572
  "@types/node": "24.12.0",
1519
- "commit-and-tag-version": "12.7.0",
1520
- "knip": "5.87.0",
1573
+ "commit-and-tag-version": "12.7.1",
1574
+ "knip": "6.0.3",
1521
1575
  "lefthook": "2.1.4",
1522
1576
  "oxfmt": "0.41.0",
1523
1577
  "oxlint": "1.56.0",
@@ -1532,8 +1586,8 @@ async function generatePackageJson(ctx) {
1532
1586
  "@changesets/cli": "2.30.0",
1533
1587
  "@release-it/bumper": "7.0.5",
1534
1588
  "@types/node": "24.12.0",
1535
- "commit-and-tag-version": "12.7.0",
1536
- "knip": "5.87.0",
1589
+ "commit-and-tag-version": "12.7.1",
1590
+ "knip": "6.0.3",
1537
1591
  "lefthook": "2.1.4",
1538
1592
  "oxfmt": "0.41.0",
1539
1593
  "oxlint": "1.56.0",
@@ -1616,8 +1670,8 @@ async function generatePackageJson(ctx) {
1616
1670
  "@changesets/cli": "2.30.0",
1617
1671
  "@release-it/bumper": "7.0.5",
1618
1672
  "@types/node": "24.12.0",
1619
- "commit-and-tag-version": "12.7.0",
1620
- "knip": "5.87.0",
1673
+ "commit-and-tag-version": "12.7.1",
1674
+ "knip": "6.0.3",
1621
1675
  "lefthook": "2.1.4",
1622
1676
  "oxfmt": "0.41.0",
1623
1677
  "oxlint": "1.56.0",
@@ -2690,7 +2744,19 @@ function simpleReleaseCommand() {
2690
2744
  "pnpm exec bst release:simple $FLAGS"
2691
2745
  ].join("\n");
2692
2746
  }
2693
- function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker) {
2747
+ /** Build the docker publish env block, omitting registry vars when derivable at runtime. */
2748
+ function dockerPublishEnv(registryDerivable) {
2749
+ return {
2750
+ ...registryDerivable ? {} : {
2751
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
2752
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")
2753
+ },
2754
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
2755
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
2756
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2757
+ };
2758
+ }
2759
+ function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable) {
2694
2760
  const releaseStep = { step: {
2695
2761
  name: "Release",
2696
2762
  env: {
@@ -2706,13 +2772,7 @@ function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker) {
2706
2772
  const dockerStep = { step: {
2707
2773
  name: "Publish Docker images",
2708
2774
  if: "success()",
2709
- env: {
2710
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
2711
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
2712
- DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
2713
- DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
2714
- RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2715
- },
2775
+ env: dockerPublishEnv(registryDerivable),
2716
2776
  run: "pnpm exec bst docker:publish"
2717
2777
  } };
2718
2778
  return [
@@ -2721,10 +2781,10 @@ function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker) {
2721
2781
  ...hasDocker ? [dockerStep] : []
2722
2782
  ];
2723
2783
  }
2724
- function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker) {
2784
+ function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable) {
2725
2785
  switch (strategy) {
2726
2786
  case "release-it": return releaseItSteps(ci, nodeVersionYaml, publishesNpm);
2727
- case "simple": return simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker);
2787
+ case "simple": return simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable);
2728
2788
  default: return null;
2729
2789
  }
2730
2790
  }
@@ -2745,7 +2805,8 @@ async function generateReleaseCi(ctx) {
2745
2805
  const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
2746
2806
  const nodeVersionYaml = computeNodeVersionYaml(ctx);
2747
2807
  const hasDocker = hasDockerPackages(ctx);
2748
- const steps = buildSteps(ctx.config.releaseStrategy, ctx.config.ci, nodeVersionYaml, publishesNpm, hasDocker);
2808
+ const registryDerivable = hasRepositoryUrl(ctx);
2809
+ const steps = buildSteps(ctx.config.releaseStrategy, ctx.config.ci, nodeVersionYaml, publishesNpm, hasDocker, registryDerivable);
2749
2810
  if (!steps) return {
2750
2811
  filePath,
2751
2812
  action: "skipped",
@@ -3140,112 +3201,116 @@ async function runGenerators(ctx) {
3140
3201
  function generateMigratePrompt(results, config, detected) {
3141
3202
  const sections = [];
3142
3203
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3143
- sections.push("# Migration Prompt");
3144
- sections.push("");
3145
- sections.push(`_Generated by \`@bensandee/tooling@0.37.0 repo:sync\` on ${timestamp}_`);
3146
- sections.push("");
3204
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.38.0 repo:sync\` on ${timestamp}_\n`);
3147
3205
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3148
3206
  if (changed.length > 0) {
3149
- sections.push("## Files changed");
3150
- sections.push("");
3151
- for (const r of changed) {
3207
+ const items = changed.map((r) => {
3152
3208
  const label = r.action === "archived" ? "archived" : r.action;
3153
- sections.push(`- \`${r.filePath}\` (${label}) — ${r.description}`);
3154
- }
3155
- sections.push("");
3209
+ return `- \`${r.filePath}\` (${label}) — ${r.description}`;
3210
+ }).join("\n");
3211
+ sections.push(`## Files changed\n\n${items}\n`);
3156
3212
  }
3157
3213
  const tasks = [];
3158
- const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
3159
- if (legacyToRemove.length > 0) {
3160
- tasks.push("### Remove legacy tooling");
3161
- tasks.push("");
3162
- for (const legacy of legacyToRemove) {
3163
- const replacement = {
3164
- eslint: "oxlint",
3165
- prettier: "oxfmt",
3166
- jest: "vitest",
3167
- webpack: "tsdown",
3168
- rollup: "tsdown"
3169
- }[legacy.tool];
3170
- tasks.push(`- Remove ${legacy.tool} config files (${legacy.files.map((f) => `\`${f}\``).join(", ")}). This project now uses **${replacement}**.`);
3171
- tasks.push(` - Uninstall ${legacy.tool}-related packages from devDependencies`);
3172
- if (legacy.tool === "eslint") tasks.push(" - Migrate any custom ESLint rules that don't have oxlint equivalents");
3173
- if (legacy.tool === "jest") tasks.push(" - Migrate jest-specific test utilities (jest.mock, jest.fn) to vitest equivalents (vi.mock, vi.fn)");
3174
- }
3175
- tasks.push("");
3214
+ addLegacyToolingTasks(tasks, config, detected);
3215
+ addArchivedFileTasks(tasks, results);
3216
+ addOxlintTasks(tasks, results, detected);
3217
+ addMonorepoTasks(tasks, config, detected);
3218
+ addTestGenerationTasks(tasks, results);
3219
+ if (tasks.length > 0) {
3220
+ sections.push(`## Migration tasks\n\n${tasks.join("\n")}`);
3221
+ sections.push(`## Ground rules
3222
+
3223
+ Prefer fixing root causes over suppressing issues:
3224
+
3225
+ - **Lint errors**: fix the code, not disable comments
3226
+ - **Type errors**: add proper types, not \`any\` or \`@ts-expect-error\`
3227
+ - **Test failures**: fix the test or bug, don't skip/delete
3228
+ - **Knip findings**: remove unused code, don't add ignores
3229
+
3230
+ It is OK to add new packages (e.g. \`zod\`, \`@bensandee/common\`) to resolve errors. Only suppress an issue with a comment explaining why the fix is not feasible.
3231
+ `);
3176
3232
  }
3233
+ sections.push(`## Verify
3234
+
3235
+ Run \`pnpm check\` and fix any errors. This runs the full pipeline:
3236
+ build → typecheck → lint → test → format → knip
3237
+
3238
+ When everything passes, run \`/init\` to generate a \`CLAUDE.md\` for this repository.
3239
+ `);
3240
+ return sections.join("\n");
3241
+ }
3242
+ const LEGACY_REPLACEMENTS = {
3243
+ eslint: "oxlint",
3244
+ prettier: "oxfmt",
3245
+ jest: "vitest",
3246
+ webpack: "tsdown",
3247
+ rollup: "tsdown"
3248
+ };
3249
+ function addLegacyToolingTasks(tasks, config, detected) {
3250
+ const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
3251
+ if (legacyToRemove.length === 0) return;
3252
+ const items = legacyToRemove.map((legacy) => {
3253
+ const replacement = LEGACY_REPLACEMENTS[legacy.tool];
3254
+ const files = legacy.files.map((f) => `\`${f}\``).join(", ");
3255
+ const lines = [`- Remove ${legacy.tool} config files (${files}). This project now uses **${replacement}**.`, ` - Uninstall ${legacy.tool}-related packages from devDependencies`];
3256
+ if (legacy.tool === "eslint") lines.push(" - Migrate any custom ESLint rules that don't have oxlint equivalents");
3257
+ if (legacy.tool === "jest") lines.push(" - Migrate jest-specific test utilities (jest.mock, jest.fn) to vitest equivalents (vi.mock, vi.fn)");
3258
+ return lines.join("\n");
3259
+ });
3260
+ tasks.push(`### Remove legacy tooling\n\n${items.join("\n")}\n`);
3261
+ }
3262
+ function addArchivedFileTasks(tasks, results) {
3177
3263
  const archived = results.filter((r) => r.action === "archived");
3178
- if (archived.length > 0) {
3179
- tasks.push("### Review archived files");
3180
- tasks.push("");
3181
- tasks.push("The originals have been saved to `.tooling-archived/`. For each, diff the old version against the new one and port any capabilities the new file lacks:");
3182
- tasks.push("");
3183
- for (const r of archived) tasks.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
3184
- tasks.push("");
3185
- tasks.push("If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`. Delete `.tooling-archived/` when done.");
3186
- tasks.push("");
3187
- }
3188
- const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
3264
+ if (archived.length === 0) return;
3265
+ const items = archived.map((r) => `- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``).join("\n");
3266
+ tasks.push(`### Review archived files
3267
+
3268
+ The originals have been saved to \`.tooling-archived/\`. For each, diff the old version against the new one and port any capabilities the new file lacks:
3269
+
3270
+ ${items}
3271
+
3272
+ Delete \`.tooling-archived/\` when done.
3273
+ `);
3274
+ }
3275
+ function addOxlintTasks(tasks, results, detected) {
3189
3276
  if (detected.hasLegacyOxlintJson) {
3190
- tasks.push("### Migrate .oxlintrc.json to oxlint.config.ts");
3191
- tasks.push("");
3192
- tasks.push("Compare `.oxlintrc.json` rules against `@bensandee/config/oxlint/recommended`. Port any custom rules/overrides into `oxlint.config.ts`, then delete `.oxlintrc.json`.");
3193
- tasks.push("");
3194
- } else if (oxlintWasSkipped && detected.hasOxlintConfig) {
3195
- tasks.push("### Verify oxlint.config.ts extends recommended rules");
3196
- tasks.push("");
3197
- tasks.push("Ensure `oxlint.config.ts` imports and extends `@bensandee/config/oxlint/recommended`:");
3198
- tasks.push("");
3199
- tasks.push("```ts");
3200
- tasks.push("import recommended from \"@bensandee/config/oxlint/recommended\";");
3201
- tasks.push("import { defineConfig } from \"oxlint\";");
3202
- tasks.push("");
3203
- tasks.push("export default defineConfig({ extends: [recommended] });");
3204
- tasks.push("```");
3205
- tasks.push("");
3206
- }
3207
- if (config.structure === "monorepo" && !detected.hasPnpmWorkspace) {
3208
- tasks.push("### Migrate to monorepo structure");
3209
- tasks.push("");
3210
- tasks.push("This project was converted to a monorepo. Complete the migration:");
3211
- tasks.push("");
3212
- tasks.push("1. Move existing source into `packages/<name>/`");
3213
- tasks.push("2. Split root `package.json` into workspace manifest + package-level `package.json`");
3214
- tasks.push("3. Move `tsconfig.json` into the package, add project references to root");
3215
- tasks.push("4. Create a package-level `tsdown.config.ts`");
3216
- tasks.push("5. Update import paths and build scripts");
3217
- tasks.push("");
3218
- }
3219
- if (results.some((r) => r.filePath === "test/example.test.ts" && r.action === "created")) {
3220
- tasks.push("### Generate tests");
3221
- tasks.push("");
3222
- tasks.push("A starter test was created at `test/example.test.ts`. Review `src/` and create additional tests covering exported functions and edge cases.");
3223
- tasks.push("");
3277
+ tasks.push(`### Migrate .oxlintrc.json to oxlint.config.ts
3278
+
3279
+ Compare \`.oxlintrc.json\` rules against \`@bensandee/config/oxlint/recommended\`. Port any custom rules/overrides into \`oxlint.config.ts\`, then delete \`.oxlintrc.json\`.
3280
+ `);
3281
+ return;
3224
3282
  }
3225
- if (tasks.length > 0) {
3226
- sections.push("## Migration tasks");
3227
- sections.push("");
3228
- sections.push(...tasks);
3229
- sections.push("## Ground rules");
3230
- sections.push("");
3231
- sections.push("Prefer fixing root causes over suppressing issues:");
3232
- sections.push("");
3233
- sections.push("- **Lint errors**: fix the code, not disable comments");
3234
- sections.push("- **Type errors**: add proper types, not `any` or `@ts-expect-error`");
3235
- sections.push("- **Test failures**: fix the test or bug, don't skip/delete");
3236
- sections.push("- **Knip findings**: remove unused code, don't add ignores");
3237
- sections.push("");
3238
- sections.push("It is OK to add new packages (e.g. `zod`, `@bensandee/common`) to resolve errors. Only suppress an issue with a comment explaining why the fix is not feasible.");
3239
- sections.push("");
3240
- }
3241
- sections.push("## Verify");
3242
- sections.push("");
3243
- sections.push("Run `pnpm check` and fix any errors. This runs the full pipeline:");
3244
- sections.push("build → typecheck → lint → test → format → knip");
3245
- sections.push("");
3246
- sections.push("When everything passes, run `/init` to generate a `CLAUDE.md` for this repository.");
3247
- sections.push("");
3248
- return sections.join("\n");
3283
+ if (results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped" && detected.hasOxlintConfig && !detected.oxlintExtendsRecommended) tasks.push(`### Verify oxlint.config.ts extends recommended rules
3284
+
3285
+ Ensure \`oxlint.config.ts\` imports and extends \`@bensandee/config/oxlint/recommended\`:
3286
+
3287
+ \`\`\`ts
3288
+ import recommended from "@bensandee/config/oxlint/recommended";
3289
+ import { defineConfig } from "oxlint";
3290
+
3291
+ export default defineConfig({ extends: [recommended] });
3292
+ \`\`\`
3293
+ `);
3294
+ }
3295
+ function addMonorepoTasks(tasks, config, detected) {
3296
+ if (config.structure !== "monorepo" || detected.hasPnpmWorkspace) return;
3297
+ tasks.push(`### Migrate to monorepo structure
3298
+
3299
+ This project was converted to a monorepo. Complete the migration:
3300
+
3301
+ 1. Move existing source into \`packages/<name>/\`
3302
+ 2. Split root \`package.json\` into workspace manifest + package-level \`package.json\`
3303
+ 3. Move \`tsconfig.json\` into the package, add project references to root
3304
+ 4. Create a package-level \`tsdown.config.ts\`
3305
+ 5. Update import paths and build scripts
3306
+ `);
3307
+ }
3308
+ function addTestGenerationTasks(tasks, results) {
3309
+ if (!results.some((r) => r.filePath === "test/example.test.ts" && r.action === "created")) return;
3310
+ tasks.push(`### Generate tests
3311
+
3312
+ A starter test was created at \`test/example.test.ts\`. Review \`src/\` and create additional tests covering exported functions and edge cases.
3313
+ `);
3249
3314
  }
3250
3315
  //#endregion
3251
3316
  //#region src/commands/repo-init.ts
@@ -3464,15 +3529,16 @@ function lineDiff(oldText, newText) {
3464
3529
  /** Create an executor that runs real commands, fetches, and reads the filesystem. */
3465
3530
  function createRealExecutor() {
3466
3531
  return {
3467
- exec(command, options) {
3532
+ exec(file, args, options) {
3468
3533
  try {
3469
3534
  return {
3470
- stdout: execSync(command, {
3535
+ stdout: execFileSync(file, args, {
3471
3536
  cwd: options?.cwd,
3472
3537
  env: {
3473
3538
  ...process.env,
3474
3539
  ...options?.env
3475
3540
  },
3541
+ input: options?.input,
3476
3542
  encoding: "utf-8",
3477
3543
  stdio: [
3478
3544
  "pipe",
@@ -3496,8 +3562,8 @@ function createRealExecutor() {
3496
3562
  };
3497
3563
  }
3498
3564
  },
3499
- execInherit(command, options) {
3500
- execSync(command, {
3565
+ execInherit(file, args, options) {
3566
+ execFileSync(file, args, {
3501
3567
  cwd: options?.cwd,
3502
3568
  env: options?.env ? {
3503
3569
  ...process.env,
@@ -3554,6 +3620,19 @@ function createRealExecutor() {
3554
3620
  }
3555
3621
  };
3556
3622
  }
3623
+ /**
3624
+ * Compute sliding version tags from a semver version string.
3625
+ * For "1.2.3" returns ["v1", "v1.2", "latest"]. Strips prerelease suffixes.
3626
+ */
3627
+ function computeSlidingTags(version) {
3628
+ const parts = (version.split("-")[0] ?? version).split(".");
3629
+ if (parts.length < 2 || !parts[0] || !parts[1]) throw new FatalError(`Invalid version format "${version}". Expected semver (X.Y.Z)`);
3630
+ return [
3631
+ `v${parts[0]}`,
3632
+ `v${parts[0]}.${parts[1]}`,
3633
+ "latest"
3634
+ ];
3635
+ }
3557
3636
  /** Parse "New tag:" lines from changeset publish output. */
3558
3637
  function parseNewTags(output) {
3559
3638
  const tags = [];
@@ -3595,7 +3674,7 @@ const RETRY_BASE_DELAY_MS = 1e3;
3595
3674
  async function responseBodyText(res) {
3596
3675
  try {
3597
3676
  const text = await res.text();
3598
- return text.length > 500 ? text.slice(0, 500) + "…" : text;
3677
+ return redact(text.length > 500 ? text.slice(0, 500) + "…" : text);
3599
3678
  } catch {
3600
3679
  return "(could not read response body)";
3601
3680
  }
@@ -3822,19 +3901,23 @@ async function runVersionMode(executor, config) {
3822
3901
  debug(config, "Temporarily disabled changeset commit:true");
3823
3902
  }
3824
3903
  }
3825
- const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
3904
+ const versionResult = executor.exec("pnpm", ["changeset", "version"], { cwd: config.cwd });
3826
3905
  debugExec(config, "pnpm changeset version", versionResult);
3827
3906
  if (originalConfig) executor.writeFile(changesetConfigPath, originalConfig);
3828
3907
  if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
3829
- debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
3908
+ debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm", ["install", "--no-frozen-lockfile"], { cwd: config.cwd }));
3830
3909
  const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
3831
3910
  debug(config, `PR title: ${title}`);
3832
- const addResult = executor.exec("git add -A", { cwd: config.cwd });
3911
+ const addResult = executor.exec("git", ["add", "-A"], { cwd: config.cwd });
3833
3912
  if (addResult.exitCode !== 0) throw new FatalError(`git add failed: ${addResult.stderr || addResult.stdout}`);
3834
3913
  const remainingChangesets = executor.listChangesetFiles(config.cwd);
3835
3914
  if (remainingChangesets.length > 0) log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
3836
3915
  debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
3837
- const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
3916
+ const commitResult = executor.exec("git", [
3917
+ "commit",
3918
+ "-m",
3919
+ "chore: version packages"
3920
+ ], { cwd: config.cwd });
3838
3921
  debugExec(config, "git commit", commitResult);
3839
3922
  if (commitResult.exitCode !== 0) {
3840
3923
  log.info("Nothing to commit after versioning");
@@ -3850,7 +3933,12 @@ async function runVersionMode(executor, config) {
3850
3933
  pr: "none"
3851
3934
  };
3852
3935
  }
3853
- const pushResult = executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd });
3936
+ const pushResult = executor.exec("git", [
3937
+ "push",
3938
+ "origin",
3939
+ `HEAD:refs/heads/${BRANCH}`,
3940
+ "--force"
3941
+ ], { cwd: config.cwd });
3854
3942
  debugExec(config, "git push", pushResult);
3855
3943
  if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
3856
3944
  const conn = {
@@ -3888,14 +3976,18 @@ async function runVersionMode(executor, config) {
3888
3976
  /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
3889
3977
  async function runPublishMode(executor, config) {
3890
3978
  log.info("No changesets — publishing packages");
3891
- const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
3979
+ const publishResult = executor.exec("pnpm", ["changeset", "publish"], { cwd: config.cwd });
3892
3980
  debugExec(config, "pnpm changeset publish", publishResult);
3893
3981
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
3894
3982
  const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
3895
3983
  debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
3896
3984
  const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
3897
3985
  debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
3898
- const remoteTags = parseRemoteTags(executor.exec("git ls-remote --tags origin", { cwd: config.cwd }).stdout);
3986
+ const remoteTags = parseRemoteTags(executor.exec("git", [
3987
+ "ls-remote",
3988
+ "--tags",
3989
+ "origin"
3990
+ ], { cwd: config.cwd }).stdout);
3899
3991
  debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
3900
3992
  const remoteSet = new Set(remoteTags);
3901
3993
  const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
@@ -3929,11 +4021,19 @@ async function runPublishMode(executor, config) {
3929
4021
  const errors = [];
3930
4022
  for (const tag of allTags) try {
3931
4023
  if (!remoteSet.has(tag)) {
3932
- if (executor.exec(`git tag -l ${JSON.stringify(tag)}`, { cwd: config.cwd }).stdout.trim() === "") {
3933
- const tagResult = executor.exec(`git tag ${JSON.stringify(tag)}`, { cwd: config.cwd });
4024
+ if (executor.exec("git", [
4025
+ "tag",
4026
+ "-l",
4027
+ tag
4028
+ ], { cwd: config.cwd }).stdout.trim() === "") {
4029
+ const tagResult = executor.exec("git", ["tag", tag], { cwd: config.cwd });
3934
4030
  if (tagResult.exitCode !== 0) throw new FatalError(`Failed to create tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
3935
4031
  }
3936
- const pushTagResult = executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
4032
+ const pushTagResult = executor.exec("git", [
4033
+ "push",
4034
+ "origin",
4035
+ `refs/tags/${tag}`
4036
+ ], { cwd: config.cwd });
3937
4037
  if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
3938
4038
  }
3939
4039
  if (await ensureRelease(executor, conn, tag) === "exists") log.warn(`Release for ${tag} already exists — skipping`);
@@ -3946,6 +4046,31 @@ async function runPublishMode(executor, config) {
3946
4046
  log.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
3947
4047
  }
3948
4048
  if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
4049
+ const slidingTags = allTags.flatMap((t) => {
4050
+ const atIndex = t.lastIndexOf("@");
4051
+ if (atIndex <= 0) return [];
4052
+ const name = t.slice(0, atIndex);
4053
+ return computeSlidingTags(t.slice(atIndex + 1)).map((suffix) => `${name}@${suffix}`);
4054
+ });
4055
+ if (slidingTags.length > 0) {
4056
+ for (const tag of slidingTags) {
4057
+ const tagResult = executor.exec("git", [
4058
+ "tag",
4059
+ "-f",
4060
+ tag
4061
+ ], { cwd: config.cwd });
4062
+ if (tagResult.exitCode !== 0) log.warn(`Warning: Failed to create sliding tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
4063
+ }
4064
+ const forcePushResult = executor.exec("git", [
4065
+ "push",
4066
+ "origin",
4067
+ ...slidingTags.map((t) => `refs/tags/${t}`),
4068
+ "--force"
4069
+ ], { cwd: config.cwd });
4070
+ debugExec(config, "force-push sliding tags", forcePushResult);
4071
+ if (forcePushResult.exitCode !== 0) log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4072
+ else log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4073
+ }
3949
4074
  return {
3950
4075
  mode: "publish",
3951
4076
  tags: allTags
@@ -4035,15 +4160,28 @@ function parseGitUrl(urlStr) {
4035
4160
  function configureGitAuth(executor, conn, cwd) {
4036
4161
  const host = conn.serverUrl.replace(/^https?:\/\//, "");
4037
4162
  const authUrl = `https://x-access-token:${conn.token}@${host}/${conn.repository}`;
4038
- executor.exec(`git remote set-url origin ${authUrl}`, { cwd });
4163
+ executor.exec("git", [
4164
+ "remote",
4165
+ "set-url",
4166
+ "origin",
4167
+ authUrl
4168
+ ], { cwd });
4039
4169
  }
4040
4170
  /** Configure git user.name and user.email for CI bot commits. */
4041
4171
  function configureGitIdentity(executor, platform, cwd) {
4042
4172
  const isGitHub = platform === "github";
4043
4173
  const name = isGitHub ? "github-actions[bot]" : "forgejo-actions[bot]";
4044
4174
  const email = isGitHub ? "github-actions[bot]@users.noreply.github.com" : "forgejo-actions[bot]@noreply.localhost";
4045
- executor.exec(`git config user.name "${name}"`, { cwd });
4046
- executor.exec(`git config user.email "${email}"`, { cwd });
4175
+ executor.exec("git", [
4176
+ "config",
4177
+ "user.name",
4178
+ name
4179
+ ], { cwd });
4180
+ executor.exec("git", [
4181
+ "config",
4182
+ "user.email",
4183
+ email
4184
+ ], { cwd });
4047
4185
  }
4048
4186
  //#endregion
4049
4187
  //#region src/commands/release-changesets.ts
@@ -4084,7 +4222,11 @@ function buildReleaseConfig(flags) {
4084
4222
  function getCurrentBranch(executor, cwd) {
4085
4223
  const ref = process.env["GITHUB_REF"];
4086
4224
  if (ref?.startsWith("refs/heads/")) return ref.slice(11);
4087
- return executor.exec("git rev-parse --abbrev-ref HEAD", { cwd }).stdout.trim();
4225
+ return executor.exec("git", [
4226
+ "rev-parse",
4227
+ "--abbrev-ref",
4228
+ "HEAD"
4229
+ ], { cwd }).stdout.trim();
4088
4230
  }
4089
4231
  /** Core release logic — testable with a mock executor. */
4090
4232
  async function runRelease(config, executor) {
@@ -4155,9 +4297,15 @@ async function triggerForgejo(conn, ref, inputs) {
4155
4297
  }
4156
4298
  function triggerGitHub(ref, inputs) {
4157
4299
  const executor = createRealExecutor();
4158
- const inputFlags = Object.entries(inputs).map(([k, v]) => `-f ${k}=${v}`).join(" ");
4159
- const cmd = `gh workflow run release.yml --ref ${ref}${inputFlags ? ` ${inputFlags}` : ""}`;
4160
- const result = executor.exec(cmd, { cwd: process.cwd() });
4300
+ const inputFlags = Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`]);
4301
+ const result = executor.exec("gh", [
4302
+ "workflow",
4303
+ "run",
4304
+ "release.yml",
4305
+ "--ref",
4306
+ ref,
4307
+ ...inputFlags
4308
+ ], { cwd: process.cwd() });
4161
4309
  if (result.exitCode !== 0) throw new FatalError(`Failed to trigger GitHub workflow: ${result.stderr || result.stdout || "unknown error"}`);
4162
4310
  log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
4163
4311
  }
@@ -4222,34 +4370,39 @@ async function mergeForgejo(conn, dryRun) {
4222
4370
  function mergeGitHub(dryRun) {
4223
4371
  const executor = createRealExecutor();
4224
4372
  if (dryRun) {
4225
- const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
4373
+ const prNum = executor.exec("gh", [
4374
+ "pr",
4375
+ "view",
4376
+ HEAD_BRANCH,
4377
+ "--json",
4378
+ "number",
4379
+ "--jq",
4380
+ ".number"
4381
+ ], { cwd: process.cwd() }).stdout.trim();
4226
4382
  if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
4227
4383
  log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
4228
4384
  return;
4229
4385
  }
4230
- const result = executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
4386
+ const result = executor.exec("gh", [
4387
+ "pr",
4388
+ "merge",
4389
+ HEAD_BRANCH,
4390
+ "--merge",
4391
+ "--delete-branch"
4392
+ ], { cwd: process.cwd() });
4231
4393
  if (result.exitCode !== 0) throw new FatalError(`Failed to merge PR: ${result.stderr || result.stdout || "unknown error"}`);
4232
4394
  log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
4233
4395
  }
4234
4396
  //#endregion
4235
4397
  //#region src/release/simple.ts
4236
- /**
4237
- * Compute sliding version tags from a semver version string.
4238
- * For "1.2.3" returns ["v1", "v1.2"]. Strips prerelease suffixes.
4239
- */
4240
- function computeSlidingTags(version) {
4241
- const parts = (version.split("-")[0] ?? version).split(".");
4242
- if (parts.length < 2 || !parts[0] || !parts[1]) throw new FatalError(`Invalid version format "${version}". Expected semver (X.Y.Z)`);
4243
- return [`v${parts[0]}`, `v${parts[0]}.${parts[1]}`];
4244
- }
4245
- /** Build the commit-and-tag-version command with appropriate flags. */
4246
- function buildCommand(config) {
4247
- const args = ["pnpm exec commit-and-tag-version"];
4398
+ /** Build the commit-and-tag-version args. */
4399
+ function buildArgs(config) {
4400
+ const args = ["exec", "commit-and-tag-version"];
4248
4401
  if (config.dryRun) args.push("--dry-run");
4249
4402
  if (config.firstRelease) args.push("--first-release");
4250
- if (config.releaseAs) args.push(`--release-as ${config.releaseAs}`);
4251
- if (config.prerelease) args.push(`--prerelease ${config.prerelease}`);
4252
- return args.join(" ");
4403
+ if (config.releaseAs) args.push("--release-as", config.releaseAs);
4404
+ if (config.prerelease) args.push("--prerelease", config.prerelease);
4405
+ return args;
4253
4406
  }
4254
4407
  /** Read the current version from package.json. */
4255
4408
  function readVersion(executor, cwd) {
@@ -4266,14 +4419,18 @@ function platformType(config) {
4266
4419
  /** Run the full commit-and-tag-version release flow. */
4267
4420
  async function runSimpleRelease(executor, config) {
4268
4421
  configureGitIdentity(executor, platformType(config), config.cwd);
4269
- const command = buildCommand(config);
4270
- log.info(`Running: ${command}`);
4271
- const versionResult = executor.exec(command, { cwd: config.cwd });
4422
+ const args = buildArgs(config);
4423
+ log.info(`Running: pnpm ${args.join(" ")}`);
4424
+ const versionResult = executor.exec("pnpm", args, { cwd: config.cwd });
4272
4425
  debugExec(config, "commit-and-tag-version", versionResult);
4273
4426
  if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
4274
4427
  const version = readVersion(executor, config.cwd);
4275
4428
  debug(config, `New version: ${version}`);
4276
- const tagResult = executor.exec("git describe --tags --abbrev=0", { cwd: config.cwd });
4429
+ const tagResult = executor.exec("git", [
4430
+ "describe",
4431
+ "--tags",
4432
+ "--abbrev=0"
4433
+ ], { cwd: config.cwd });
4277
4434
  debugExec(config, "git describe", tagResult);
4278
4435
  const tag = tagResult.stdout.trim();
4279
4436
  if (!tag) throw new FatalError("Could not determine the new tag from git describe");
@@ -4297,9 +4454,18 @@ async function runSimpleRelease(executor, config) {
4297
4454
  configureGitAuth(executor, config.platform.conn, config.cwd);
4298
4455
  debug(config, "Configured git remote with RELEASE_TOKEN auth");
4299
4456
  }
4300
- const branch = executor.exec("git rev-parse --abbrev-ref HEAD", { cwd: config.cwd }).stdout.trim() || "main";
4457
+ const branch = executor.exec("git", [
4458
+ "rev-parse",
4459
+ "--abbrev-ref",
4460
+ "HEAD"
4461
+ ], { cwd: config.cwd }).stdout.trim() || "main";
4301
4462
  debug(config, `Pushing to origin/${branch}`);
4302
- const pushResult = executor.exec(`git push --follow-tags origin ${branch}`, { cwd: config.cwd });
4463
+ const pushResult = executor.exec("git", [
4464
+ "push",
4465
+ "--follow-tags",
4466
+ "origin",
4467
+ branch
4468
+ ], { cwd: config.cwd });
4303
4469
  debugExec(config, "git push", pushResult);
4304
4470
  if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
4305
4471
  pushed = true;
@@ -4309,10 +4475,19 @@ async function runSimpleRelease(executor, config) {
4309
4475
  if (!config.noSlidingTags && pushed) {
4310
4476
  slidingTags = computeSlidingTags(version);
4311
4477
  for (const slidingTag of slidingTags) {
4312
- const slidingTagResult = executor.exec(`git tag -f ${slidingTag}`, { cwd: config.cwd });
4478
+ const slidingTagResult = executor.exec("git", [
4479
+ "tag",
4480
+ "-f",
4481
+ slidingTag
4482
+ ], { cwd: config.cwd });
4313
4483
  if (slidingTagResult.exitCode !== 0) throw new FatalError(`Failed to create sliding tag ${slidingTag}: ${slidingTagResult.stderr || slidingTagResult.stdout}`);
4314
4484
  }
4315
- const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
4485
+ const forcePushResult = executor.exec("git", [
4486
+ "push",
4487
+ "origin",
4488
+ ...slidingTags,
4489
+ "--force"
4490
+ ], { cwd: config.cwd });
4316
4491
  debugExec(config, "force-push sliding tags", forcePushResult);
4317
4492
  if (forcePushResult.exitCode !== 0) log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4318
4493
  else log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
@@ -4337,7 +4512,12 @@ async function createPlatformRelease(executor, config, tag) {
4337
4512
  log.info(`Created Forgejo release for ${tag}`);
4338
4513
  return true;
4339
4514
  }
4340
- const ghResult = executor.exec(`gh release create ${tag} --generate-notes`, { cwd: config.cwd });
4515
+ const ghResult = executor.exec("gh", [
4516
+ "release",
4517
+ "create",
4518
+ tag,
4519
+ "--generate-notes"
4520
+ ], { cwd: config.cwd });
4341
4521
  debugExec(config, "gh release create", ghResult);
4342
4522
  if (ghResult.exitCode !== 0) {
4343
4523
  log.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
@@ -4575,6 +4755,45 @@ function requireEnv(name) {
4575
4755
  if (!value) throw new FatalError(`Missing required environment variable: ${name}`);
4576
4756
  return value;
4577
4757
  }
4758
+ /**
4759
+ * Derive the registry host from a repository URL hostname.
4760
+ * GitHub uses ghcr.io; all other hosts (Forgejo, GitLab, etc.) use the hostname directly.
4761
+ */
4762
+ function registryHostFromHostname(hostname) {
4763
+ return hostname === "github.com" ? "ghcr.io" : hostname;
4764
+ }
4765
+ /**
4766
+ * Resolve docker registry config using a fallback chain:
4767
+ * 1. Environment variables (DOCKER_REGISTRY_HOST / DOCKER_REGISTRY_NAMESPACE)
4768
+ * 2. .tooling.json registryHost / registryNamespace fields
4769
+ * 3. Derived from package.json repository URL
4770
+ */
4771
+ function resolveRegistryConfig(cwd) {
4772
+ const envHost = process.env["DOCKER_REGISTRY_HOST"];
4773
+ const envNamespace = process.env["DOCKER_REGISTRY_NAMESPACE"];
4774
+ if (envHost && envNamespace) return {
4775
+ registryHost: envHost,
4776
+ registryNamespace: envNamespace
4777
+ };
4778
+ const toolingConfig = loadToolingConfig(cwd);
4779
+ const toolingHost = toolingConfig?.registryHost;
4780
+ const toolingNamespace = toolingConfig?.registryNamespace;
4781
+ if (toolingHost && toolingNamespace) return {
4782
+ registryHost: envHost ?? toolingHost,
4783
+ registryNamespace: envNamespace ?? toolingNamespace
4784
+ };
4785
+ const parsed = parseRepositoryUrl(cwd);
4786
+ if (parsed) {
4787
+ const derivedHost = registryHostFromHostname(parsed.hostname);
4788
+ const owner = parsed.repository.split("/")[0];
4789
+ const host = envHost ?? toolingHost ?? derivedHost;
4790
+ return {
4791
+ registryHost: host,
4792
+ registryNamespace: envNamespace ?? toolingNamespace ?? `${host}/${owner}`
4793
+ };
4794
+ }
4795
+ throw new FatalError("Could not determine docker registry. Set DOCKER_REGISTRY_HOST and DOCKER_REGISTRY_NAMESPACE, or add registryHost/registryNamespace to .tooling.json, or ensure package.json has a repository URL.");
4796
+ }
4578
4797
  const publishDockerCommand = defineCommand({
4579
4798
  meta: {
4580
4799
  name: "docker:publish",
@@ -4591,10 +4810,12 @@ const publishDockerCommand = defineCommand({
4591
4810
  }
4592
4811
  },
4593
4812
  async run({ args }) {
4813
+ const cwd = process.cwd();
4814
+ const { registryHost, registryNamespace } = resolveRegistryConfig(cwd);
4594
4815
  const config = {
4595
- cwd: process.cwd(),
4596
- registryHost: requireEnv("DOCKER_REGISTRY_HOST"),
4597
- registryNamespace: requireEnv("DOCKER_REGISTRY_NAMESPACE"),
4816
+ cwd,
4817
+ registryHost,
4818
+ registryNamespace,
4598
4819
  username: requireEnv("DOCKER_USERNAME"),
4599
4820
  password: requireEnv("DOCKER_PASSWORD"),
4600
4821
  dryRun: args["dry-run"] === true,
@@ -4869,8 +5090,8 @@ function toHttpHealthChecks(checks) {
4869
5090
  }
4870
5091
  /** Write the generated check overlay to a temp file. Returns the absolute path. */
4871
5092
  function writeTempOverlay(content) {
4872
- const name = `tooling-check-overlay-${process.pid}.yaml`;
4873
- const filePath = path.join(tmpdir(), name);
5093
+ const dir = mkdtempSync(path.join(tmpdir(), "tooling-check-"));
5094
+ const filePath = path.join(dir, "overlay.yaml");
4874
5095
  writeFileSync(filePath, content, "utf-8");
4875
5096
  return filePath;
4876
5097
  }
@@ -4946,7 +5167,7 @@ const dockerCheckCommand = defineCommand({
4946
5167
  if (!result.success) throw new FatalError(`Check failed (${result.reason}): ${result.message}`);
4947
5168
  } finally {
4948
5169
  try {
4949
- unlinkSync(tempOverlayPath);
5170
+ rmSync(path.dirname(tempOverlayPath), { recursive: true });
4950
5171
  } catch (_error) {}
4951
5172
  }
4952
5173
  }
@@ -4956,7 +5177,7 @@ const dockerCheckCommand = defineCommand({
4956
5177
  const main = defineCommand({
4957
5178
  meta: {
4958
5179
  name: "bst",
4959
- version: "0.37.0",
5180
+ version: "0.38.0",
4960
5181
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4961
5182
  },
4962
5183
  subCommands: {
@@ -4972,7 +5193,7 @@ const main = defineCommand({
4972
5193
  "docker:check": dockerCheckCommand
4973
5194
  }
4974
5195
  });
4975
- console.log(`@bensandee/tooling v0.37.0`);
5196
+ console.log(`@bensandee/tooling v0.38.0`);
4976
5197
  async function run() {
4977
5198
  await runMain(main);
4978
5199
  process.exit(process.exitCode ?? 0);