@bensandee/tooling 0.32.0 → 0.34.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-DMDdHanG.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";
@@ -13,22 +13,6 @@ import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
13
  import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify } 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
@@ -1590,7 +1577,7 @@ function getAddedDevDepNames(config) {
1590
1577
  const deps = { ...ROOT_DEV_DEPS };
1591
1578
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
1592
1579
  deps["@bensandee/config"] = "0.9.1";
1593
- deps["@bensandee/tooling"] = "0.32.0";
1580
+ deps["@bensandee/tooling"] = "0.34.0";
1594
1581
  if (config.formatter === "oxfmt") deps["oxfmt"] = {
1595
1582
  "@changesets/cli": "2.30.0",
1596
1583
  "@release-it/bumper": "7.0.5",
@@ -1645,7 +1632,7 @@ async function generatePackageJson(ctx) {
1645
1632
  const devDeps = { ...ROOT_DEV_DEPS };
1646
1633
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1647
1634
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1648
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.32.0";
1635
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.34.0";
1649
1636
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1650
1637
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1651
1638
  "@changesets/cli": "2.30.0",
@@ -2804,6 +2791,41 @@ function releaseItSteps(ci, nodeVersionYaml, publishesNpm) {
2804
2791
  }
2805
2792
  }];
2806
2793
  }
2794
+ /** Build the workflow_dispatch trigger with optional inputs for the simple strategy. */
2795
+ function simpleWorkflowDispatchTrigger() {
2796
+ return { workflow_dispatch: { inputs: {
2797
+ bump: {
2798
+ description: "Version bump type (default: conventional-commits auto-detect)",
2799
+ required: false,
2800
+ type: "choice",
2801
+ default: "auto",
2802
+ options: [
2803
+ "auto",
2804
+ "major",
2805
+ "minor",
2806
+ "patch",
2807
+ "first-release"
2808
+ ]
2809
+ },
2810
+ prerelease: {
2811
+ description: "Create a prerelease with the given tag (e.g., beta, alpha)",
2812
+ required: false,
2813
+ type: "string"
2814
+ }
2815
+ } } };
2816
+ }
2817
+ /** Build the release:simple run command with conditional flags from workflow inputs. */
2818
+ function simpleReleaseCommand() {
2819
+ return [
2820
+ "FLAGS=",
2821
+ `case "${actionsExpr("inputs.bump")}" in`,
2822
+ " major|minor|patch) FLAGS=\"$FLAGS --release-as " + actionsExpr("inputs.bump") + "\" ;;",
2823
+ " first-release) FLAGS=\"$FLAGS --first-release\" ;;",
2824
+ "esac",
2825
+ `if [ -n "${actionsExpr("inputs.prerelease")}" ]; then FLAGS="$FLAGS --prerelease ${actionsExpr("inputs.prerelease")}"; fi`,
2826
+ "pnpm exec bst release:simple $FLAGS"
2827
+ ].join("\n");
2828
+ }
2807
2829
  function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker) {
2808
2830
  const releaseStep = {
2809
2831
  match: { run: "release:simple" },
@@ -2814,7 +2836,7 @@ function simpleReleaseSteps(ci, nodeVersionYaml, publishesNpm, hasDocker) {
2814
2836
  FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2815
2837
  RELEASE_TOKEN: actionsExpr("secrets.RELEASE_TOKEN")
2816
2838
  },
2817
- run: "pnpm exec bst release:simple"
2839
+ run: simpleReleaseCommand()
2818
2840
  }
2819
2841
  };
2820
2842
  const dockerStep = {
@@ -2929,10 +2951,11 @@ async function generateReleaseCi(ctx) {
2929
2951
  action: "skipped",
2930
2952
  description: "Release CI workflow not applicable"
2931
2953
  };
2954
+ const on = ctx.config.releaseStrategy === "simple" ? simpleWorkflowDispatchTrigger() : { workflow_dispatch: null };
2932
2955
  const content = buildWorkflowYaml({
2933
2956
  ci: ctx.config.ci,
2934
2957
  name: "Release",
2935
- on: { workflow_dispatch: null },
2958
+ on,
2936
2959
  ...isGitHub && { permissions: { contents: "write" } },
2937
2960
  jobName: "release",
2938
2961
  steps
@@ -3285,7 +3308,7 @@ function generateMigratePrompt(results, config, detected) {
3285
3308
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3286
3309
  sections.push("# Migration Prompt");
3287
3310
  sections.push("");
3288
- sections.push(`_Generated by \`@bensandee/tooling@0.32.0 repo:sync\` on ${timestamp}_`);
3311
+ sections.push(`_Generated by \`@bensandee/tooling@0.34.0 repo:sync\` on ${timestamp}_`);
3289
3312
  sections.push("");
3290
3313
  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.");
3291
3314
  sections.push("");
@@ -3469,11 +3492,11 @@ function contextAsDockerReader(ctx) {
3469
3492
  function logDetectionSummary(ctx) {
3470
3493
  if (ctx.config.publishDocker) {
3471
3494
  const dockerPackages = detectDockerPackages(contextAsDockerReader(ctx), ctx.targetDir, ctx.config.name);
3472
- if (dockerPackages.length > 0) log$2.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3495
+ if (dockerPackages.length > 0) log.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3473
3496
  }
3474
3497
  if (ctx.config.publishNpm) {
3475
3498
  const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
3476
- if (publishable.length > 0) log$2.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3499
+ if (publishable.length > 0) log.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3477
3500
  }
3478
3501
  }
3479
3502
  async function runInit(config, options = {}) {
@@ -3507,7 +3530,7 @@ async function runInit(config, options = {}) {
3507
3530
  const promptPath = ".tooling-migrate.md";
3508
3531
  ctx.write(promptPath, prompt);
3509
3532
  if (!hasChanges && options.noPrompt) {
3510
- log$2.success("Repository is up to date.");
3533
+ log.success("Repository is up to date.");
3511
3534
  return results;
3512
3535
  }
3513
3536
  if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
@@ -3522,13 +3545,13 @@ async function runInit(config, options = {}) {
3522
3545
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
3523
3546
  note(summaryLines.join("\n"), "Summary");
3524
3547
  if (!options.noPrompt) {
3525
- log$2.info(`Migration prompt written to ${promptPath}`);
3526
- log$2.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3548
+ log.info(`Migration prompt written to ${promptPath}`);
3549
+ log.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3527
3550
  }
3528
3551
  const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
3529
3552
  const hasLockfile = ctx.exists("pnpm-lock.yaml");
3530
3553
  if (bensandeeDeps.length > 0 && hasLockfile) {
3531
- log$2.info("Updating @bensandee/* packages...");
3554
+ log.info("Updating @bensandee/* packages...");
3532
3555
  try {
3533
3556
  execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
3534
3557
  cwd: config.targetDir,
@@ -3536,7 +3559,7 @@ async function runInit(config, options = {}) {
3536
3559
  timeout: 6e4
3537
3560
  });
3538
3561
  } catch (_error) {
3539
- log$2.warn("Could not update @bensandee/* packages — run pnpm install manually");
3562
+ log.warn("Could not update @bensandee/* packages — run pnpm install manually");
3540
3563
  }
3541
3564
  }
3542
3565
  if (hasChanges && ctx.exists("package.json")) try {
@@ -3628,22 +3651,22 @@ async function runCheck(targetDir) {
3628
3651
  return true;
3629
3652
  });
3630
3653
  if (actionable.length === 0) {
3631
- log$2.success("Repository is up to date.");
3654
+ log.success("Repository is up to date.");
3632
3655
  return 0;
3633
3656
  }
3634
- log$2.warn(`${actionable.length} file(s) would be changed by repo:sync`);
3657
+ log.warn(`${actionable.length} file(s) would be changed by repo:sync`);
3635
3658
  for (const r of actionable) {
3636
- log$2.info(` ${r.action}: ${r.filePath} — ${r.description}`);
3659
+ log.info(` ${r.action}: ${r.filePath} — ${r.description}`);
3637
3660
  const newContent = pendingWrites.get(r.filePath);
3638
3661
  if (!newContent) continue;
3639
3662
  const existingPath = path.join(targetDir, r.filePath);
3640
3663
  const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
3641
3664
  if (!existing) {
3642
3665
  const lineCount = newContent.split("\n").length - 1;
3643
- log$2.info(` + ${lineCount} new lines`);
3666
+ log.info(` + ${lineCount} new lines`);
3644
3667
  } else {
3645
3668
  const diff = lineDiff(existing, newContent);
3646
- for (const line of diff) log$2.info(` ${line}`);
3669
+ for (const line of diff) log.info(` ${line}`);
3647
3670
  }
3648
3671
  }
3649
3672
  return 1;
@@ -3791,6 +3814,17 @@ function reconcileTags(expectedTags, remoteTags, stdoutTags) {
3791
3814
  }
3792
3815
  //#endregion
3793
3816
  //#region src/release/forgejo.ts
3817
+ const RETRY_ATTEMPTS = 3;
3818
+ const RETRY_BASE_DELAY_MS = 1e3;
3819
+ /** Safely read response body text for inclusion in error messages. */
3820
+ async function responseBodyText(res) {
3821
+ try {
3822
+ const text = await res.text();
3823
+ return text.length > 500 ? text.slice(0, 500) + "…" : text;
3824
+ } catch {
3825
+ return "(could not read response body)";
3826
+ }
3827
+ }
3794
3828
  const PullRequestSchema = z.array(z.object({
3795
3829
  number: z.number(),
3796
3830
  head: z.object({ ref: z.string() })
@@ -3804,7 +3838,10 @@ const PullRequestSchema = z.array(z.object({
3804
3838
  async function findOpenPr(executor, conn, head) {
3805
3839
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?state=open`;
3806
3840
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3807
- if (!res.ok) throw new TransientError(`Failed to list PRs: ${res.status} ${res.statusText}`);
3841
+ if (!res.ok) {
3842
+ const body = await responseBodyText(res);
3843
+ throw new TransientError(`Failed to list PRs: ${res.status} ${res.statusText}\n${body}`);
3844
+ }
3808
3845
  const parsed = PullRequestSchema.safeParse(await res.json());
3809
3846
  if (!parsed.success) throw new UnexpectedError(`Unexpected PR list response: ${parsed.error.message}`);
3810
3847
  return parsed.data.find((pr) => pr.head.ref === head)?.number ?? null;
@@ -3826,7 +3863,10 @@ async function createPr(executor, conn, options) {
3826
3863
  },
3827
3864
  body: JSON.stringify(payload)
3828
3865
  });
3829
- if (!res.ok) throw new TransientError(`Failed to create PR: ${res.status} ${res.statusText}`);
3866
+ if (!res.ok) {
3867
+ const body = await responseBodyText(res);
3868
+ throw new TransientError(`Failed to create PR: ${res.status} ${res.statusText}\n${body}`);
3869
+ }
3830
3870
  }
3831
3871
  /** Update an existing pull request's title and body. */
3832
3872
  async function updatePr(executor, conn, prNumber, options) {
@@ -3842,7 +3882,10 @@ async function updatePr(executor, conn, prNumber, options) {
3842
3882
  body: options.body
3843
3883
  })
3844
3884
  });
3845
- if (!res.ok) throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
3885
+ if (!res.ok) {
3886
+ const body = await responseBodyText(res);
3887
+ throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}\n${body}`);
3888
+ }
3846
3889
  }
3847
3890
  /** Merge a pull request by number. */
3848
3891
  async function mergePr(executor, conn, prNumber, options) {
@@ -3858,7 +3901,10 @@ async function mergePr(executor, conn, prNumber, options) {
3858
3901
  delete_branch_after_merge: options?.deleteBranch ?? true
3859
3902
  })
3860
3903
  });
3861
- if (!res.ok) throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
3904
+ if (!res.ok) {
3905
+ const body = await responseBodyText(res);
3906
+ throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}\n${body}`);
3907
+ }
3862
3908
  }
3863
3909
  /** Check whether a Forgejo release already exists for a given tag. */
3864
3910
  async function findRelease(executor, conn, tag) {
@@ -3867,7 +3913,8 @@ async function findRelease(executor, conn, tag) {
3867
3913
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3868
3914
  if (res.status === 200) return true;
3869
3915
  if (res.status === 404) return false;
3870
- throw new TransientError(`Failed to check release for ${tag}: ${res.status} ${res.statusText}`);
3916
+ const body = await responseBodyText(res);
3917
+ throw new TransientError(`Failed to check release for ${tag}: ${res.status} ${res.statusText}\n${body}`);
3871
3918
  }
3872
3919
  /** Create a Forgejo release for a given tag. */
3873
3920
  async function createRelease(executor, conn, tag) {
@@ -3884,21 +3931,35 @@ async function createRelease(executor, conn, tag) {
3884
3931
  body: `Published ${tag}`
3885
3932
  })
3886
3933
  });
3887
- if (!res.ok) throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}`);
3934
+ if (!res.ok) {
3935
+ const body = await responseBodyText(res);
3936
+ throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}\n${body}`);
3937
+ }
3888
3938
  }
3889
- //#endregion
3890
- //#region src/release/log.ts
3891
- /** Log a debug message when verbose mode is enabled. */
3892
- function debug(config, message) {
3893
- if (config.verbose) log$2.info(`[debug] ${message}`);
3894
- }
3895
- /** Log the result of an exec call when verbose mode is enabled. */
3896
- function debugExec(config, label, result) {
3897
- if (!config.verbose) return;
3898
- const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
3899
- if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
3900
- if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
3901
- log$2.info(lines.join("\n"));
3939
+ /**
3940
+ * Ensure a Forgejo release exists for a tag, creating it if necessary.
3941
+ *
3942
+ * Handles two edge cases:
3943
+ * - The release already exists before we try (skips creation)
3944
+ * - Forgejo auto-creates a release when a tag is pushed, causing a 500 race
3945
+ * condition (detected by re-checking after failure)
3946
+ *
3947
+ * Retries on transient errors with exponential backoff.
3948
+ * Returns "created" | "exists" | "race" indicating what happened.
3949
+ */
3950
+ async function ensureRelease(executor, conn, tag) {
3951
+ if (await findRelease(executor, conn, tag)) return "exists";
3952
+ for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) try {
3953
+ await createRelease(executor, conn, tag);
3954
+ return "created";
3955
+ } catch (error) {
3956
+ if (await findRelease(executor, conn, tag)) return "race";
3957
+ if (attempt >= RETRY_ATTEMPTS) throw error;
3958
+ log.warn(`Release creation attempt ${String(attempt)}/${String(RETRY_ATTEMPTS)} failed: ${error instanceof Error ? error.message : String(error)}`);
3959
+ const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
3960
+ await new Promise((resolve) => setTimeout(resolve, delay));
3961
+ }
3962
+ throw new TransientError(`Failed to create release for ${tag} after ${String(RETRY_ATTEMPTS)} attempts`);
3902
3963
  }
3903
3964
  //#endregion
3904
3965
  //#region src/release/version.ts
@@ -3970,7 +4031,7 @@ function buildPrContent(executor, cwd, packagesBefore) {
3970
4031
  }
3971
4032
  /** Mode 1: version packages and create/update a PR. */
3972
4033
  async function runVersionMode(executor, config) {
3973
- log$2.info("Changesets detected — versioning packages");
4034
+ log.info("Changesets detected — versioning packages");
3974
4035
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
3975
4036
  debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
3976
4037
  const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
@@ -3996,19 +4057,19 @@ async function runVersionMode(executor, config) {
3996
4057
  const addResult = executor.exec("git add -A", { cwd: config.cwd });
3997
4058
  if (addResult.exitCode !== 0) throw new FatalError(`git add failed: ${addResult.stderr || addResult.stdout}`);
3998
4059
  const remainingChangesets = executor.listChangesetFiles(config.cwd);
3999
- if (remainingChangesets.length > 0) log$2.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
4060
+ if (remainingChangesets.length > 0) log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
4000
4061
  debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
4001
4062
  const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
4002
4063
  debugExec(config, "git commit", commitResult);
4003
4064
  if (commitResult.exitCode !== 0) {
4004
- log$2.info("Nothing to commit after versioning");
4065
+ log.info("Nothing to commit after versioning");
4005
4066
  return {
4006
4067
  mode: "version",
4007
4068
  pr: "none"
4008
4069
  };
4009
4070
  }
4010
4071
  if (config.dryRun) {
4011
- log$2.info("[dry-run] Would push and create/update PR");
4072
+ log.info("[dry-run] Would push and create/update PR");
4012
4073
  return {
4013
4074
  mode: "version",
4014
4075
  pr: "none"
@@ -4031,7 +4092,7 @@ async function runVersionMode(executor, config) {
4031
4092
  base: "main",
4032
4093
  body
4033
4094
  });
4034
- log$2.info("Created version PR");
4095
+ log.info("Created version PR");
4035
4096
  return {
4036
4097
  mode: "version",
4037
4098
  pr: "created"
@@ -4041,7 +4102,7 @@ async function runVersionMode(executor, config) {
4041
4102
  title,
4042
4103
  body
4043
4104
  });
4044
- log$2.info(`Updated version PR #${String(existingPr)}`);
4105
+ log.info(`Updated version PR #${String(existingPr)}`);
4045
4106
  return {
4046
4107
  mode: "version",
4047
4108
  pr: "updated"
@@ -4049,24 +4110,9 @@ async function runVersionMode(executor, config) {
4049
4110
  }
4050
4111
  //#endregion
4051
4112
  //#region src/release/publish.ts
4052
- const RETRY_ATTEMPTS = 3;
4053
- const RETRY_BASE_DELAY_MS = 1e3;
4054
- async function retryAsync(fn) {
4055
- let lastError;
4056
- for (let attempt = 0; attempt <= RETRY_ATTEMPTS; attempt++) try {
4057
- return await fn();
4058
- } catch (error) {
4059
- lastError = error;
4060
- if (attempt < RETRY_ATTEMPTS) {
4061
- const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
4062
- await new Promise((resolve) => setTimeout(resolve, delay));
4063
- }
4064
- }
4065
- throw lastError;
4066
- }
4067
4113
  /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
4068
4114
  async function runPublishMode(executor, config) {
4069
- log$2.info("No changesets — publishing packages");
4115
+ log.info("No changesets — publishing packages");
4070
4116
  const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
4071
4117
  debugExec(config, "pnpm changeset publish", publishResult);
4072
4118
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
@@ -4081,11 +4127,11 @@ async function runPublishMode(executor, config) {
4081
4127
  debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
4082
4128
  if (config.dryRun) {
4083
4129
  if (tagsToPush.length === 0) {
4084
- log$2.info("No packages were published");
4130
+ log.info("No packages were published");
4085
4131
  return { mode: "none" };
4086
4132
  }
4087
- log$2.info(`Tags to process: ${tagsToPush.join(", ")}`);
4088
- log$2.info("[dry-run] Would push tags and create releases");
4133
+ log.info(`Tags to process: ${tagsToPush.join(", ")}`);
4134
+ log.info("[dry-run] Would push tags and create releases");
4089
4135
  return {
4090
4136
  mode: "publish",
4091
4137
  tags: tagsToPush
@@ -4101,10 +4147,10 @@ async function runPublishMode(executor, config) {
4101
4147
  for (const tag of remoteExpectedTags) if (!await findRelease(executor, conn, tag)) tagsWithMissingReleases.push(tag);
4102
4148
  const allTags = [...tagsToPush, ...tagsWithMissingReleases];
4103
4149
  if (allTags.length === 0) {
4104
- log$2.info("No packages were published");
4150
+ log.info("No packages were published");
4105
4151
  return { mode: "none" };
4106
4152
  }
4107
- log$2.info(`Tags to process: ${allTags.join(", ")}`);
4153
+ log.info(`Tags to process: ${allTags.join(", ")}`);
4108
4154
  const errors = [];
4109
4155
  for (const tag of allTags) try {
4110
4156
  if (!remoteSet.has(tag)) {
@@ -4115,24 +4161,14 @@ async function runPublishMode(executor, config) {
4115
4161
  const pushTagResult = executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
4116
4162
  if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
4117
4163
  }
4118
- if (await findRelease(executor, conn, tag)) log$2.warn(`Release for ${tag} already exists — skipping`);
4119
- else {
4120
- await retryAsync(async () => {
4121
- try {
4122
- await createRelease(executor, conn, tag);
4123
- } catch (error) {
4124
- if (await findRelease(executor, conn, tag)) return;
4125
- throw error;
4126
- }
4127
- });
4128
- log$2.info(`Created release for ${tag}`);
4129
- }
4164
+ if (await ensureRelease(executor, conn, tag) === "exists") log.warn(`Release for ${tag} already exists — skipping`);
4165
+ else log.info(`Created release for ${tag}`);
4130
4166
  } catch (error) {
4131
4167
  errors.push({
4132
4168
  tag,
4133
4169
  error
4134
4170
  });
4135
- log$2.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
4171
+ log.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
4136
4172
  }
4137
4173
  if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
4138
4174
  return {
@@ -4226,6 +4262,14 @@ function configureGitAuth(executor, conn, cwd) {
4226
4262
  const authUrl = `https://x-access-token:${conn.token}@${host}/${conn.repository}`;
4227
4263
  executor.exec(`git remote set-url origin ${authUrl}`, { cwd });
4228
4264
  }
4265
+ /** Configure git user.name and user.email for CI bot commits. */
4266
+ function configureGitIdentity(executor, platform, cwd) {
4267
+ const isGitHub = platform === "github";
4268
+ const name = isGitHub ? "github-actions[bot]" : "forgejo-actions[bot]";
4269
+ const email = isGitHub ? "github-actions[bot]@users.noreply.github.com" : "forgejo-actions[bot]@noreply.localhost";
4270
+ executor.exec(`git config user.name "${name}"`, { cwd });
4271
+ executor.exec(`git config user.email "${email}"`, { cwd });
4272
+ }
4229
4273
  //#endregion
4230
4274
  //#region src/commands/release-changesets.ts
4231
4275
  const releaseForgejoCommand = defineCommand({
@@ -4240,13 +4284,13 @@ const releaseForgejoCommand = defineCommand({
4240
4284
  },
4241
4285
  verbose: {
4242
4286
  type: "boolean",
4243
- description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
4287
+ description: "Enable detailed debug logging (also enabled by TOOLING_DEBUG env var)"
4244
4288
  }
4245
4289
  },
4246
4290
  async run({ args }) {
4247
4291
  if ((await runRelease(buildReleaseConfig({
4248
4292
  dryRun: args["dry-run"] === true,
4249
- verbose: args.verbose === true || process.env["RELEASE_DEBUG"] === "true"
4293
+ verbose: args.verbose === true || isEnvVerbose()
4250
4294
  }), createRealExecutor())).mode === "none") process.exitCode = 0;
4251
4295
  }
4252
4296
  });
@@ -4274,8 +4318,7 @@ async function runRelease(config, executor) {
4274
4318
  debug(config, `Skipping release on non-main branch: ${branch}`);
4275
4319
  return { mode: "none" };
4276
4320
  }
4277
- executor.exec("git config user.name \"forgejo-actions[bot]\"", { cwd: config.cwd });
4278
- executor.exec("git config user.email \"forgejo-actions[bot]@noreply.localhost\"", { cwd: config.cwd });
4321
+ configureGitIdentity(executor, "forgejo", config.cwd);
4279
4322
  configureGitAuth(executor, config, config.cwd);
4280
4323
  const changesetFiles = executor.listChangesetFiles(config.cwd);
4281
4324
  debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
@@ -4293,35 +4336,55 @@ const releaseTriggerCommand = defineCommand({
4293
4336
  name: "release:trigger",
4294
4337
  description: "Trigger the release CI workflow"
4295
4338
  },
4296
- args: { ref: {
4297
- type: "string",
4298
- description: "Git ref to trigger on (default: main)",
4299
- required: false
4300
- } },
4339
+ args: {
4340
+ ref: {
4341
+ type: "string",
4342
+ description: "Git ref to trigger on (default: main)",
4343
+ required: false
4344
+ },
4345
+ bump: {
4346
+ type: "string",
4347
+ description: "Version bump type: auto, major, minor, patch, or first-release",
4348
+ required: false
4349
+ },
4350
+ prerelease: {
4351
+ type: "string",
4352
+ description: "Create a prerelease with the given tag (e.g., beta, alpha)",
4353
+ required: false
4354
+ }
4355
+ },
4301
4356
  async run({ args }) {
4302
4357
  const ref = args.ref ?? "main";
4358
+ const inputs = {};
4359
+ if (args.bump) inputs["bump"] = args.bump;
4360
+ if (args.prerelease) inputs["prerelease"] = args.prerelease;
4303
4361
  const resolved = resolveConnection(process.cwd());
4304
- if (resolved.type === "forgejo") await triggerForgejo(resolved.conn, ref);
4305
- else triggerGitHub(ref);
4362
+ if (resolved.type === "forgejo") await triggerForgejo(resolved.conn, ref, inputs);
4363
+ else triggerGitHub(ref, inputs);
4306
4364
  }
4307
4365
  });
4308
- async function triggerForgejo(conn, ref) {
4366
+ async function triggerForgejo(conn, ref, inputs) {
4309
4367
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/workflows/release.yml/dispatches`;
4368
+ const body = { ref };
4369
+ if (Object.keys(inputs).length > 0) body["inputs"] = inputs;
4310
4370
  const res = await fetch(url, {
4311
4371
  method: "POST",
4312
4372
  headers: {
4313
4373
  Authorization: `token ${conn.token}`,
4314
4374
  "Content-Type": "application/json"
4315
4375
  },
4316
- body: JSON.stringify({ ref })
4376
+ body: JSON.stringify(body)
4317
4377
  });
4318
4378
  if (!res.ok) throw new FatalError(`Failed to trigger Forgejo workflow: ${res.status} ${res.statusText}`);
4319
- log$2.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
4379
+ log.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
4320
4380
  }
4321
- function triggerGitHub(ref) {
4322
- const result = createRealExecutor().exec(`gh workflow run release.yml --ref ${ref}`, { cwd: process.cwd() });
4381
+ function triggerGitHub(ref, inputs) {
4382
+ const executor = createRealExecutor();
4383
+ const inputFlags = Object.entries(inputs).map(([k, v]) => `-f ${k}=${v}`).join(" ");
4384
+ const cmd = `gh workflow run release.yml --ref ${ref}${inputFlags ? ` ${inputFlags}` : ""}`;
4385
+ const result = executor.exec(cmd, { cwd: process.cwd() });
4323
4386
  if (result.exitCode !== 0) throw new FatalError(`Failed to trigger GitHub workflow: ${result.stderr || result.stdout || "unknown error"}`);
4324
- log$2.info(`Triggered release workflow on GitHub (ref: ${ref})`);
4387
+ log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
4325
4388
  }
4326
4389
  //#endregion
4327
4390
  //#region src/commands/forgejo-create-release.ts
@@ -4341,11 +4404,11 @@ const createForgejoReleaseCommand = defineCommand({
4341
4404
  const executor = createRealExecutor();
4342
4405
  const conn = resolved.conn;
4343
4406
  if (await findRelease(executor, conn, args.tag)) {
4344
- log$2.info(`Release for ${args.tag} already exists — skipping`);
4407
+ log.info(`Release for ${args.tag} already exists — skipping`);
4345
4408
  return;
4346
4409
  }
4347
4410
  await createRelease(executor, conn, args.tag);
4348
- log$2.info(`Created Forgejo release for ${args.tag}`);
4411
+ log.info(`Created Forgejo release for ${args.tag}`);
4349
4412
  }
4350
4413
  });
4351
4414
  //#endregion
@@ -4372,26 +4435,26 @@ async function mergeForgejo(conn, dryRun) {
4372
4435
  const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
4373
4436
  if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
4374
4437
  if (dryRun) {
4375
- log$2.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
4438
+ log.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
4376
4439
  return;
4377
4440
  }
4378
4441
  await mergePr(executor, conn, prNumber, {
4379
4442
  method: "merge",
4380
4443
  deleteBranch: true
4381
4444
  });
4382
- log$2.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
4445
+ log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
4383
4446
  }
4384
4447
  function mergeGitHub(dryRun) {
4385
4448
  const executor = createRealExecutor();
4386
4449
  if (dryRun) {
4387
4450
  const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
4388
4451
  if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
4389
- log$2.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
4452
+ log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
4390
4453
  return;
4391
4454
  }
4392
4455
  const result = executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
4393
4456
  if (result.exitCode !== 0) throw new FatalError(`Failed to merge PR: ${result.stderr || result.stdout || "unknown error"}`);
4394
- log$2.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
4457
+ log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
4395
4458
  }
4396
4459
  //#endregion
4397
4460
  //#region src/release/simple.ts
@@ -4421,20 +4484,15 @@ function readVersion(executor, cwd) {
4421
4484
  if (!pkg?.version) throw new FatalError("No version field found in package.json");
4422
4485
  return pkg.version;
4423
4486
  }
4424
- /** Configure git identity for CI bot commits. */
4425
- function configureGitIdentity(executor, config) {
4426
- const isGitHub = config.platform?.type === "github";
4427
- const name = isGitHub ? "github-actions[bot]" : "forgejo-actions[bot]";
4428
- const email = isGitHub ? "github-actions[bot]@users.noreply.github.com" : "forgejo-actions[bot]@noreply.localhost";
4429
- executor.exec(`git config user.name "${name}"`, { cwd: config.cwd });
4430
- executor.exec(`git config user.email "${email}"`, { cwd: config.cwd });
4431
- debug(config, `Configured git identity: ${name} <${email}>`);
4487
+ /** Resolve the platform type string for git identity configuration. */
4488
+ function platformType(config) {
4489
+ return config.platform?.type === "github" ? "github" : "forgejo";
4432
4490
  }
4433
4491
  /** Run the full commit-and-tag-version release flow. */
4434
4492
  async function runSimpleRelease(executor, config) {
4435
- configureGitIdentity(executor, config);
4493
+ configureGitIdentity(executor, platformType(config), config.cwd);
4436
4494
  const command = buildCommand(config);
4437
- log$2.info(`Running: ${command}`);
4495
+ log.info(`Running: ${command}`);
4438
4496
  const versionResult = executor.exec(command, { cwd: config.cwd });
4439
4497
  debugExec(config, "commit-and-tag-version", versionResult);
4440
4498
  if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
@@ -4444,12 +4502,12 @@ async function runSimpleRelease(executor, config) {
4444
4502
  debugExec(config, "git describe", tagResult);
4445
4503
  const tag = tagResult.stdout.trim();
4446
4504
  if (!tag) throw new FatalError("Could not determine the new tag from git describe");
4447
- log$2.info(`Version ${version} tagged as ${tag}`);
4505
+ log.info(`Version ${version} tagged as ${tag}`);
4448
4506
  if (config.dryRun) {
4449
4507
  const slidingTags = config.noSlidingTags ? [] : computeSlidingTags(version);
4450
- log$2.info(`[dry-run] Would push to origin with --follow-tags`);
4451
- if (slidingTags.length > 0) log$2.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
4452
- if (!config.noRelease && config.platform) log$2.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
4508
+ log.info(`[dry-run] Would push to origin with --follow-tags`);
4509
+ if (slidingTags.length > 0) log.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
4510
+ if (!config.noRelease && config.platform) log.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
4453
4511
  return {
4454
4512
  version,
4455
4513
  tag,
@@ -4470,7 +4528,7 @@ async function runSimpleRelease(executor, config) {
4470
4528
  debugExec(config, "git push", pushResult);
4471
4529
  if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
4472
4530
  pushed = true;
4473
- log$2.info("Pushed to origin");
4531
+ log.info("Pushed to origin");
4474
4532
  }
4475
4533
  let slidingTags = [];
4476
4534
  if (!config.noSlidingTags && pushed) {
@@ -4481,8 +4539,8 @@ async function runSimpleRelease(executor, config) {
4481
4539
  }
4482
4540
  const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
4483
4541
  debugExec(config, "force-push sliding tags", forcePushResult);
4484
- if (forcePushResult.exitCode !== 0) log$2.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4485
- else log$2.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4542
+ if (forcePushResult.exitCode !== 0) log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4543
+ else log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4486
4544
  }
4487
4545
  let releaseCreated = false;
4488
4546
  if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
@@ -4497,21 +4555,20 @@ async function runSimpleRelease(executor, config) {
4497
4555
  async function createPlatformRelease(executor, config, tag) {
4498
4556
  if (!config.platform) return false;
4499
4557
  if (config.platform.type === "forgejo") {
4500
- if (await findRelease(executor, config.platform.conn, tag)) {
4558
+ if (await ensureRelease(executor, config.platform.conn, tag) === "exists") {
4501
4559
  debug(config, `Release for ${tag} already exists, skipping`);
4502
4560
  return false;
4503
4561
  }
4504
- await createRelease(executor, config.platform.conn, tag);
4505
- log$2.info(`Created Forgejo release for ${tag}`);
4562
+ log.info(`Created Forgejo release for ${tag}`);
4506
4563
  return true;
4507
4564
  }
4508
4565
  const ghResult = executor.exec(`gh release create ${tag} --generate-notes`, { cwd: config.cwd });
4509
4566
  debugExec(config, "gh release create", ghResult);
4510
4567
  if (ghResult.exitCode !== 0) {
4511
- log$2.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
4568
+ log.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
4512
4569
  return false;
4513
4570
  }
4514
- log$2.info(`Created GitHub release for ${tag}`);
4571
+ log.info(`Created GitHub release for ${tag}`);
4515
4572
  return true;
4516
4573
  }
4517
4574
  //#endregion
@@ -4528,7 +4585,7 @@ const releaseSimpleCommand = defineCommand({
4528
4585
  },
4529
4586
  verbose: {
4530
4587
  type: "boolean",
4531
- description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
4588
+ description: "Enable detailed debug logging (also enabled by TOOLING_DEBUG env var)"
4532
4589
  },
4533
4590
  "no-push": {
4534
4591
  type: "boolean",
@@ -4557,7 +4614,7 @@ const releaseSimpleCommand = defineCommand({
4557
4614
  },
4558
4615
  async run({ args }) {
4559
4616
  const cwd = process.cwd();
4560
- const verbose = args.verbose === true || process.env["RELEASE_DEBUG"] === "true";
4617
+ const verbose = args.verbose === true || isEnvVerbose();
4561
4618
  const noRelease = args["no-release"] === true;
4562
4619
  let platform;
4563
4620
  if (!noRelease) {
@@ -4641,12 +4698,12 @@ const ciReporter = {
4641
4698
  const localReporter = {
4642
4699
  groupStart: (_name) => {},
4643
4700
  groupEnd: () => {},
4644
- passed: (name) => log$2.success(name),
4645
- failed: (name) => log$2.error(`${name} failed`),
4646
- undefinedCheck: (name) => log$2.error(`${name} not defined in package.json`),
4647
- skippedNotDefined: (names) => log$2.info(`Skipped (not defined): ${names.join(", ")}`),
4648
- allPassed: () => log$2.success("All checks passed"),
4649
- anyFailed: (names) => log$2.error(`Failed checks: ${names.join(", ")}`)
4701
+ passed: (name) => log.success(name),
4702
+ failed: (name) => log.error(`${name} failed`),
4703
+ undefinedCheck: (name) => log.error(`${name} not defined in package.json`),
4704
+ skippedNotDefined: (names) => log.info(`Skipped (not defined): ${names.join(", ")}`),
4705
+ allPassed: () => log.success("All checks passed"),
4706
+ anyFailed: (names) => log.error(`Failed checks: ${names.join(", ")}`)
4650
4707
  };
4651
4708
  function runRunChecks(targetDir, options = {}) {
4652
4709
  const exec = options.execCommand ?? defaultExecCommand;
@@ -4656,6 +4713,7 @@ function runRunChecks(targetDir, options = {}) {
4656
4713
  const isCI = Boolean(process.env["CI"]);
4657
4714
  const failFast = options.failFast ?? !isCI;
4658
4715
  const reporter = isCI ? ciReporter : localReporter;
4716
+ const vc = { verbose: options.verbose ?? false };
4659
4717
  const definedScripts = getScripts(targetDir);
4660
4718
  const addedNames = new Set(add);
4661
4719
  const allChecks = [...CHECKS, ...add.map((name) => ({ name }))];
@@ -4671,11 +4729,13 @@ function runRunChecks(targetDir, options = {}) {
4671
4729
  continue;
4672
4730
  }
4673
4731
  const cmd = check.args ? `pnpm run ${check.name} ${check.args}` : `pnpm run ${check.name}`;
4732
+ debug(vc, `Running: ${cmd} (in ${targetDir})`);
4674
4733
  reporter.groupStart(check.name);
4675
4734
  const start = Date.now();
4676
4735
  const exitCode = exec(cmd, targetDir);
4677
4736
  const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
4678
4737
  reporter.groupEnd();
4738
+ debug(vc, `${check.name}: exit code ${String(exitCode)}, ${elapsed}s`);
4679
4739
  if (exitCode === 0) reporter.passed(check.name, elapsed);
4680
4740
  else {
4681
4741
  reporter.failed(check.name, elapsed);
@@ -4716,13 +4776,19 @@ const runChecksCommand = defineCommand({
4716
4776
  type: "boolean",
4717
4777
  description: "Stop on first failure (default: true in dev, false in CI)",
4718
4778
  required: false
4779
+ },
4780
+ verbose: {
4781
+ type: "boolean",
4782
+ description: "Emit detailed debug logging",
4783
+ required: false
4719
4784
  }
4720
4785
  },
4721
4786
  run({ args }) {
4722
4787
  const exitCode = runRunChecks(path.resolve(args.dir ?? "."), {
4723
4788
  skip: args.skip ? new Set(args.skip.split(",").map((s) => s.trim())) : void 0,
4724
4789
  add: args.add ? args.add.split(",").map((s) => s.trim()) : void 0,
4725
- failFast: args["fail-fast"] ? true : void 0
4790
+ failFast: args["fail-fast"] ? true : void 0,
4791
+ verbose: args.verbose === true || isEnvVerbose()
4726
4792
  });
4727
4793
  process.exitCode = exitCode;
4728
4794
  }
@@ -4739,10 +4805,16 @@ const publishDockerCommand = defineCommand({
4739
4805
  name: "docker:publish",
4740
4806
  description: "Build, tag, and push Docker images for packages with an image:build script"
4741
4807
  },
4742
- args: { "dry-run": {
4743
- type: "boolean",
4744
- description: "Build and tag images but skip login, push, and logout"
4745
- } },
4808
+ args: {
4809
+ "dry-run": {
4810
+ type: "boolean",
4811
+ description: "Build and tag images but skip login, push, and logout"
4812
+ },
4813
+ verbose: {
4814
+ type: "boolean",
4815
+ description: "Emit detailed debug logging"
4816
+ }
4817
+ },
4746
4818
  async run({ args }) {
4747
4819
  const config = {
4748
4820
  cwd: process.cwd(),
@@ -4750,7 +4822,8 @@ const publishDockerCommand = defineCommand({
4750
4822
  registryNamespace: requireEnv("DOCKER_REGISTRY_NAMESPACE"),
4751
4823
  username: requireEnv("DOCKER_USERNAME"),
4752
4824
  password: requireEnv("DOCKER_PASSWORD"),
4753
- dryRun: args["dry-run"] === true
4825
+ dryRun: args["dry-run"] === true,
4826
+ verbose: args.verbose === true || isEnvVerbose()
4754
4827
  };
4755
4828
  runDockerPublish(createRealExecutor(), config);
4756
4829
  }
@@ -4783,6 +4856,10 @@ const dockerBuildCommand = defineCommand({
4783
4856
  type: "string",
4784
4857
  description: "Build a single package by directory path (e.g. packages/server). Useful as an image:build script."
4785
4858
  },
4859
+ verbose: {
4860
+ type: "boolean",
4861
+ description: "Emit detailed debug logging"
4862
+ },
4786
4863
  _: {
4787
4864
  type: "positional",
4788
4865
  required: false,
@@ -4793,6 +4870,7 @@ const dockerBuildCommand = defineCommand({
4793
4870
  const executor = createRealExecutor();
4794
4871
  const rawExtra = args._ ?? [];
4795
4872
  const extraArgs = Array.isArray(rawExtra) ? rawExtra.map(String) : [String(rawExtra)];
4873
+ const verbose = args.verbose === true || isEnvVerbose();
4796
4874
  let cwd = process.cwd();
4797
4875
  let packageDir = args.package;
4798
4876
  if (!packageDir) {
@@ -4803,7 +4881,8 @@ const dockerBuildCommand = defineCommand({
4803
4881
  runDockerBuild(executor, {
4804
4882
  cwd,
4805
4883
  packageDir,
4806
- extraArgs: extraArgs.filter((a) => a.length > 0)
4884
+ extraArgs: extraArgs.filter((a) => a.length > 0),
4885
+ verbose
4807
4886
  });
4808
4887
  }
4809
4888
  });
@@ -5020,12 +5099,6 @@ function writeTempOverlay(content) {
5020
5099
  writeFileSync(filePath, content, "utf-8");
5021
5100
  return filePath;
5022
5101
  }
5023
- function log(message) {
5024
- console.log(message);
5025
- }
5026
- function warn(message) {
5027
- console.warn(message);
5028
- }
5029
5102
  const dockerCheckCommand = defineCommand({
5030
5103
  meta: {
5031
5104
  name: "docker:check",
@@ -5039,12 +5112,16 @@ const dockerCheckCommand = defineCommand({
5039
5112
  "poll-interval": {
5040
5113
  type: "string",
5041
5114
  description: "Interval between polling attempts, in ms (default: 5000)"
5115
+ },
5116
+ verbose: {
5117
+ type: "boolean",
5118
+ description: "Emit detailed debug logging"
5042
5119
  }
5043
5120
  },
5044
5121
  async run({ args }) {
5045
5122
  const cwd = process.cwd();
5046
5123
  if (loadToolingConfig(cwd)?.dockerCheck === false) {
5047
- log("Docker check is disabled in .tooling.json");
5124
+ log.info("Docker check is disabled in .tooling.json");
5048
5125
  return;
5049
5126
  }
5050
5127
  const defaults = computeCheckDefaults(cwd);
@@ -5052,8 +5129,8 @@ const dockerCheckCommand = defineCommand({
5052
5129
  if (!defaults.checkOverlay) {
5053
5130
  const composeCwd = defaults.composeCwd ?? cwd;
5054
5131
  const expectedOverlay = (defaults.composeFiles[0] ?? "docker-compose.yaml").replace(/\.(yaml|yml)$/, ".check.$1");
5055
- warn(`Compose files found but no check overlay. Create ${path.relative(cwd, path.join(composeCwd, expectedOverlay))} to enable docker:check.`);
5056
- warn("To suppress this warning, set \"dockerCheck\": false in .tooling.json.");
5132
+ log.warn(`Compose files found but no check overlay. Create ${path.relative(cwd, path.join(composeCwd, expectedOverlay))} to enable docker:check.`);
5133
+ log.warn("To suppress this warning, set \"dockerCheck\": false in .tooling.json.");
5057
5134
  return;
5058
5135
  }
5059
5136
  if (!defaults.services || defaults.services.length === 0) throw new FatalError("No services found in compose files.");
@@ -5066,7 +5143,7 @@ const dockerCheckCommand = defineCommand({
5066
5143
  if (rootPkg?.name) {
5067
5144
  const dockerPackages = detectDockerPackages(fileReader, cwd, rootPkg.name);
5068
5145
  const composeImages = extractComposeImageNames(services);
5069
- 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.`);
5146
+ 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.`);
5070
5147
  }
5071
5148
  }
5072
5149
  const tempOverlayPath = writeTempOverlay(generateCheckOverlay(services));
@@ -5087,7 +5164,8 @@ const dockerCheckCommand = defineCommand({
5087
5164
  buildCwd: defaults.buildCwd,
5088
5165
  healthChecks: defaults.healthChecks ? toHttpHealthChecks(defaults.healthChecks) : [],
5089
5166
  timeoutMs: args.timeout ? Number.parseInt(args.timeout, 10) : defaults.timeoutMs,
5090
- pollIntervalMs: args["poll-interval"] ? Number.parseInt(args["poll-interval"], 10) : defaults.pollIntervalMs
5167
+ pollIntervalMs: args["poll-interval"] ? Number.parseInt(args["poll-interval"], 10) : defaults.pollIntervalMs,
5168
+ verbose: args.verbose === true || isEnvVerbose()
5091
5169
  };
5092
5170
  const result = await runDockerCheck(createRealExecutor$1(), config);
5093
5171
  if (!result.success) throw new FatalError(`Check failed (${result.reason}): ${result.message}`);
@@ -5103,7 +5181,7 @@ const dockerCheckCommand = defineCommand({
5103
5181
  const main = defineCommand({
5104
5182
  meta: {
5105
5183
  name: "bst",
5106
- version: "0.32.0",
5184
+ version: "0.34.0",
5107
5185
  description: "Bootstrap and maintain standardized TypeScript project tooling"
5108
5186
  },
5109
5187
  subCommands: {
@@ -5119,7 +5197,7 @@ const main = defineCommand({
5119
5197
  "docker:check": dockerCheckCommand
5120
5198
  }
5121
5199
  });
5122
- console.log(`@bensandee/tooling v0.32.0`);
5200
+ console.log(`@bensandee/tooling v0.34.0`);
5123
5201
  async function run() {
5124
5202
  await runMain(main);
5125
5203
  process.exit(process.exitCode ?? 0);