@bensandee/tooling 0.53.0 → 0.54.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.
Files changed (2) hide show
  1. package/dist/bin.mjs +88 -23
  2. package/package.json +1 -1
package/dist/bin.mjs CHANGED
@@ -715,8 +715,8 @@ function mergeWithSavedConfig(detected, saved) {
715
715
  //#endregion
716
716
  //#region src/release/docker.ts
717
717
  const ToolingDockerMapSchema = z.record(z.string(), z.object({
718
- dockerfile: z.string(),
719
- context: z.string().default("."),
718
+ dockerfile: z.string().optional(),
719
+ context: z.string().optional(),
720
720
  buildArgs: z.record(z.string(), z.string()).optional()
721
721
  }));
722
722
  const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
@@ -737,6 +737,21 @@ function loadDockerMap(executor, cwd) {
737
737
  return {};
738
738
  }
739
739
  }
740
+ /**
741
+ * Merge a convention-discovered config with a user override. Missing override fields
742
+ * fall back to convention. Returns undefined if no dockerfile can be resolved from
743
+ * either source.
744
+ */
745
+ function mergeDockerConfig(convention, override) {
746
+ if (!override) return convention;
747
+ const dockerfile = override.dockerfile ?? convention?.dockerfile;
748
+ if (!dockerfile) return void 0;
749
+ return {
750
+ dockerfile,
751
+ context: override.context ?? convention?.context ?? ".",
752
+ buildArgs: override.buildArgs ?? convention?.buildArgs
753
+ };
754
+ }
740
755
  /** Read name and version from a package's package.json. */
741
756
  function readPackageInfo(executor, packageJsonPath) {
742
757
  const raw = executor.readFile(packageJsonPath);
@@ -821,16 +836,23 @@ function detectDockerPackages(executor, cwd, repoName) {
821
836
  if (packageDirs.length > 0) {
822
837
  for (const dir of packageDirs) {
823
838
  const convention = findConventionDockerfile(executor, cwd, dir);
824
- const docker = overrides[dir] ?? convention;
839
+ const override = overrides[dir];
840
+ const docker = mergeDockerConfig(convention, override);
825
841
  if (docker) {
826
842
  packages.push(createDockerPackage(executor, cwd, dir, repoName, docker));
827
843
  seen.add(dir);
828
844
  }
829
845
  }
830
- for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) packages.push(createDockerPackage(executor, cwd, dir, repoName, docker));
846
+ for (const [dir, override] of Object.entries(overrides)) {
847
+ if (seen.has(dir)) continue;
848
+ const docker = mergeDockerConfig(void 0, override);
849
+ if (!docker) throw new FatalError(`Docker override for "${dir}" has no dockerfile and no convention Dockerfile was found`);
850
+ packages.push(createDockerPackage(executor, cwd, dir, repoName, docker));
851
+ }
831
852
  } else {
832
853
  const convention = findRootDockerfile(executor, cwd);
833
- const docker = overrides["."] ?? convention;
854
+ const override = overrides["."];
855
+ const docker = mergeDockerConfig(convention, override);
834
856
  if (docker) packages.push(createDockerPackage(executor, cwd, ".", repoName, docker));
835
857
  }
836
858
  return packages;
@@ -842,7 +864,8 @@ function detectDockerPackages(executor, cwd, repoName) {
842
864
  function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
843
865
  const dir = path.basename(path.resolve(cwd, packageDir));
844
866
  const convention = findConventionDockerfile(executor, cwd, dir);
845
- const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
867
+ const override = loadDockerMap(executor, cwd)[dir];
868
+ const docker = mergeDockerConfig(convention, override);
846
869
  if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
847
870
  return createDockerPackage(executor, cwd, dir, repoName, docker);
848
871
  }
@@ -896,36 +919,39 @@ function interpolate(value, env, onMissing) {
896
919
  });
897
920
  }
898
921
  /**
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.
922
+ * Resolve a package's configured buildArgs map into `K=V` pairs, expanding
923
+ * env-var references in values. Warns once per unset variable per build.
901
924
  */
902
925
  function resolveBuildArgs(pkg, env) {
903
926
  const entries = Object.entries(pkg.docker.buildArgs ?? {});
904
927
  if (entries.length === 0) return [];
905
928
  const warned = /* @__PURE__ */ new Set();
906
- const flags = [];
929
+ const pairs = [];
907
930
  for (const [k, rawValue] of entries) {
908
931
  const resolved = interpolate(rawValue, env, (missing) => {
909
932
  if (warned.has(missing)) return;
910
933
  warned.add(missing);
911
934
  log.warn(`docker build arg "${k}" for ${pkg.dir}: env var ${missing} is not set; using empty string`);
912
935
  });
913
- flags.push("--build-arg", `${k}=${resolved}`);
936
+ pairs.push(`${k}=${resolved}`);
914
937
  }
915
- return flags;
938
+ return pairs;
916
939
  }
917
940
  /** Build a single docker image from its config. Paths are resolved relative to cwd. */
918
941
  function buildImage(executor, pkg, cwd, extraArgs) {
919
942
  const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
920
943
  const contextPath = path.resolve(cwd, pkg.docker.context);
921
- const configuredArgs = resolveBuildArgs(pkg, process.env);
944
+ const configuredPairs = resolveBuildArgs(pkg, process.env);
945
+ log.info(` build args: ${configuredPairs.length > 0 ? configuredPairs.join(" ") : "(none)"}`);
946
+ log.info(` extra args: ${extraArgs.length > 0 ? extraArgs.join(" ") : "(none)"}`);
947
+ const buildArgFlags = configuredPairs.flatMap((pair) => ["--build-arg", pair]);
922
948
  executor.execInherit("docker", [
923
949
  "build",
924
950
  "-f",
925
951
  dockerfilePath,
926
952
  "-t",
927
953
  `${pkg.imageName}:latest`,
928
- ...configuredArgs,
954
+ ...buildArgFlags,
929
955
  ...extraArgs,
930
956
  contextPath
931
957
  ]);
@@ -1188,6 +1214,45 @@ function preserveDevBinaryPath(generated, existing) {
1188
1214
  if (!match) return generated;
1189
1215
  return generated.replaceAll("pnpm exec bst", match[0]).replaceAll("pnpm dlx @bensandee/tooling", match[0]);
1190
1216
  }
1217
+ function escapeRegex(value) {
1218
+ return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
1219
+ }
1220
+ /** Match a `uses: <name>@<ref>` line, capturing indentation, action name, ref, and trailing comment. */
1221
+ const USES_LINE_PATTERN = /^(?<indent>\s*(?:-\s+)?uses:\s*)(?<name>[^\s@#]+)@(?<ref>\S+)(?<comment>[ \t]+#[^\n]*)?$/gm;
1222
+ /**
1223
+ * Extract a map of action-name → full verbatim tail (`<ref>` + optional inline comment)
1224
+ * from an existing workflow's `uses:` lines. Local actions (starting with `./` or
1225
+ * `docker://`) are skipped. First occurrence wins.
1226
+ */
1227
+ function extractPinnedActions(existing) {
1228
+ const map = /* @__PURE__ */ new Map();
1229
+ for (const match of existing.matchAll(USES_LINE_PATTERN)) {
1230
+ const { name, ref, comment } = match.groups ?? {};
1231
+ if (!name || !ref) continue;
1232
+ if (name.startsWith("./") || name.startsWith("docker://")) continue;
1233
+ if (!map.has(name)) map.set(name, `${ref}${comment ?? ""}`);
1234
+ }
1235
+ return map;
1236
+ }
1237
+ /**
1238
+ * Preserve existing Renovate-managed action pins when rewriting a workflow.
1239
+ *
1240
+ * For each `uses: <name>@<ref>` in the existing file, if the generated file references the
1241
+ * same action name, substitute the existing ref (including any inline comment like `# v6`)
1242
+ * into the generated output. This keeps SHA pins and their version annotations stable when
1243
+ * unrelated workflow changes force a rewrite.
1244
+ */
1245
+ function preservePinnedActions(generated, existing) {
1246
+ if (!existing) return generated;
1247
+ const pins = extractPinnedActions(existing);
1248
+ if (pins.size === 0) return generated;
1249
+ let result = generated;
1250
+ for (const [name, tail] of pins) {
1251
+ const pattern = new RegExp(String.raw`^(\s*(?:-\s+)?uses:\s*)${escapeRegex(name)}@\S+(?:[ \t]+#[^\n]*)?$`, "gm");
1252
+ result = result.replace(pattern, `$1${name}@${tail}`);
1253
+ }
1254
+ return result;
1255
+ }
1191
1256
  /** Build a complete workflow YAML string from structured options. Single source of truth for workflow files. */
1192
1257
  function buildWorkflowYaml(options) {
1193
1258
  const doc = { name: options.name };
@@ -1350,8 +1415,8 @@ async function generateDeployCi(ctx) {
1350
1415
  description: "Publish workflow already up to date"
1351
1416
  };
1352
1417
  }
1353
- const withDevPath = preserveDevBinaryPath(content, existing);
1354
- const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
1418
+ const withPinnedActions = preservePinnedActions(preserveDevBinaryPath(content, existing), existing);
1419
+ const final = existing ? insertCustomBlocks(withPinnedActions, extractCustomBlocks(existing)) : withPinnedActions;
1355
1420
  ctx.write(workflowPath, final);
1356
1421
  return {
1357
1422
  filePath: workflowPath,
@@ -1635,7 +1700,7 @@ async function generatePackageJson(ctx) {
1635
1700
  const devDeps = { ...ROOT_DEV_DEPS };
1636
1701
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1637
1702
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.10.2";
1638
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.53.0";
1703
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.54.0";
1639
1704
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.12.0";
1640
1705
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1641
1706
  "@changesets/cli": "2.30.0",
@@ -2427,8 +2492,8 @@ async function generateReleaseCi(ctx) {
2427
2492
  description: "Release workflow already up to date"
2428
2493
  };
2429
2494
  }
2430
- const withDevPath = preserveDevBinaryPath(content, existing);
2431
- const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
2495
+ const withPinnedActions = preservePinnedActions(preserveDevBinaryPath(content, existing), existing);
2496
+ const final = existing ? insertCustomBlocks(withPinnedActions, extractCustomBlocks(existing)) : withPinnedActions;
2432
2497
  ctx.write(workflowPath, final);
2433
2498
  return {
2434
2499
  filePath: workflowPath,
@@ -2557,8 +2622,8 @@ async function generateCi(ctx) {
2557
2622
  description: "CI workflow already up to date"
2558
2623
  };
2559
2624
  }
2560
- const withDevPath = preserveDevBinaryPath(content, existing);
2561
- const final = existing ? insertCustomBlocks(withDevPath, extractCustomBlocks(existing)) : withDevPath;
2625
+ const withPinnedActions = preservePinnedActions(preserveDevBinaryPath(content, existing), existing);
2626
+ const final = existing ? insertCustomBlocks(withPinnedActions, extractCustomBlocks(existing)) : withPinnedActions;
2562
2627
  ctx.write(filePath, final);
2563
2628
  return {
2564
2629
  filePath,
@@ -3411,7 +3476,7 @@ async function runGenerators(ctx) {
3411
3476
  function generateMigratePrompt(results, config, detected) {
3412
3477
  const sections = [];
3413
3478
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3414
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.53.0 repo:sync\` on ${timestamp}_\n`);
3479
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.54.0 repo:sync\` on ${timestamp}_\n`);
3415
3480
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
3416
3481
  if (changed.length > 0) {
3417
3482
  const items = changed.map((r) => {
@@ -6714,7 +6779,7 @@ async function sendWebhookWithLogging(url, secret, payload) {
6714
6779
  const main = defineCommand({
6715
6780
  meta: {
6716
6781
  name: "bst",
6717
- version: "0.53.0",
6782
+ version: "0.54.0",
6718
6783
  description: "Bootstrap and maintain standardized TypeScript project tooling"
6719
6784
  },
6720
6785
  subCommands: {
@@ -6782,7 +6847,7 @@ const main = defineCommand({
6782
6847
  })
6783
6848
  }
6784
6849
  });
6785
- console.log(`@bensandee/tooling v0.53.0`);
6850
+ console.log(`@bensandee/tooling v0.54.0`);
6786
6851
  async function run() {
6787
6852
  await runMain(main);
6788
6853
  process.exit(process.exitCode ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {