@bensandee/tooling 0.33.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/README.md CHANGED
@@ -32,7 +32,7 @@ The tool auto-detects project structure, CI platform, project type, and Docker p
32
32
  | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
33
  | `tooling repo:sync [dir]` | Detect, generate, and sync project tooling (idempotent). First run prompts for release strategy, CI platform (if not detected), and formatter (if Prettier found). Subsequent runs are non-interactive. |
34
34
  | `tooling repo:sync --check [dir]` | Dry-run drift detection. Exits 1 if files would change. CI-friendly. |
35
- | `tooling checks:run` | Run project checks (build, docker:build, typecheck, lint, test, format, knip, tooling:check, docker:check). Flags: `--skip`, `--add`, `--fail-fast`. |
35
+ | `tooling checks:run` | Run project checks (build, docker:build, typecheck, lint, test, format, knip, tooling:check, docker:check). Flags: `--skip`, `--add`, `--fail-fast`, `--verbose`. |
36
36
 
37
37
  **Flags:** `--yes` (accept all defaults), `--no-ci`, `--no-prompt`, `--eslint-plugin`
38
38
 
@@ -60,13 +60,13 @@ The generated `ci:check` script defaults to `pnpm check --skip 'docker:*'` since
60
60
 
61
61
  ### Release management
62
62
 
63
- | Command | Description |
64
- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
65
- | `tooling release:changesets` | Changesets version/publish for Forgejo CI. Flag: `--dry-run`. Env: `FORGEJO_SERVER_URL`, `FORGEJO_REPOSITORY`, `RELEASE_TOKEN`. |
66
- | `tooling release:simple` | Streamlined release using commit-and-tag-version. Flags: `--release-as`, `--first-release`, `--prerelease`. |
67
- | `tooling release:trigger` | Trigger a release workflow. |
68
- | `tooling forgejo:create-release` | Create a Forgejo release from a tag. |
69
- | `tooling changesets:merge` | Merge a changesets version PR. |
63
+ | Command | Description |
64
+ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `tooling release:changesets` | Changesets version/publish for Forgejo CI. Flags: `--dry-run`, `--verbose`. Env: `FORGEJO_SERVER_URL`, `FORGEJO_REPOSITORY`, `RELEASE_TOKEN`. |
66
+ | `tooling release:simple` | Streamlined release using commit-and-tag-version. Flags: `--release-as`, `--first-release`, `--prerelease`, `--verbose`. |
67
+ | `tooling release:trigger` | Trigger a release workflow. |
68
+ | `tooling forgejo:create-release` | Create a Forgejo release from a tag. |
69
+ | `tooling changesets:merge` | Merge a changesets version PR. |
70
70
 
71
71
  #### `release:simple`
72
72
 
@@ -147,7 +147,7 @@ To give individual packages a standalone `image:build` script for local testing:
147
147
  }
148
148
  ```
149
149
 
150
- **Flags:** `--package <dir>` (build a single package)
150
+ **Flags:** `--package <dir>` (build a single package), `--verbose`
151
151
 
152
152
  #### `docker:publish`
153
153
 
@@ -157,7 +157,7 @@ Tags generated per package: `latest`, `vX.Y.Z`, `vX.Y`, `vX`
157
157
 
158
158
  Each package is tagged independently using its own version, so packages in a monorepo can have different release cadences. Packages without a `version` field are rejected at publish time.
159
159
 
160
- **Flags:** `--dry-run` (build and tag only, skip login/push/logout)
160
+ **Flags:** `--dry-run` (build and tag only, skip login/push/logout), `--verbose`
161
161
 
162
162
  **Required CI variables:**
163
163
 
@@ -188,6 +188,17 @@ Available override fields:
188
188
  | `projectType` | string | Auto-detected from `package.json` deps |
189
189
  | `detectPackageTypes` | boolean | `true` |
190
190
 
191
+ ## Debug logging
192
+
193
+ All CLI commands support a `--verbose` flag for detailed debug output. Alternatively, set `TOOLING_DEBUG=true` as an environment variable — useful in CI workflows:
194
+
195
+ ```yaml
196
+ env:
197
+ TOOLING_DEBUG: "true"
198
+ ```
199
+
200
+ Debug output is prefixed with `[debug]` and includes exec results (exit codes, stdout/stderr), compose configuration details, container health statuses, and retry attempts.
201
+
191
202
  ## Library API
192
203
 
193
204
  The `"."` export provides type-only exports for programmatic use:
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";
@@ -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.33.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.33.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",
@@ -3321,7 +3308,7 @@ function generateMigratePrompt(results, config, detected) {
3321
3308
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3322
3309
  sections.push("# Migration Prompt");
3323
3310
  sections.push("");
3324
- sections.push(`_Generated by \`@bensandee/tooling@0.33.0 repo:sync\` on ${timestamp}_`);
3311
+ sections.push(`_Generated by \`@bensandee/tooling@0.34.0 repo:sync\` on ${timestamp}_`);
3325
3312
  sections.push("");
3326
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.");
3327
3314
  sections.push("");
@@ -3505,11 +3492,11 @@ function contextAsDockerReader(ctx) {
3505
3492
  function logDetectionSummary(ctx) {
3506
3493
  if (ctx.config.publishDocker) {
3507
3494
  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(", ")}`);
3495
+ if (dockerPackages.length > 0) log.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3509
3496
  }
3510
3497
  if (ctx.config.publishNpm) {
3511
3498
  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(", ")}`);
3499
+ if (publishable.length > 0) log.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3513
3500
  }
3514
3501
  }
3515
3502
  async function runInit(config, options = {}) {
@@ -3543,7 +3530,7 @@ async function runInit(config, options = {}) {
3543
3530
  const promptPath = ".tooling-migrate.md";
3544
3531
  ctx.write(promptPath, prompt);
3545
3532
  if (!hasChanges && options.noPrompt) {
3546
- log$2.success("Repository is up to date.");
3533
+ log.success("Repository is up to date.");
3547
3534
  return results;
3548
3535
  }
3549
3536
  if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
@@ -3558,13 +3545,13 @@ async function runInit(config, options = {}) {
3558
3545
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
3559
3546
  note(summaryLines.join("\n"), "Summary");
3560
3547
  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\"");
3548
+ log.info(`Migration prompt written to ${promptPath}`);
3549
+ log.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3563
3550
  }
3564
3551
  const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
3565
3552
  const hasLockfile = ctx.exists("pnpm-lock.yaml");
3566
3553
  if (bensandeeDeps.length > 0 && hasLockfile) {
3567
- log$2.info("Updating @bensandee/* packages...");
3554
+ log.info("Updating @bensandee/* packages...");
3568
3555
  try {
3569
3556
  execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
3570
3557
  cwd: config.targetDir,
@@ -3572,7 +3559,7 @@ async function runInit(config, options = {}) {
3572
3559
  timeout: 6e4
3573
3560
  });
3574
3561
  } catch (_error) {
3575
- log$2.warn("Could not update @bensandee/* packages — run pnpm install manually");
3562
+ log.warn("Could not update @bensandee/* packages — run pnpm install manually");
3576
3563
  }
3577
3564
  }
3578
3565
  if (hasChanges && ctx.exists("package.json")) try {
@@ -3664,22 +3651,22 @@ async function runCheck(targetDir) {
3664
3651
  return true;
3665
3652
  });
3666
3653
  if (actionable.length === 0) {
3667
- log$2.success("Repository is up to date.");
3654
+ log.success("Repository is up to date.");
3668
3655
  return 0;
3669
3656
  }
3670
- 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`);
3671
3658
  for (const r of actionable) {
3672
- log$2.info(` ${r.action}: ${r.filePath} — ${r.description}`);
3659
+ log.info(` ${r.action}: ${r.filePath} — ${r.description}`);
3673
3660
  const newContent = pendingWrites.get(r.filePath);
3674
3661
  if (!newContent) continue;
3675
3662
  const existingPath = path.join(targetDir, r.filePath);
3676
3663
  const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
3677
3664
  if (!existing) {
3678
3665
  const lineCount = newContent.split("\n").length - 1;
3679
- log$2.info(` + ${lineCount} new lines`);
3666
+ log.info(` + ${lineCount} new lines`);
3680
3667
  } else {
3681
3668
  const diff = lineDiff(existing, newContent);
3682
- for (const line of diff) log$2.info(` ${line}`);
3669
+ for (const line of diff) log.info(` ${line}`);
3683
3670
  }
3684
3671
  }
3685
3672
  return 1;
@@ -3827,6 +3814,17 @@ function reconcileTags(expectedTags, remoteTags, stdoutTags) {
3827
3814
  }
3828
3815
  //#endregion
3829
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
+ }
3830
3828
  const PullRequestSchema = z.array(z.object({
3831
3829
  number: z.number(),
3832
3830
  head: z.object({ ref: z.string() })
@@ -3840,7 +3838,10 @@ const PullRequestSchema = z.array(z.object({
3840
3838
  async function findOpenPr(executor, conn, head) {
3841
3839
  const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?state=open`;
3842
3840
  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}`);
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
+ }
3844
3845
  const parsed = PullRequestSchema.safeParse(await res.json());
3845
3846
  if (!parsed.success) throw new UnexpectedError(`Unexpected PR list response: ${parsed.error.message}`);
3846
3847
  return parsed.data.find((pr) => pr.head.ref === head)?.number ?? null;
@@ -3862,7 +3863,10 @@ async function createPr(executor, conn, options) {
3862
3863
  },
3863
3864
  body: JSON.stringify(payload)
3864
3865
  });
3865
- 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
+ }
3866
3870
  }
3867
3871
  /** Update an existing pull request's title and body. */
3868
3872
  async function updatePr(executor, conn, prNumber, options) {
@@ -3878,7 +3882,10 @@ async function updatePr(executor, conn, prNumber, options) {
3878
3882
  body: options.body
3879
3883
  })
3880
3884
  });
3881
- 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
+ }
3882
3889
  }
3883
3890
  /** Merge a pull request by number. */
3884
3891
  async function mergePr(executor, conn, prNumber, options) {
@@ -3894,7 +3901,10 @@ async function mergePr(executor, conn, prNumber, options) {
3894
3901
  delete_branch_after_merge: options?.deleteBranch ?? true
3895
3902
  })
3896
3903
  });
3897
- 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
+ }
3898
3908
  }
3899
3909
  /** Check whether a Forgejo release already exists for a given tag. */
3900
3910
  async function findRelease(executor, conn, tag) {
@@ -3903,7 +3913,8 @@ async function findRelease(executor, conn, tag) {
3903
3913
  const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3904
3914
  if (res.status === 200) return true;
3905
3915
  if (res.status === 404) return false;
3906
- 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}`);
3907
3918
  }
3908
3919
  /** Create a Forgejo release for a given tag. */
3909
3920
  async function createRelease(executor, conn, tag) {
@@ -3920,21 +3931,35 @@ async function createRelease(executor, conn, tag) {
3920
3931
  body: `Published ${tag}`
3921
3932
  })
3922
3933
  });
3923
- 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
+ }
3924
3938
  }
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"));
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`);
3938
3963
  }
3939
3964
  //#endregion
3940
3965
  //#region src/release/version.ts
@@ -4006,7 +4031,7 @@ function buildPrContent(executor, cwd, packagesBefore) {
4006
4031
  }
4007
4032
  /** Mode 1: version packages and create/update a PR. */
4008
4033
  async function runVersionMode(executor, config) {
4009
- log$2.info("Changesets detected — versioning packages");
4034
+ log.info("Changesets detected — versioning packages");
4010
4035
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
4011
4036
  debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
4012
4037
  const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
@@ -4032,19 +4057,19 @@ async function runVersionMode(executor, config) {
4032
4057
  const addResult = executor.exec("git add -A", { cwd: config.cwd });
4033
4058
  if (addResult.exitCode !== 0) throw new FatalError(`git add failed: ${addResult.stderr || addResult.stdout}`);
4034
4059
  const remainingChangesets = executor.listChangesetFiles(config.cwd);
4035
- 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(", ")}`);
4036
4061
  debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
4037
4062
  const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
4038
4063
  debugExec(config, "git commit", commitResult);
4039
4064
  if (commitResult.exitCode !== 0) {
4040
- log$2.info("Nothing to commit after versioning");
4065
+ log.info("Nothing to commit after versioning");
4041
4066
  return {
4042
4067
  mode: "version",
4043
4068
  pr: "none"
4044
4069
  };
4045
4070
  }
4046
4071
  if (config.dryRun) {
4047
- log$2.info("[dry-run] Would push and create/update PR");
4072
+ log.info("[dry-run] Would push and create/update PR");
4048
4073
  return {
4049
4074
  mode: "version",
4050
4075
  pr: "none"
@@ -4067,7 +4092,7 @@ async function runVersionMode(executor, config) {
4067
4092
  base: "main",
4068
4093
  body
4069
4094
  });
4070
- log$2.info("Created version PR");
4095
+ log.info("Created version PR");
4071
4096
  return {
4072
4097
  mode: "version",
4073
4098
  pr: "created"
@@ -4077,7 +4102,7 @@ async function runVersionMode(executor, config) {
4077
4102
  title,
4078
4103
  body
4079
4104
  });
4080
- log$2.info(`Updated version PR #${String(existingPr)}`);
4105
+ log.info(`Updated version PR #${String(existingPr)}`);
4081
4106
  return {
4082
4107
  mode: "version",
4083
4108
  pr: "updated"
@@ -4085,24 +4110,9 @@ async function runVersionMode(executor, config) {
4085
4110
  }
4086
4111
  //#endregion
4087
4112
  //#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
4113
  /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
4104
4114
  async function runPublishMode(executor, config) {
4105
- log$2.info("No changesets — publishing packages");
4115
+ log.info("No changesets — publishing packages");
4106
4116
  const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
4107
4117
  debugExec(config, "pnpm changeset publish", publishResult);
4108
4118
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
@@ -4117,11 +4127,11 @@ async function runPublishMode(executor, config) {
4117
4127
  debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
4118
4128
  if (config.dryRun) {
4119
4129
  if (tagsToPush.length === 0) {
4120
- log$2.info("No packages were published");
4130
+ log.info("No packages were published");
4121
4131
  return { mode: "none" };
4122
4132
  }
4123
- log$2.info(`Tags to process: ${tagsToPush.join(", ")}`);
4124
- 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");
4125
4135
  return {
4126
4136
  mode: "publish",
4127
4137
  tags: tagsToPush
@@ -4137,10 +4147,10 @@ async function runPublishMode(executor, config) {
4137
4147
  for (const tag of remoteExpectedTags) if (!await findRelease(executor, conn, tag)) tagsWithMissingReleases.push(tag);
4138
4148
  const allTags = [...tagsToPush, ...tagsWithMissingReleases];
4139
4149
  if (allTags.length === 0) {
4140
- log$2.info("No packages were published");
4150
+ log.info("No packages were published");
4141
4151
  return { mode: "none" };
4142
4152
  }
4143
- log$2.info(`Tags to process: ${allTags.join(", ")}`);
4153
+ log.info(`Tags to process: ${allTags.join(", ")}`);
4144
4154
  const errors = [];
4145
4155
  for (const tag of allTags) try {
4146
4156
  if (!remoteSet.has(tag)) {
@@ -4151,24 +4161,14 @@ async function runPublishMode(executor, config) {
4151
4161
  const pushTagResult = executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
4152
4162
  if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
4153
4163
  }
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
- }
4164
+ if (await ensureRelease(executor, conn, tag) === "exists") log.warn(`Release for ${tag} already exists — skipping`);
4165
+ else log.info(`Created release for ${tag}`);
4166
4166
  } catch (error) {
4167
4167
  errors.push({
4168
4168
  tag,
4169
4169
  error
4170
4170
  });
4171
- 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)}`);
4172
4172
  }
4173
4173
  if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
4174
4174
  return {
@@ -4262,6 +4262,14 @@ function configureGitAuth(executor, conn, cwd) {
4262
4262
  const authUrl = `https://x-access-token:${conn.token}@${host}/${conn.repository}`;
4263
4263
  executor.exec(`git remote set-url origin ${authUrl}`, { cwd });
4264
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
+ }
4265
4273
  //#endregion
4266
4274
  //#region src/commands/release-changesets.ts
4267
4275
  const releaseForgejoCommand = defineCommand({
@@ -4276,13 +4284,13 @@ const releaseForgejoCommand = defineCommand({
4276
4284
  },
4277
4285
  verbose: {
4278
4286
  type: "boolean",
4279
- 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)"
4280
4288
  }
4281
4289
  },
4282
4290
  async run({ args }) {
4283
4291
  if ((await runRelease(buildReleaseConfig({
4284
4292
  dryRun: args["dry-run"] === true,
4285
- verbose: args.verbose === true || process.env["RELEASE_DEBUG"] === "true"
4293
+ verbose: args.verbose === true || isEnvVerbose()
4286
4294
  }), createRealExecutor())).mode === "none") process.exitCode = 0;
4287
4295
  }
4288
4296
  });
@@ -4310,8 +4318,7 @@ async function runRelease(config, executor) {
4310
4318
  debug(config, `Skipping release on non-main branch: ${branch}`);
4311
4319
  return { mode: "none" };
4312
4320
  }
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 });
4321
+ configureGitIdentity(executor, "forgejo", config.cwd);
4315
4322
  configureGitAuth(executor, config, config.cwd);
4316
4323
  const changesetFiles = executor.listChangesetFiles(config.cwd);
4317
4324
  debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
@@ -4369,7 +4376,7 @@ async function triggerForgejo(conn, ref, inputs) {
4369
4376
  body: JSON.stringify(body)
4370
4377
  });
4371
4378
  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})`);
4379
+ log.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
4373
4380
  }
4374
4381
  function triggerGitHub(ref, inputs) {
4375
4382
  const executor = createRealExecutor();
@@ -4377,7 +4384,7 @@ function triggerGitHub(ref, inputs) {
4377
4384
  const cmd = `gh workflow run release.yml --ref ${ref}${inputFlags ? ` ${inputFlags}` : ""}`;
4378
4385
  const result = executor.exec(cmd, { cwd: process.cwd() });
4379
4386
  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})`);
4387
+ log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
4381
4388
  }
4382
4389
  //#endregion
4383
4390
  //#region src/commands/forgejo-create-release.ts
@@ -4397,11 +4404,11 @@ const createForgejoReleaseCommand = defineCommand({
4397
4404
  const executor = createRealExecutor();
4398
4405
  const conn = resolved.conn;
4399
4406
  if (await findRelease(executor, conn, args.tag)) {
4400
- log$2.info(`Release for ${args.tag} already exists — skipping`);
4407
+ log.info(`Release for ${args.tag} already exists — skipping`);
4401
4408
  return;
4402
4409
  }
4403
4410
  await createRelease(executor, conn, args.tag);
4404
- log$2.info(`Created Forgejo release for ${args.tag}`);
4411
+ log.info(`Created Forgejo release for ${args.tag}`);
4405
4412
  }
4406
4413
  });
4407
4414
  //#endregion
@@ -4428,26 +4435,26 @@ async function mergeForgejo(conn, dryRun) {
4428
4435
  const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
4429
4436
  if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
4430
4437
  if (dryRun) {
4431
- 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}`);
4432
4439
  return;
4433
4440
  }
4434
4441
  await mergePr(executor, conn, prNumber, {
4435
4442
  method: "merge",
4436
4443
  deleteBranch: true
4437
4444
  });
4438
- log$2.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
4445
+ log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
4439
4446
  }
4440
4447
  function mergeGitHub(dryRun) {
4441
4448
  const executor = createRealExecutor();
4442
4449
  if (dryRun) {
4443
4450
  const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
4444
4451
  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}`);
4452
+ log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
4446
4453
  return;
4447
4454
  }
4448
4455
  const result = executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
4449
4456
  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}`);
4457
+ log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
4451
4458
  }
4452
4459
  //#endregion
4453
4460
  //#region src/release/simple.ts
@@ -4477,20 +4484,15 @@ function readVersion(executor, cwd) {
4477
4484
  if (!pkg?.version) throw new FatalError("No version field found in package.json");
4478
4485
  return pkg.version;
4479
4486
  }
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}>`);
4487
+ /** Resolve the platform type string for git identity configuration. */
4488
+ function platformType(config) {
4489
+ return config.platform?.type === "github" ? "github" : "forgejo";
4488
4490
  }
4489
4491
  /** Run the full commit-and-tag-version release flow. */
4490
4492
  async function runSimpleRelease(executor, config) {
4491
- configureGitIdentity(executor, config);
4493
+ configureGitIdentity(executor, platformType(config), config.cwd);
4492
4494
  const command = buildCommand(config);
4493
- log$2.info(`Running: ${command}`);
4495
+ log.info(`Running: ${command}`);
4494
4496
  const versionResult = executor.exec(command, { cwd: config.cwd });
4495
4497
  debugExec(config, "commit-and-tag-version", versionResult);
4496
4498
  if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
@@ -4500,12 +4502,12 @@ async function runSimpleRelease(executor, config) {
4500
4502
  debugExec(config, "git describe", tagResult);
4501
4503
  const tag = tagResult.stdout.trim();
4502
4504
  if (!tag) throw new FatalError("Could not determine the new tag from git describe");
4503
- log$2.info(`Version ${version} tagged as ${tag}`);
4505
+ log.info(`Version ${version} tagged as ${tag}`);
4504
4506
  if (config.dryRun) {
4505
4507
  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}`);
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}`);
4509
4511
  return {
4510
4512
  version,
4511
4513
  tag,
@@ -4526,7 +4528,7 @@ async function runSimpleRelease(executor, config) {
4526
4528
  debugExec(config, "git push", pushResult);
4527
4529
  if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
4528
4530
  pushed = true;
4529
- log$2.info("Pushed to origin");
4531
+ log.info("Pushed to origin");
4530
4532
  }
4531
4533
  let slidingTags = [];
4532
4534
  if (!config.noSlidingTags && pushed) {
@@ -4537,8 +4539,8 @@ async function runSimpleRelease(executor, config) {
4537
4539
  }
4538
4540
  const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
4539
4541
  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(", ")}`);
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(", ")}`);
4542
4544
  }
4543
4545
  let releaseCreated = false;
4544
4546
  if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
@@ -4553,21 +4555,20 @@ async function runSimpleRelease(executor, config) {
4553
4555
  async function createPlatformRelease(executor, config, tag) {
4554
4556
  if (!config.platform) return false;
4555
4557
  if (config.platform.type === "forgejo") {
4556
- if (await findRelease(executor, config.platform.conn, tag)) {
4558
+ if (await ensureRelease(executor, config.platform.conn, tag) === "exists") {
4557
4559
  debug(config, `Release for ${tag} already exists, skipping`);
4558
4560
  return false;
4559
4561
  }
4560
- await createRelease(executor, config.platform.conn, tag);
4561
- log$2.info(`Created Forgejo release for ${tag}`);
4562
+ log.info(`Created Forgejo release for ${tag}`);
4562
4563
  return true;
4563
4564
  }
4564
4565
  const ghResult = executor.exec(`gh release create ${tag} --generate-notes`, { cwd: config.cwd });
4565
4566
  debugExec(config, "gh release create", ghResult);
4566
4567
  if (ghResult.exitCode !== 0) {
4567
- 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}`);
4568
4569
  return false;
4569
4570
  }
4570
- log$2.info(`Created GitHub release for ${tag}`);
4571
+ log.info(`Created GitHub release for ${tag}`);
4571
4572
  return true;
4572
4573
  }
4573
4574
  //#endregion
@@ -4584,7 +4585,7 @@ const releaseSimpleCommand = defineCommand({
4584
4585
  },
4585
4586
  verbose: {
4586
4587
  type: "boolean",
4587
- 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)"
4588
4589
  },
4589
4590
  "no-push": {
4590
4591
  type: "boolean",
@@ -4613,7 +4614,7 @@ const releaseSimpleCommand = defineCommand({
4613
4614
  },
4614
4615
  async run({ args }) {
4615
4616
  const cwd = process.cwd();
4616
- const verbose = args.verbose === true || process.env["RELEASE_DEBUG"] === "true";
4617
+ const verbose = args.verbose === true || isEnvVerbose();
4617
4618
  const noRelease = args["no-release"] === true;
4618
4619
  let platform;
4619
4620
  if (!noRelease) {
@@ -4697,12 +4698,12 @@ const ciReporter = {
4697
4698
  const localReporter = {
4698
4699
  groupStart: (_name) => {},
4699
4700
  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(", ")}`)
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(", ")}`)
4706
4707
  };
4707
4708
  function runRunChecks(targetDir, options = {}) {
4708
4709
  const exec = options.execCommand ?? defaultExecCommand;
@@ -4712,6 +4713,7 @@ function runRunChecks(targetDir, options = {}) {
4712
4713
  const isCI = Boolean(process.env["CI"]);
4713
4714
  const failFast = options.failFast ?? !isCI;
4714
4715
  const reporter = isCI ? ciReporter : localReporter;
4716
+ const vc = { verbose: options.verbose ?? false };
4715
4717
  const definedScripts = getScripts(targetDir);
4716
4718
  const addedNames = new Set(add);
4717
4719
  const allChecks = [...CHECKS, ...add.map((name) => ({ name }))];
@@ -4727,11 +4729,13 @@ function runRunChecks(targetDir, options = {}) {
4727
4729
  continue;
4728
4730
  }
4729
4731
  const cmd = check.args ? `pnpm run ${check.name} ${check.args}` : `pnpm run ${check.name}`;
4732
+ debug(vc, `Running: ${cmd} (in ${targetDir})`);
4730
4733
  reporter.groupStart(check.name);
4731
4734
  const start = Date.now();
4732
4735
  const exitCode = exec(cmd, targetDir);
4733
4736
  const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
4734
4737
  reporter.groupEnd();
4738
+ debug(vc, `${check.name}: exit code ${String(exitCode)}, ${elapsed}s`);
4735
4739
  if (exitCode === 0) reporter.passed(check.name, elapsed);
4736
4740
  else {
4737
4741
  reporter.failed(check.name, elapsed);
@@ -4772,13 +4776,19 @@ const runChecksCommand = defineCommand({
4772
4776
  type: "boolean",
4773
4777
  description: "Stop on first failure (default: true in dev, false in CI)",
4774
4778
  required: false
4779
+ },
4780
+ verbose: {
4781
+ type: "boolean",
4782
+ description: "Emit detailed debug logging",
4783
+ required: false
4775
4784
  }
4776
4785
  },
4777
4786
  run({ args }) {
4778
4787
  const exitCode = runRunChecks(path.resolve(args.dir ?? "."), {
4779
4788
  skip: args.skip ? new Set(args.skip.split(",").map((s) => s.trim())) : void 0,
4780
4789
  add: args.add ? args.add.split(",").map((s) => s.trim()) : void 0,
4781
- failFast: args["fail-fast"] ? true : void 0
4790
+ failFast: args["fail-fast"] ? true : void 0,
4791
+ verbose: args.verbose === true || isEnvVerbose()
4782
4792
  });
4783
4793
  process.exitCode = exitCode;
4784
4794
  }
@@ -4795,10 +4805,16 @@ const publishDockerCommand = defineCommand({
4795
4805
  name: "docker:publish",
4796
4806
  description: "Build, tag, and push Docker images for packages with an image:build script"
4797
4807
  },
4798
- args: { "dry-run": {
4799
- type: "boolean",
4800
- description: "Build and tag images but skip login, push, and logout"
4801
- } },
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
+ },
4802
4818
  async run({ args }) {
4803
4819
  const config = {
4804
4820
  cwd: process.cwd(),
@@ -4806,7 +4822,8 @@ const publishDockerCommand = defineCommand({
4806
4822
  registryNamespace: requireEnv("DOCKER_REGISTRY_NAMESPACE"),
4807
4823
  username: requireEnv("DOCKER_USERNAME"),
4808
4824
  password: requireEnv("DOCKER_PASSWORD"),
4809
- dryRun: args["dry-run"] === true
4825
+ dryRun: args["dry-run"] === true,
4826
+ verbose: args.verbose === true || isEnvVerbose()
4810
4827
  };
4811
4828
  runDockerPublish(createRealExecutor(), config);
4812
4829
  }
@@ -4839,6 +4856,10 @@ const dockerBuildCommand = defineCommand({
4839
4856
  type: "string",
4840
4857
  description: "Build a single package by directory path (e.g. packages/server). Useful as an image:build script."
4841
4858
  },
4859
+ verbose: {
4860
+ type: "boolean",
4861
+ description: "Emit detailed debug logging"
4862
+ },
4842
4863
  _: {
4843
4864
  type: "positional",
4844
4865
  required: false,
@@ -4849,6 +4870,7 @@ const dockerBuildCommand = defineCommand({
4849
4870
  const executor = createRealExecutor();
4850
4871
  const rawExtra = args._ ?? [];
4851
4872
  const extraArgs = Array.isArray(rawExtra) ? rawExtra.map(String) : [String(rawExtra)];
4873
+ const verbose = args.verbose === true || isEnvVerbose();
4852
4874
  let cwd = process.cwd();
4853
4875
  let packageDir = args.package;
4854
4876
  if (!packageDir) {
@@ -4859,7 +4881,8 @@ const dockerBuildCommand = defineCommand({
4859
4881
  runDockerBuild(executor, {
4860
4882
  cwd,
4861
4883
  packageDir,
4862
- extraArgs: extraArgs.filter((a) => a.length > 0)
4884
+ extraArgs: extraArgs.filter((a) => a.length > 0),
4885
+ verbose
4863
4886
  });
4864
4887
  }
4865
4888
  });
@@ -5076,12 +5099,6 @@ function writeTempOverlay(content) {
5076
5099
  writeFileSync(filePath, content, "utf-8");
5077
5100
  return filePath;
5078
5101
  }
5079
- function log(message) {
5080
- console.log(message);
5081
- }
5082
- function warn(message) {
5083
- console.warn(message);
5084
- }
5085
5102
  const dockerCheckCommand = defineCommand({
5086
5103
  meta: {
5087
5104
  name: "docker:check",
@@ -5095,12 +5112,16 @@ const dockerCheckCommand = defineCommand({
5095
5112
  "poll-interval": {
5096
5113
  type: "string",
5097
5114
  description: "Interval between polling attempts, in ms (default: 5000)"
5115
+ },
5116
+ verbose: {
5117
+ type: "boolean",
5118
+ description: "Emit detailed debug logging"
5098
5119
  }
5099
5120
  },
5100
5121
  async run({ args }) {
5101
5122
  const cwd = process.cwd();
5102
5123
  if (loadToolingConfig(cwd)?.dockerCheck === false) {
5103
- log("Docker check is disabled in .tooling.json");
5124
+ log.info("Docker check is disabled in .tooling.json");
5104
5125
  return;
5105
5126
  }
5106
5127
  const defaults = computeCheckDefaults(cwd);
@@ -5108,8 +5129,8 @@ const dockerCheckCommand = defineCommand({
5108
5129
  if (!defaults.checkOverlay) {
5109
5130
  const composeCwd = defaults.composeCwd ?? cwd;
5110
5131
  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.");
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.");
5113
5134
  return;
5114
5135
  }
5115
5136
  if (!defaults.services || defaults.services.length === 0) throw new FatalError("No services found in compose files.");
@@ -5122,7 +5143,7 @@ const dockerCheckCommand = defineCommand({
5122
5143
  if (rootPkg?.name) {
5123
5144
  const dockerPackages = detectDockerPackages(fileReader, cwd, rootPkg.name);
5124
5145
  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.`);
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.`);
5126
5147
  }
5127
5148
  }
5128
5149
  const tempOverlayPath = writeTempOverlay(generateCheckOverlay(services));
@@ -5143,7 +5164,8 @@ const dockerCheckCommand = defineCommand({
5143
5164
  buildCwd: defaults.buildCwd,
5144
5165
  healthChecks: defaults.healthChecks ? toHttpHealthChecks(defaults.healthChecks) : [],
5145
5166
  timeoutMs: args.timeout ? Number.parseInt(args.timeout, 10) : defaults.timeoutMs,
5146
- 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()
5147
5169
  };
5148
5170
  const result = await runDockerCheck(createRealExecutor$1(), config);
5149
5171
  if (!result.success) throw new FatalError(`Check failed (${result.reason}): ${result.message}`);
@@ -5159,7 +5181,7 @@ const dockerCheckCommand = defineCommand({
5159
5181
  const main = defineCommand({
5160
5182
  meta: {
5161
5183
  name: "bst",
5162
- version: "0.33.0",
5184
+ version: "0.34.0",
5163
5185
  description: "Bootstrap and maintain standardized TypeScript project tooling"
5164
5186
  },
5165
5187
  subCommands: {
@@ -5175,7 +5197,7 @@ const main = defineCommand({
5175
5197
  "docker:check": dockerCheckCommand
5176
5198
  }
5177
5199
  });
5178
- console.log(`@bensandee/tooling v0.33.0`);
5200
+ console.log(`@bensandee/tooling v0.34.0`);
5179
5201
  async function run() {
5180
5202
  await runMain(main);
5181
5203
  process.exit(process.exitCode ?? 0);
@@ -1,5 +1,40 @@
1
+ import * as clack from "@clack/prompts";
1
2
  import { execSync } from "node:child_process";
2
3
  import { z } from "zod";
4
+ //#region src/utils/log.ts
5
+ const out = (msg) => console.log(msg);
6
+ const isCI = Boolean(process.env["CI"]);
7
+ const log = isCI ? {
8
+ info: out,
9
+ warn: (msg) => out(`[warn] ${msg}`),
10
+ error: (msg) => out(`[error] ${msg}`),
11
+ success: (msg) => out(`✓ ${msg}`)
12
+ } : clack.log;
13
+ function note(body, title) {
14
+ if (isCI) {
15
+ if (title) out(`--- ${title} ---`);
16
+ out(body);
17
+ } else clack.note(body, title);
18
+ }
19
+ //#endregion
20
+ //#region src/utils/debug.ts
21
+ /** Check if verbose/debug mode is enabled via environment variables. */
22
+ function isEnvVerbose() {
23
+ return process.env["TOOLING_DEBUG"] === "true";
24
+ }
25
+ /** Log a debug message when verbose mode is enabled. */
26
+ function debug(config, message) {
27
+ if (config.verbose) log.info(`[debug] ${message}`);
28
+ }
29
+ /** Log the result of an exec call when verbose mode is enabled. */
30
+ function debugExec(config, label, result) {
31
+ if (!config.verbose) return;
32
+ const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
33
+ if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
34
+ if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
35
+ log.info(lines.join("\n"));
36
+ }
37
+ //#endregion
3
38
  //#region src/utils/exec.ts
4
39
  /** Type guard for `execSync` errors that carry stdout/stderr/status. */
5
40
  function isExecSyncError(err) {
@@ -144,6 +179,11 @@ async function runDockerCheck(executor, config) {
144
179
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
145
180
  const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
146
181
  const { compose } = config;
182
+ const vc = { verbose: config.verbose ?? false };
183
+ debug(vc, `Compose files: ${compose.composeFiles.join(", ")}`);
184
+ debug(vc, `Services: ${compose.services.join(", ")}`);
185
+ debug(vc, `Health checks: ${config.healthChecks.map((c) => c.name).join(", ") || "(none)"}`);
186
+ debug(vc, `Timeout: ${String(timeoutMs)}ms, poll interval: ${String(pollIntervalMs)}ms`);
147
187
  const cleanup = () => composeDown(executor, compose);
148
188
  let cleaningUp = false;
149
189
  const gracefulShutdown = () => {
@@ -171,9 +211,11 @@ async function runDockerCheck(executor, config) {
171
211
  const healthStatus = new Map(config.healthChecks.map((c) => [c.name, false]));
172
212
  while (executor.now() - startTime < timeoutMs) {
173
213
  const containers = composePs(executor, compose);
214
+ debug(vc, `compose ps: ${containers.length} container(s)`);
174
215
  let allContainersHealthy = true;
175
216
  for (const service of compose.services) {
176
217
  const status = getContainerHealth(containers, service);
218
+ debug(vc, `Container ${service}: ${status || "(no healthcheck)"}`);
177
219
  if (status === "unhealthy") {
178
220
  executor.logError(`Container ${service} is unhealthy`);
179
221
  composeLogs(executor, compose, service);
@@ -233,4 +275,4 @@ async function runDockerCheck(executor, config) {
233
275
  }
234
276
  }
235
277
  //#endregion
236
- export { composeDown as a, composeUp as c, composeCommand as i, createRealExecutor as l, checkHttpHealth as n, composeLogs as o, getContainerHealth as r, composePs as s, runDockerCheck as t, isExecSyncError as u };
278
+ export { composeDown as a, composeUp as c, debug as d, debugExec as f, note as h, composeCommand as i, createRealExecutor as l, log as m, checkHttpHealth as n, composeLogs as o, isEnvVerbose as p, getContainerHealth as r, composePs as s, runDockerCheck as t, isExecSyncError as u };
@@ -67,6 +67,8 @@ interface CheckConfig {
67
67
  timeoutMs?: number;
68
68
  /** Interval between polling attempts, in ms. Default: 5000. */
69
69
  pollIntervalMs?: number;
70
+ /** If true, emit detailed debug logging. */
71
+ verbose?: boolean;
70
72
  }
71
73
  /** Result of the docker check run. */
72
74
  type CheckResult = {
@@ -1,2 +1,2 @@
1
- import { a as composeDown, c as composeUp, i as composeCommand, l as createRealExecutor, n as checkHttpHealth, o as composeLogs, r as getContainerHealth, s as composePs, t as runDockerCheck } from "../check-B2AAPCBO.mjs";
1
+ import { a as composeDown, c as composeUp, i as composeCommand, l as createRealExecutor, n as checkHttpHealth, o as composeLogs, r as getContainerHealth, s as composePs, t as runDockerCheck } from "../check-Ceom_OgJ.mjs";
2
2
  export { checkHttpHealth, composeCommand, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runDockerCheck };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "bst": "./dist/bin.mjs"