@bensandee/tooling 0.53.1 → 0.54.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
@@ -623,23 +623,44 @@ const ToolingConfigSchema = z.strictObject({
623
623
  ]).optional().meta({ description: "Project type (determines tsconfig base)" }),
624
624
  detectPackageTypes: z.boolean().optional().meta({ description: "Auto-detect project types for monorepo packages" }),
625
625
  docker: z.record(z.string(), z.object({
626
- dockerfile: z.string().meta({ description: "Path to Dockerfile relative to package" }),
627
- context: z.string().default(".").meta({ description: "Docker build context relative to package" }),
626
+ dockerfile: z.string().optional().meta({ description: "Path to Dockerfile relative to package (falls back to convention when omitted)" }),
627
+ context: z.string().optional().meta({ description: "Docker build context relative to package (falls back to convention, default '.')" }),
628
628
  buildArgs: z.record(z.string(), z.string()).optional().meta({ description: "Build args passed to `docker build` for this package. Values support ${VAR} and ${VAR:-default} expansion against process.env." })
629
- })).optional().meta({ description: "Docker package overrides (package name → config)" }),
629
+ })).optional().meta({ description: "Docker package overrides (package name → partial config; any field may be omitted to fall back to convention)" }),
630
630
  dockerCheck: z.union([z.literal(false), DockerCheckConfigSchema]).optional().meta({ description: "Docker health check configuration or false to disable" }),
631
631
  registryHost: z.string().optional().meta({ description: "Docker registry hostname (e.g. ghcr.io, code.example.com)" }),
632
632
  registryNamespace: z.string().optional().meta({ description: "Docker registry namespace (e.g. ghcr.io/myorg, code.example.com/myorg)" }),
633
633
  postReleaseHook: z.string().optional().meta({ description: "Override the post-release hook command (default: POST to POST_RELEASE_HOOK_URL with HMAC-SHA256 auth)" }),
634
634
  postReleaseHookSecrets: z.array(z.string()).optional().meta({ description: "Additional secret names to inject as env vars in the post-release hook step (e.g. ['WEBHOOK_SECRET'])" })
635
635
  });
636
+ /** Format a Zod issue as a human-readable bullet: location + plain-English explanation. */
637
+ function formatIssue(issue) {
638
+ const loc = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
639
+ if (issue.code === "invalid_type") {
640
+ if (issue.input === void 0) return ` ${loc}: missing (expected ${issue.expected})`;
641
+ return ` ${loc}: expected ${issue.expected}, got ${typeof issue.input}`;
642
+ }
643
+ if (issue.code === "unrecognized_keys") return ` ${loc}: ${issue.keys.length > 1 ? "unknown fields" : "unknown field"} ${issue.keys.map((k) => `"${k}"`).join(", ")}`;
644
+ return ` ${loc}: ${issue.message}`;
645
+ }
636
646
  /** Load saved tooling config from the target directory. Returns undefined if missing, throws on invalid. */
637
647
  function loadToolingConfig(targetDir) {
638
648
  const fullPath = path.join(targetDir, CONFIG_FILE);
639
649
  if (!existsSync(fullPath)) return void 0;
640
650
  const raw = readFileSync(fullPath, "utf-8");
641
- const result = ToolingConfigSchema.safeParse(JSON.parse(raw));
642
- if (!result.success) throw new FatalError(`Invalid .tooling.json:\n${result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`);
651
+ const displayPath = path.relative(process.cwd(), fullPath) || fullPath;
652
+ let parsed;
653
+ try {
654
+ parsed = JSON.parse(raw);
655
+ } catch (error) {
656
+ throw new FatalError(`Could not parse ${displayPath} as JSON: ${error instanceof Error ? error.message : String(error)}`);
657
+ }
658
+ const result = ToolingConfigSchema.safeParse(parsed);
659
+ if (!result.success) {
660
+ const issues = result.error.issues.map(formatIssue).join("\n");
661
+ const count = result.error.issues.length;
662
+ throw new FatalError(`Invalid .tooling.json (${displayPath}) — ${count === 1 ? "1 problem" : `${count} problems`}:\n${issues}`);
663
+ }
643
664
  return result.data;
644
665
  }
645
666
  /** Config fields that can be overridden in .tooling.json. */
@@ -658,30 +679,42 @@ const OVERRIDE_KEYS = [
658
679
  ];
659
680
  /** Keys that have no effect for monorepos (generators ignore them). */
660
681
  const MONOREPO_IGNORED_KEYS = new Set(["setupVitest", "projectType"]);
682
+ const SCHEMA_REF = "node_modules/@bensandee/tooling/tooling.schema.json";
683
+ const RawJsonObjectSchema = z.record(z.string(), z.unknown());
684
+ /** Parse the raw .tooling.json contents into a plain object, or {} if invalid/missing. */
685
+ function parseExistingConfig(raw) {
686
+ if (raw === void 0) return {};
687
+ try {
688
+ const result = RawJsonObjectSchema.safeParse(JSON.parse(raw));
689
+ if (result.success) return result.data;
690
+ } catch (_error) {}
691
+ return {};
692
+ }
661
693
  /**
662
694
  * Save only the fields that differ from detected defaults to .tooling.json.
663
695
  * A fully conventional project produces `{}` (or a minimal set of overrides).
664
696
  * Keys that have no effect for the current structure are omitted.
697
+ *
698
+ * Non-override fields (e.g. `docker`, `dockerCheck`) are copied verbatim from the
699
+ * raw JSON — they are not re-parsed through Zod, so schema defaults like
700
+ * `docker.<pkg>.context: "."` are never injected into the user's file.
665
701
  */
666
702
  function saveToolingConfig(ctx, config) {
667
703
  const defaults = computeDefaults(config.targetDir);
668
704
  const isMonorepo = config.structure === "monorepo";
669
705
  const existing = ctx.exists(CONFIG_FILE) ? ctx.read(CONFIG_FILE) : void 0;
706
+ const existingJson = parseExistingConfig(existing);
670
707
  const overrideKeySet = new Set(OVERRIDE_KEYS);
671
- const preserved = {};
672
- if (existing) {
673
- const result = ToolingConfigSchema.safeParse(JSON.parse(existing));
674
- if (result.success) {
675
- for (const [key, value] of Object.entries(result.data)) if (key !== "$schema" && !overrideKeySet.has(key)) preserved[key] = value;
676
- }
677
- }
678
- const overrides = { $schema: "node_modules/@bensandee/tooling/tooling.schema.json" };
708
+ const result = { $schema: SCHEMA_REF };
679
709
  for (const key of OVERRIDE_KEYS) {
680
710
  if (isMonorepo && MONOREPO_IGNORED_KEYS.has(key)) continue;
681
- if (config[key] !== defaults[key]) overrides[key] = config[key];
711
+ if (config[key] !== defaults[key]) result[key] = config[key];
682
712
  }
683
- Object.assign(overrides, preserved);
684
- const content = JSON.stringify(overrides, null, 2) + "\n";
713
+ for (const [key, value] of Object.entries(existingJson)) {
714
+ if (key === "$schema" || overrideKeySet.has(key)) continue;
715
+ result[key] = value;
716
+ }
717
+ const content = JSON.stringify(result, null, 2) + "\n";
685
718
  if (existing !== void 0 && contentEqual(CONFIG_FILE, existing, content)) return {
686
719
  filePath: CONFIG_FILE,
687
720
  action: "skipped",
@@ -715,8 +748,8 @@ function mergeWithSavedConfig(detected, saved) {
715
748
  //#endregion
716
749
  //#region src/release/docker.ts
717
750
  const ToolingDockerMapSchema = z.record(z.string(), z.object({
718
- dockerfile: z.string(),
719
- context: z.string().default("."),
751
+ dockerfile: z.string().optional(),
752
+ context: z.string().optional(),
720
753
  buildArgs: z.record(z.string(), z.string()).optional()
721
754
  }));
722
755
  const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
@@ -737,6 +770,21 @@ function loadDockerMap(executor, cwd) {
737
770
  return {};
738
771
  }
739
772
  }
773
+ /**
774
+ * Merge a convention-discovered config with a user override. Missing override fields
775
+ * fall back to convention. Returns undefined if no dockerfile can be resolved from
776
+ * either source.
777
+ */
778
+ function mergeDockerConfig(convention, override) {
779
+ if (!override) return convention;
780
+ const dockerfile = override.dockerfile ?? convention?.dockerfile;
781
+ if (!dockerfile) return void 0;
782
+ return {
783
+ dockerfile,
784
+ context: override.context ?? convention?.context ?? ".",
785
+ buildArgs: override.buildArgs ?? convention?.buildArgs
786
+ };
787
+ }
740
788
  /** Read name and version from a package's package.json. */
741
789
  function readPackageInfo(executor, packageJsonPath) {
742
790
  const raw = executor.readFile(packageJsonPath);
@@ -821,16 +869,23 @@ function detectDockerPackages(executor, cwd, repoName) {
821
869
  if (packageDirs.length > 0) {
822
870
  for (const dir of packageDirs) {
823
871
  const convention = findConventionDockerfile(executor, cwd, dir);
824
- const docker = overrides[dir] ?? convention;
872
+ const override = overrides[dir];
873
+ const docker = mergeDockerConfig(convention, override);
825
874
  if (docker) {
826
875
  packages.push(createDockerPackage(executor, cwd, dir, repoName, docker));
827
876
  seen.add(dir);
828
877
  }
829
878
  }
830
- for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) packages.push(createDockerPackage(executor, cwd, dir, repoName, docker));
879
+ for (const [dir, override] of Object.entries(overrides)) {
880
+ if (seen.has(dir)) continue;
881
+ const docker = mergeDockerConfig(void 0, override);
882
+ if (!docker) throw new FatalError(`Docker override for "${dir}" has no dockerfile and no convention Dockerfile was found`);
883
+ packages.push(createDockerPackage(executor, cwd, dir, repoName, docker));
884
+ }
831
885
  } else {
832
886
  const convention = findRootDockerfile(executor, cwd);
833
- const docker = overrides["."] ?? convention;
887
+ const override = overrides["."];
888
+ const docker = mergeDockerConfig(convention, override);
834
889
  if (docker) packages.push(createDockerPackage(executor, cwd, ".", repoName, docker));
835
890
  }
836
891
  return packages;
@@ -842,7 +897,8 @@ function detectDockerPackages(executor, cwd, repoName) {
842
897
  function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
843
898
  const dir = path.basename(path.resolve(cwd, packageDir));
844
899
  const convention = findConventionDockerfile(executor, cwd, dir);
845
- const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
900
+ const override = loadDockerMap(executor, cwd)[dir];
901
+ const docker = mergeDockerConfig(convention, override);
846
902
  if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
847
903
  return createDockerPackage(executor, cwd, dir, repoName, docker);
848
904
  }
@@ -896,36 +952,39 @@ function interpolate(value, env, onMissing) {
896
952
  });
897
953
  }
898
954
  /**
899
- * Translate a package's configured buildArgs map into `--build-arg K=V` flags,
900
- * expanding env-var references in values. Warns once per unset variable per build.
955
+ * Resolve a package's configured buildArgs map into `K=V` pairs, expanding
956
+ * env-var references in values. Warns once per unset variable per build.
901
957
  */
902
958
  function resolveBuildArgs(pkg, env) {
903
959
  const entries = Object.entries(pkg.docker.buildArgs ?? {});
904
960
  if (entries.length === 0) return [];
905
961
  const warned = /* @__PURE__ */ new Set();
906
- const flags = [];
962
+ const pairs = [];
907
963
  for (const [k, rawValue] of entries) {
908
964
  const resolved = interpolate(rawValue, env, (missing) => {
909
965
  if (warned.has(missing)) return;
910
966
  warned.add(missing);
911
967
  log.warn(`docker build arg "${k}" for ${pkg.dir}: env var ${missing} is not set; using empty string`);
912
968
  });
913
- flags.push("--build-arg", `${k}=${resolved}`);
969
+ pairs.push(`${k}=${resolved}`);
914
970
  }
915
- return flags;
971
+ return pairs;
916
972
  }
917
973
  /** Build a single docker image from its config. Paths are resolved relative to cwd. */
918
974
  function buildImage(executor, pkg, cwd, extraArgs) {
919
975
  const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
920
976
  const contextPath = path.resolve(cwd, pkg.docker.context);
921
- const configuredArgs = resolveBuildArgs(pkg, process.env);
977
+ const configuredPairs = resolveBuildArgs(pkg, process.env);
978
+ log.info(` build args: ${configuredPairs.length > 0 ? configuredPairs.join(" ") : "(none)"}`);
979
+ log.info(` extra args: ${extraArgs.length > 0 ? extraArgs.join(" ") : "(none)"}`);
980
+ const buildArgFlags = configuredPairs.flatMap((pair) => ["--build-arg", pair]);
922
981
  executor.execInherit("docker", [
923
982
  "build",
924
983
  "-f",
925
984
  dockerfilePath,
926
985
  "-t",
927
986
  `${pkg.imageName}:latest`,
928
- ...configuredArgs,
987
+ ...buildArgFlags,
929
988
  ...extraArgs,
930
989
  contextPath
931
990
  ]);
@@ -1674,7 +1733,7 @@ async function generatePackageJson(ctx) {
1674
1733
  const devDeps = { ...ROOT_DEV_DEPS };
1675
1734
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1676
1735
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.10.2";
1677
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.53.1";
1736
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.54.1";
1678
1737
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.12.0";
1679
1738
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1680
1739
  "@changesets/cli": "2.30.0",
@@ -3450,7 +3509,7 @@ async function runGenerators(ctx) {
3450
3509
  function generateMigratePrompt(results, config, detected) {
3451
3510
  const sections = [];
3452
3511
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3453
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.53.1 repo:sync\` on ${timestamp}_\n`);
3512
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.54.1 repo:sync\` on ${timestamp}_\n`);
3454
3513
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3455
3514
  if (changed.length > 0) {
3456
3515
  const items = changed.map((r) => {
@@ -6753,7 +6812,7 @@ async function sendWebhookWithLogging(url, secret, payload) {
6753
6812
  const main = defineCommand({
6754
6813
  meta: {
6755
6814
  name: "bst",
6756
- version: "0.53.1",
6815
+ version: "0.54.1",
6757
6816
  description: "Bootstrap and maintain standardized TypeScript project tooling"
6758
6817
  },
6759
6818
  subCommands: {
@@ -6821,7 +6880,7 @@ const main = defineCommand({
6821
6880
  })
6822
6881
  }
6823
6882
  });
6824
- console.log(`@bensandee/tooling v0.53.1`);
6883
+ console.log(`@bensandee/tooling v0.54.1`);
6825
6884
  async function run() {
6826
6885
  await runMain(main);
6827
6886
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.53.1",
3
+ "version": "0.54.1",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,8 +54,8 @@
54
54
  "prettier": "3.8.3",
55
55
  "release-it": "20.0.0",
56
56
  "tsdown": "0.21.9",
57
- "@bensandee/claude-hooks": "0.7.0",
58
- "@bensandee/config": "0.10.2"
57
+ "@bensandee/config": "0.10.2",
58
+ "@bensandee/claude-hooks": "0.7.0"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsdown",
@@ -58,7 +58,7 @@
58
58
  "type": "boolean"
59
59
  },
60
60
  "docker": {
61
- "description": "Docker package overrides (package name → config)",
61
+ "description": "Docker package overrides (package name → partial config; any field may be omitted to fall back to convention)",
62
62
  "type": "object",
63
63
  "propertyNames": {
64
64
  "type": "string"
@@ -67,12 +67,11 @@
67
67
  "type": "object",
68
68
  "properties": {
69
69
  "dockerfile": {
70
- "type": "string",
71
- "description": "Path to Dockerfile relative to package"
70
+ "description": "Path to Dockerfile relative to package (falls back to convention when omitted)",
71
+ "type": "string"
72
72
  },
73
73
  "context": {
74
- "default": ".",
75
- "description": "Docker build context relative to package",
74
+ "description": "Docker build context relative to package (falls back to convention, default '.')",
76
75
  "type": "string"
77
76
  },
78
77
  "buildArgs": {
@@ -86,7 +85,6 @@
86
85
  }
87
86
  }
88
87
  },
89
- "required": ["dockerfile", "context"],
90
88
  "additionalProperties": false
91
89
  }
92
90
  },