@bensandee/tooling 0.33.0 → 0.35.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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { l as createRealExecutor$1, t as runDockerCheck, u as isExecSyncError } from "./check-B2AAPCBO.mjs";
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";
3
3
  import { defineCommand, runMain } from "citty";
4
- import * as clack from "@clack/prompts";
4
+ import * as p from "@clack/prompts";
5
5
  import { isCancel, select } from "@clack/prompts";
6
6
  import path from "node:path";
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -10,25 +10,9 @@ import JSON5 from "json5";
10
10
  import { parse } from "jsonc-parser";
11
11
  import { z } from "zod";
12
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
- import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify } from "yaml";
13
+ import { isMap, isScalar, parse as parse$1, parseDocument, stringify, visit } from "yaml";
14
14
  import picomatch from "picomatch";
15
15
  import { tmpdir } from "node:os";
16
- //#region src/utils/log.ts
17
- const out = (msg) => console.log(msg);
18
- const isCI = Boolean(process.env["CI"]);
19
- const log$2 = isCI ? {
20
- info: out,
21
- warn: (msg) => out(`[warn] ${msg}`),
22
- error: (msg) => out(`[error] ${msg}`),
23
- success: (msg) => out(`✓ ${msg}`)
24
- } : clack.log;
25
- function note(body, title) {
26
- if (isCI) {
27
- if (title) out(`--- ${title} ---`);
28
- out(body);
29
- } else clack.note(body, title);
30
- }
31
- //#endregion
32
16
  //#region src/types.ts
33
17
  const LEGACY_TOOLS = [
34
18
  "eslint",
@@ -312,7 +296,7 @@ function getMonorepoPackages(targetDir) {
312
296
  //#endregion
313
297
  //#region src/prompts/init-prompts.ts
314
298
  function isCancelled(value) {
315
- return clack.isCancel(value);
299
+ return p.isCancel(value);
316
300
  }
317
301
  function detectProjectInfo(targetDir) {
318
302
  const existingPkg = readPackageJson(targetDir);
@@ -323,7 +307,7 @@ function detectProjectInfo(targetDir) {
323
307
  };
324
308
  }
325
309
  async function runInitPrompts(targetDir, saved) {
326
- clack.intro("@bensandee/tooling repo:sync");
310
+ p.intro("@bensandee/tooling repo:sync");
327
311
  const { detected, defaults, name } = detectProjectInfo(targetDir);
328
312
  const isFirstInit = !saved;
329
313
  const structure = saved?.structure ?? defaults.structure;
@@ -336,7 +320,7 @@ async function runInitPrompts(targetDir, saved) {
336
320
  const projectType = saved?.projectType ?? defaults.projectType;
337
321
  const detectPackageTypes = saved?.detectPackageTypes ?? defaults.detectPackageTypes;
338
322
  if (detected.legacyConfigs.some((l) => l.tool === "prettier") && isFirstInit) {
339
- const formatterAnswer = await clack.select({
323
+ const formatterAnswer = await p.select({
340
324
  message: "Existing Prettier config found. Keep Prettier or migrate to oxfmt?",
341
325
  initialValue: "prettier",
342
326
  options: [{
@@ -349,14 +333,14 @@ async function runInitPrompts(targetDir, saved) {
349
333
  }]
350
334
  });
351
335
  if (isCancelled(formatterAnswer)) {
352
- clack.cancel("Cancelled.");
336
+ p.cancel("Cancelled.");
353
337
  process.exit(0);
354
338
  }
355
339
  formatter = formatterAnswer;
356
340
  }
357
341
  const detectedCi = detectCiPlatform(targetDir);
358
342
  if (isFirstInit && detectedCi === "none") {
359
- const ciAnswer = await clack.select({
343
+ const ciAnswer = await p.select({
360
344
  message: "CI workflow",
361
345
  initialValue: "forgejo",
362
346
  options: [
@@ -375,14 +359,14 @@ async function runInitPrompts(targetDir, saved) {
375
359
  ]
376
360
  });
377
361
  if (isCancelled(ciAnswer)) {
378
- clack.cancel("Cancelled.");
362
+ p.cancel("Cancelled.");
379
363
  process.exit(0);
380
364
  }
381
365
  ci = ciAnswer;
382
366
  }
383
367
  const hasExistingRelease = detected.hasReleaseItConfig || detected.hasSimpleReleaseConfig || detected.hasChangesetsConfig;
384
368
  if (isFirstInit && !hasExistingRelease) {
385
- const releaseAnswer = await clack.select({
369
+ const releaseAnswer = await p.select({
386
370
  message: "Release management",
387
371
  initialValue: defaults.releaseStrategy,
388
372
  options: [
@@ -408,7 +392,7 @@ async function runInitPrompts(targetDir, saved) {
408
392
  ]
409
393
  });
410
394
  if (isCancelled(releaseAnswer)) {
411
- clack.cancel("Cancelled.");
395
+ p.cancel("Cancelled.");
412
396
  process.exit(0);
413
397
  }
414
398
  releaseStrategy = releaseAnswer;
@@ -416,12 +400,12 @@ async function runInitPrompts(targetDir, saved) {
416
400
  let publishNpm = saved?.publishNpm ?? false;
417
401
  if (isFirstInit && releaseStrategy !== "none") {
418
402
  if (getPublishablePackages(targetDir, structure).length > 0) {
419
- const answer = await clack.confirm({
403
+ const answer = await p.confirm({
420
404
  message: "Publish packages to npm?",
421
405
  initialValue: false
422
406
  });
423
407
  if (isCancelled(answer)) {
424
- clack.cancel("Cancelled.");
408
+ p.cancel("Cancelled.");
425
409
  process.exit(0);
426
410
  }
427
411
  publishNpm = answer;
@@ -430,18 +414,18 @@ async function runInitPrompts(targetDir, saved) {
430
414
  let publishDocker = saved?.publishDocker ?? false;
431
415
  if (isFirstInit) {
432
416
  if (existsSync(path.join(targetDir, "Dockerfile")) || existsSync(path.join(targetDir, "docker/Dockerfile"))) {
433
- const answer = await clack.confirm({
417
+ const answer = await p.confirm({
434
418
  message: "Publish Docker images to a registry?",
435
419
  initialValue: false
436
420
  });
437
421
  if (isCancelled(answer)) {
438
- clack.cancel("Cancelled.");
422
+ p.cancel("Cancelled.");
439
423
  process.exit(0);
440
424
  }
441
425
  publishDocker = answer;
442
426
  }
443
427
  }
444
- clack.outro("Configuration complete!");
428
+ p.outro("Configuration complete!");
445
429
  return {
446
430
  name,
447
431
  structure,
@@ -859,9 +843,6 @@ function generateTags(version) {
859
843
  function imageRef(namespace, imageName, tag) {
860
844
  return `${namespace}/${imageName}:${tag}`;
861
845
  }
862
- function log$1(message) {
863
- console.log(message);
864
- }
865
846
  /** Read the repo name from root package.json. */
866
847
  function readRepoName(executor, cwd) {
867
848
  const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
@@ -894,22 +875,24 @@ function runDockerBuild(executor, config) {
894
875
  const repoName = readRepoName(executor, config.cwd);
895
876
  if (config.packageDir) {
896
877
  const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
897
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
878
+ log.info(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
879
+ debug(config, `Dockerfile: ${pkg.docker.dockerfile}, context: ${pkg.docker.context}`);
898
880
  buildImage(executor, pkg, config.cwd, config.extraArgs);
899
- log$1(`Built ${pkg.imageName}:latest`);
881
+ log.info(`Built ${pkg.imageName}:latest`);
900
882
  return { packages: [pkg] };
901
883
  }
902
884
  const packages = detectDockerPackages(executor, config.cwd, repoName);
903
885
  if (packages.length === 0) {
904
- log$1("No packages with docker config found");
886
+ log.info("No packages with docker config found");
905
887
  return { packages: [] };
906
888
  }
907
- log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
889
+ log.info(`Found ${String(packages.length)} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
908
890
  for (const pkg of packages) {
909
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
891
+ log.info(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
892
+ debug(config, `Dockerfile: ${pkg.docker.dockerfile}, context: ${pkg.docker.context}`);
910
893
  buildImage(executor, pkg, config.cwd, config.extraArgs);
911
894
  }
912
- log$1(`Built ${packages.length} image(s)`);
895
+ log.info(`Built ${String(packages.length)} image(s)`);
913
896
  return { packages };
914
897
  }
915
898
  /**
@@ -924,7 +907,8 @@ function runDockerPublish(executor, config) {
924
907
  const { packages } = runDockerBuild(executor, {
925
908
  cwd: config.cwd,
926
909
  packageDir: void 0,
927
- extraArgs: []
910
+ extraArgs: [],
911
+ verbose: config.verbose
928
912
  });
929
913
  if (packages.length === 0) return {
930
914
  packages: [],
@@ -932,35 +916,38 @@ function runDockerPublish(executor, config) {
932
916
  };
933
917
  for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
934
918
  if (!config.dryRun) {
935
- log$1(`Logging in to ${config.registryHost}...`);
919
+ log.info(`Logging in to ${config.registryHost}...`);
936
920
  const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
921
+ debugExec(config, "docker login", loginResult);
937
922
  if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
938
- } else log$1("[dry-run] Skipping docker login");
923
+ } else log.info("[dry-run] Skipping docker login");
939
924
  const allTags = [];
940
925
  try {
941
926
  for (const pkg of packages) {
942
927
  const tags = generateTags(pkg.version ?? "");
943
- log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
928
+ log.info(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
944
929
  for (const tag of tags) {
945
930
  const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
946
931
  allTags.push(ref);
947
- log$1(`Tagging ${pkg.imageName} → ${ref}`);
932
+ log.info(`Tagging ${pkg.imageName} → ${ref}`);
948
933
  const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
934
+ debugExec(config, `docker tag ${pkg.imageName} ${ref}`, tagResult);
949
935
  if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
950
936
  if (!config.dryRun) {
951
- log$1(`Pushing ${ref}...`);
937
+ log.info(`Pushing ${ref}...`);
952
938
  const pushResult = executor.exec(`docker push ${ref}`);
939
+ debugExec(config, `docker push ${ref}`, pushResult);
953
940
  if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
954
- } else log$1(`[dry-run] Skipping push for ${ref}`);
941
+ } else log.info(`[dry-run] Skipping push for ${ref}`);
955
942
  }
956
943
  }
957
944
  } finally {
958
945
  if (!config.dryRun) {
959
- log$1(`Logging out from ${config.registryHost}...`);
946
+ log.info(`Logging out from ${config.registryHost}...`);
960
947
  executor.exec(`docker logout ${config.registryHost}`);
961
948
  }
962
949
  }
963
- log$1(`Published ${allTags.length} image tag(s)`);
950
+ log.info(`Published ${String(allTags.length)} image tag(s)`);
964
951
  return {
965
952
  packages,
966
953
  tags: allTags
@@ -976,10 +963,6 @@ function ensureSchemaComment(content, ci) {
976
963
  if (content.includes("yaml-language-server")) return content;
977
964
  return FORGEJO_SCHEMA_COMMENT + content;
978
965
  }
979
- /** Migrate content from old tooling binary name to new. */
980
- function migrateToolingBinary(content) {
981
- return content.replaceAll("pnpm exec tooling ", "pnpm exec bst ");
982
- }
983
966
  /** Check if a YAML file has an opt-out comment in the first 10 lines. */
984
967
  function isToolingIgnored(content) {
985
968
  return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
@@ -1024,14 +1007,42 @@ function mergeLefthookCommands(existing, requiredCommands) {
1024
1007
  };
1025
1008
  }
1026
1009
  }
1027
- /** Extract only the mergeable steps (those with a match) as RequiredSteps. */
1028
- function toRequiredSteps(steps) {
1029
- return steps.filter((s) => s.match !== void 0).map((s) => ({
1030
- match: s.match,
1031
- step: s.step
1032
- }));
1010
+ /**
1011
+ * Normalize a workflow YAML string for comparison purposes.
1012
+ * Parses the YAML DOM to strip action versions from `uses:` values and remove all comments,
1013
+ * then re-serializes so that pinned hashes, older tags, or formatting differences
1014
+ * don't cause unnecessary overwrites.
1015
+ */
1016
+ function normalizeWorkflow(content) {
1017
+ const doc = parseDocument(content.replaceAll(/node\s+packages\/tooling-cli\/dist\/bin\.mjs/g, "pnpm exec bst"));
1018
+ doc.comment = null;
1019
+ doc.commentBefore = null;
1020
+ visit(doc, {
1021
+ Node(_key, node) {
1022
+ if ("comment" in node) node.comment = null;
1023
+ if ("commentBefore" in node) node.commentBefore = null;
1024
+ },
1025
+ Pair(_key, node) {
1026
+ if (isScalar(node.key) && node.key.value === "uses" && isScalar(node.value) && typeof node.value.value === "string") node.value.value = node.value.value.replace(/@.*$/, "");
1027
+ }
1028
+ });
1029
+ return doc.toString({
1030
+ lineWidth: 0,
1031
+ nullStr: ""
1032
+ });
1033
1033
  }
1034
- /** Build a complete workflow YAML string from structured options. Single source of truth for both new files and merge steps. */
1034
+ const DEV_BINARY_PATTERN = /node\s+packages\/tooling-cli\/dist\/bin\.mjs/;
1035
+ /**
1036
+ * If the existing file uses the dev binary path (`node packages/tooling-cli/dist/bin.mjs`),
1037
+ * substitute it back into the generated content so we don't overwrite it with `pnpm exec bst`.
1038
+ */
1039
+ function preserveDevBinaryPath(generated, existing) {
1040
+ if (!existing) return generated;
1041
+ const match = DEV_BINARY_PATTERN.exec(existing);
1042
+ if (!match) return generated;
1043
+ return generated.replaceAll("pnpm exec bst", match[0]);
1044
+ }
1045
+ /** Build a complete workflow YAML string from structured options. Single source of truth for workflow files. */
1035
1046
  function buildWorkflowYaml(options) {
1036
1047
  const doc = { name: options.name };
1037
1048
  if (options.enableEmailNotifications) doc["enable-email-notifications"] = true;
@@ -1045,132 +1056,7 @@ function buildWorkflowYaml(options) {
1045
1056
  return ensureSchemaComment(stringify(doc, {
1046
1057
  lineWidth: 0,
1047
1058
  nullStr: ""
1048
- }), options.ci);
1049
- }
1050
- /**
1051
- * Ensure required steps exist in a workflow job's steps array.
1052
- * Only adds missing steps at the end — never modifies existing ones.
1053
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1054
- */
1055
- function mergeWorkflowSteps(existing, jobName, requiredSteps) {
1056
- if (isToolingIgnored(existing)) return {
1057
- content: existing,
1058
- changed: false
1059
- };
1060
- try {
1061
- const doc = parseDocument(existing);
1062
- const steps = doc.getIn([
1063
- "jobs",
1064
- jobName,
1065
- "steps"
1066
- ]);
1067
- if (!isSeq(steps)) return {
1068
- content: existing,
1069
- changed: false
1070
- };
1071
- let changed = false;
1072
- for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
1073
- if (!isMap(item)) return false;
1074
- if (match.run) {
1075
- const run = item.get("run");
1076
- return typeof run === "string" && run.includes(match.run);
1077
- }
1078
- if (match.uses) {
1079
- const uses = item.get("uses");
1080
- return typeof uses === "string" && uses.startsWith(match.uses);
1081
- }
1082
- return false;
1083
- })) {
1084
- steps.add(doc.createNode(step));
1085
- changed = true;
1086
- }
1087
- return {
1088
- content: changed ? doc.toString() : existing,
1089
- changed
1090
- };
1091
- } catch {
1092
- return {
1093
- content: existing,
1094
- changed: false
1095
- };
1096
- }
1097
- }
1098
- /**
1099
- * Add a job to an existing workflow YAML if it doesn't already exist.
1100
- * Returns unchanged content if the job already exists, the file has an opt-out comment,
1101
- * or the document can't be parsed.
1102
- */
1103
- /**
1104
- * Ensure a `concurrency` block exists at the workflow top level.
1105
- * Adds it if missing — never modifies an existing one.
1106
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1107
- */
1108
- /**
1109
- * Ensure `on.push` has `tags-ignore: ["**"]` so tag pushes don't trigger CI.
1110
- * Only adds the filter when `on.push` exists and `tags-ignore` is absent.
1111
- */
1112
- function ensureWorkflowTagsIgnore(existing) {
1113
- if (isToolingIgnored(existing)) return {
1114
- content: existing,
1115
- changed: false
1116
- };
1117
- try {
1118
- const doc = parseDocument(existing);
1119
- const on = doc.get("on");
1120
- if (!isMap(on)) return {
1121
- content: existing,
1122
- changed: false
1123
- };
1124
- const push = on.get("push");
1125
- if (!isMap(push)) return {
1126
- content: existing,
1127
- changed: false
1128
- };
1129
- if (push.has("tags-ignore")) return {
1130
- content: existing,
1131
- changed: false
1132
- };
1133
- push.set("tags-ignore", ["**"]);
1134
- return {
1135
- content: doc.toString(),
1136
- changed: true
1137
- };
1138
- } catch {
1139
- return {
1140
- content: existing,
1141
- changed: false
1142
- };
1143
- }
1144
- }
1145
- function ensureWorkflowConcurrency(existing, concurrency) {
1146
- if (isToolingIgnored(existing)) return {
1147
- content: existing,
1148
- changed: false
1149
- };
1150
- try {
1151
- const doc = parseDocument(existing);
1152
- if (doc.has("concurrency")) return {
1153
- content: existing,
1154
- changed: false
1155
- };
1156
- doc.set("concurrency", concurrency);
1157
- const contents = doc.contents;
1158
- if (isMap(contents)) {
1159
- const items = contents.items;
1160
- const nameIdx = items.findIndex((p) => isScalar(p.key) && p.key.value === "name");
1161
- const concPair = items.pop();
1162
- if (concPair) items.splice(nameIdx + 1, 0, concPair);
1163
- }
1164
- return {
1165
- content: doc.toString(),
1166
- changed: true
1167
- };
1168
- } catch {
1169
- return {
1170
- content: existing,
1171
- changed: false
1172
- };
1173
- }
1059
+ }).replace(/^(?=\S)/gm, (match, offset) => offset === 0 ? match : `\n${match}`), options.ci);
1174
1060
  }
1175
1061
  //#endregion
1176
1062
  //#region src/generators/ci-utils.ts
@@ -1190,38 +1076,24 @@ function computeNodeVersionYaml(ctx) {
1190
1076
  //#region src/generators/publish-ci.ts
1191
1077
  function publishSteps(nodeVersionYaml) {
1192
1078
  return [
1193
- {
1194
- match: { uses: "actions/checkout" },
1195
- step: { uses: "actions/checkout@v6" }
1196
- },
1197
- {
1198
- match: { uses: "pnpm/action-setup" },
1199
- step: { uses: "pnpm/action-setup@v5" }
1200
- },
1201
- {
1202
- match: { uses: "actions/setup-node" },
1203
- step: {
1204
- uses: "actions/setup-node@v6",
1205
- with: nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" }
1206
- }
1207
- },
1208
- {
1209
- match: { run: "pnpm install" },
1210
- step: { run: "pnpm install --frozen-lockfile" }
1211
- },
1212
- {
1213
- match: { run: "docker:publish" },
1214
- step: {
1215
- name: "Publish Docker images",
1216
- env: {
1217
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1218
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
1219
- DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1220
- DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD")
1221
- },
1222
- run: "pnpm exec bst docker:publish"
1223
- }
1224
- }
1079
+ { step: { uses: "actions/checkout@v6" } },
1080
+ { step: { uses: "pnpm/action-setup@v5" } },
1081
+ { step: {
1082
+ uses: "actions/setup-node@v6",
1083
+ with: nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" }
1084
+ } },
1085
+ { step: { run: "pnpm install --frozen-lockfile" } },
1086
+ { step: {
1087
+ name: "Publish Docker images",
1088
+ env: {
1089
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
1090
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
1091
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
1092
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
1093
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
1094
+ },
1095
+ run: "pnpm exec bst docker:publish"
1096
+ } }
1225
1097
  ];
1226
1098
  }
1227
1099
  const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
@@ -1270,59 +1142,24 @@ async function generateDeployCi(ctx) {
1270
1142
  jobName: "publish",
1271
1143
  steps
1272
1144
  });
1273
- if (ctx.exists(workflowPath)) {
1274
- const raw = ctx.read(workflowPath);
1275
- if (raw) {
1276
- const existing = migrateToolingBinary(raw);
1277
- if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) {
1278
- if (existing !== raw) {
1279
- ctx.write(workflowPath, ensureSchemaComment(existing, ctx.config.ci));
1280
- return {
1281
- filePath: workflowPath,
1282
- action: "updated",
1283
- description: "Migrated tooling binary name in publish workflow"
1284
- };
1285
- }
1286
- return {
1287
- filePath: workflowPath,
1288
- action: "skipped",
1289
- description: "Publish workflow already up to date"
1290
- };
1291
- }
1292
- const merged = mergeWorkflowSteps(existing, "publish", toRequiredSteps(steps));
1293
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
1294
- if (!merged.changed) {
1295
- if (withComment !== raw) {
1296
- ctx.write(workflowPath, withComment);
1297
- return {
1298
- filePath: workflowPath,
1299
- action: "updated",
1300
- description: existing !== raw ? "Migrated tooling binary name in publish workflow" : "Added schema comment to publish workflow"
1301
- };
1302
- }
1303
- return {
1304
- filePath: workflowPath,
1305
- action: "skipped",
1306
- description: "Existing publish workflow preserved"
1307
- };
1308
- }
1309
- ctx.write(workflowPath, withComment);
1310
- return {
1311
- filePath: workflowPath,
1312
- action: "updated",
1313
- description: "Added missing steps to publish workflow"
1314
- };
1315
- }
1316
- return {
1145
+ const alreadyExists = ctx.exists(workflowPath);
1146
+ const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
1147
+ if (existing) {
1148
+ if (isToolingIgnored(existing)) return {
1149
+ filePath: workflowPath,
1150
+ action: "skipped",
1151
+ description: "Publish workflow has ignore comment"
1152
+ };
1153
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
1317
1154
  filePath: workflowPath,
1318
1155
  action: "skipped",
1319
1156
  description: "Publish workflow already up to date"
1320
1157
  };
1321
1158
  }
1322
- ctx.write(workflowPath, content);
1159
+ ctx.write(workflowPath, preserveDevBinaryPath(content, existing));
1323
1160
  return {
1324
1161
  filePath: workflowPath,
1325
- action: "created",
1162
+ action: alreadyExists ? "updated" : "created",
1326
1163
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions publish workflow`
1327
1164
  };
1328
1165
  }
@@ -1590,7 +1427,7 @@ function getAddedDevDepNames(config) {
1590
1427
  const deps = { ...ROOT_DEV_DEPS };
1591
1428
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
1592
1429
  deps["@bensandee/config"] = "0.9.1";
1593
- deps["@bensandee/tooling"] = "0.33.0";
1430
+ deps["@bensandee/tooling"] = "0.35.0";
1594
1431
  if (config.formatter === "oxfmt") deps["oxfmt"] = {
1595
1432
  "@changesets/cli": "2.30.0",
1596
1433
  "@release-it/bumper": "7.0.5",
@@ -1645,7 +1482,7 @@ async function generatePackageJson(ctx) {
1645
1482
  const devDeps = { ...ROOT_DEV_DEPS };
1646
1483
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1647
1484
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1648
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.33.0";
1485
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.35.0";
1649
1486
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1650
1487
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1651
1488
  "@changesets/cli": "2.30.0",
@@ -1693,10 +1530,6 @@ async function generatePackageJson(ctx) {
1693
1530
  changes.push("set type: \"module\"");
1694
1531
  }
1695
1532
  const existingScripts = pkg.scripts ?? {};
1696
- for (const [key, value] of Object.entries(existingScripts)) if (typeof value === "string" && value.includes("pnpm exec tooling ")) {
1697
- existingScripts[key] = migrateToolingBinary(value);
1698
- changes.push(`migrated script: ${key}`);
1699
- }
1700
1533
  for (const [key, value] of Object.entries(allScripts)) if (!(key in existingScripts)) {
1701
1534
  existingScripts[key] = value;
1702
1535
  changes.push(`added script: ${key}`);
@@ -2183,41 +2016,58 @@ async function generateGitignore(ctx) {
2183
2016
  }
2184
2017
  //#endregion
2185
2018
  //#region src/generators/ci.ts
2019
+ /** Build the release step for the check job (changesets strategy). */
2020
+ function changesetsReleaseStep(ci, publishesNpm) {
2021
+ if (ci === "github") return { step: {
2022
+ uses: "changesets/action@v1",
2023
+ if: "github.ref == 'refs/heads/main'",
2024
+ with: {
2025
+ publish: "pnpm changeset publish",
2026
+ version: "pnpm changeset version"
2027
+ },
2028
+ env: {
2029
+ GITHUB_TOKEN: actionsExpr("github.token"),
2030
+ ...publishesNpm && { NPM_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2031
+ }
2032
+ } };
2033
+ return { step: {
2034
+ name: "Release",
2035
+ if: "github.ref == 'refs/heads/main'",
2036
+ env: {
2037
+ FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2038
+ FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2039
+ RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
2040
+ ...publishesNpm && { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") },
2041
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2042
+ },
2043
+ run: "pnpm exec bst release:changesets"
2044
+ } };
2045
+ }
2186
2046
  const CI_CONCURRENCY = {
2187
2047
  group: `ci-${actionsExpr("github.ref")}`,
2188
2048
  "cancel-in-progress": actionsExpr("github.ref != 'refs/heads/main'")
2189
2049
  };
2190
- function checkSteps(nodeVersionYaml) {
2050
+ function checkSteps(nodeVersionYaml, publishesNpm) {
2051
+ const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2191
2052
  return [
2192
- {
2193
- match: { uses: "actions/checkout" },
2194
- step: { uses: "actions/checkout@v6" }
2195
- },
2196
- {
2197
- match: { uses: "pnpm/action-setup" },
2198
- step: { uses: "pnpm/action-setup@v5" }
2199
- },
2200
- {
2201
- match: { uses: "actions/setup-node" },
2202
- step: {
2203
- uses: "actions/setup-node@v6",
2204
- with: {
2205
- ...nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" },
2206
- cache: "pnpm"
2207
- }
2208
- }
2209
- },
2210
- {
2211
- match: { run: "pnpm install" },
2212
- step: { run: "pnpm install --frozen-lockfile" }
2213
- },
2214
- {
2215
- match: { run: "check" },
2216
- step: {
2217
- name: "Run all checks",
2218
- run: "pnpm ci:check"
2053
+ { step: {
2054
+ uses: "actions/checkout@v6",
2055
+ with: { "fetch-depth": 0 }
2056
+ } },
2057
+ { step: { uses: "pnpm/action-setup@v5" } },
2058
+ { step: {
2059
+ uses: "actions/setup-node@v6",
2060
+ with: {
2061
+ ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2062
+ cache: "pnpm",
2063
+ ...publishesNpm && { "registry-url": actionsExpr("vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org'") }
2219
2064
  }
2220
- }
2065
+ } },
2066
+ { step: { run: "pnpm install --frozen-lockfile" } },
2067
+ { step: {
2068
+ name: "Run all checks",
2069
+ run: "pnpm ci:check"
2070
+ } }
2221
2071
  ];
2222
2072
  }
2223
2073
  /** Resolve the CI workflow filename based on release strategy. */
@@ -2233,7 +2083,9 @@ async function generateCi(ctx) {
2233
2083
  const isGitHub = ctx.config.ci === "github";
2234
2084
  const isForgejo = !isGitHub;
2235
2085
  const isChangesets = ctx.config.releaseStrategy === "changesets";
2236
- const steps = checkSteps(computeNodeVersionYaml(ctx));
2086
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
2087
+ const publishesNpm = ctx.config.publishNpm === true;
2088
+ const steps = [...checkSteps(nodeVersionYaml, isChangesets && publishesNpm), ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm)] : []];
2237
2089
  const content = buildWorkflowYaml({
2238
2090
  ci: ctx.config.ci,
2239
2091
  name: "CI",
@@ -2250,42 +2102,24 @@ async function generateCi(ctx) {
2250
2102
  steps
2251
2103
  });
2252
2104
  const filePath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
2253
- if (ctx.exists(filePath)) {
2254
- const existing = ctx.read(filePath);
2255
- if (existing) {
2256
- let result = mergeWorkflowSteps(existing, "check", toRequiredSteps(steps));
2257
- const withTagsIgnore = ensureWorkflowTagsIgnore(result.content);
2258
- result = {
2259
- content: withTagsIgnore.content,
2260
- changed: result.changed || withTagsIgnore.changed
2261
- };
2262
- if (isChangesets) {
2263
- const withConcurrency = ensureWorkflowConcurrency(result.content, CI_CONCURRENCY);
2264
- result = {
2265
- content: withConcurrency.content,
2266
- changed: result.changed || withConcurrency.changed
2267
- };
2268
- }
2269
- const withComment = ensureSchemaComment(result.content, isGitHub ? "github" : "forgejo");
2270
- if (result.changed || withComment !== result.content) {
2271
- ctx.write(filePath, withComment);
2272
- return {
2273
- filePath,
2274
- action: "updated",
2275
- description: "Added missing steps to CI workflow"
2276
- };
2277
- }
2278
- }
2279
- return {
2105
+ const alreadyExists = ctx.exists(filePath);
2106
+ const existing = alreadyExists ? ctx.read(filePath) : void 0;
2107
+ if (existing) {
2108
+ if (isToolingIgnored(existing)) return {
2109
+ filePath,
2110
+ action: "skipped",
2111
+ description: "CI workflow has ignore comment"
2112
+ };
2113
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
2280
2114
  filePath,
2281
2115
  action: "skipped",
2282
2116
  description: "CI workflow already up to date"
2283
2117
  };
2284
2118
  }
2285
- ctx.write(filePath, content);
2119
+ ctx.write(filePath, preserveDevBinaryPath(content, existing));
2286
2120
  return {
2287
2121
  filePath,
2288
- action: "created",
2122
+ action: alreadyExists ? "updated" : "created",
2289
2123
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions CI workflow`
2290
2124
  };
2291
2125
  }
@@ -2762,47 +2596,33 @@ async function generateChangesets(ctx) {
2762
2596
  function commonSteps(nodeVersionYaml, publishesNpm) {
2763
2597
  const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2764
2598
  return [
2765
- {
2766
- match: { uses: "actions/checkout" },
2767
- step: {
2768
- uses: "actions/checkout@v6",
2769
- with: { "fetch-depth": 0 }
2770
- }
2771
- },
2772
- {
2773
- match: { uses: "pnpm/action-setup" },
2774
- step: { uses: "pnpm/action-setup@v5" }
2775
- },
2776
- {
2777
- match: { uses: "actions/setup-node" },
2778
- step: {
2779
- uses: "actions/setup-node@v6",
2780
- with: {
2781
- ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2782
- cache: "pnpm",
2783
- ...publishesNpm && { "registry-url": "https://registry.npmjs.org" }
2784
- }
2599
+ { step: {
2600
+ uses: "actions/checkout@v6",
2601
+ with: { "fetch-depth": 0 }
2602
+ } },
2603
+ { step: { uses: "pnpm/action-setup@v5" } },
2604
+ { step: {
2605
+ uses: "actions/setup-node@v6",
2606
+ with: {
2607
+ ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2608
+ cache: "pnpm",
2609
+ ...publishesNpm && { "registry-url": actionsExpr("vars.NPM_REGISTRY_URL || 'https://registry.npmjs.org'") }
2785
2610
  }
2786
- },
2787
- {
2788
- match: { run: "pnpm install" },
2789
- step: { run: "pnpm install --frozen-lockfile" }
2790
- }
2611
+ } },
2612
+ { step: { run: "pnpm install --frozen-lockfile" } }
2791
2613
  ];
2792
2614
  }
2793
2615
  function releaseItSteps(ci, nodeVersionYaml, publishesNpm) {
2794
2616
  const tokenEnv = ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : { RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN") };
2795
2617
  const npmEnv = publishesNpm ? { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") } : {};
2796
- return [...commonSteps(nodeVersionYaml, publishesNpm), {
2797
- match: { run: "release-it" },
2798
- step: {
2799
- run: "pnpm release-it --ci",
2800
- env: {
2801
- ...tokenEnv,
2802
- ...npmEnv
2803
- }
2618
+ return [...commonSteps(nodeVersionYaml, publishesNpm), { step: {
2619
+ run: "pnpm release-it --ci",
2620
+ env: {
2621
+ ...tokenEnv,
2622
+ ...npmEnv,
2623
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2804
2624
  }
2805
- }];
2625
+ } }];
2806
2626
  }
2807
2627
  /** Build the workflow_dispatch trigger with optional inputs for the simple strategy. */
2808
2628
  function simpleWorkflowDispatchTrigger() {
@@ -2840,70 +2660,36 @@ function simpleReleaseCommand() {
2840
2660
  ].join("\n");
2841
2661
  }
2842
2662
  function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker) {
2843
- const releaseStep = {
2844
- match: { run: "release:simple" },
2845
- step: {
2846
- name: "Release",
2847
- env: ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
2663
+ const releaseStep = { step: {
2664
+ name: "Release",
2665
+ env: {
2666
+ ...ci === "github" ? { GITHUB_TOKEN: actionsExpr("github.token") } : {
2848
2667
  FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2849
2668
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2850
2669
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
2851
2670
  },
2852
- run: simpleReleaseCommand()
2853
- }
2854
- };
2855
- const dockerStep = {
2856
- match: { run: "docker:publish" },
2857
- step: {
2858
- name: "Publish Docker images",
2859
- if: "success()",
2860
- env: {
2861
- DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
2862
- DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
2863
- DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
2864
- DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD")
2865
- },
2866
- run: "pnpm exec bst docker:publish"
2867
- }
2868
- };
2671
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2672
+ },
2673
+ run: simpleReleaseCommand()
2674
+ } };
2675
+ const dockerStep = { step: {
2676
+ name: "Publish Docker images",
2677
+ if: "success()",
2678
+ env: {
2679
+ DOCKER_REGISTRY_HOST: actionsExpr("vars.DOCKER_REGISTRY_HOST"),
2680
+ DOCKER_REGISTRY_NAMESPACE: actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE"),
2681
+ DOCKER_USERNAME: actionsExpr("secrets.DOCKER_USERNAME"),
2682
+ DOCKER_PASSWORD: actionsExpr("secrets.DOCKER_PASSWORD"),
2683
+ RELEASE_DEBUG: actionsExpr("vars.RELEASE_DEBUG || 'false'")
2684
+ },
2685
+ run: "pnpm exec bst docker:publish"
2686
+ } };
2869
2687
  return [
2870
2688
  ...commonSteps(nodeVersionYaml, publishesNpm),
2871
2689
  releaseStep,
2872
2690
  ...hasDocker ? [dockerStep] : []
2873
2691
  ];
2874
2692
  }
2875
- /** Build the required release step for the check job (changesets). */
2876
- function changesetsReleaseStep(ci, publishesNpm) {
2877
- if (ci === "github") return {
2878
- match: { uses: "changesets/action" },
2879
- step: {
2880
- uses: "changesets/action@v1",
2881
- if: "github.ref == 'refs/heads/main'",
2882
- with: {
2883
- publish: "pnpm changeset publish",
2884
- version: "pnpm changeset version"
2885
- },
2886
- env: {
2887
- GITHUB_TOKEN: actionsExpr("github.token"),
2888
- ...publishesNpm && { NPM_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2889
- }
2890
- }
2891
- };
2892
- return {
2893
- match: { run: "release:changesets" },
2894
- step: {
2895
- name: "Release",
2896
- if: "github.ref == 'refs/heads/main'",
2897
- env: {
2898
- FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2899
- FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2900
- RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
2901
- ...publishesNpm && { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2902
- },
2903
- run: "pnpm exec bst release:changesets"
2904
- }
2905
- };
2906
- }
2907
2693
  function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker) {
2908
2694
  switch (strategy) {
2909
2695
  case "release-it": return releaseItSteps(ci, nodeVersionYaml, publishesNpm);
@@ -2911,40 +2697,6 @@ function buildSteps(strategy, ci, nodeVersionYaml, publishesNpm, hasDocker) {
2911
2697
  default: return null;
2912
2698
  }
2913
2699
  }
2914
- function generateChangesetsReleaseCi(ctx, publishesNpm) {
2915
- const ciPath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
2916
- const raw = ctx.read(ciPath);
2917
- if (!raw) return {
2918
- filePath: ciPath,
2919
- action: "skipped",
2920
- description: "CI workflow not found — run check generator first"
2921
- };
2922
- const existing = migrateToolingBinary(raw);
2923
- const merged = mergeWorkflowSteps(existing, "check", [changesetsReleaseStep(ctx.config.ci, publishesNpm)]);
2924
- if (!merged.changed) {
2925
- if (existing !== raw) {
2926
- const withComment = ensureSchemaComment(existing, ctx.config.ci);
2927
- ctx.write(ciPath, withComment);
2928
- return {
2929
- filePath: ciPath,
2930
- action: "updated",
2931
- description: "Migrated tooling binary name in CI workflow"
2932
- };
2933
- }
2934
- return {
2935
- filePath: ciPath,
2936
- action: "skipped",
2937
- description: "Release step in CI workflow already up to date"
2938
- };
2939
- }
2940
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2941
- ctx.write(ciPath, withComment);
2942
- return {
2943
- filePath: ciPath,
2944
- action: "updated",
2945
- description: "Added release step to CI workflow"
2946
- };
2947
- }
2948
2700
  async function generateReleaseCi(ctx) {
2949
2701
  const filePath = "release-ci";
2950
2702
  if (ctx.config.releaseStrategy === "none" || ctx.config.ci === "none") return {
@@ -2953,7 +2705,11 @@ async function generateReleaseCi(ctx) {
2953
2705
  description: "Release CI workflow not applicable"
2954
2706
  };
2955
2707
  const publishesNpm = ctx.config.publishNpm === true;
2956
- if (ctx.config.releaseStrategy === "changesets") return generateChangesetsReleaseCi(ctx, publishesNpm);
2708
+ if (ctx.config.releaseStrategy === "changesets") return {
2709
+ filePath,
2710
+ action: "skipped",
2711
+ description: "Release step included in CI workflow"
2712
+ };
2957
2713
  const isGitHub = ctx.config.ci === "github";
2958
2714
  const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
2959
2715
  const nodeVersionYaml = computeNodeVersionYaml(ctx);
@@ -2973,59 +2729,24 @@ async function generateReleaseCi(ctx) {
2973
2729
  jobName: "release",
2974
2730
  steps
2975
2731
  });
2976
- if (ctx.exists(workflowPath)) {
2977
- const raw = ctx.read(workflowPath);
2978
- if (raw) {
2979
- const existing = migrateToolingBinary(raw);
2980
- if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) {
2981
- if (existing !== raw) {
2982
- ctx.write(workflowPath, ensureSchemaComment(existing, ctx.config.ci));
2983
- return {
2984
- filePath: workflowPath,
2985
- action: "updated",
2986
- description: "Migrated tooling binary name in release workflow"
2987
- };
2988
- }
2989
- return {
2990
- filePath: workflowPath,
2991
- action: "skipped",
2992
- description: "Release workflow already up to date"
2993
- };
2994
- }
2995
- const merged = mergeWorkflowSteps(existing, "release", toRequiredSteps(steps));
2996
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2997
- if (!merged.changed) {
2998
- if (withComment !== raw) {
2999
- ctx.write(workflowPath, withComment);
3000
- return {
3001
- filePath: workflowPath,
3002
- action: "updated",
3003
- description: existing !== raw ? "Migrated tooling binary name in release workflow" : "Added schema comment to release workflow"
3004
- };
3005
- }
3006
- return {
3007
- filePath: workflowPath,
3008
- action: "skipped",
3009
- description: "Existing release workflow preserved"
3010
- };
3011
- }
3012
- ctx.write(workflowPath, withComment);
3013
- return {
3014
- filePath: workflowPath,
3015
- action: "updated",
3016
- description: "Added missing steps to release workflow"
3017
- };
3018
- }
3019
- return {
2732
+ const alreadyExists = ctx.exists(workflowPath);
2733
+ const existing = alreadyExists ? ctx.read(workflowPath) : void 0;
2734
+ if (existing) {
2735
+ if (isToolingIgnored(existing)) return {
2736
+ filePath: workflowPath,
2737
+ action: "skipped",
2738
+ description: "Release workflow has ignore comment"
2739
+ };
2740
+ if (normalizeWorkflow(existing) === normalizeWorkflow(content)) return {
3020
2741
  filePath: workflowPath,
3021
2742
  action: "skipped",
3022
2743
  description: "Release workflow already up to date"
3023
2744
  };
3024
2745
  }
3025
- ctx.write(workflowPath, content);
2746
+ ctx.write(workflowPath, preserveDevBinaryPath(content, existing));
3026
2747
  return {
3027
2748
  filePath: workflowPath,
3028
- action: "created",
2749
+ action: alreadyExists ? "updated" : "created",
3029
2750
  description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions release workflow`
3030
2751
  };
3031
2752
  }
@@ -3321,7 +3042,7 @@ function generateMigratePrompt(results, config, detected) {
3321
3042
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3322
3043
  sections.push("# Migration Prompt");
3323
3044
  sections.push("");
3324
- sections.push(`_Generated by \`@bensandee/tooling@0.33.0 repo:sync\` on ${timestamp}_`);
3045
+ sections.push(`_Generated by \`@bensandee/tooling@0.35.0 repo:sync\` on ${timestamp}_`);
3325
3046
  sections.push("");
3326
3047
  sections.push("The following prompt was generated by `@bensandee/tooling repo:sync`. Paste it into Claude Code or another AI assistant to finish migrating this repository.");
3327
3048
  sections.push("");
@@ -3505,11 +3226,11 @@ function contextAsDockerReader(ctx) {
3505
3226
  function logDetectionSummary(ctx) {
3506
3227
  if (ctx.config.publishDocker) {
3507
3228
  const dockerPackages = detectDockerPackages(contextAsDockerReader(ctx), ctx.targetDir, ctx.config.name);
3508
- if (dockerPackages.length > 0) log$2.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3229
+ if (dockerPackages.length > 0) log.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3509
3230
  }
3510
3231
  if (ctx.config.publishNpm) {
3511
3232
  const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
3512
- if (publishable.length > 0) log$2.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3233
+ if (publishable.length > 0) log.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3513
3234
  }
3514
3235
  }
3515
3236
  async function runInit(config, options = {}) {
@@ -3543,7 +3264,7 @@ async function runInit(config, options = {}) {
3543
3264
  const promptPath = ".tooling-migrate.md";
3544
3265
  ctx.write(promptPath, prompt);
3545
3266
  if (!hasChanges && options.noPrompt) {
3546
- log$2.success("Repository is up to date.");
3267
+ log.success("Repository is up to date.");
3547
3268
  return results;
3548
3269
  }
3549
3270
  if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
@@ -3558,13 +3279,13 @@ async function runInit(config, options = {}) {
3558
3279
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
3559
3280
  note(summaryLines.join("\n"), "Summary");
3560
3281
  if (!options.noPrompt) {
3561
- log$2.info(`Migration prompt written to ${promptPath}`);
3562
- log$2.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3282
+ log.info(`Migration prompt written to ${promptPath}`);
3283
+ log.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3563
3284
  }
3564
3285
  const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
3565
3286
  const hasLockfile = ctx.exists("pnpm-lock.yaml");
3566
3287
  if (bensandeeDeps.length > 0 && hasLockfile) {
3567
- log$2.info("Updating @bensandee/* packages...");
3288
+ log.info("Updating @bensandee/* packages...");
3568
3289
  try {
3569
3290
  execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
3570
3291
  cwd: config.targetDir,
@@ -3572,7 +3293,7 @@ async function runInit(config, options = {}) {
3572
3293
  timeout: 6e4
3573
3294
  });
3574
3295
  } catch (_error) {
3575
- log$2.warn("Could not update @bensandee/* packages — run pnpm install manually");
3296
+ log.warn("Could not update @bensandee/* packages — run pnpm install manually");
3576
3297
  }
3577
3298
  }
3578
3299
  if (hasChanges && ctx.exists("package.json")) try {
@@ -3664,22 +3385,22 @@ async function runCheck(targetDir) {
3664
3385
  return true;
3665
3386
  });
3666
3387
  if (actionable.length === 0) {
3667
- log$2.success("Repository is up to date.");
3388
+ log.success("Repository is up to date.");
3668
3389
  return 0;
3669
3390
  }
3670
- log$2.warn(`${actionable.length} file(s) would be changed by repo:sync`);
3391
+ log.warn(`${actionable.length} file(s) would be changed by repo:sync`);
3671
3392
  for (const r of actionable) {
3672
- log$2.info(` ${r.action}: ${r.filePath} — ${r.description}`);
3393
+ log.info(` ${r.action}: ${r.filePath} — ${r.description}`);
3673
3394
  const newContent = pendingWrites.get(r.filePath);
3674
3395
  if (!newContent) continue;
3675
3396
  const existingPath = path.join(targetDir, r.filePath);
3676
3397
  const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
3677
3398
  if (!existing) {
3678
3399
  const lineCount = newContent.split("\n").length - 1;
3679
- log$2.info(` + ${lineCount} new lines`);
3400
+ log.info(` + ${lineCount} new lines`);
3680
3401
  } else {
3681
3402
  const diff = lineDiff(existing, newContent);
3682
- for (const line of diff) log$2.info(` ${line}`);
3403
+ for (const line of diff) log.info(` ${line}`);
3683
3404
  }
3684
3405
  }
3685
3406
  return 1;
@@ -3827,6 +3548,17 @@ function reconcileTags(expectedTags, remoteTags, stdoutTags) {
3827
3548
  }
3828
3549
  //#endregion
3829
3550
  //#region src/release/forgejo.ts
3551
+ const RETRY_ATTEMPTS = 3;
3552
+ const RETRY_BASE_DELAY_MS = 1e3;
3553
+ /** Safely read response body text for inclusion in error messages. */
3554
+ async function responseBodyText(res) {
3555
+ try {
3556
+ const text = await res.text();
3557
+ return text.length > 500 ? text.slice(0, 500) + "…" : text;
3558
+ } catch {
3559
+ return "(could not read response body)";
3560
+ }
3561
+ }
3830
3562
  const PullRequestSchema = z.array(z.object({
3831
3563
  number: z.number(),
3832
3564
  head: z.object({ ref: z.string() })
@@ -3840,7 +3572,10 @@ const PullRequestSchema = z.array(z.object({
3840
3572
  async function findOpenPr(executor, conn, head) {
3841
3573
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?state=open`;
3842
3574
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3843
- if (!res.ok) throw new TransientError(`Failed to list PRs: ${res.status} ${res.statusText}`);
3575
+ if (!res.ok) {
3576
+ const body = await responseBodyText(res);
3577
+ throw new TransientError(`Failed to list PRs: ${res.status} ${res.statusText}\n${body}`);
3578
+ }
3844
3579
  const parsed = PullRequestSchema.safeParse(await res.json());
3845
3580
  if (!parsed.success) throw new UnexpectedError(`Unexpected PR list response: ${parsed.error.message}`);
3846
3581
  return parsed.data.find((pr) => pr.head.ref === head)?.number ?? null;
@@ -3862,7 +3597,10 @@ async function createPr(executor, conn, options) {
3862
3597
  },
3863
3598
  body: JSON.stringify(payload)
3864
3599
  });
3865
- if (!res.ok) throw new TransientError(`Failed to create PR: ${res.status} ${res.statusText}`);
3600
+ if (!res.ok) {
3601
+ const body = await responseBodyText(res);
3602
+ throw new TransientError(`Failed to create PR: ${res.status} ${res.statusText}\n${body}`);
3603
+ }
3866
3604
  }
3867
3605
  /** Update an existing pull request's title and body. */
3868
3606
  async function updatePr(executor, conn, prNumber, options) {
@@ -3878,7 +3616,10 @@ async function updatePr(executor, conn, prNumber, options) {
3878
3616
  body: options.body
3879
3617
  })
3880
3618
  });
3881
- if (!res.ok) throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
3619
+ if (!res.ok) {
3620
+ const body = await responseBodyText(res);
3621
+ throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}\n${body}`);
3622
+ }
3882
3623
  }
3883
3624
  /** Merge a pull request by number. */
3884
3625
  async function mergePr(executor, conn, prNumber, options) {
@@ -3894,7 +3635,10 @@ async function mergePr(executor, conn, prNumber, options) {
3894
3635
  delete_branch_after_merge: options?.deleteBranch ?? true
3895
3636
  })
3896
3637
  });
3897
- if (!res.ok) throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
3638
+ if (!res.ok) {
3639
+ const body = await responseBodyText(res);
3640
+ throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}\n${body}`);
3641
+ }
3898
3642
  }
3899
3643
  /** Check whether a Forgejo release already exists for a given tag. */
3900
3644
  async function findRelease(executor, conn, tag) {
@@ -3903,7 +3647,8 @@ async function findRelease(executor, conn, tag) {
3903
3647
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3904
3648
  if (res.status === 200) return true;
3905
3649
  if (res.status === 404) return false;
3906
- throw new TransientError(`Failed to check release for ${tag}: ${res.status} ${res.statusText}`);
3650
+ const body = await responseBodyText(res);
3651
+ throw new TransientError(`Failed to check release for ${tag}: ${res.status} ${res.statusText}\n${body}`);
3907
3652
  }
3908
3653
  /** Create a Forgejo release for a given tag. */
3909
3654
  async function createRelease(executor, conn, tag) {
@@ -3920,21 +3665,35 @@ async function createRelease(executor, conn, tag) {
3920
3665
  body: `Published ${tag}`
3921
3666
  })
3922
3667
  });
3923
- if (!res.ok) throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}`);
3668
+ if (!res.ok) {
3669
+ const body = await responseBodyText(res);
3670
+ throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}\n${body}`);
3671
+ }
3924
3672
  }
3925
- //#endregion
3926
- //#region src/release/log.ts
3927
- /** Log a debug message when verbose mode is enabled. */
3928
- function debug(config, message) {
3929
- if (config.verbose) log$2.info(`[debug] ${message}`);
3930
- }
3931
- /** Log the result of an exec call when verbose mode is enabled. */
3932
- function debugExec(config, label, result) {
3933
- if (!config.verbose) return;
3934
- const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
3935
- if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
3936
- if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
3937
- log$2.info(lines.join("\n"));
3673
+ /**
3674
+ * Ensure a Forgejo release exists for a tag, creating it if necessary.
3675
+ *
3676
+ * Handles two edge cases:
3677
+ * - The release already exists before we try (skips creation)
3678
+ * - Forgejo auto-creates a release when a tag is pushed, causing a 500 race
3679
+ * condition (detected by re-checking after failure)
3680
+ *
3681
+ * Retries on transient errors with exponential backoff.
3682
+ * Returns "created" | "exists" | "race" indicating what happened.
3683
+ */
3684
+ async function ensureRelease(executor, conn, tag) {
3685
+ if (await findRelease(executor, conn, tag)) return "exists";
3686
+ for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) try {
3687
+ await createRelease(executor, conn, tag);
3688
+ return "created";
3689
+ } catch (error) {
3690
+ if (await findRelease(executor, conn, tag)) return "race";
3691
+ if (attempt >= RETRY_ATTEMPTS) throw error;
3692
+ log.warn(`Release creation attempt ${String(attempt)}/${String(RETRY_ATTEMPTS)} failed: ${error instanceof Error ? error.message : String(error)}`);
3693
+ const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
3694
+ await new Promise((resolve) => setTimeout(resolve, delay));
3695
+ }
3696
+ throw new TransientError(`Failed to create release for ${tag} after ${String(RETRY_ATTEMPTS)} attempts`);
3938
3697
  }
3939
3698
  //#endregion
3940
3699
  //#region src/release/version.ts
@@ -4006,7 +3765,7 @@ function buildPrContent(executor, cwd, packagesBefore) {
4006
3765
  }
4007
3766
  /** Mode 1: version packages and create/update a PR. */
4008
3767
  async function runVersionMode(executor, config) {
4009
- log$2.info("Changesets detected — versioning packages");
3768
+ log.info("Changesets detected — versioning packages");
4010
3769
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
4011
3770
  debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
4012
3771
  const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
@@ -4032,19 +3791,19 @@ async function runVersionMode(executor, config) {
4032
3791
  const addResult = executor.exec("git add -A", { cwd: config.cwd });
4033
3792
  if (addResult.exitCode !== 0) throw new FatalError(`git add failed: ${addResult.stderr || addResult.stdout}`);
4034
3793
  const remainingChangesets = executor.listChangesetFiles(config.cwd);
4035
- if (remainingChangesets.length > 0) log$2.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
3794
+ if (remainingChangesets.length > 0) log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
4036
3795
  debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
4037
3796
  const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
4038
3797
  debugExec(config, "git commit", commitResult);
4039
3798
  if (commitResult.exitCode !== 0) {
4040
- log$2.info("Nothing to commit after versioning");
3799
+ log.info("Nothing to commit after versioning");
4041
3800
  return {
4042
3801
  mode: "version",
4043
3802
  pr: "none"
4044
3803
  };
4045
3804
  }
4046
3805
  if (config.dryRun) {
4047
- log$2.info("[dry-run] Would push and create/update PR");
3806
+ log.info("[dry-run] Would push and create/update PR");
4048
3807
  return {
4049
3808
  mode: "version",
4050
3809
  pr: "none"
@@ -4067,7 +3826,7 @@ async function runVersionMode(executor, config) {
4067
3826
  base: "main",
4068
3827
  body
4069
3828
  });
4070
- log$2.info("Created version PR");
3829
+ log.info("Created version PR");
4071
3830
  return {
4072
3831
  mode: "version",
4073
3832
  pr: "created"
@@ -4077,7 +3836,7 @@ async function runVersionMode(executor, config) {
4077
3836
  title,
4078
3837
  body
4079
3838
  });
4080
- log$2.info(`Updated version PR #${String(existingPr)}`);
3839
+ log.info(`Updated version PR #${String(existingPr)}`);
4081
3840
  return {
4082
3841
  mode: "version",
4083
3842
  pr: "updated"
@@ -4085,24 +3844,9 @@ async function runVersionMode(executor, config) {
4085
3844
  }
4086
3845
  //#endregion
4087
3846
  //#region src/release/publish.ts
4088
- const RETRY_ATTEMPTS = 3;
4089
- const RETRY_BASE_DELAY_MS = 1e3;
4090
- async function retryAsync(fn) {
4091
- let lastError;
4092
- for (let attempt = 0; attempt <= RETRY_ATTEMPTS; attempt++) try {
4093
- return await fn();
4094
- } catch (error) {
4095
- lastError = error;
4096
- if (attempt < RETRY_ATTEMPTS) {
4097
- const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
4098
- await new Promise((resolve) => setTimeout(resolve, delay));
4099
- }
4100
- }
4101
- throw lastError;
4102
- }
4103
3847
  /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
4104
3848
  async function runPublishMode(executor, config) {
4105
- log$2.info("No changesets — publishing packages");
3849
+ log.info("No changesets — publishing packages");
4106
3850
  const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
4107
3851
  debugExec(config, "pnpm changeset publish", publishResult);
4108
3852
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
@@ -4117,11 +3861,11 @@ async function runPublishMode(executor, config) {
4117
3861
  debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
4118
3862
  if (config.dryRun) {
4119
3863
  if (tagsToPush.length === 0) {
4120
- log$2.info("No packages were published");
3864
+ log.info("No packages were published");
4121
3865
  return { mode: "none" };
4122
3866
  }
4123
- log$2.info(`Tags to process: ${tagsToPush.join(", ")}`);
4124
- log$2.info("[dry-run] Would push tags and create releases");
3867
+ log.info(`Tags to process: ${tagsToPush.join(", ")}`);
3868
+ log.info("[dry-run] Would push tags and create releases");
4125
3869
  return {
4126
3870
  mode: "publish",
4127
3871
  tags: tagsToPush
@@ -4137,10 +3881,10 @@ async function runPublishMode(executor, config) {
4137
3881
  for (const tag of remoteExpectedTags) if (!await findRelease(executor, conn, tag)) tagsWithMissingReleases.push(tag);
4138
3882
  const allTags = [...tagsToPush, ...tagsWithMissingReleases];
4139
3883
  if (allTags.length === 0) {
4140
- log$2.info("No packages were published");
3884
+ log.info("No packages were published");
4141
3885
  return { mode: "none" };
4142
3886
  }
4143
- log$2.info(`Tags to process: ${allTags.join(", ")}`);
3887
+ log.info(`Tags to process: ${allTags.join(", ")}`);
4144
3888
  const errors = [];
4145
3889
  for (const tag of allTags) try {
4146
3890
  if (!remoteSet.has(tag)) {
@@ -4151,24 +3895,14 @@ async function runPublishMode(executor, config) {
4151
3895
  const pushTagResult = executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
4152
3896
  if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
4153
3897
  }
4154
- if (await findRelease(executor, conn, tag)) log$2.warn(`Release for ${tag} already exists — skipping`);
4155
- else {
4156
- await retryAsync(async () => {
4157
- try {
4158
- await createRelease(executor, conn, tag);
4159
- } catch (error) {
4160
- if (await findRelease(executor, conn, tag)) return;
4161
- throw error;
4162
- }
4163
- });
4164
- log$2.info(`Created release for ${tag}`);
4165
- }
3898
+ if (await ensureRelease(executor, conn, tag) === "exists") log.warn(`Release for ${tag} already exists — skipping`);
3899
+ else log.info(`Created release for ${tag}`);
4166
3900
  } catch (error) {
4167
3901
  errors.push({
4168
3902
  tag,
4169
3903
  error
4170
3904
  });
4171
- log$2.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
3905
+ log.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
4172
3906
  }
4173
3907
  if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
4174
3908
  return {
@@ -4262,6 +3996,14 @@ function configureGitAuth(executor, conn, cwd) {
4262
3996
  const authUrl = `https://x-access-token:${conn.token}@${host}/${conn.repository}`;
4263
3997
  executor.exec(`git remote set-url origin ${authUrl}`, { cwd });
4264
3998
  }
3999
+ /** Configure git user.name and user.email for CI bot commits. */
4000
+ function configureGitIdentity(executor, platform, cwd) {
4001
+ const isGitHub = platform === "github";
4002
+ const name = isGitHub ? "github-actions[bot]" : "forgejo-actions[bot]";
4003
+ const email = isGitHub ? "github-actions[bot]@users.noreply.github.com" : "forgejo-actions[bot]@noreply.localhost";
4004
+ executor.exec(`git config user.name "${name}"`, { cwd });
4005
+ executor.exec(`git config user.email "${email}"`, { cwd });
4006
+ }
4265
4007
  //#endregion
4266
4008
  //#region src/commands/release-changesets.ts
4267
4009
  const releaseForgejoCommand = defineCommand({
@@ -4276,13 +4018,13 @@ const releaseForgejoCommand = defineCommand({
4276
4018
  },
4277
4019
  verbose: {
4278
4020
  type: "boolean",
4279
- description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
4021
+ description: "Enable detailed debug logging (also enabled by TOOLING_DEBUG env var)"
4280
4022
  }
4281
4023
  },
4282
4024
  async run({ args }) {
4283
4025
  if ((await runRelease(buildReleaseConfig({
4284
4026
  dryRun: args["dry-run"] === true,
4285
- verbose: args.verbose === true || process.env["RELEASE_DEBUG"] === "true"
4027
+ verbose: args.verbose === true || isEnvVerbose()
4286
4028
  }), createRealExecutor())).mode === "none") process.exitCode = 0;
4287
4029
  }
4288
4030
  });
@@ -4310,8 +4052,7 @@ async function runRelease(config, executor) {
4310
4052
  debug(config, `Skipping release on non-main branch: ${branch}`);
4311
4053
  return { mode: "none" };
4312
4054
  }
4313
- executor.exec("git config user.name \"forgejo-actions[bot]\"", { cwd: config.cwd });
4314
- executor.exec("git config user.email \"forgejo-actions[bot]@noreply.localhost\"", { cwd: config.cwd });
4055
+ configureGitIdentity(executor, "forgejo", config.cwd);
4315
4056
  configureGitAuth(executor, config, config.cwd);
4316
4057
  const changesetFiles = executor.listChangesetFiles(config.cwd);
4317
4058
  debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
@@ -4369,7 +4110,7 @@ async function triggerForgejo(conn, ref, inputs) {
4369
4110
  body: JSON.stringify(body)
4370
4111
  });
4371
4112
  if (!res.ok) throw new FatalError(`Failed to trigger Forgejo workflow: ${res.status} ${res.statusText}`);
4372
- log$2.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
4113
+ log.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
4373
4114
  }
4374
4115
  function triggerGitHub(ref, inputs) {
4375
4116
  const executor = createRealExecutor();
@@ -4377,7 +4118,7 @@ function triggerGitHub(ref, inputs) {
4377
4118
  const cmd = `gh workflow run release.yml --ref ${ref}${inputFlags ? ` ${inputFlags}` : ""}`;
4378
4119
  const result = executor.exec(cmd, { cwd: process.cwd() });
4379
4120
  if (result.exitCode !== 0) throw new FatalError(`Failed to trigger GitHub workflow: ${result.stderr || result.stdout || "unknown error"}`);
4380
- log$2.info(`Triggered release workflow on GitHub (ref: ${ref})`);
4121
+ log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
4381
4122
  }
4382
4123
  //#endregion
4383
4124
  //#region src/commands/forgejo-create-release.ts
@@ -4397,11 +4138,11 @@ const createForgejoReleaseCommand = defineCommand({
4397
4138
  const executor = createRealExecutor();
4398
4139
  const conn = resolved.conn;
4399
4140
  if (await findRelease(executor, conn, args.tag)) {
4400
- log$2.info(`Release for ${args.tag} already exists — skipping`);
4141
+ log.info(`Release for ${args.tag} already exists — skipping`);
4401
4142
  return;
4402
4143
  }
4403
4144
  await createRelease(executor, conn, args.tag);
4404
- log$2.info(`Created Forgejo release for ${args.tag}`);
4145
+ log.info(`Created Forgejo release for ${args.tag}`);
4405
4146
  }
4406
4147
  });
4407
4148
  //#endregion
@@ -4428,26 +4169,26 @@ async function mergeForgejo(conn, dryRun) {
4428
4169
  const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
4429
4170
  if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
4430
4171
  if (dryRun) {
4431
- log$2.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
4172
+ log.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
4432
4173
  return;
4433
4174
  }
4434
4175
  await mergePr(executor, conn, prNumber, {
4435
4176
  method: "merge",
4436
4177
  deleteBranch: true
4437
4178
  });
4438
- log$2.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
4179
+ log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
4439
4180
  }
4440
4181
  function mergeGitHub(dryRun) {
4441
4182
  const executor = createRealExecutor();
4442
4183
  if (dryRun) {
4443
4184
  const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
4444
4185
  if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
4445
- log$2.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
4186
+ log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
4446
4187
  return;
4447
4188
  }
4448
4189
  const result = executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
4449
4190
  if (result.exitCode !== 0) throw new FatalError(`Failed to merge PR: ${result.stderr || result.stdout || "unknown error"}`);
4450
- log$2.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
4191
+ log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
4451
4192
  }
4452
4193
  //#endregion
4453
4194
  //#region src/release/simple.ts
@@ -4477,20 +4218,15 @@ function readVersion(executor, cwd) {
4477
4218
  if (!pkg?.version) throw new FatalError("No version field found in package.json");
4478
4219
  return pkg.version;
4479
4220
  }
4480
- /** Configure git identity for CI bot commits. */
4481
- function configureGitIdentity(executor, config) {
4482
- const isGitHub = config.platform?.type === "github";
4483
- const name = isGitHub ? "github-actions[bot]" : "forgejo-actions[bot]";
4484
- const email = isGitHub ? "github-actions[bot]@users.noreply.github.com" : "forgejo-actions[bot]@noreply.localhost";
4485
- executor.exec(`git config user.name "${name}"`, { cwd: config.cwd });
4486
- executor.exec(`git config user.email "${email}"`, { cwd: config.cwd });
4487
- debug(config, `Configured git identity: ${name} <${email}>`);
4221
+ /** Resolve the platform type string for git identity configuration. */
4222
+ function platformType(config) {
4223
+ return config.platform?.type === "github" ? "github" : "forgejo";
4488
4224
  }
4489
4225
  /** Run the full commit-and-tag-version release flow. */
4490
4226
  async function runSimpleRelease(executor, config) {
4491
- configureGitIdentity(executor, config);
4227
+ configureGitIdentity(executor, platformType(config), config.cwd);
4492
4228
  const command = buildCommand(config);
4493
- log$2.info(`Running: ${command}`);
4229
+ log.info(`Running: ${command}`);
4494
4230
  const versionResult = executor.exec(command, { cwd: config.cwd });
4495
4231
  debugExec(config, "commit-and-tag-version", versionResult);
4496
4232
  if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
@@ -4500,12 +4236,12 @@ async function runSimpleRelease(executor, config) {
4500
4236
  debugExec(config, "git describe", tagResult);
4501
4237
  const tag = tagResult.stdout.trim();
4502
4238
  if (!tag) throw new FatalError("Could not determine the new tag from git describe");
4503
- log$2.info(`Version ${version} tagged as ${tag}`);
4239
+ log.info(`Version ${version} tagged as ${tag}`);
4504
4240
  if (config.dryRun) {
4505
4241
  const slidingTags = config.noSlidingTags ? [] : computeSlidingTags(version);
4506
- log$2.info(`[dry-run] Would push to origin with --follow-tags`);
4507
- if (slidingTags.length > 0) log$2.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
4508
- if (!config.noRelease && config.platform) log$2.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
4242
+ log.info(`[dry-run] Would push to origin with --follow-tags`);
4243
+ if (slidingTags.length > 0) log.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
4244
+ if (!config.noRelease && config.platform) log.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
4509
4245
  return {
4510
4246
  version,
4511
4247
  tag,
@@ -4526,7 +4262,7 @@ async function runSimpleRelease(executor, config) {
4526
4262
  debugExec(config, "git push", pushResult);
4527
4263
  if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
4528
4264
  pushed = true;
4529
- log$2.info("Pushed to origin");
4265
+ log.info("Pushed to origin");
4530
4266
  }
4531
4267
  let slidingTags = [];
4532
4268
  if (!config.noSlidingTags && pushed) {
@@ -4537,8 +4273,8 @@ async function runSimpleRelease(executor, config) {
4537
4273
  }
4538
4274
  const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
4539
4275
  debugExec(config, "force-push sliding tags", forcePushResult);
4540
- if (forcePushResult.exitCode !== 0) log$2.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4541
- else log$2.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4276
+ if (forcePushResult.exitCode !== 0) log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4277
+ else log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4542
4278
  }
4543
4279
  let releaseCreated = false;
4544
4280
  if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
@@ -4553,21 +4289,20 @@ async function runSimpleRelease(executor, config) {
4553
4289
  async function createPlatformRelease(executor, config, tag) {
4554
4290
  if (!config.platform) return false;
4555
4291
  if (config.platform.type === "forgejo") {
4556
- if (await findRelease(executor, config.platform.conn, tag)) {
4292
+ if (await ensureRelease(executor, config.platform.conn, tag) === "exists") {
4557
4293
  debug(config, `Release for ${tag} already exists, skipping`);
4558
4294
  return false;
4559
4295
  }
4560
- await createRelease(executor, config.platform.conn, tag);
4561
- log$2.info(`Created Forgejo release for ${tag}`);
4296
+ log.info(`Created Forgejo release for ${tag}`);
4562
4297
  return true;
4563
4298
  }
4564
4299
  const ghResult = executor.exec(`gh release create ${tag} --generate-notes`, { cwd: config.cwd });
4565
4300
  debugExec(config, "gh release create", ghResult);
4566
4301
  if (ghResult.exitCode !== 0) {
4567
- log$2.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
4302
+ log.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
4568
4303
  return false;
4569
4304
  }
4570
- log$2.info(`Created GitHub release for ${tag}`);
4305
+ log.info(`Created GitHub release for ${tag}`);
4571
4306
  return true;
4572
4307
  }
4573
4308
  //#endregion
@@ -4584,7 +4319,7 @@ const releaseSimpleCommand = defineCommand({
4584
4319
  },
4585
4320
  verbose: {
4586
4321
  type: "boolean",
4587
- description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
4322
+ description: "Enable detailed debug logging (also enabled by TOOLING_DEBUG env var)"
4588
4323
  },
4589
4324
  "no-push": {
4590
4325
  type: "boolean",
@@ -4613,7 +4348,7 @@ const releaseSimpleCommand = defineCommand({
4613
4348
  },
4614
4349
  async run({ args }) {
4615
4350
  const cwd = process.cwd();
4616
- const verbose = args.verbose === true || process.env["RELEASE_DEBUG"] === "true";
4351
+ const verbose = args.verbose === true || isEnvVerbose();
4617
4352
  const noRelease = args["no-release"] === true;
4618
4353
  let platform;
4619
4354
  if (!noRelease) {
@@ -4697,12 +4432,12 @@ const ciReporter = {
4697
4432
  const localReporter = {
4698
4433
  groupStart: (_name) => {},
4699
4434
  groupEnd: () => {},
4700
- passed: (name) => log$2.success(name),
4701
- failed: (name) => log$2.error(`${name} failed`),
4702
- undefinedCheck: (name) => log$2.error(`${name} not defined in package.json`),
4703
- skippedNotDefined: (names) => log$2.info(`Skipped (not defined): ${names.join(", ")}`),
4704
- allPassed: () => log$2.success("All checks passed"),
4705
- anyFailed: (names) => log$2.error(`Failed checks: ${names.join(", ")}`)
4435
+ passed: (name) => log.success(name),
4436
+ failed: (name) => log.error(`${name} failed`),
4437
+ undefinedCheck: (name) => log.error(`${name} not defined in package.json`),
4438
+ skippedNotDefined: (names) => log.info(`Skipped (not defined): ${names.join(", ")}`),
4439
+ allPassed: () => log.success("All checks passed"),
4440
+ anyFailed: (names) => log.error(`Failed checks: ${names.join(", ")}`)
4706
4441
  };
4707
4442
  function runRunChecks(targetDir, options = {}) {
4708
4443
  const exec = options.execCommand ?? defaultExecCommand;
@@ -4712,6 +4447,7 @@ function runRunChecks(targetDir, options = {}) {
4712
4447
  const isCI = Boolean(process.env["CI"]);
4713
4448
  const failFast = options.failFast ?? !isCI;
4714
4449
  const reporter = isCI ? ciReporter : localReporter;
4450
+ const vc = { verbose: options.verbose ?? false };
4715
4451
  const definedScripts = getScripts(targetDir);
4716
4452
  const addedNames = new Set(add);
4717
4453
  const allChecks = [...CHECKS, ...add.map((name) => ({ name }))];
@@ -4727,11 +4463,13 @@ function runRunChecks(targetDir, options = {}) {
4727
4463
  continue;
4728
4464
  }
4729
4465
  const cmd = check.args ? `pnpm run ${check.name} ${check.args}` : `pnpm run ${check.name}`;
4466
+ debug(vc, `Running: ${cmd} (in ${targetDir})`);
4730
4467
  reporter.groupStart(check.name);
4731
4468
  const start = Date.now();
4732
4469
  const exitCode = exec(cmd, targetDir);
4733
4470
  const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
4734
4471
  reporter.groupEnd();
4472
+ debug(vc, `${check.name}: exit code ${String(exitCode)}, ${elapsed}s`);
4735
4473
  if (exitCode === 0) reporter.passed(check.name, elapsed);
4736
4474
  else {
4737
4475
  reporter.failed(check.name, elapsed);
@@ -4772,13 +4510,19 @@ const runChecksCommand = defineCommand({
4772
4510
  type: "boolean",
4773
4511
  description: "Stop on first failure (default: true in dev, false in CI)",
4774
4512
  required: false
4513
+ },
4514
+ verbose: {
4515
+ type: "boolean",
4516
+ description: "Emit detailed debug logging",
4517
+ required: false
4775
4518
  }
4776
4519
  },
4777
4520
  run({ args }) {
4778
4521
  const exitCode = runRunChecks(path.resolve(args.dir ?? "."), {
4779
4522
  skip: args.skip ? new Set(args.skip.split(",").map((s) => s.trim())) : void 0,
4780
4523
  add: args.add ? args.add.split(",").map((s) => s.trim()) : void 0,
4781
- failFast: args["fail-fast"] ? true : void 0
4524
+ failFast: args["fail-fast"] ? true : void 0,
4525
+ verbose: args.verbose === true || isEnvVerbose()
4782
4526
  });
4783
4527
  process.exitCode = exitCode;
4784
4528
  }
@@ -4795,10 +4539,16 @@ const publishDockerCommand = defineCommand({
4795
4539
  name: "docker:publish",
4796
4540
  description: "Build, tag, and push Docker images for packages with an image:build script"
4797
4541
  },
4798
- args: { "dry-run": {
4799
- type: "boolean",
4800
- description: "Build and tag images but skip login, push, and logout"
4801
- } },
4542
+ args: {
4543
+ "dry-run": {
4544
+ type: "boolean",
4545
+ description: "Build and tag images but skip login, push, and logout"
4546
+ },
4547
+ verbose: {
4548
+ type: "boolean",
4549
+ description: "Emit detailed debug logging"
4550
+ }
4551
+ },
4802
4552
  async run({ args }) {
4803
4553
  const config = {
4804
4554
  cwd: process.cwd(),
@@ -4806,7 +4556,8 @@ const publishDockerCommand = defineCommand({
4806
4556
  registryNamespace: requireEnv("DOCKER_REGISTRY_NAMESPACE"),
4807
4557
  username: requireEnv("DOCKER_USERNAME"),
4808
4558
  password: requireEnv("DOCKER_PASSWORD"),
4809
- dryRun: args["dry-run"] === true
4559
+ dryRun: args["dry-run"] === true,
4560
+ verbose: args.verbose === true || isEnvVerbose()
4810
4561
  };
4811
4562
  runDockerPublish(createRealExecutor(), config);
4812
4563
  }
@@ -4839,6 +4590,10 @@ const dockerBuildCommand = defineCommand({
4839
4590
  type: "string",
4840
4591
  description: "Build a single package by directory path (e.g. packages/server). Useful as an image:build script."
4841
4592
  },
4593
+ verbose: {
4594
+ type: "boolean",
4595
+ description: "Emit detailed debug logging"
4596
+ },
4842
4597
  _: {
4843
4598
  type: "positional",
4844
4599
  required: false,
@@ -4849,6 +4604,7 @@ const dockerBuildCommand = defineCommand({
4849
4604
  const executor = createRealExecutor();
4850
4605
  const rawExtra = args._ ?? [];
4851
4606
  const extraArgs = Array.isArray(rawExtra) ? rawExtra.map(String) : [String(rawExtra)];
4607
+ const verbose = args.verbose === true || isEnvVerbose();
4852
4608
  let cwd = process.cwd();
4853
4609
  let packageDir = args.package;
4854
4610
  if (!packageDir) {
@@ -4859,7 +4615,8 @@ const dockerBuildCommand = defineCommand({
4859
4615
  runDockerBuild(executor, {
4860
4616
  cwd,
4861
4617
  packageDir,
4862
- extraArgs: extraArgs.filter((a) => a.length > 0)
4618
+ extraArgs: extraArgs.filter((a) => a.length > 0),
4619
+ verbose
4863
4620
  });
4864
4621
  }
4865
4622
  });
@@ -5076,12 +4833,6 @@ function writeTempOverlay(content) {
5076
4833
  writeFileSync(filePath, content, "utf-8");
5077
4834
  return filePath;
5078
4835
  }
5079
- function log(message) {
5080
- console.log(message);
5081
- }
5082
- function warn(message) {
5083
- console.warn(message);
5084
- }
5085
4836
  const dockerCheckCommand = defineCommand({
5086
4837
  meta: {
5087
4838
  name: "docker:check",
@@ -5095,12 +4846,16 @@ const dockerCheckCommand = defineCommand({
5095
4846
  "poll-interval": {
5096
4847
  type: "string",
5097
4848
  description: "Interval between polling attempts, in ms (default: 5000)"
4849
+ },
4850
+ verbose: {
4851
+ type: "boolean",
4852
+ description: "Emit detailed debug logging"
5098
4853
  }
5099
4854
  },
5100
4855
  async run({ args }) {
5101
4856
  const cwd = process.cwd();
5102
4857
  if (loadToolingConfig(cwd)?.dockerCheck === false) {
5103
- log("Docker check is disabled in .tooling.json");
4858
+ log.info("Docker check is disabled in .tooling.json");
5104
4859
  return;
5105
4860
  }
5106
4861
  const defaults = computeCheckDefaults(cwd);
@@ -5108,8 +4863,8 @@ const dockerCheckCommand = defineCommand({
5108
4863
  if (!defaults.checkOverlay) {
5109
4864
  const composeCwd = defaults.composeCwd ?? cwd;
5110
4865
  const expectedOverlay = (defaults.composeFiles[0] ?? "docker-compose.yaml").replace(/\.(yaml|yml)$/, ".check.$1");
5111
- warn(`Compose files found but no check overlay. Create ${path.relative(cwd, path.join(composeCwd, expectedOverlay))} to enable docker:check.`);
5112
- warn("To suppress this warning, set \"dockerCheck\": false in .tooling.json.");
4866
+ log.warn(`Compose files found but no check overlay. Create ${path.relative(cwd, path.join(composeCwd, expectedOverlay))} to enable docker:check.`);
4867
+ log.warn("To suppress this warning, set \"dockerCheck\": false in .tooling.json.");
5113
4868
  return;
5114
4869
  }
5115
4870
  if (!defaults.services || defaults.services.length === 0) throw new FatalError("No services found in compose files.");
@@ -5122,7 +4877,7 @@ const dockerCheckCommand = defineCommand({
5122
4877
  if (rootPkg?.name) {
5123
4878
  const dockerPackages = detectDockerPackages(fileReader, cwd, rootPkg.name);
5124
4879
  const composeImages = extractComposeImageNames(services);
5125
- for (const pkg of dockerPackages) if (!composeImages.some((img) => img === pkg.imageName || img.endsWith(`/${pkg.imageName}`))) warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
4880
+ for (const pkg of dockerPackages) if (!composeImages.some((img) => img === pkg.imageName || img.endsWith(`/${pkg.imageName}`))) log.warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
5126
4881
  }
5127
4882
  }
5128
4883
  const tempOverlayPath = writeTempOverlay(generateCheckOverlay(services));
@@ -5143,7 +4898,8 @@ const dockerCheckCommand = defineCommand({
5143
4898
  buildCwd: defaults.buildCwd,
5144
4899
  healthChecks: defaults.healthChecks ? toHttpHealthChecks(defaults.healthChecks) : [],
5145
4900
  timeoutMs: args.timeout ? Number.parseInt(args.timeout, 10) : defaults.timeoutMs,
5146
- pollIntervalMs: args["poll-interval"] ? Number.parseInt(args["poll-interval"], 10) : defaults.pollIntervalMs
4901
+ pollIntervalMs: args["poll-interval"] ? Number.parseInt(args["poll-interval"], 10) : defaults.pollIntervalMs,
4902
+ verbose: args.verbose === true || isEnvVerbose()
5147
4903
  };
5148
4904
  const result = await runDockerCheck(createRealExecutor$1(), config);
5149
4905
  if (!result.success) throw new FatalError(`Check failed (${result.reason}): ${result.message}`);
@@ -5159,7 +4915,7 @@ const dockerCheckCommand = defineCommand({
5159
4915
  const main = defineCommand({
5160
4916
  meta: {
5161
4917
  name: "bst",
5162
- version: "0.33.0",
4918
+ version: "0.35.0",
5163
4919
  description: "Bootstrap and maintain standardized TypeScript project tooling"
5164
4920
  },
5165
4921
  subCommands: {
@@ -5175,7 +4931,7 @@ const main = defineCommand({
5175
4931
  "docker:check": dockerCheckCommand
5176
4932
  }
5177
4933
  });
5178
- console.log(`@bensandee/tooling v0.33.0`);
4934
+ console.log(`@bensandee/tooling v0.35.0`);
5179
4935
  async function run() {
5180
4936
  await runMain(main);
5181
4937
  process.exit(process.exitCode ?? 0);