@bensandee/tooling 0.26.0 → 0.27.1

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,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { l as createRealExecutor$1, t as runDockerCheck, u as isExecSyncError } from "./check-D41R218h.mjs";
3
3
  import { defineCommand, runMain } from "citty";
4
- import * as p from "@clack/prompts";
4
+ import * as clack from "@clack/prompts";
5
+ import { isCancel, select } from "@clack/prompts";
5
6
  import path from "node:path";
6
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
7
8
  import JSON5 from "json5";
@@ -12,6 +13,22 @@ import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify } fr
12
13
  import { execSync } from "node:child_process";
13
14
  import picomatch from "picomatch";
14
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
15
32
  //#region src/types.ts
16
33
  const LEGACY_TOOLS = [
17
34
  "eslint",
@@ -205,6 +222,8 @@ function computeDefaults(targetDir) {
205
222
  ci: detectCiPlatform(targetDir),
206
223
  setupRenovate: true,
207
224
  releaseStrategy: "none",
225
+ publishNpm: false,
226
+ publishDocker: false,
208
227
  projectType: isMonorepo ? "default" : detectProjectType(targetDir),
209
228
  detectPackageTypes: true
210
229
  };
@@ -257,10 +276,10 @@ function getMonorepoPackages(targetDir) {
257
276
  //#endregion
258
277
  //#region src/prompts/init-prompts.ts
259
278
  function isCancelled(value) {
260
- return p.isCancel(value);
279
+ return clack.isCancel(value);
261
280
  }
262
281
  async function runInitPrompts(targetDir, saved) {
263
- p.intro("@bensandee/tooling repo:sync");
282
+ clack.intro("@bensandee/tooling repo:sync");
264
283
  const existingPkg = readPackageJson(targetDir);
265
284
  const detected = detectProject(targetDir);
266
285
  const defaults = computeDefaults(targetDir);
@@ -277,7 +296,7 @@ async function runInitPrompts(targetDir, saved) {
277
296
  const projectType = saved?.projectType ?? defaults.projectType;
278
297
  const detectPackageTypes = saved?.detectPackageTypes ?? defaults.detectPackageTypes;
279
298
  if (detected.legacyConfigs.some((l) => l.tool === "prettier") && isFirstInit) {
280
- const formatterAnswer = await p.select({
299
+ const formatterAnswer = await clack.select({
281
300
  message: "Existing Prettier config found. Keep Prettier or migrate to oxfmt?",
282
301
  initialValue: "prettier",
283
302
  options: [{
@@ -290,14 +309,14 @@ async function runInitPrompts(targetDir, saved) {
290
309
  }]
291
310
  });
292
311
  if (isCancelled(formatterAnswer)) {
293
- p.cancel("Cancelled.");
312
+ clack.cancel("Cancelled.");
294
313
  process.exit(0);
295
314
  }
296
315
  formatter = formatterAnswer;
297
316
  }
298
317
  const detectedCi = detectCiPlatform(targetDir);
299
318
  if (isFirstInit && detectedCi === "none") {
300
- const ciAnswer = await p.select({
319
+ const ciAnswer = await clack.select({
301
320
  message: "CI workflow",
302
321
  initialValue: "forgejo",
303
322
  options: [
@@ -316,14 +335,14 @@ async function runInitPrompts(targetDir, saved) {
316
335
  ]
317
336
  });
318
337
  if (isCancelled(ciAnswer)) {
319
- p.cancel("Cancelled.");
338
+ clack.cancel("Cancelled.");
320
339
  process.exit(0);
321
340
  }
322
341
  ci = ciAnswer;
323
342
  }
324
343
  const hasExistingRelease = detected.hasReleaseItConfig || detected.hasSimpleReleaseConfig || detected.hasChangesetsConfig;
325
344
  if (isFirstInit && !hasExistingRelease) {
326
- const releaseAnswer = await p.select({
345
+ const releaseAnswer = await clack.select({
327
346
  message: "Release management",
328
347
  initialValue: defaults.releaseStrategy,
329
348
  options: [
@@ -349,12 +368,40 @@ async function runInitPrompts(targetDir, saved) {
349
368
  ]
350
369
  });
351
370
  if (isCancelled(releaseAnswer)) {
352
- p.cancel("Cancelled.");
371
+ clack.cancel("Cancelled.");
353
372
  process.exit(0);
354
373
  }
355
374
  releaseStrategy = releaseAnswer;
356
375
  }
357
- p.outro("Configuration complete!");
376
+ let publishNpm = saved?.publishNpm ?? false;
377
+ if (isFirstInit && releaseStrategy !== "none") {
378
+ if (getPublishablePackages(targetDir, structure).length > 0) {
379
+ const answer = await clack.confirm({
380
+ message: "Publish packages to npm?",
381
+ initialValue: false
382
+ });
383
+ if (isCancelled(answer)) {
384
+ clack.cancel("Cancelled.");
385
+ process.exit(0);
386
+ }
387
+ publishNpm = answer;
388
+ }
389
+ }
390
+ let publishDocker = saved?.publishDocker ?? false;
391
+ if (isFirstInit) {
392
+ if (existsSync(path.join(targetDir, "Dockerfile")) || existsSync(path.join(targetDir, "docker/Dockerfile"))) {
393
+ const answer = await clack.confirm({
394
+ message: "Publish Docker images to a registry?",
395
+ initialValue: false
396
+ });
397
+ if (isCancelled(answer)) {
398
+ clack.cancel("Cancelled.");
399
+ process.exit(0);
400
+ }
401
+ publishDocker = answer;
402
+ }
403
+ }
404
+ clack.outro("Configuration complete!");
358
405
  return {
359
406
  name,
360
407
  isNew: !isExisting,
@@ -365,6 +412,8 @@ async function runInitPrompts(targetDir, saved) {
365
412
  ci,
366
413
  setupRenovate,
367
414
  releaseStrategy,
415
+ publishNpm,
416
+ publishDocker,
368
417
  projectType,
369
418
  detectPackageTypes,
370
419
  targetDir
@@ -482,51 +531,53 @@ function createDryRunContext(config) {
482
531
  //#region src/utils/tooling-config.ts
483
532
  const CONFIG_FILE = ".tooling.json";
484
533
  const DeclarativeHealthCheckSchema = z.object({
485
- name: z.string(),
486
- url: z.string(),
487
- status: z.number().int().optional()
534
+ name: z.string().meta({ description: "Service name" }),
535
+ url: z.string().meta({ description: "Health check URL" }),
536
+ status: z.number().int().optional().meta({ description: "Expected HTTP status code" })
488
537
  });
489
538
  const DockerCheckConfigSchema = z.object({
490
- composeFiles: z.array(z.string()).optional(),
491
- envFile: z.string().optional(),
492
- services: z.array(z.string()).optional(),
493
- healthChecks: z.array(DeclarativeHealthCheckSchema).optional(),
494
- buildCommand: z.string().optional(),
495
- buildCwd: z.string().optional(),
496
- timeoutMs: z.number().int().positive().optional(),
497
- pollIntervalMs: z.number().int().positive().optional()
539
+ composeFiles: z.array(z.string()).optional().meta({ description: "Compose files to use" }),
540
+ envFile: z.string().optional().meta({ description: "Environment file for compose" }),
541
+ services: z.array(z.string()).optional().meta({ description: "Services to check (default: all)" }),
542
+ healthChecks: z.array(DeclarativeHealthCheckSchema).optional().meta({ description: "Health check definitions" }),
543
+ buildCommand: z.string().optional().meta({ description: "Command to build images before checking" }),
544
+ buildCwd: z.string().optional().meta({ description: "Working directory for build command" }),
545
+ timeoutMs: z.number().int().positive().optional().meta({ description: "Overall timeout in milliseconds" }),
546
+ pollIntervalMs: z.number().int().positive().optional().meta({ description: "Poll interval in milliseconds" })
498
547
  });
499
548
  const ToolingConfigSchema = z.strictObject({
500
- $schema: z.string().optional(),
501
- structure: z.enum(["single", "monorepo"]).optional(),
502
- useEslintPlugin: z.boolean().optional(),
503
- formatter: z.enum(["oxfmt", "prettier"]).optional(),
504
- setupVitest: z.boolean().optional(),
549
+ $schema: z.string().optional().meta({ description: "JSON Schema reference (ignored by tooling)" }),
550
+ structure: z.enum(["single", "monorepo"]).optional().meta({ description: "Project structure" }),
551
+ useEslintPlugin: z.boolean().optional().meta({ description: "Include @bensandee/eslint-plugin oxlint plugin" }),
552
+ formatter: z.enum(["oxfmt", "prettier"]).optional().meta({ description: "Formatter choice" }),
553
+ setupVitest: z.boolean().optional().meta({ description: "Generate vitest config and example test" }),
505
554
  ci: z.enum([
506
555
  "github",
507
556
  "forgejo",
508
557
  "none"
509
- ]).optional(),
510
- setupRenovate: z.boolean().optional(),
558
+ ]).optional().meta({ description: "CI platform" }),
559
+ setupRenovate: z.boolean().optional().meta({ description: "Generate Renovate config" }),
511
560
  releaseStrategy: z.enum([
512
561
  "release-it",
513
562
  "simple",
514
563
  "changesets",
515
564
  "none"
516
- ]).optional(),
565
+ ]).optional().meta({ description: "Release management strategy" }),
566
+ publishNpm: z.boolean().optional().meta({ description: "Publish packages to npm (opt-in)" }),
567
+ publishDocker: z.boolean().optional().meta({ description: "Publish Docker images to a registry (opt-in)" }),
517
568
  projectType: z.enum([
518
569
  "default",
519
570
  "node",
520
571
  "react",
521
572
  "library"
522
- ]).optional(),
523
- detectPackageTypes: z.boolean().optional(),
524
- setupDocker: z.boolean().optional(),
573
+ ]).optional().meta({ description: "Project type (determines tsconfig base)" }),
574
+ detectPackageTypes: z.boolean().optional().meta({ description: "Auto-detect project types for monorepo packages" }),
575
+ setupDocker: z.boolean().optional().meta({ description: "Generate Docker build/check scripts" }),
525
576
  docker: z.record(z.string(), z.object({
526
- dockerfile: z.string(),
527
- context: z.string().default(".")
528
- })).optional(),
529
- dockerCheck: z.union([z.literal(false), DockerCheckConfigSchema]).optional()
577
+ dockerfile: z.string().meta({ description: "Path to Dockerfile relative to package" }),
578
+ context: z.string().default(".").meta({ description: "Docker build context relative to package" })
579
+ })).optional().meta({ description: "Docker package overrides (package name → config)" }),
580
+ dockerCheck: z.union([z.literal(false), DockerCheckConfigSchema]).optional().meta({ description: "Docker health check configuration or false to disable" })
530
581
  });
531
582
  /** Load saved tooling config from the target directory. Returns undefined if missing, throws on invalid. */
532
583
  function loadToolingConfig(targetDir) {
@@ -546,6 +597,8 @@ const OVERRIDE_KEYS = [
546
597
  "ci",
547
598
  "setupRenovate",
548
599
  "releaseStrategy",
600
+ "publishNpm",
601
+ "publishDocker",
549
602
  "projectType",
550
603
  "detectPackageTypes"
551
604
  ];
@@ -591,6 +644,8 @@ function mergeWithSavedConfig(detected, saved) {
591
644
  ci: saved.ci ?? detected.ci,
592
645
  setupRenovate: saved.setupRenovate ?? detected.setupRenovate,
593
646
  releaseStrategy: saved.releaseStrategy ?? detected.releaseStrategy,
647
+ publishNpm: saved.publishNpm ?? detected.publishNpm,
648
+ publishDocker: saved.publishDocker ?? detected.publishDocker,
594
649
  projectType: saved.projectType ?? detected.projectType,
595
650
  detectPackageTypes: saved.detectPackageTypes ?? detected.detectPackageTypes
596
651
  };
@@ -609,6 +664,10 @@ function ensureSchemaComment(content, ci) {
609
664
  if (content.includes("yaml-language-server")) return content;
610
665
  return FORGEJO_SCHEMA_COMMENT + content;
611
666
  }
667
+ /** Migrate content from old tooling binary name to new. */
668
+ function migrateToolingBinary(content) {
669
+ return content.replaceAll("pnpm exec tooling ", "pnpm exec bst ");
670
+ }
612
671
  /** Check if a YAML file has an opt-out comment in the first 10 lines. */
613
672
  function isToolingIgnored(content) {
614
673
  return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
@@ -773,7 +832,7 @@ jobs:
773
832
  DOCKER_REGISTRY_NAMESPACE: ${actionsExpr$2("vars.DOCKER_REGISTRY_NAMESPACE")}
774
833
  DOCKER_USERNAME: ${actionsExpr$2("secrets.DOCKER_USERNAME")}
775
834
  DOCKER_PASSWORD: ${actionsExpr$2("secrets.DOCKER_PASSWORD")}
776
- run: pnpm exec tooling docker:publish
835
+ run: pnpm exec bst docker:publish
777
836
  `;
778
837
  }
779
838
  function requiredDeploySteps() {
@@ -796,7 +855,7 @@ function requiredDeploySteps() {
796
855
  },
797
856
  {
798
857
  match: { run: "docker:publish" },
799
- step: { run: "pnpm exec tooling docker:publish" }
858
+ step: { run: "pnpm exec bst docker:publish" }
800
859
  }
801
860
  ];
802
861
  }
@@ -833,7 +892,7 @@ function hasDockerPackages(ctx) {
833
892
  }
834
893
  async function generateDeployCi(ctx) {
835
894
  const filePath = "deploy-ci";
836
- if (!hasDockerPackages(ctx) || ctx.config.ci === "none") return {
895
+ if (!ctx.config.publishDocker || ctx.config.ci === "none") return {
837
896
  filePath,
838
897
  action: "skipped",
839
898
  description: "Deploy CI workflow not applicable"
@@ -843,30 +902,33 @@ async function generateDeployCi(ctx) {
843
902
  const nodeVersionYaml = hasEnginesNode$2(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
844
903
  const content = deployWorkflow(ctx.config.ci, nodeVersionYaml);
845
904
  if (ctx.exists(workflowPath)) {
846
- const existing = ctx.read(workflowPath);
847
- if (existing) {
848
- if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) return {
849
- filePath: workflowPath,
850
- action: "skipped",
851
- description: "Deploy workflow already up to date"
852
- };
853
- const merged = mergeWorkflowSteps(existing, "deploy", requiredDeploySteps());
854
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
855
- if (withComment === content) {
856
- ctx.write(workflowPath, content);
905
+ const raw = ctx.read(workflowPath);
906
+ if (raw) {
907
+ const existing = migrateToolingBinary(raw);
908
+ if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) {
909
+ if (existing !== raw) {
910
+ ctx.write(workflowPath, ensureSchemaComment(existing, ctx.config.ci));
911
+ return {
912
+ filePath: workflowPath,
913
+ action: "updated",
914
+ description: "Migrated tooling binary name in deploy workflow"
915
+ };
916
+ }
857
917
  return {
858
918
  filePath: workflowPath,
859
- action: "updated",
860
- description: "Added missing steps to deploy workflow"
919
+ action: "skipped",
920
+ description: "Deploy workflow already up to date"
861
921
  };
862
922
  }
863
- if (await ctx.confirmOverwrite(workflowPath) === "skip") {
864
- if (merged.changed || withComment !== merged.content) {
923
+ const merged = mergeWorkflowSteps(existing, "deploy", requiredDeploySteps());
924
+ const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
925
+ if (!merged.changed) {
926
+ if (withComment !== raw) {
865
927
  ctx.write(workflowPath, withComment);
866
928
  return {
867
929
  filePath: workflowPath,
868
930
  action: "updated",
869
- description: "Added missing steps to deploy workflow"
931
+ description: existing !== raw ? "Migrated tooling binary name in deploy workflow" : "Added schema comment to deploy workflow"
870
932
  };
871
933
  }
872
934
  return {
@@ -875,11 +937,11 @@ async function generateDeployCi(ctx) {
875
937
  description: "Existing deploy workflow preserved"
876
938
  };
877
939
  }
878
- ctx.write(workflowPath, content);
940
+ ctx.write(workflowPath, withComment);
879
941
  return {
880
942
  filePath: workflowPath,
881
943
  action: "updated",
882
- description: "Replaced deploy workflow with updated template"
944
+ description: "Added missing steps to deploy workflow"
883
945
  };
884
946
  }
885
947
  return {
@@ -904,10 +966,10 @@ const STANDARD_SCRIPTS_SINGLE = {
904
966
  test: "vitest run",
905
967
  lint: "oxlint",
906
968
  knip: "knip",
907
- check: "pnpm exec tooling checks:run",
969
+ check: "bst checks:run",
908
970
  "ci:check": "pnpm check --skip 'docker:*'",
909
- "tooling:check": "pnpm exec tooling repo:sync --check",
910
- "tooling:sync": "pnpm exec tooling repo:sync"
971
+ "tooling:check": "bst repo:sync --check",
972
+ "tooling:sync": "bst repo:sync"
911
973
  };
912
974
  const STANDARD_SCRIPTS_MONOREPO = {
913
975
  build: "pnpm -r build",
@@ -915,20 +977,28 @@ const STANDARD_SCRIPTS_MONOREPO = {
915
977
  typecheck: "pnpm -r --parallel run typecheck",
916
978
  lint: "oxlint",
917
979
  knip: "knip",
918
- check: "pnpm exec tooling checks:run",
980
+ check: "bst checks:run",
919
981
  "ci:check": "pnpm check --skip 'docker:*'",
920
- "tooling:check": "pnpm exec tooling repo:sync --check",
921
- "tooling:sync": "pnpm exec tooling repo:sync"
982
+ "tooling:check": "bst repo:sync --check",
983
+ "tooling:sync": "bst repo:sync"
922
984
  };
923
985
  /** Scripts that tooling owns — map from script name to keyword that must appear in the value. */
924
986
  const MANAGED_SCRIPTS = {
925
- check: "checks:run",
987
+ check: "bst checks:run",
926
988
  "ci:check": "pnpm check",
927
- "tooling:check": "repo:sync --check",
928
- "tooling:sync": "repo:sync",
929
- "docker:build": "docker:build",
930
- "docker:check": "docker:check"
989
+ "tooling:check": "bst repo:sync --check",
990
+ "tooling:sync": "bst repo:sync",
991
+ "trigger-release": "bst release:trigger",
992
+ "docker:build": "bst docker:build",
993
+ "docker:check": "bst docker:check"
931
994
  };
995
+ /** Check if an existing script value satisfies a managed script requirement.
996
+ * Accepts both `bst <cmd>` and `bin.mjs <cmd>` (used in the tooling repo itself). */
997
+ function matchesManagedScript(scriptValue, expectedFragment) {
998
+ if (scriptValue.includes(expectedFragment)) return true;
999
+ const binMjsFragment = expectedFragment.replace(/^bst /, "bin.mjs ");
1000
+ return scriptValue.includes(binMjsFragment);
1001
+ }
932
1002
  /** Deprecated scripts to remove during migration. */
933
1003
  const DEPRECATED_SCRIPTS = ["tooling:init", "tooling:update"];
934
1004
  /** DevDeps that belong in every project (single repo) or per-package (monorepo). */
@@ -983,8 +1053,8 @@ function addReleaseDeps(deps, config) {
983
1053
  function getAddedDevDepNames(config) {
984
1054
  const deps = { ...ROOT_DEV_DEPS };
985
1055
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
986
- deps["@bensandee/config"] = "0.9.0";
987
- deps["@bensandee/tooling"] = "0.26.0";
1056
+ deps["@bensandee/config"] = "0.9.1";
1057
+ deps["@bensandee/tooling"] = "0.27.1";
988
1058
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
989
1059
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
990
1060
  addReleaseDeps(deps, config);
@@ -1001,15 +1071,15 @@ async function generatePackageJson(ctx) {
1001
1071
  format: formatScript
1002
1072
  };
1003
1073
  if (ctx.config.releaseStrategy === "changesets") allScripts["changeset"] = "changeset";
1004
- if (ctx.config.releaseStrategy !== "none" && ctx.config.releaseStrategy !== "changesets") allScripts["trigger-release"] = "pnpm exec tooling release:trigger";
1074
+ if (ctx.config.releaseStrategy !== "none" && ctx.config.releaseStrategy !== "changesets") allScripts["trigger-release"] = "bst release:trigger";
1005
1075
  if (hasDockerPackages(ctx)) {
1006
- allScripts["docker:build"] = "pnpm exec tooling docker:build";
1007
- allScripts["docker:check"] = "pnpm exec tooling docker:check";
1076
+ allScripts["docker:build"] = "bst docker:build";
1077
+ allScripts["docker:check"] = "bst docker:check";
1008
1078
  }
1009
1079
  const devDeps = { ...ROOT_DEV_DEPS };
1010
1080
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1011
- devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.0";
1012
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.26.0";
1081
+ devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1082
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.27.1";
1013
1083
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1014
1084
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
1015
1085
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1027,10 +1097,14 @@ async function generatePackageJson(ctx) {
1027
1097
  changes.push("set type: \"module\"");
1028
1098
  }
1029
1099
  const existingScripts = pkg.scripts ?? {};
1100
+ for (const [key, value] of Object.entries(existingScripts)) if (typeof value === "string" && value.includes("pnpm exec tooling ")) {
1101
+ existingScripts[key] = migrateToolingBinary(value);
1102
+ changes.push(`migrated script: ${key}`);
1103
+ }
1030
1104
  for (const [key, value] of Object.entries(allScripts)) if (!(key in existingScripts)) {
1031
1105
  existingScripts[key] = value;
1032
1106
  changes.push(`added script: ${key}`);
1033
- } else if (key in MANAGED_SCRIPTS && !existingScripts[key]?.includes(MANAGED_SCRIPTS[key])) {
1107
+ } else if (key in MANAGED_SCRIPTS && !matchesManagedScript(existingScripts[key] ?? "", MANAGED_SCRIPTS[key] ?? "")) {
1034
1108
  existingScripts[key] = value;
1035
1109
  changes.push(`updated script: ${key}`);
1036
1110
  }
@@ -1770,8 +1844,14 @@ const ClaudeSettingsSchema = z.object({
1770
1844
  });
1771
1845
  function parseClaudeSettings(raw) {
1772
1846
  try {
1773
- const result = ClaudeSettingsSchema.safeParse(JSON.parse(raw));
1774
- return result.success ? result.data : void 0;
1847
+ const json = JSON.parse(raw);
1848
+ const rawResult = z.record(z.string(), z.unknown()).safeParse(json);
1849
+ const settingsResult = ClaudeSettingsSchema.safeParse(json);
1850
+ if (!rawResult.success || !settingsResult.success) return void 0;
1851
+ return {
1852
+ settings: settingsResult.data,
1853
+ rawJson: rawResult.data
1854
+ };
1775
1855
  } catch {
1776
1856
  return;
1777
1857
  }
@@ -1830,7 +1910,6 @@ function buildSettings(ctx) {
1830
1910
  "Bash(wc *)",
1831
1911
  "Bash(test *)",
1832
1912
  "Bash([ *)",
1833
- "Bash(find *)",
1834
1913
  "Bash(grep *)",
1835
1914
  "Bash(which *)",
1836
1915
  "Bash(node -e *)",
@@ -1897,7 +1976,7 @@ function buildSettings(ctx) {
1897
1976
  instructions: [
1898
1977
  "Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
1899
1978
  "No typecasts (as/any). Use zod schemas, type guards, or narrowing instead.",
1900
- "Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option.",
1979
+ "Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option. For no-empty-function: add a `{ /* no-op */ }` comment body instead of a disable comment.",
1901
1980
  "Prefer extensionless imports; if an extension is required, use .ts over .js."
1902
1981
  ],
1903
1982
  enabledPlugins,
@@ -1921,27 +2000,44 @@ function writeOrMergeSettings(ctx, filePath, generated) {
1921
2000
  action: "skipped",
1922
2001
  description: "Could not parse existing settings"
1923
2002
  };
1924
- const missingAllow = generated.permissions.allow.filter((rule) => !parsed.permissions.allow.includes(rule));
1925
- const missingDeny = generated.permissions.deny.filter((rule) => !parsed.permissions.deny.includes(rule));
1926
- const missingInstructions = generated.instructions.filter((inst) => !parsed.instructions.includes(inst));
1927
- const missingPlugins = Object.entries(generated.enabledPlugins).filter(([key]) => !(key in parsed.enabledPlugins));
1928
- const missingMarketplaces = Object.entries(generated.extraKnownMarketplaces).filter(([key]) => !(key in parsed.extraKnownMarketplaces));
1929
- const added = missingAllow.length + missingDeny.length + missingInstructions.length + missingPlugins.length + missingMarketplaces.length;
1930
- if (added === 0) return {
2003
+ const { settings, rawJson } = parsed;
2004
+ const missingAllow = generated.permissions.allow.filter((rule) => !settings.permissions.allow.includes(rule));
2005
+ const missingDeny = generated.permissions.deny.filter((rule) => !settings.permissions.deny.includes(rule));
2006
+ const mergedInstructions = [...settings.instructions];
2007
+ let instructionChanges = 0;
2008
+ for (const inst of generated.instructions) {
2009
+ if (mergedInstructions.includes(inst)) continue;
2010
+ const prefixIdx = mergedInstructions.findIndex((e) => inst.startsWith(e) || e.startsWith(inst));
2011
+ if (prefixIdx !== -1) mergedInstructions[prefixIdx] = inst;
2012
+ else mergedInstructions.push(inst);
2013
+ instructionChanges++;
2014
+ }
2015
+ const missingPlugins = Object.entries(generated.enabledPlugins).filter(([key]) => !(key in settings.enabledPlugins));
2016
+ const missingMarketplaces = Object.entries(generated.extraKnownMarketplaces).filter(([key]) => !(key in settings.extraKnownMarketplaces));
2017
+ const changed = missingAllow.length + missingDeny.length + instructionChanges + missingPlugins.length + missingMarketplaces.length;
2018
+ if (changed === 0) return {
1931
2019
  filePath,
1932
2020
  action: "skipped",
1933
2021
  description: "Already has all rules and instructions"
1934
2022
  };
1935
- parsed.permissions.allow = [...parsed.permissions.allow, ...missingAllow];
1936
- parsed.permissions.deny = [...parsed.permissions.deny, ...missingDeny];
1937
- parsed.instructions = [...parsed.instructions, ...missingInstructions];
1938
- for (const [key, value] of missingPlugins) parsed.enabledPlugins[key] = value;
1939
- for (const [key, value] of missingMarketplaces) parsed.extraKnownMarketplaces[key] = value;
1940
- ctx.write(filePath, serializeSettings$1(parsed));
2023
+ rawJson["permissions"] = {
2024
+ allow: [...settings.permissions.allow, ...missingAllow],
2025
+ deny: [...settings.permissions.deny, ...missingDeny]
2026
+ };
2027
+ rawJson["instructions"] = mergedInstructions;
2028
+ const updatedPlugins = { ...settings.enabledPlugins };
2029
+ for (const [key, value] of missingPlugins) updatedPlugins[key] = value;
2030
+ const updatedMarketplaces = { ...settings.extraKnownMarketplaces };
2031
+ for (const [key, value] of missingMarketplaces) updatedMarketplaces[key] = value;
2032
+ if (Object.keys(updatedPlugins).length > 0) rawJson["enabledPlugins"] = updatedPlugins;
2033
+ else delete rawJson["enabledPlugins"];
2034
+ if (Object.keys(updatedMarketplaces).length > 0) rawJson["extraKnownMarketplaces"] = updatedMarketplaces;
2035
+ else delete rawJson["extraKnownMarketplaces"];
2036
+ ctx.write(filePath, JSON.stringify(rawJson, null, 2) + "\n");
1941
2037
  return {
1942
2038
  filePath,
1943
2039
  action: "updated",
1944
- description: `Added ${String(added)} rules/instructions`
2040
+ description: `Updated ${String(changed)} rules/instructions`
1945
2041
  };
1946
2042
  }
1947
2043
  ctx.write(filePath, serializeSettings$1(generated));
@@ -2115,13 +2211,13 @@ permissions:
2115
2211
  - name: Release
2116
2212
  env:
2117
2213
  GITHUB_TOKEN: \${{ github.token }}
2118
- run: pnpm exec tooling release:simple` : `
2214
+ run: pnpm exec bst release:simple` : `
2119
2215
  - name: Release
2120
2216
  env:
2121
2217
  FORGEJO_SERVER_URL: \${{ github.server_url }}
2122
2218
  FORGEJO_REPOSITORY: \${{ github.repository }}
2123
2219
  FORGEJO_TOKEN: \${{ secrets.FORGEJO_TOKEN }}
2124
- run: pnpm exec tooling release:simple`;
2220
+ run: pnpm exec bst release:simple`;
2125
2221
  return `${workflowSchemaComment(ci)}name: Release
2126
2222
  on:
2127
2223
  workflow_dispatch:
@@ -2161,7 +2257,7 @@ function changesetsReleaseStep(ci, publishesNpm) {
2161
2257
  FORGEJO_TOKEN: actionsExpr("secrets.FORGEJO_TOKEN"),
2162
2258
  ...publishesNpm && { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2163
2259
  },
2164
- run: "pnpm exec tooling release:changesets"
2260
+ run: "pnpm exec bst release:changesets"
2165
2261
  }
2166
2262
  };
2167
2263
  }
@@ -2209,13 +2305,13 @@ function requiredReleaseSteps(strategy, nodeVersionYaml, publishesNpm) {
2209
2305
  case "simple":
2210
2306
  steps.push({
2211
2307
  match: { run: "release:simple" },
2212
- step: { run: "pnpm exec tooling release:simple" }
2308
+ step: { run: "pnpm exec bst release:simple" }
2213
2309
  });
2214
2310
  break;
2215
2311
  case "changesets":
2216
2312
  steps.push({
2217
2313
  match: { run: "changeset" },
2218
- step: { run: "pnpm exec tooling release:changesets" }
2314
+ step: { run: "pnpm exec bst release:changesets" }
2219
2315
  });
2220
2316
  break;
2221
2317
  }
@@ -2230,18 +2326,30 @@ function buildWorkflow(strategy, ci, nodeVersionYaml, publishesNpm) {
2230
2326
  }
2231
2327
  function generateChangesetsReleaseCi(ctx, publishesNpm) {
2232
2328
  const ciPath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
2233
- const existing = ctx.read(ciPath);
2234
- if (!existing) return {
2329
+ const raw = ctx.read(ciPath);
2330
+ if (!raw) return {
2235
2331
  filePath: ciPath,
2236
2332
  action: "skipped",
2237
2333
  description: "CI workflow not found — run check generator first"
2238
2334
  };
2335
+ const existing = migrateToolingBinary(raw);
2239
2336
  const merged = mergeWorkflowSteps(existing, "check", [changesetsReleaseStep(ctx.config.ci, publishesNpm)]);
2240
- if (!merged.changed) return {
2241
- filePath: ciPath,
2242
- action: "skipped",
2243
- description: "Release step in CI workflow already up to date"
2244
- };
2337
+ if (!merged.changed) {
2338
+ if (existing !== raw) {
2339
+ const withComment = ensureSchemaComment(existing, ctx.config.ci);
2340
+ ctx.write(ciPath, withComment);
2341
+ return {
2342
+ filePath: ciPath,
2343
+ action: "updated",
2344
+ description: "Migrated tooling binary name in CI workflow"
2345
+ };
2346
+ }
2347
+ return {
2348
+ filePath: ciPath,
2349
+ action: "skipped",
2350
+ description: "Release step in CI workflow already up to date"
2351
+ };
2352
+ }
2245
2353
  const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2246
2354
  ctx.write(ciPath, withComment);
2247
2355
  return {
@@ -2257,7 +2365,7 @@ async function generateReleaseCi(ctx) {
2257
2365
  action: "skipped",
2258
2366
  description: "Release CI workflow not applicable"
2259
2367
  };
2260
- const publishesNpm = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson).length > 0;
2368
+ const publishesNpm = ctx.config.publishNpm === true;
2261
2369
  if (ctx.config.releaseStrategy === "changesets") return generateChangesetsReleaseCi(ctx, publishesNpm);
2262
2370
  const isGitHub = ctx.config.ci === "github";
2263
2371
  const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
@@ -2269,30 +2377,33 @@ async function generateReleaseCi(ctx) {
2269
2377
  description: "Release CI workflow not applicable"
2270
2378
  };
2271
2379
  if (ctx.exists(workflowPath)) {
2272
- const existing = ctx.read(workflowPath);
2273
- if (existing) {
2274
- if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) return {
2275
- filePath: workflowPath,
2276
- action: "skipped",
2277
- description: "Release workflow already up to date"
2278
- };
2279
- const merged = mergeWorkflowSteps(existing, "release", requiredReleaseSteps(ctx.config.releaseStrategy, nodeVersionYaml, publishesNpm));
2280
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2281
- if (withComment === content) {
2282
- ctx.write(workflowPath, content);
2380
+ const raw = ctx.read(workflowPath);
2381
+ if (raw) {
2382
+ const existing = migrateToolingBinary(raw);
2383
+ if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) {
2384
+ if (existing !== raw) {
2385
+ ctx.write(workflowPath, ensureSchemaComment(existing, ctx.config.ci));
2386
+ return {
2387
+ filePath: workflowPath,
2388
+ action: "updated",
2389
+ description: "Migrated tooling binary name in release workflow"
2390
+ };
2391
+ }
2283
2392
  return {
2284
2393
  filePath: workflowPath,
2285
- action: "updated",
2286
- description: "Added missing steps to release workflow"
2394
+ action: "skipped",
2395
+ description: "Release workflow already up to date"
2287
2396
  };
2288
2397
  }
2289
- if (await ctx.confirmOverwrite(workflowPath) === "skip") {
2290
- if (merged.changed || withComment !== merged.content) {
2398
+ const merged = mergeWorkflowSteps(existing, "release", requiredReleaseSteps(ctx.config.releaseStrategy, nodeVersionYaml, publishesNpm));
2399
+ const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2400
+ if (!merged.changed) {
2401
+ if (withComment !== raw) {
2291
2402
  ctx.write(workflowPath, withComment);
2292
2403
  return {
2293
2404
  filePath: workflowPath,
2294
2405
  action: "updated",
2295
- description: "Added missing steps to release workflow"
2406
+ description: existing !== raw ? "Migrated tooling binary name in release workflow" : "Added schema comment to release workflow"
2296
2407
  };
2297
2408
  }
2298
2409
  return {
@@ -2301,11 +2412,11 @@ async function generateReleaseCi(ctx) {
2301
2412
  description: "Existing release workflow preserved"
2302
2413
  };
2303
2414
  }
2304
- ctx.write(workflowPath, content);
2415
+ ctx.write(workflowPath, withComment);
2305
2416
  return {
2306
2417
  filePath: workflowPath,
2307
2418
  action: "updated",
2308
- description: "Replaced release workflow with updated template"
2419
+ description: "Added missing steps to release workflow"
2309
2420
  };
2310
2421
  }
2311
2422
  return {
@@ -3085,15 +3196,19 @@ function contextAsDockerReader(ctx) {
3085
3196
  }
3086
3197
  /** Log what was detected so the user understands generator decisions. */
3087
3198
  function logDetectionSummary(ctx) {
3088
- const dockerPackages = detectDockerPackages(contextAsDockerReader(ctx), ctx.targetDir, ctx.config.name);
3089
- if (dockerPackages.length > 0) p.log.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3090
- const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
3091
- if (publishable.length > 0) p.log.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3199
+ if (ctx.config.publishDocker) {
3200
+ const dockerPackages = detectDockerPackages(contextAsDockerReader(ctx), ctx.targetDir, ctx.config.name);
3201
+ if (dockerPackages.length > 0) log$2.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3202
+ }
3203
+ if (ctx.config.publishNpm) {
3204
+ const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
3205
+ if (publishable.length > 0) log$2.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3206
+ }
3092
3207
  }
3093
3208
  async function runInit(config, options = {}) {
3094
3209
  const detected = detectProject(config.targetDir);
3095
3210
  const { ctx, archivedFiles } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
3096
- const result = await p.select({
3211
+ const result = await select({
3097
3212
  message: `${relativePath} already exists. What do you want to do?`,
3098
3213
  options: [{
3099
3214
  value: "overwrite",
@@ -3103,10 +3218,9 @@ async function runInit(config, options = {}) {
3103
3218
  label: "Skip"
3104
3219
  }]
3105
3220
  });
3106
- if (p.isCancel(result)) return "skip";
3221
+ if (isCancel(result)) return "skip";
3107
3222
  return result;
3108
3223
  }));
3109
- if (config.releaseStrategy !== "none" && !ctx.packageJson?.repository) p.log.warn(`package.json is missing a "repository" field — required for release strategy "${config.releaseStrategy}"`);
3110
3224
  logDetectionSummary(ctx);
3111
3225
  const results = await runGenerators(ctx);
3112
3226
  const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
@@ -3117,8 +3231,12 @@ async function runInit(config, options = {}) {
3117
3231
  });
3118
3232
  const created = results.filter((r) => r.action === "created");
3119
3233
  const updated = results.filter((r) => r.action === "updated");
3120
- if (!(created.length > 0 || updated.length > 0 || archivedFiles.length > 0) && options.noPrompt) {
3121
- p.log.success("Repository is up to date.");
3234
+ const hasChanges = created.length > 0 || updated.length > 0 || archivedFiles.length > 0;
3235
+ const prompt = generateMigratePrompt(results, config, detected);
3236
+ const promptPath = ".tooling-migrate.md";
3237
+ ctx.write(promptPath, prompt);
3238
+ if (!hasChanges && options.noPrompt) {
3239
+ log$2.success("Repository is up to date.");
3122
3240
  return results;
3123
3241
  }
3124
3242
  if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
@@ -3131,18 +3249,15 @@ async function runInit(config, options = {}) {
3131
3249
  const summaryLines = [];
3132
3250
  if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
3133
3251
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
3134
- p.note(summaryLines.join("\n"), "Summary");
3252
+ note(summaryLines.join("\n"), "Summary");
3135
3253
  if (!options.noPrompt) {
3136
- const prompt = generateMigratePrompt(results, config, detected);
3137
- const promptPath = ".tooling-migrate.md";
3138
- ctx.write(promptPath, prompt);
3139
- p.log.info(`Migration prompt written to ${promptPath}`);
3140
- p.log.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3254
+ log$2.info(`Migration prompt written to ${promptPath}`);
3255
+ log$2.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3141
3256
  }
3142
3257
  const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
3143
3258
  const hasLockfile = ctx.exists("pnpm-lock.yaml");
3144
3259
  if (bensandeeDeps.length > 0 && hasLockfile) {
3145
- p.log.info("Updating @bensandee/* packages...");
3260
+ log$2.info("Updating @bensandee/* packages...");
3146
3261
  try {
3147
3262
  execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
3148
3263
  cwd: config.targetDir,
@@ -3150,10 +3265,17 @@ async function runInit(config, options = {}) {
3150
3265
  timeout: 6e4
3151
3266
  });
3152
3267
  } catch (_error) {
3153
- p.log.warn("Could not update @bensandee/* packages — run pnpm install manually");
3268
+ log$2.warn("Could not update @bensandee/* packages — run pnpm install manually");
3154
3269
  }
3155
3270
  }
3156
- p.note([
3271
+ if (hasChanges && ctx.exists("package.json")) try {
3272
+ execSync("pnpm format", {
3273
+ cwd: config.targetDir,
3274
+ stdio: "ignore",
3275
+ timeout: 3e4
3276
+ });
3277
+ } catch (_error) {}
3278
+ note([
3157
3279
  "1. Run: pnpm install",
3158
3280
  "2. Run: pnpm check",
3159
3281
  ...options.noPrompt ? [] : ["3. In Claude Code, run: \"Execute the steps in .tooling-migrate.md\""]
@@ -3235,22 +3357,22 @@ async function runCheck(targetDir) {
3235
3357
  return true;
3236
3358
  });
3237
3359
  if (actionable.length === 0) {
3238
- p.log.success("Repository is up to date.");
3360
+ log$2.success("Repository is up to date.");
3239
3361
  return 0;
3240
3362
  }
3241
- p.log.warn(`${actionable.length} file(s) would be changed by repo:sync`);
3363
+ log$2.warn(`${actionable.length} file(s) would be changed by repo:sync`);
3242
3364
  for (const r of actionable) {
3243
- p.log.info(` ${r.action}: ${r.filePath} — ${r.description}`);
3365
+ log$2.info(` ${r.action}: ${r.filePath} — ${r.description}`);
3244
3366
  const newContent = pendingWrites.get(r.filePath);
3245
3367
  if (!newContent) continue;
3246
3368
  const existingPath = path.join(targetDir, r.filePath);
3247
3369
  const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
3248
3370
  if (!existing) {
3249
3371
  const lineCount = newContent.split("\n").length - 1;
3250
- p.log.info(` + ${lineCount} new lines`);
3372
+ log$2.info(` + ${lineCount} new lines`);
3251
3373
  } else {
3252
3374
  const diff = lineDiff(existing, newContent);
3253
- for (const line of diff) p.log.info(` ${line}`);
3375
+ for (const line of diff) log$2.info(` ${line}`);
3254
3376
  }
3255
3377
  }
3256
3378
  return 1;
@@ -3497,7 +3619,7 @@ async function createRelease(executor, conn, tag) {
3497
3619
  //#region src/release/log.ts
3498
3620
  /** Log a debug message when verbose mode is enabled. */
3499
3621
  function debug(config, message) {
3500
- if (config.verbose) p.log.info(`[debug] ${message}`);
3622
+ if (config.verbose) log$2.info(`[debug] ${message}`);
3501
3623
  }
3502
3624
  /** Log the result of an exec call when verbose mode is enabled. */
3503
3625
  function debugExec(config, label, result) {
@@ -3505,7 +3627,7 @@ function debugExec(config, label, result) {
3505
3627
  const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
3506
3628
  if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
3507
3629
  if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
3508
- p.log.info(lines.join("\n"));
3630
+ log$2.info(lines.join("\n"));
3509
3631
  }
3510
3632
  //#endregion
3511
3633
  //#region src/release/version.ts
@@ -3577,7 +3699,7 @@ function buildPrContent(executor, cwd, packagesBefore) {
3577
3699
  }
3578
3700
  /** Mode 1: version packages and create/update a PR. */
3579
3701
  async function runVersionMode(executor, config) {
3580
- p.log.info("Changesets detected — versioning packages");
3702
+ log$2.info("Changesets detected — versioning packages");
3581
3703
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
3582
3704
  debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
3583
3705
  const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
@@ -3603,19 +3725,19 @@ async function runVersionMode(executor, config) {
3603
3725
  const addResult = executor.exec("git add -A", { cwd: config.cwd });
3604
3726
  if (addResult.exitCode !== 0) throw new FatalError(`git add failed: ${addResult.stderr || addResult.stdout}`);
3605
3727
  const remainingChangesets = executor.listChangesetFiles(config.cwd);
3606
- if (remainingChangesets.length > 0) p.log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
3728
+ if (remainingChangesets.length > 0) log$2.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
3607
3729
  debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
3608
3730
  const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
3609
3731
  debugExec(config, "git commit", commitResult);
3610
3732
  if (commitResult.exitCode !== 0) {
3611
- p.log.info("Nothing to commit after versioning");
3733
+ log$2.info("Nothing to commit after versioning");
3612
3734
  return {
3613
3735
  mode: "version",
3614
3736
  pr: "none"
3615
3737
  };
3616
3738
  }
3617
3739
  if (config.dryRun) {
3618
- p.log.info("[dry-run] Would push and create/update PR");
3740
+ log$2.info("[dry-run] Would push and create/update PR");
3619
3741
  return {
3620
3742
  mode: "version",
3621
3743
  pr: "none"
@@ -3638,7 +3760,7 @@ async function runVersionMode(executor, config) {
3638
3760
  base: "main",
3639
3761
  body
3640
3762
  });
3641
- p.log.info("Created version PR");
3763
+ log$2.info("Created version PR");
3642
3764
  return {
3643
3765
  mode: "version",
3644
3766
  pr: "created"
@@ -3648,7 +3770,7 @@ async function runVersionMode(executor, config) {
3648
3770
  title,
3649
3771
  body
3650
3772
  });
3651
- p.log.info(`Updated version PR #${String(existingPr)}`);
3773
+ log$2.info(`Updated version PR #${String(existingPr)}`);
3652
3774
  return {
3653
3775
  mode: "version",
3654
3776
  pr: "updated"
@@ -3673,7 +3795,7 @@ async function retryAsync(fn) {
3673
3795
  }
3674
3796
  /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
3675
3797
  async function runPublishMode(executor, config) {
3676
- p.log.info("No changesets — publishing packages");
3798
+ log$2.info("No changesets — publishing packages");
3677
3799
  const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
3678
3800
  debugExec(config, "pnpm changeset publish", publishResult);
3679
3801
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
@@ -3688,11 +3810,11 @@ async function runPublishMode(executor, config) {
3688
3810
  debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
3689
3811
  if (config.dryRun) {
3690
3812
  if (tagsToPush.length === 0) {
3691
- p.log.info("No packages were published");
3813
+ log$2.info("No packages were published");
3692
3814
  return { mode: "none" };
3693
3815
  }
3694
- p.log.info(`Tags to process: ${tagsToPush.join(", ")}`);
3695
- p.log.info("[dry-run] Would push tags and create releases");
3816
+ log$2.info(`Tags to process: ${tagsToPush.join(", ")}`);
3817
+ log$2.info("[dry-run] Would push tags and create releases");
3696
3818
  return {
3697
3819
  mode: "publish",
3698
3820
  tags: tagsToPush
@@ -3708,10 +3830,10 @@ async function runPublishMode(executor, config) {
3708
3830
  for (const tag of remoteExpectedTags) if (!await findRelease(executor, conn, tag)) tagsWithMissingReleases.push(tag);
3709
3831
  const allTags = [...tagsToPush, ...tagsWithMissingReleases];
3710
3832
  if (allTags.length === 0) {
3711
- p.log.info("No packages were published");
3833
+ log$2.info("No packages were published");
3712
3834
  return { mode: "none" };
3713
3835
  }
3714
- p.log.info(`Tags to process: ${allTags.join(", ")}`);
3836
+ log$2.info(`Tags to process: ${allTags.join(", ")}`);
3715
3837
  const errors = [];
3716
3838
  for (const tag of allTags) try {
3717
3839
  if (!remoteSet.has(tag)) {
@@ -3722,7 +3844,7 @@ async function runPublishMode(executor, config) {
3722
3844
  const pushTagResult = executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
3723
3845
  if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
3724
3846
  }
3725
- if (await findRelease(executor, conn, tag)) p.log.warn(`Release for ${tag} already exists — skipping`);
3847
+ if (await findRelease(executor, conn, tag)) log$2.warn(`Release for ${tag} already exists — skipping`);
3726
3848
  else {
3727
3849
  await retryAsync(async () => {
3728
3850
  try {
@@ -3732,14 +3854,14 @@ async function runPublishMode(executor, config) {
3732
3854
  throw error;
3733
3855
  }
3734
3856
  });
3735
- p.log.info(`Created release for ${tag}`);
3857
+ log$2.info(`Created release for ${tag}`);
3736
3858
  }
3737
3859
  } catch (error) {
3738
3860
  errors.push({
3739
3861
  tag,
3740
3862
  error
3741
3863
  });
3742
- p.log.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
3864
+ log$2.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
3743
3865
  }
3744
3866
  if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
3745
3867
  return {
@@ -3909,12 +4031,12 @@ async function triggerForgejo(conn, ref) {
3909
4031
  body: JSON.stringify({ ref })
3910
4032
  });
3911
4033
  if (!res.ok) throw new FatalError(`Failed to trigger Forgejo workflow: ${res.status} ${res.statusText}`);
3912
- p.log.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
4034
+ log$2.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
3913
4035
  }
3914
4036
  function triggerGitHub(ref) {
3915
4037
  const result = createRealExecutor().exec(`gh workflow run release.yml --ref ${ref}`, { cwd: process.cwd() });
3916
4038
  if (result.exitCode !== 0) throw new FatalError(`Failed to trigger GitHub workflow: ${result.stderr || result.stdout || "unknown error"}`);
3917
- p.log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
4039
+ log$2.info(`Triggered release workflow on GitHub (ref: ${ref})`);
3918
4040
  }
3919
4041
  //#endregion
3920
4042
  //#region src/commands/forgejo-create-release.ts
@@ -3934,11 +4056,11 @@ const createForgejoReleaseCommand = defineCommand({
3934
4056
  const executor = createRealExecutor();
3935
4057
  const conn = resolved.conn;
3936
4058
  if (await findRelease(executor, conn, args.tag)) {
3937
- p.log.info(`Release for ${args.tag} already exists — skipping`);
4059
+ log$2.info(`Release for ${args.tag} already exists — skipping`);
3938
4060
  return;
3939
4061
  }
3940
4062
  await createRelease(executor, conn, args.tag);
3941
- p.log.info(`Created Forgejo release for ${args.tag}`);
4063
+ log$2.info(`Created Forgejo release for ${args.tag}`);
3942
4064
  }
3943
4065
  });
3944
4066
  //#endregion
@@ -3965,26 +4087,26 @@ async function mergeForgejo(conn, dryRun) {
3965
4087
  const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
3966
4088
  if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
3967
4089
  if (dryRun) {
3968
- p.log.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
4090
+ log$2.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
3969
4091
  return;
3970
4092
  }
3971
4093
  await mergePr(executor, conn, prNumber, {
3972
4094
  method: "merge",
3973
4095
  deleteBranch: true
3974
4096
  });
3975
- p.log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
4097
+ log$2.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
3976
4098
  }
3977
4099
  function mergeGitHub(dryRun) {
3978
4100
  const executor = createRealExecutor();
3979
4101
  if (dryRun) {
3980
4102
  const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
3981
4103
  if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
3982
- p.log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
4104
+ log$2.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
3983
4105
  return;
3984
4106
  }
3985
4107
  const result = executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
3986
4108
  if (result.exitCode !== 0) throw new FatalError(`Failed to merge PR: ${result.stderr || result.stdout || "unknown error"}`);
3987
- p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
4109
+ log$2.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
3988
4110
  }
3989
4111
  //#endregion
3990
4112
  //#region src/release/simple.ts
@@ -4017,7 +4139,7 @@ function readVersion(executor, cwd) {
4017
4139
  /** Run the full commit-and-tag-version release flow. */
4018
4140
  async function runSimpleRelease(executor, config) {
4019
4141
  const command = buildCommand(config);
4020
- p.log.info(`Running: ${command}`);
4142
+ log$2.info(`Running: ${command}`);
4021
4143
  const versionResult = executor.exec(command, { cwd: config.cwd });
4022
4144
  debugExec(config, "commit-and-tag-version", versionResult);
4023
4145
  if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
@@ -4027,12 +4149,12 @@ async function runSimpleRelease(executor, config) {
4027
4149
  debugExec(config, "git describe", tagResult);
4028
4150
  const tag = tagResult.stdout.trim();
4029
4151
  if (!tag) throw new FatalError("Could not determine the new tag from git describe");
4030
- p.log.info(`Version ${version} tagged as ${tag}`);
4152
+ log$2.info(`Version ${version} tagged as ${tag}`);
4031
4153
  if (config.dryRun) {
4032
4154
  const slidingTags = config.noSlidingTags ? [] : computeSlidingTags(version);
4033
- p.log.info(`[dry-run] Would push to origin with --follow-tags`);
4034
- if (slidingTags.length > 0) p.log.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
4035
- if (!config.noRelease && config.platform) p.log.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
4155
+ log$2.info(`[dry-run] Would push to origin with --follow-tags`);
4156
+ if (slidingTags.length > 0) log$2.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
4157
+ if (!config.noRelease && config.platform) log$2.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
4036
4158
  return {
4037
4159
  version,
4038
4160
  tag,
@@ -4049,7 +4171,7 @@ async function runSimpleRelease(executor, config) {
4049
4171
  debugExec(config, "git push", pushResult);
4050
4172
  if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
4051
4173
  pushed = true;
4052
- p.log.info("Pushed to origin");
4174
+ log$2.info("Pushed to origin");
4053
4175
  }
4054
4176
  let slidingTags = [];
4055
4177
  if (!config.noSlidingTags && pushed) {
@@ -4060,8 +4182,8 @@ async function runSimpleRelease(executor, config) {
4060
4182
  }
4061
4183
  const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
4062
4184
  debugExec(config, "force-push sliding tags", forcePushResult);
4063
- if (forcePushResult.exitCode !== 0) p.log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4064
- else p.log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4185
+ if (forcePushResult.exitCode !== 0) log$2.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4186
+ else log$2.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4065
4187
  }
4066
4188
  let releaseCreated = false;
4067
4189
  if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
@@ -4081,16 +4203,16 @@ async function createPlatformRelease(executor, config, tag) {
4081
4203
  return false;
4082
4204
  }
4083
4205
  await createRelease(executor, config.platform.conn, tag);
4084
- p.log.info(`Created Forgejo release for ${tag}`);
4206
+ log$2.info(`Created Forgejo release for ${tag}`);
4085
4207
  return true;
4086
4208
  }
4087
4209
  const ghResult = executor.exec(`gh release create ${tag} --generate-notes`, { cwd: config.cwd });
4088
4210
  debugExec(config, "gh release create", ghResult);
4089
4211
  if (ghResult.exitCode !== 0) {
4090
- p.log.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
4212
+ log$2.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
4091
4213
  return false;
4092
4214
  }
4093
- p.log.info(`Created GitHub release for ${tag}`);
4215
+ log$2.info(`Created GitHub release for ${tag}`);
4094
4216
  return true;
4095
4217
  }
4096
4218
  //#endregion
@@ -4166,17 +4288,17 @@ const releaseSimpleCommand = defineCommand({
4166
4288
  //#region src/commands/repo-run-checks.ts
4167
4289
  const CHECKS = [
4168
4290
  { name: "build" },
4169
- { name: "docker:build" },
4170
4291
  { name: "typecheck" },
4171
4292
  { name: "lint" },
4172
4293
  { name: "test" },
4294
+ { name: "docker:build" },
4295
+ { name: "tooling:check" },
4296
+ { name: "docker:check" },
4173
4297
  {
4174
4298
  name: "format",
4175
4299
  args: "--check"
4176
4300
  },
4177
- { name: "knip" },
4178
- { name: "tooling:check" },
4179
- { name: "docker:check" }
4301
+ { name: "knip" }
4180
4302
  ];
4181
4303
  /** Check if a name matches any skip pattern. Supports glob syntax via picomatch. */
4182
4304
  function shouldSkip(name, patterns) {
@@ -4203,7 +4325,30 @@ function defaultExecCommand(cmd, cwd) {
4203
4325
  return 1;
4204
4326
  }
4205
4327
  }
4206
- const ciLog = (msg) => console.log(msg);
4328
+ const rawLog = (msg) => console.log(msg);
4329
+ const ciReporter = {
4330
+ groupStart: (name) => rawLog(`::group::${name}`),
4331
+ groupEnd: () => rawLog("::endgroup::"),
4332
+ passed: (name, elapsedS) => rawLog(`✓ ${name} (${elapsedS}s)`),
4333
+ failed: (name, elapsedS) => {
4334
+ rawLog(`✗ ${name} (${elapsedS}s)`);
4335
+ rawLog(`::error::${name} failed`);
4336
+ },
4337
+ undefinedCheck: (name) => rawLog(`::error::${name} not defined in package.json`),
4338
+ skippedNotDefined: (names) => rawLog(`Skipped (not defined): ${names.join(", ")}`),
4339
+ allPassed: () => rawLog("✓ All checks passed"),
4340
+ anyFailed: (names) => rawLog(`::error::Failed checks: ${names.join(", ")}`)
4341
+ };
4342
+ const localReporter = {
4343
+ groupStart: (_name) => {},
4344
+ groupEnd: () => {},
4345
+ passed: (name) => log$2.success(name),
4346
+ failed: (name) => log$2.error(`${name} failed`),
4347
+ undefinedCheck: (name) => log$2.error(`${name} not defined in package.json`),
4348
+ skippedNotDefined: (names) => log$2.info(`Skipped (not defined): ${names.join(", ")}`),
4349
+ allPassed: () => log$2.success("All checks passed"),
4350
+ anyFailed: (names) => log$2.error(`Failed checks: ${names.join(", ")}`)
4351
+ };
4207
4352
  function runRunChecks(targetDir, options = {}) {
4208
4353
  const exec = options.execCommand ?? defaultExecCommand;
4209
4354
  const getScripts = options.getScripts ?? defaultGetScripts;
@@ -4211,6 +4356,7 @@ function runRunChecks(targetDir, options = {}) {
4211
4356
  const add = options.add ?? [];
4212
4357
  const isCI = Boolean(process.env["CI"]);
4213
4358
  const failFast = options.failFast ?? !isCI;
4359
+ const reporter = isCI ? ciReporter : localReporter;
4214
4360
  const definedScripts = getScripts(targetDir);
4215
4361
  const addedNames = new Set(add);
4216
4362
  const allChecks = [...CHECKS, ...add.map((name) => ({ name }))];
@@ -4220,29 +4366,30 @@ function runRunChecks(targetDir, options = {}) {
4220
4366
  if (shouldSkip(check.name, skip)) continue;
4221
4367
  if (!definedScripts.has(check.name)) {
4222
4368
  if (addedNames.has(check.name)) {
4223
- p.log.error(`${check.name} not defined in package.json`);
4369
+ reporter.undefinedCheck(check.name);
4224
4370
  failures.push(check.name);
4225
4371
  } else notDefined.push(check.name);
4226
4372
  continue;
4227
4373
  }
4228
4374
  const cmd = check.args ? `pnpm run ${check.name} ${check.args}` : `pnpm run ${check.name}`;
4229
- if (isCI) ciLog(`::group::${check.name}`);
4375
+ reporter.groupStart(check.name);
4376
+ const start = Date.now();
4230
4377
  const exitCode = exec(cmd, targetDir);
4231
- if (isCI) ciLog("::endgroup::");
4232
- if (exitCode === 0) p.log.success(check.name);
4378
+ const elapsed = ((Date.now() - start) / 1e3).toFixed(1);
4379
+ reporter.groupEnd();
4380
+ if (exitCode === 0) reporter.passed(check.name, elapsed);
4233
4381
  else {
4234
- if (isCI) ciLog(`::error::${check.name} failed`);
4235
- p.log.error(`${check.name} failed`);
4382
+ reporter.failed(check.name, elapsed);
4236
4383
  failures.push(check.name);
4237
4384
  if (failFast) return 1;
4238
4385
  }
4239
4386
  }
4240
- if (notDefined.length > 0) p.log.info(`Skipped (not defined): ${notDefined.join(", ")}`);
4387
+ if (notDefined.length > 0) reporter.skippedNotDefined(notDefined);
4241
4388
  if (failures.length > 0) {
4242
- p.log.error(`Failed checks: ${failures.join(", ")}`);
4389
+ reporter.anyFailed(failures);
4243
4390
  return 1;
4244
4391
  }
4245
- p.log.success("All checks passed");
4392
+ reporter.allPassed();
4246
4393
  return 0;
4247
4394
  }
4248
4395
  const runChecksCommand = defineCommand({
@@ -4276,7 +4423,7 @@ const runChecksCommand = defineCommand({
4276
4423
  const exitCode = runRunChecks(path.resolve(args.dir ?? "."), {
4277
4424
  skip: args.skip ? new Set(args.skip.split(",").map((s) => s.trim())) : void 0,
4278
4425
  add: args.add ? args.add.split(",").map((s) => s.trim()) : void 0,
4279
- failFast: args["fail-fast"] === true ? true : args["fail-fast"] === false ? false : void 0
4426
+ failFast: args["fail-fast"] ? true : void 0
4280
4427
  });
4281
4428
  process.exitCode = exitCode;
4282
4429
  }
@@ -4656,8 +4803,8 @@ const dockerCheckCommand = defineCommand({
4656
4803
  //#region src/bin.ts
4657
4804
  const main = defineCommand({
4658
4805
  meta: {
4659
- name: "tooling",
4660
- version: "0.26.0",
4806
+ name: "bst",
4807
+ version: "0.27.1",
4661
4808
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4662
4809
  },
4663
4810
  subCommands: {
@@ -4673,7 +4820,7 @@ const main = defineCommand({
4673
4820
  "docker:check": dockerCheckCommand
4674
4821
  }
4675
4822
  });
4676
- console.log(`@bensandee/tooling v0.26.0`);
4823
+ console.log(`@bensandee/tooling v0.27.1`);
4677
4824
  async function run() {
4678
4825
  await runMain(main);
4679
4826
  process.exit(process.exitCode ?? 0);