@bensandee/tooling 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { d as debug, f as debugExec, g as note, h as log, l as createRealExecutor$1, m as redact, p as isEnvVerbose, t as runDockerCheck, u as isExecSyncError } from "./check-B5DFYJ3x.mjs";
2
+ import { _ as log, d as isExecSyncError, f as lintComposeFiles, g as redact, h as isEnvVerbose, m as debugExec, n as runDockerCheck, p as debug, t as deriveProjectName, u as createRealExecutor$1, v as note } from "./project-name-CAVRoqNy.mjs";
3
3
  import { defineCommand, runMain } from "citty";
4
4
  import * as p from "@clack/prompts";
5
5
  import { isCancel, select } from "@clack/prompts";
@@ -10,7 +10,7 @@ import JSON5 from "json5";
10
10
  import { parse } from "jsonc-parser";
11
11
  import { z } from "zod";
12
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
- import { isMap, isScalar, parse as parse$1, parseDocument, stringify, visit } from "yaml";
13
+ import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify, visit } from "yaml";
14
14
  import picomatch from "picomatch";
15
15
  import { tmpdir } from "node:os";
16
16
  import { createHmac } from "node:crypto";
@@ -1808,8 +1808,8 @@ async function generatePackageJson(ctx) {
1808
1808
  }
1809
1809
  const devDeps = { ...ROOT_DEV_DEPS };
1810
1810
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1811
- devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.11.1";
1812
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.58.0";
1811
+ devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.11.2";
1812
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.59.0";
1813
1813
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.0";
1814
1814
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1815
1815
  "@changesets/cli": "2.31.0",
@@ -3876,6 +3876,7 @@ async function generateRenovate(ctx) {
3876
3876
  //#region src/generators/pnpm-workspace.ts
3877
3877
  const FILE_PATH = "pnpm-workspace.yaml";
3878
3878
  const DEFAULT_MIN_RELEASE_AGE = 1440;
3879
+ const BENSANDEE_EXCLUDE_PATTERN = "@bensandee/*";
3879
3880
  const FRESH_MONOREPO = `packages:
3880
3881
  - "packages/*"
3881
3882
 
@@ -3909,9 +3910,11 @@ function isPnpmWorkspaceDisabled(ctx) {
3909
3910
  * Mandated:
3910
3911
  * - `blockExoticSubdeps: true`
3911
3912
  * - `minimumReleaseAge`: any non-negative number; seeded to 1440 if missing/invalid.
3913
+ * - `minimumReleaseAgeExclude`: must contain `"@bensandee/*"` so this monorepo's
3914
+ * own packages can be installed immediately after publish. Other entries are
3915
+ * preserved.
3912
3916
  *
3913
3917
  * Not enforced (left alone after creation):
3914
- * - `minimumReleaseAgeExclude`
3915
3918
  * - everything else (`packages`, `allowBuilds`, etc.)
3916
3919
  */
3917
3920
  function applyManagedKeys(doc) {
@@ -3925,6 +3928,14 @@ function applyManagedKeys(doc) {
3925
3928
  doc.set("minimumReleaseAge", DEFAULT_MIN_RELEASE_AGE);
3926
3929
  changes.push(`set minimumReleaseAge: ${DEFAULT_MIN_RELEASE_AGE}`);
3927
3930
  }
3931
+ const excludeNode = doc.get("minimumReleaseAgeExclude");
3932
+ if (!isSeq(excludeNode)) {
3933
+ doc.set("minimumReleaseAgeExclude", [BENSANDEE_EXCLUDE_PATTERN]);
3934
+ changes.push(`set minimumReleaseAgeExclude: ["${BENSANDEE_EXCLUDE_PATTERN}"]`);
3935
+ } else if (!excludeNode.items.some((item) => isScalar(item) && item.value === BENSANDEE_EXCLUDE_PATTERN)) {
3936
+ excludeNode.add(BENSANDEE_EXCLUDE_PATTERN);
3937
+ changes.push(`added "${BENSANDEE_EXCLUDE_PATTERN}" to minimumReleaseAgeExclude`);
3938
+ }
3928
3939
  return changes;
3929
3940
  }
3930
3941
  async function generatePnpmWorkspace(ctx) {
@@ -4020,6 +4031,22 @@ function parseClaudeSettings(raw) {
4020
4031
  return;
4021
4032
  }
4022
4033
  }
4034
+ /**
4035
+ * Instructions previously emitted to `.claude/settings.json#instructions`.
4036
+ * Migrated to a managed block in CLAUDE.md (see `claude-md.ts`); on sync we
4037
+ * strip any existing entry that matches (by either-direction prefix, mirroring
4038
+ * the upgrade-in-place semantics the merge logic used to apply on add).
4039
+ */
4040
+ const MIGRATED_INSTRUCTIONS = [
4041
+ "Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
4042
+ "No typecasts (as/any). Use zod schemas, type guards, or narrowing instead.",
4043
+ "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.",
4044
+ "Prefer extensionless imports; if an extension is required, use .ts over .js.",
4045
+ "The bst-hook manages Bash/file permissions. See .claude/hooks/bst-hook-guide.md for auto-approved, denied, and passthrough commands."
4046
+ ];
4047
+ function isMigratedInstruction(s) {
4048
+ return MIGRATED_INSTRUCTIONS.some((c) => s.startsWith(c) || c.startsWith(s));
4049
+ }
4023
4050
  function buildSettings(ctx) {
4024
4051
  const allow = [];
4025
4052
  const enabledPlugins = { "code-simplifier@claude-plugins-official": true };
@@ -4039,13 +4066,7 @@ function buildSettings(ctx) {
4039
4066
  command: `node "$(git rev-parse --show-toplevel)/.claude/hooks/bst-hook.mjs"`
4040
4067
  }]
4041
4068
  }] },
4042
- instructions: [
4043
- "Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
4044
- "No typecasts (as/any). Use zod schemas, type guards, or narrowing instead.",
4045
- "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.",
4046
- "Prefer extensionless imports; if an extension is required, use .ts over .js.",
4047
- "The bst-hook manages Bash/file permissions. See .claude/hooks/bst-hook-guide.md for auto-approved, denied, and passthrough commands."
4048
- ],
4069
+ instructions: [],
4049
4070
  enabledPlugins,
4050
4071
  extraKnownMarketplaces
4051
4072
  };
@@ -4060,11 +4081,12 @@ function removeObsoleteBstHookEntries(matchers, consolidatedMatcher) {
4060
4081
  return false;
4061
4082
  });
4062
4083
  }
4063
- /** Remove enabledPlugins/extraKnownMarketplaces/hooks when empty to keep the file clean. */
4084
+ /** Remove empty optional fields to keep the file clean. */
4064
4085
  function serializeSettings$1(settings) {
4065
- const { enabledPlugins, extraKnownMarketplaces, hooks, ...rest } = settings;
4086
+ const { enabledPlugins, extraKnownMarketplaces, hooks, instructions, ...rest } = settings;
4066
4087
  const out = { ...rest };
4067
4088
  if (Object.keys(hooks).length > 0) out["hooks"] = hooks;
4089
+ if (instructions.length > 0) out["instructions"] = instructions;
4068
4090
  if (Object.keys(enabledPlugins).length > 0) out["enabledPlugins"] = enabledPlugins;
4069
4091
  if (Object.keys(extraKnownMarketplaces).length > 0) out["extraKnownMarketplaces"] = extraKnownMarketplaces;
4070
4092
  return JSON.stringify(out, null, 2) + "\n";
@@ -4081,15 +4103,9 @@ function writeOrMergeSettings(ctx, filePath, generated) {
4081
4103
  const { settings, rawJson } = parsed;
4082
4104
  const missingAllow = generated.permissions.allow.filter((rule) => !settings.permissions.allow.includes(rule));
4083
4105
  const missingDeny = generated.permissions.deny.filter((rule) => !settings.permissions.deny.includes(rule));
4084
- const mergedInstructions = [...settings.instructions];
4085
- let instructionChanges = 0;
4086
- for (const inst of generated.instructions) {
4087
- if (mergedInstructions.includes(inst)) continue;
4088
- const prefixIdx = mergedInstructions.findIndex((e) => inst.startsWith(e) || e.startsWith(inst));
4089
- if (prefixIdx !== -1) mergedInstructions[prefixIdx] = inst;
4090
- else mergedInstructions.push(inst);
4091
- instructionChanges++;
4092
- }
4106
+ const filteredInstructions = settings.instructions.filter((s) => !isMigratedInstruction(s));
4107
+ const instructionChanges = settings.instructions.length - filteredInstructions.length;
4108
+ const mergedInstructions = filteredInstructions;
4093
4109
  let hookChanges = 0;
4094
4110
  const mergedHooks = { ...settings.hooks };
4095
4111
  for (const [event, generatedMatchers] of Object.entries(generated.hooks)) {
@@ -4130,7 +4146,8 @@ function writeOrMergeSettings(ctx, filePath, generated) {
4130
4146
  deny: [...settings.permissions.deny, ...missingDeny]
4131
4147
  };
4132
4148
  if (Object.keys(mergedHooks).length > 0) rawJson["hooks"] = mergedHooks;
4133
- rawJson["instructions"] = mergedInstructions;
4149
+ if (mergedInstructions.length > 0) rawJson["instructions"] = mergedInstructions;
4150
+ else delete rawJson["instructions"];
4134
4151
  const updatedPlugins = { ...settings.enabledPlugins };
4135
4152
  for (const [key, value] of missingPlugins) updatedPlugins[key] = value;
4136
4153
  const updatedMarketplaces = { ...settings.extraKnownMarketplaces };
@@ -4610,52 +4627,72 @@ async function generateCoverageCi(ctx) {
4610
4627
  //#endregion
4611
4628
  //#region src/generators/claude-md.ts
4612
4629
  const FILE = "CLAUDE.md";
4613
- const START_MARKER = "<!-- @tooling:coverage-rule -->";
4614
- const END_MARKER = "<!-- @tooling:endcoverage-rule -->";
4630
+ /**
4631
+ * General agent rules previously emitted as the `instructions` array in
4632
+ * `.claude/settings.json`. Migrated here so CLAUDE.md is the single source
4633
+ * for human-readable agent guidance.
4634
+ *
4635
+ * Also includes the plan-lifecycle convention so `docs/plan-*.md` files get
4636
+ * retired consistently when they ship.
4637
+ */
4638
+ const AGENT_RULES_BODY = [
4639
+ "### Agent rules",
4640
+ "",
4641
+ "- Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
4642
+ "- No typecasts (`as`/`any`). Use zod schemas, type guards, or narrowing instead.",
4643
+ "- 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.",
4644
+ "- Prefer extensionless imports; if an extension is required, use `.ts` over `.js`.",
4645
+ "- The bst-hook manages Bash/file permissions. See `.claude/hooks/bst-hook-guide.md` for auto-approved, denied, and passthrough commands.",
4646
+ "",
4647
+ "#### Plan lifecycle",
4648
+ "",
4649
+ "When a plan under `docs/` (e.g. `docs/plan-foo.md`) is fully implemented, retire it:",
4650
+ "",
4651
+ "1. Move the file into `docs/completed/`.",
4652
+ "2. Rename so the `plan-` prefix becomes `completed-` — `docs/plan-foo.md` → `docs/completed/completed-foo.md`. Don't keep both prefixes (no `completed-plan-foo.md`).",
4653
+ "3. Update the plan body: change the `Status:` line to `Status: **completed YYYY-MM-DD**` using today's date, and add a short \"Shipped:\" paragraph naming what landed and where the post-implementation truth lives (CLAUDE.md, source files, etc.).",
4654
+ "4. Fix any relative links inside the moved file that broke from the move (e.g. `[todo.md](todo.md)` → `[todo.md](../todo.md)`).",
4655
+ "5. Confirm no stale references to the old path remain elsewhere in the repo."
4656
+ ].join("\n");
4615
4657
  /**
4616
4658
  * The coverage-baseline rule that the agent must consult before declaring a
4617
4659
  * task complete. Kept terse — the linked plan and `--help` carry the long form.
4618
4660
  */
4619
- const RULE_BODY = [
4661
+ const COVERAGE_RULE_BODY = [
4620
4662
  "### Coverage baseline",
4621
4663
  "",
4622
4664
  "Before declaring a task complete that touched files under `packages/*/src/` (or `src/` for single-package repos), run `pnpm test:coverage` then `pnpm exec bst coverage:check`. If any of the four overall totals (lines/statements/branches/functions) dropped by more than the configured tolerance, add tests until parity is restored — or, if a touched file is genuinely not worth covering, exclude it via `coverage.exclude` in `vitest.config.ts` and explain why in the commit message. If all four are flat-or-up, run `pnpm exec bst coverage:check --update-baseline` and stage `coverage-baseline.json` with the change. Skip this gate for docs-only, config-only, dependency-bump, or test-only changes."
4623
4665
  ].join("\n");
4624
- /** Build the full managed block including markers. */
4625
- function buildBlock() {
4626
- return `${START_MARKER}\n\n${RULE_BODY}\n\n${END_MARKER}\n`;
4666
+ function buildBlockText(block) {
4667
+ return `${block.startMarker}\n\n${block.body}\n\n${block.endMarker}\n`;
4627
4668
  }
4628
- function findBlock(existing) {
4669
+ function findBlock(existing, block) {
4629
4670
  const lines = existing.split("\n");
4630
4671
  let startLine = -1;
4631
- for (let i = 0; i < lines.length; i++) if (lines[i]?.trim() === START_MARKER) {
4672
+ for (let i = 0; i < lines.length; i++) if (lines[i]?.trim() === block.startMarker) {
4632
4673
  startLine = i;
4633
4674
  break;
4634
4675
  }
4635
4676
  if (startLine === -1) return void 0;
4636
- for (let i = startLine + 1; i < lines.length; i++) if (lines[i]?.trim() === END_MARKER) return {
4677
+ for (let i = startLine + 1; i < lines.length; i++) if (lines[i]?.trim() === block.endMarker) return {
4637
4678
  startLine,
4638
4679
  endLine: i
4639
4680
  };
4640
4681
  }
4641
- /**
4642
- * Replace any existing `<!-- @tooling:coverage-rule -->...<!-- @tooling:endcoverage-rule -->`
4643
- * block in `existing` with `block`, or append it at the end if no block is found.
4644
- */
4645
4682
  function upsertBlock(existing, block) {
4646
- const found = findBlock(existing);
4647
- if (!found) return `${existing.replace(/\n+$/, "")}\n\n${block}`;
4683
+ const blockText = buildBlockText(block);
4684
+ const found = findBlock(existing, block);
4685
+ if (!found) return `${existing.replace(/\n+$/, "")}\n\n${blockText}`;
4648
4686
  const lines = existing.split("\n");
4649
4687
  const beforeLines = lines.slice(0, found.startLine);
4650
4688
  const afterLines = lines.slice(found.endLine + 1);
4651
4689
  const before = beforeLines.join("\n").replace(/\n+$/, "");
4652
4690
  const after = afterLines.join("\n").replace(/^\n+/, "");
4653
4691
  const tail = after.length > 0 ? `\n\n${after}` : "\n";
4654
- return `${before}\n\n${block.replace(/\n+$/, "")}${tail}`;
4692
+ return `${before}\n\n${blockText.replace(/\n+$/, "")}${tail}`;
4655
4693
  }
4656
- /** Strip a coverage-rule managed block (markers + body) from CLAUDE.md content. */
4657
- function removeBlock(existing) {
4658
- const found = findBlock(existing);
4694
+ function removeBlock(existing, block) {
4695
+ const found = findBlock(existing, block);
4659
4696
  if (!found) return existing;
4660
4697
  const lines = existing.split("\n");
4661
4698
  const beforeLines = lines.slice(0, found.startLine);
@@ -4665,50 +4702,46 @@ function removeBlock(existing) {
4665
4702
  if (after.length === 0) return `${before}\n`;
4666
4703
  return `${before}\n\n${after}`;
4667
4704
  }
4705
+ function applyBlock(existing, block) {
4706
+ return block.enabled ? upsertBlock(existing, block) : removeBlock(existing, block);
4707
+ }
4668
4708
  const STARTER_CLAUDE_MD = "# CLAUDE.md\n\nProject guidance for Claude Code.\n";
4669
4709
  async function generateClaudeMd(ctx) {
4670
- const config = resolveCoverageConfigFromContext(ctx, ctx.config.setupVitest);
4710
+ const coverageConfig = resolveCoverageConfigFromContext(ctx, ctx.config.setupVitest);
4711
+ const blocks = [{
4712
+ name: "agent-rules",
4713
+ startMarker: "<!-- @tooling:agent-rules -->",
4714
+ endMarker: "<!-- @tooling:endagent-rules -->",
4715
+ body: AGENT_RULES_BODY,
4716
+ enabled: true
4717
+ }, {
4718
+ name: "coverage-rule",
4719
+ startMarker: "<!-- @tooling:coverage-rule -->",
4720
+ endMarker: "<!-- @tooling:endcoverage-rule -->",
4721
+ body: COVERAGE_RULE_BODY,
4722
+ enabled: coverageConfig.enabled
4723
+ }];
4671
4724
  const existing = ctx.read(FILE);
4672
- if (!config.enabled) {
4673
- if (!existing) return {
4674
- filePath: FILE,
4675
- action: "skipped",
4676
- description: "Coverage disabled; no CLAUDE.md to manage"
4677
- };
4678
- if (!existing.includes(START_MARKER)) return {
4679
- filePath: FILE,
4680
- action: "skipped",
4681
- description: "Coverage disabled; no managed block to remove"
4682
- };
4683
- const updated = removeBlock(existing);
4684
- ctx.write(FILE, updated);
4685
- return {
4686
- filePath: FILE,
4687
- action: "updated",
4688
- description: "Removed coverage-rule managed block"
4689
- };
4690
- }
4691
- const block = buildBlock();
4725
+ let updated = existing ?? STARTER_CLAUDE_MD;
4726
+ for (const block of blocks) updated = applyBlock(updated, block);
4692
4727
  if (!existing) {
4693
- const content = upsertBlock(STARTER_CLAUDE_MD, block);
4694
- ctx.write(FILE, content);
4728
+ ctx.write(FILE, updated);
4695
4729
  return {
4696
4730
  filePath: FILE,
4697
4731
  action: "created",
4698
- description: "Generated CLAUDE.md with coverage-rule block"
4732
+ description: "Generated CLAUDE.md with managed rule blocks"
4699
4733
  };
4700
4734
  }
4701
- const updated = upsertBlock(existing, block);
4702
4735
  if (updated === existing) return {
4703
4736
  filePath: FILE,
4704
4737
  action: "skipped",
4705
- description: "Coverage-rule block already up to date"
4738
+ description: "Managed rule blocks already up to date"
4706
4739
  };
4707
4740
  ctx.write(FILE, updated);
4708
4741
  return {
4709
4742
  filePath: FILE,
4710
4743
  action: "updated",
4711
- description: "Updated coverage-rule managed block"
4744
+ description: "Updated managed rule blocks"
4712
4745
  };
4713
4746
  }
4714
4747
  //#endregion
@@ -4816,7 +4849,7 @@ async function runGenerators(ctx) {
4816
4849
  function generateMigratePrompt(results, config, detected) {
4817
4850
  const sections = [];
4818
4851
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
4819
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.58.0 repo:sync\` on ${timestamp}_\n`);
4852
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.59.0 repo:sync\` on ${timestamp}_\n`);
4820
4853
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
4821
4854
  if (changed.length > 0) {
4822
4855
  const items = changed.map((r) => {
@@ -5083,6 +5116,10 @@ const syncCommand = defineCommand({
5083
5116
  return;
5084
5117
  }
5085
5118
  const saved = loadToolingConfig(targetDir);
5119
+ if (runComposeLintGate(targetDir, saved) > 0) {
5120
+ process.exitCode = 1;
5121
+ return;
5122
+ }
5086
5123
  const isFirstRun = !saved;
5087
5124
  let config;
5088
5125
  if (args.yes || !isFirstRun) {
@@ -5101,6 +5138,7 @@ const syncCommand = defineCommand({
5101
5138
  /** Run sync in check mode: dry-run drift detection. */
5102
5139
  async function runCheck(targetDir) {
5103
5140
  const saved = loadToolingConfig(targetDir);
5141
+ if (runComposeLintGate(targetDir, saved) > 0) return 1;
5104
5142
  const detected = buildDefaultConfig(targetDir, {});
5105
5143
  const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
5106
5144
  logDetectionSummary(ctx);
@@ -5137,6 +5175,16 @@ async function runCheck(targetDir) {
5137
5175
  }
5138
5176
  return 1;
5139
5177
  }
5178
+ /**
5179
+ * Lint compose files before any generator or apply step runs. Returns the number
5180
+ * of issues found (0 = clean). Each issue is emitted via `log.error` so the
5181
+ * user sees the full message and the suggested fix.
5182
+ */
5183
+ function runComposeLintGate(targetDir, config) {
5184
+ const issues = lintComposeFiles(targetDir, config);
5185
+ for (const issue of issues) log.error(issue.message);
5186
+ return issues.length;
5187
+ }
5140
5188
  /** Files that are minified/bundled and produce unreadable diffs. */
5141
5189
  function isBundledFile(filePath) {
5142
5190
  return filePath.endsWith("bst-hook.mjs");
@@ -6926,20 +6974,19 @@ const dockerCheckCommand = defineCommand({
6926
6974
  const services = parseComposeServices(composeCwd, defaults.composeFiles);
6927
6975
  const fileReader = createFileReader();
6928
6976
  const rootPkgRaw = fileReader.readFile(path.join(cwd, "package.json"));
6977
+ const rootPkg = rootPkgRaw ? parsePackageJson(rootPkgRaw) : void 0;
6978
+ const projectName = deriveProjectName(rootPkg?.name ?? path.basename(cwd));
6929
6979
  const locallyBuiltImageNames = [];
6930
- if (rootPkgRaw) {
6931
- const rootPkg = parsePackageJson(rootPkgRaw);
6932
- if (rootPkg?.name) {
6933
- const dockerPackages = detectDockerPackages(fileReader, cwd, rootPkg.name);
6934
- const composeImages = extractComposeImageNames(services);
6935
- const composeBuildDockerfiles = new Set(extractComposeBuildDockerfiles(services));
6936
- for (const pkg of dockerPackages) {
6937
- locallyBuiltImageNames.push(pkg.imageName);
6938
- const matchesImage = composeImages.some((img) => img === pkg.imageName || img.endsWith(`/${pkg.imageName}`));
6939
- const pkgDockerfileAbs = path.resolve(cwd, pkg.docker.dockerfile);
6940
- const matchesBuild = composeBuildDockerfiles.has(pkgDockerfileAbs);
6941
- if (!matchesImage && !matchesBuild) log.warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
6942
- }
6980
+ if (rootPkg?.name) {
6981
+ const dockerPackages = detectDockerPackages(fileReader, cwd, rootPkg.name);
6982
+ const composeImages = extractComposeImageNames(services);
6983
+ const composeBuildDockerfiles = new Set(extractComposeBuildDockerfiles(services));
6984
+ for (const pkg of dockerPackages) {
6985
+ locallyBuiltImageNames.push(pkg.imageName);
6986
+ const matchesImage = composeImages.some((img) => img === pkg.imageName || img.endsWith(`/${pkg.imageName}`));
6987
+ const pkgDockerfileAbs = path.resolve(cwd, pkg.docker.dockerfile);
6988
+ const matchesBuild = composeBuildDockerfiles.has(pkgDockerfileAbs);
6989
+ if (!matchesImage && !matchesBuild) log.warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
6943
6990
  }
6944
6991
  }
6945
6992
  const tempOverlayPath = writeTempOverlay(generateCheckOverlay(services, locallyBuiltImageNames));
@@ -6953,6 +7000,7 @@ const dockerCheckCommand = defineCommand({
6953
7000
  compose: {
6954
7001
  cwd: composeCwd,
6955
7002
  composeFiles,
7003
+ projectName,
6956
7004
  envFile: defaults.envFile,
6957
7005
  services: defaults.services
6958
7006
  },
@@ -8271,7 +8319,7 @@ const out = (msg) => console.log(msg);
8271
8319
  const main = defineCommand({
8272
8320
  meta: {
8273
8321
  name: "bst",
8274
- version: "0.58.0",
8322
+ version: "0.59.0",
8275
8323
  description: "Bootstrap and maintain standardized TypeScript project tooling"
8276
8324
  },
8277
8325
  subCommands: {
@@ -8343,7 +8391,7 @@ const main = defineCommand({
8343
8391
  })
8344
8392
  }
8345
8393
  });
8346
- console.log(`@bensandee/tooling v0.58.0`);
8394
+ console.log(`@bensandee/tooling v0.59.0`);
8347
8395
  async function run() {
8348
8396
  await runMain(main);
8349
8397
  process.exit(process.exitCode ?? 0);
@@ -53,6 +53,12 @@ interface ComposeConfig {
53
53
  cwd: string;
54
54
  /** Compose files to use (e.g. ["docker-compose.yaml", "docker-compose.check.yaml"]). */
55
55
  composeFiles: string[];
56
+ /**
57
+ * Project namespace passed to `docker compose --project-name`. Required so a
58
+ * check run cannot share a project (and thus its named volumes) with another
59
+ * stack on the same docker daemon. See `deriveProjectName()`.
60
+ */
61
+ projectName: string;
56
62
  /** Optional env file for compose. */
57
63
  envFile?: string;
58
64
  /** Service names to monitor for container-level health. */
@@ -125,4 +131,118 @@ declare function getContainerHealth(containers: ContainerInfo[], serviceName: st
125
131
  /** Run a single HTTP health check, returning true if the validator passes. */
126
132
  declare function checkHttpHealth(executor: DockerCheckExecutor, check: HttpHealthCheck): Promise<boolean>;
127
133
  //#endregion
128
- export { type CheckConfig, type CheckResult, type ComposeConfig, type ContainerInfo, type DockerCheckExecutor, type HttpHealthCheck, checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runDockerCheck };
134
+ //#region src/docker-check/project-name.d.ts
135
+ /**
136
+ * Derive the `docker compose --project-name` value for a check run.
137
+ *
138
+ * Always returns a unique, non-colliding namespace so a stray `compose down -v`
139
+ * during the check cannot target volumes belonging to a long-running stack
140
+ * sharing the same docker daemon (the failure mode that wiped prod volumes
141
+ * when a CI runner bind-mounted /var/run/docker.sock and the check overlay
142
+ * inherited the base file's `name:`).
143
+ *
144
+ * Format: `<sanitized-repo>-check-<suffix>` where suffix prefers a CI run id
145
+ * for traceability and falls back to `process.pid` locally so two concurrent
146
+ * `bst docker:check` invocations on the same machine don't collide.
147
+ */
148
+ declare function deriveProjectName(repoName: string, env?: NodeJS.ProcessEnv): string;
149
+ //#endregion
150
+ //#region src/utils/tooling-config.d.ts
151
+ declare const ToolingConfigSchema: z.ZodObject<{
152
+ $schema: z.ZodOptional<z.ZodString>;
153
+ structure: z.ZodOptional<z.ZodEnum<{
154
+ single: "single";
155
+ monorepo: "monorepo";
156
+ }>>;
157
+ useEslintPlugin: z.ZodOptional<z.ZodBoolean>;
158
+ formatter: z.ZodOptional<z.ZodEnum<{
159
+ prettier: "prettier";
160
+ oxfmt: "oxfmt";
161
+ }>>;
162
+ setupVitest: z.ZodOptional<z.ZodBoolean>;
163
+ ci: z.ZodOptional<z.ZodEnum<{
164
+ github: "github";
165
+ forgejo: "forgejo";
166
+ none: "none";
167
+ }>>;
168
+ setupRenovate: z.ZodOptional<z.ZodBoolean>;
169
+ releaseStrategy: z.ZodOptional<z.ZodEnum<{
170
+ none: "none";
171
+ "release-it": "release-it";
172
+ simple: "simple";
173
+ changesets: "changesets";
174
+ }>>;
175
+ publishNpm: z.ZodOptional<z.ZodBoolean>;
176
+ publishDocker: z.ZodOptional<z.ZodBoolean>;
177
+ projectType: z.ZodOptional<z.ZodEnum<{
178
+ default: "default";
179
+ node: "node";
180
+ react: "react";
181
+ library: "library";
182
+ }>>;
183
+ detectPackageTypes: z.ZodOptional<z.ZodBoolean>;
184
+ docker: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
185
+ dockerfile: z.ZodOptional<z.ZodString>;
186
+ context: z.ZodOptional<z.ZodString>;
187
+ buildArgs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
188
+ }, z.core.$strip>>>;
189
+ dockerCheck: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
190
+ composeFiles: z.ZodOptional<z.ZodArray<z.ZodString>>;
191
+ envFile: z.ZodOptional<z.ZodString>;
192
+ services: z.ZodOptional<z.ZodArray<z.ZodString>>;
193
+ healthChecks: z.ZodOptional<z.ZodArray<z.ZodObject<{
194
+ name: z.ZodString;
195
+ url: z.ZodString;
196
+ status: z.ZodOptional<z.ZodNumber>;
197
+ }, z.core.$strip>>>;
198
+ buildCommand: z.ZodOptional<z.ZodArray<z.ZodString>>;
199
+ buildCwd: z.ZodOptional<z.ZodString>;
200
+ smoketest: z.ZodOptional<z.ZodArray<z.ZodString>>;
201
+ smoketestCwd: z.ZodOptional<z.ZodString>;
202
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
203
+ pollIntervalMs: z.ZodOptional<z.ZodNumber>;
204
+ }, z.core.$strip>]>>;
205
+ registryHost: z.ZodOptional<z.ZodString>;
206
+ registryNamespace: z.ZodOptional<z.ZodString>;
207
+ releaseAssets: z.ZodOptional<z.ZodArray<z.ZodObject<{
208
+ command: z.ZodOptional<z.ZodArray<z.ZodString>>;
209
+ file: z.ZodString;
210
+ name: z.ZodOptional<z.ZodString>;
211
+ }, z.core.$strict>>>;
212
+ postReleaseHook: z.ZodOptional<z.ZodString>;
213
+ postReleaseHookSecrets: z.ZodOptional<z.ZodArray<z.ZodString>>;
214
+ coverage: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
215
+ history: z.ZodOptional<z.ZodEnum<{
216
+ none: "none";
217
+ "orphan-branch": "orphan-branch";
218
+ }>>;
219
+ historyBranch: z.ZodOptional<z.ZodString>;
220
+ tolerancePp: z.ZodOptional<z.ZodNumber>;
221
+ gateOnPullRequests: z.ZodOptional<z.ZodBoolean>;
222
+ }, z.core.$strict>]>>;
223
+ pnpmWorkspace: z.ZodOptional<z.ZodLiteral<false>>;
224
+ }, z.core.$strict>;
225
+ type ToolingConfig = z.infer<typeof ToolingConfigSchema>;
226
+ //#endregion
227
+ //#region src/docker-check/compose-lint.d.ts
228
+ interface ComposeLintIssue {
229
+ kind: "missing-name" | "duplicate-name";
230
+ /** Repo-relative paths involved in this issue (1 for missing-name, 2+ for duplicate-name). */
231
+ files: string[];
232
+ /** Pre-rendered human-readable message ready for `log.error`. */
233
+ message: string;
234
+ }
235
+ /**
236
+ * Walk compose files in `targetDir` and report lint violations:
237
+ *
238
+ * - Rule A (`missing-name`): every override compose file must declare a
239
+ * top-level `name:` directive. Base files (docker-compose.yml etc.) are
240
+ * exempt — their default of dir-basename is fine — but overrides without
241
+ * `name:` silently inherit the base's project namespace, which is how a
242
+ * `compose down -v` against the check overlay wiped the prod-volume project.
243
+ * - Rule B (`duplicate-name`): no two compose files in a single dir may
244
+ * declare the same `name:`. Base files participate in collision detection.
245
+ */
246
+ declare function lintComposeFiles(targetDir: string, config: ToolingConfig | undefined): ComposeLintIssue[];
247
+ //#endregion
248
+ export { type CheckConfig, type CheckResult, type ComposeConfig, type ComposeLintIssue, type ContainerInfo, type DockerCheckExecutor, type HttpHealthCheck, checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, deriveProjectName, getContainerHealth, lintComposeFiles, runDockerCheck };
@@ -1,2 +1,2 @@
1
- import { a as composeDown, c as composeUp, i as composeArgs, l as createRealExecutor, n as checkHttpHealth, o as composeLogs, r as getContainerHealth, s as composePs, t as runDockerCheck } from "../check-B5DFYJ3x.mjs";
2
- export { checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runDockerCheck };
1
+ import { a as composeArgs, c as composePs, f as lintComposeFiles, i as getContainerHealth, l as composeUp, n as runDockerCheck, o as composeDown, r as checkHttpHealth, s as composeLogs, t as deriveProjectName, u as createRealExecutor } from "../project-name-CAVRoqNy.mjs";
2
+ export { checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, deriveProjectName, getContainerHealth, lintComposeFiles, runDockerCheck };
package/dist/index.d.mts CHANGED
@@ -1,119 +1,2 @@
1
- import { z } from "zod";
2
-
3
- //#region src/utils/json.d.ts
4
- declare const PackageJsonSchema: z.ZodObject<{
5
- name: z.ZodOptional<z.ZodString>;
6
- version: z.ZodOptional<z.ZodString>;
7
- private: z.ZodOptional<z.ZodBoolean>;
8
- type: z.ZodOptional<z.ZodString>;
9
- scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
10
- dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
11
- devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
12
- bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
13
- exports: z.ZodOptional<z.ZodUnknown>;
14
- main: z.ZodOptional<z.ZodString>;
15
- types: z.ZodOptional<z.ZodString>;
16
- typings: z.ZodOptional<z.ZodString>;
17
- engines: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
18
- repository: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
19
- url: z.ZodString;
20
- }, z.core.$loose>]>>;
21
- packageManager: z.ZodOptional<z.ZodString>;
22
- }, z.core.$loose>;
23
- type PackageJson = z.infer<typeof PackageJsonSchema>;
24
- //#endregion
25
- //#region src/types.d.ts
26
- type CiPlatform = "github" | "forgejo" | "none";
27
- type ReleaseStrategy = "release-it" | "simple" | "changesets" | "none";
28
- type Formatter = "oxfmt" | "prettier";
29
- /** User's answers from the interactive prompt or CLI flags. */
30
- interface ProjectConfig {
31
- /** Project name (from package.json name or user input) */
32
- name: string;
33
- /** Project structure */
34
- structure: "single" | "monorepo";
35
- /** Include @bensandee/eslint-plugin oxlint plugin */
36
- useEslintPlugin: boolean;
37
- /** Formatter choice */
38
- formatter: Formatter;
39
- /** Set up vitest with a starter test */
40
- setupVitest: boolean;
41
- /** CI platform choice */
42
- ci: CiPlatform;
43
- /** Set up Renovate for automated dependency updates */
44
- setupRenovate: boolean;
45
- /** Release management strategy */
46
- releaseStrategy: ReleaseStrategy;
47
- /** Opt-in: publish packages to npm registry */
48
- publishNpm: boolean;
49
- /** Opt-in: publish Docker images to a registry */
50
- publishDocker: boolean;
51
- /** Project type determines tsconfig base configuration */
52
- projectType: "default" | "node" | "react" | "library";
53
- /** Auto-detect and configure tsconfig bases for monorepo packages */
54
- detectPackageTypes: boolean;
55
- /** Target directory (default: cwd) */
56
- targetDir: string;
57
- }
58
- /** Result from a single generator: what file was written and how. */
59
- interface GeneratorResult {
60
- filePath: string;
61
- action: "created" | "updated" | "skipped" | "archived" | "removed";
62
- /** Human-readable description of what changed */
63
- description: string;
64
- }
65
- /** Context passed to each generator function. */
66
- interface GeneratorContext {
67
- config: ProjectConfig;
68
- /** Absolute path to target directory */
69
- targetDir: string;
70
- /** Pre-parsed package.json from the target directory, or undefined if missing/invalid */
71
- packageJson: PackageJson | undefined;
72
- /** Check whether a file exists in the target directory */
73
- exists: (relativePath: string) => boolean;
74
- /** Read an existing file from the target directory, returns undefined if not found */
75
- read: (relativePath: string) => string | undefined;
76
- /** Write a file to the target directory (creating directories as needed) */
77
- write: (relativePath: string, content: string) => void;
78
- /** Remove a file from the target directory (no-op if not found) */
79
- remove: (relativePath: string) => void;
80
- /** Prompt user for conflict resolution on non-mergeable files */
81
- confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
82
- }
83
- /** Generator function signature. */
84
- type Generator = (ctx: GeneratorContext) => Promise<GeneratorResult>;
85
- /** State detected from an existing project directory. */
86
- interface DetectedProjectState {
87
- hasPackageJson: boolean;
88
- hasTsconfig: boolean;
89
- hasOxlintConfig: boolean;
90
- /** Existing oxlint.config.ts already imports @bensandee/config/oxlint/recommended */
91
- oxlintExtendsRecommended: boolean;
92
- /** Legacy .oxlintrc.json found (should be migrated to oxlint.config.ts) */
93
- hasLegacyOxlintJson: boolean;
94
- hasGitignore: boolean;
95
- hasVitestConfig: boolean;
96
- hasTsdownConfig: boolean;
97
- hasPnpmWorkspace: boolean;
98
- hasKnipConfig: boolean;
99
- hasRenovateConfig: boolean;
100
- hasReleaseItConfig: boolean;
101
- hasSimpleReleaseConfig: boolean;
102
- hasChangesetsConfig: boolean;
103
- /** Whether package.json has a repository field (needed for release workflows) */
104
- hasRepositoryField: boolean;
105
- /** Whether commit-and-tag-version is in root devDependencies (required for simple release strategy) */
106
- hasCommitAndTagVersion: boolean;
107
- /** Git remote origin URL (for inferring repository field) */
108
- gitRemoteUrl: string | null;
109
- /** Legacy tooling configs found */
110
- legacyConfigs: LegacyConfig[];
111
- }
112
- declare const LEGACY_TOOLS: readonly ["eslint", "prettier", "jest", "webpack", "rollup"];
113
- type LegacyTool = (typeof LEGACY_TOOLS)[number];
114
- interface LegacyConfig {
115
- tool: LegacyTool;
116
- files: string[];
117
- }
118
- //#endregion
1
+ import { a as LegacyConfig, i as GeneratorResult, n as Generator, o as ProjectConfig, r as GeneratorContext, t as DetectedProjectState } from "./types-DtwkblrK.mjs";
119
2
  export { type DetectedProjectState, type Generator, type GeneratorContext, type GeneratorResult, type LegacyConfig, type ProjectConfig };
@@ -1,6 +1,9 @@
1
1
  import * as clack from "@clack/prompts";
2
+ import path from "node:path";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
4
  import { execFileSync } from "node:child_process";
3
5
  import { z } from "zod";
6
+ import { parse } from "yaml";
4
7
  //#region src/utils/log.ts
5
8
  const out = (msg) => console.log(msg);
6
9
  const isCI = Boolean(process.env["CI"]);
@@ -42,6 +45,148 @@ function debugExec(config, label, result) {
42
45
  log.info(lines.join("\n"));
43
46
  }
44
47
  //#endregion
48
+ //#region src/docker-check/compose-lint.ts
49
+ const ComposeFileShape = z.looseObject({ name: z.unknown().optional() });
50
+ /**
51
+ * Compose files live by convention in `./` or `docker/`. The lint walks those
52
+ * by default; if `.tooling.json#dockerCheck.composeFiles` overrides the
53
+ * location, each listed file (and its parent dir) is also linted.
54
+ */
55
+ const COMPOSE_DIR_CANDIDATES = [".", "docker"];
56
+ /** Base compose file names whose project namespace defaults to dir basename. */
57
+ const BASE_COMPOSE_FILES = new Set([
58
+ "docker-compose.yml",
59
+ "docker-compose.yaml",
60
+ "compose.yml",
61
+ "compose.yaml"
62
+ ]);
63
+ const COMPOSE_FILENAME_PATTERN = /^(?:docker-compose|compose).*\.(?:yml|yaml)$/;
64
+ /**
65
+ * Walk compose files in `targetDir` and report lint violations:
66
+ *
67
+ * - Rule A (`missing-name`): every override compose file must declare a
68
+ * top-level `name:` directive. Base files (docker-compose.yml etc.) are
69
+ * exempt — their default of dir-basename is fine — but overrides without
70
+ * `name:` silently inherit the base's project namespace, which is how a
71
+ * `compose down -v` against the check overlay wiped the prod-volume project.
72
+ * - Rule B (`duplicate-name`): no two compose files in a single dir may
73
+ * declare the same `name:`. Base files participate in collision detection.
74
+ */
75
+ function lintComposeFiles(targetDir, config) {
76
+ const issues = [];
77
+ const visitedDirs = /* @__PURE__ */ new Set();
78
+ const standaloneFiles = [];
79
+ const dockerCheck = config?.dockerCheck;
80
+ const isObjectConfig = dockerCheck !== void 0 && dockerCheck !== false;
81
+ for (const candidate of COMPOSE_DIR_CANDIDATES) {
82
+ const absDir = path.resolve(targetDir, candidate);
83
+ if (existsSync(absDir)) visitDir(targetDir, absDir, visitedDirs, issues);
84
+ }
85
+ if (isObjectConfig && dockerCheck.composeFiles) for (const rel of dockerCheck.composeFiles) {
86
+ const absFile = path.resolve(targetDir, rel);
87
+ if (!existsSync(absFile)) continue;
88
+ const dir = path.dirname(absFile);
89
+ if (visitedDirs.has(dir)) continue;
90
+ const basename = path.basename(absFile);
91
+ standaloneFiles.push({
92
+ absPath: absFile,
93
+ relPath: path.relative(targetDir, absFile),
94
+ basename
95
+ });
96
+ }
97
+ const standaloneByDir = /* @__PURE__ */ new Map();
98
+ for (const file of standaloneFiles) {
99
+ const dir = path.dirname(file.absPath);
100
+ const list = standaloneByDir.get(dir) ?? [];
101
+ list.push(file);
102
+ standaloneByDir.set(dir, list);
103
+ }
104
+ for (const files of standaloneByDir.values()) {
105
+ files.sort((a, b) => a.relPath.localeCompare(b.relPath));
106
+ lintDirFiles(files, issues);
107
+ }
108
+ return issues;
109
+ }
110
+ function visitDir(targetDir, absDir, visitedDirs, issues) {
111
+ if (visitedDirs.has(absDir)) return;
112
+ visitedDirs.add(absDir);
113
+ let entries;
114
+ try {
115
+ entries = readdirSync(absDir);
116
+ } catch (_error) {
117
+ return;
118
+ }
119
+ const files = entries.filter((name) => COMPOSE_FILENAME_PATTERN.test(name)).toSorted().map((name) => {
120
+ const absPath = path.join(absDir, name);
121
+ return {
122
+ absPath,
123
+ relPath: path.relative(targetDir, absPath),
124
+ basename: name
125
+ };
126
+ });
127
+ if (files.length === 0) return;
128
+ lintDirFiles(files, issues);
129
+ }
130
+ function lintDirFiles(files, issues) {
131
+ const namesByFile = /* @__PURE__ */ new Map();
132
+ for (const file of files) {
133
+ const declaredName = readTopLevelName(file.absPath);
134
+ namesByFile.set(file.relPath, declaredName);
135
+ if (!BASE_COMPOSE_FILES.has(file.basename) && (declaredName === void 0 || declaredName === "")) {
136
+ const suggested = suggestProjectName(file);
137
+ issues.push({
138
+ kind: "missing-name",
139
+ files: [file.relPath],
140
+ message: `${file.relPath}: override compose file is missing required top-level 'name:' directive.\n Add: name: ${suggested}\n Why: without an explicit name, this override inherits the base file's project namespace.\n A 'docker compose down -v' against this override would target the base project's volumes\n — which has caused production volume loss in the past.`
141
+ });
142
+ }
143
+ }
144
+ const filesByName = /* @__PURE__ */ new Map();
145
+ for (const [relPath, declaredName] of namesByFile) {
146
+ if (declaredName === void 0 || declaredName === "") continue;
147
+ const list = filesByName.get(declaredName) ?? [];
148
+ list.push(relPath);
149
+ filesByName.set(declaredName, list);
150
+ }
151
+ for (const [declaredName, dupFiles] of filesByName) {
152
+ if (dupFiles.length < 2) continue;
153
+ dupFiles.sort();
154
+ const [first, second, ...rest] = dupFiles;
155
+ const ext = rest.length > 0 ? `, ${rest.join(", ")}` : "";
156
+ issues.push({
157
+ kind: "duplicate-name",
158
+ files: dupFiles,
159
+ message: `${String(first)} and ${String(second)}${ext} both declare 'name: ${declaredName}'.\n Each compose file in the same directory must declare a unique 'name:'.\n Suggested fix: rename one (e.g. 'name: ${declaredName}-test' in one of them).`
160
+ });
161
+ }
162
+ }
163
+ /** Suggest a default name for an override file by stripping the variant from the filename. */
164
+ function suggestProjectName(file) {
165
+ const parts = file.basename.replace(/\.(yml|yaml)$/, "").split(".");
166
+ const variant = parts[parts.length - 1];
167
+ if (parts.length > 1 && variant) return `<project>-${variant}`;
168
+ return "<project>-check";
169
+ }
170
+ function readTopLevelName(absPath) {
171
+ let raw;
172
+ try {
173
+ raw = readFileSync(absPath, "utf-8");
174
+ } catch (_error) {
175
+ return;
176
+ }
177
+ let yamlValue;
178
+ try {
179
+ yamlValue = parse(raw);
180
+ } catch (_error) {
181
+ return;
182
+ }
183
+ const parsed = ComposeFileShape.safeParse(yamlValue);
184
+ if (!parsed.success) return void 0;
185
+ const value = parsed.data.name;
186
+ if (typeof value !== "string") return void 0;
187
+ return value;
188
+ }
189
+ //#endregion
45
190
  //#region src/utils/exec.ts
46
191
  /** Type guard for `execSync` errors that carry stdout/stderr/status. */
47
192
  function isExecSyncError(err) {
@@ -116,7 +261,11 @@ const ContainerInfoSchema = z.object({
116
261
  });
117
262
  /** Build the `docker compose` base args from config. */
118
263
  function composeArgs(config) {
119
- const args = ["compose"];
264
+ const args = [
265
+ "compose",
266
+ "--project-name",
267
+ config.projectName
268
+ ];
120
269
  for (const f of config.composeFiles) args.push("-f", f);
121
270
  if (config.envFile) args.push("--env-file", config.envFile);
122
271
  return args;
@@ -340,4 +489,25 @@ async function runDockerCheck(executor, config) {
340
489
  }
341
490
  }
342
491
  //#endregion
343
- export { composeDown as a, composeUp as c, debug as d, debugExec as f, note as g, log as h, composeArgs as i, createRealExecutor as l, redact as m, checkHttpHealth as n, composeLogs as o, isEnvVerbose as p, getContainerHealth as r, composePs as s, runDockerCheck as t, isExecSyncError as u };
492
+ //#region src/docker-check/project-name.ts
493
+ /**
494
+ * Derive the `docker compose --project-name` value for a check run.
495
+ *
496
+ * Always returns a unique, non-colliding namespace so a stray `compose down -v`
497
+ * during the check cannot target volumes belonging to a long-running stack
498
+ * sharing the same docker daemon (the failure mode that wiped prod volumes
499
+ * when a CI runner bind-mounted /var/run/docker.sock and the check overlay
500
+ * inherited the base file's `name:`).
501
+ *
502
+ * Format: `<sanitized-repo>-check-<suffix>` where suffix prefers a CI run id
503
+ * for traceability and falls back to `process.pid` locally so two concurrent
504
+ * `bst docker:check` invocations on the same machine don't collide.
505
+ */
506
+ function deriveProjectName(repoName, env = process.env) {
507
+ return `${sanitize(repoName)}-check-${sanitize(env["GITHUB_RUN_ID"] ?? env["CI_RUN_ID"] ?? String(process.pid))}`;
508
+ }
509
+ function sanitize(name) {
510
+ return name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
511
+ }
512
+ //#endregion
513
+ export { log as _, composeArgs as a, composePs as c, isExecSyncError as d, lintComposeFiles as f, redact as g, isEnvVerbose as h, getContainerHealth as i, composeUp as l, debugExec as m, runDockerCheck as n, composeDown as o, debug as p, checkHttpHealth as r, composeLogs as s, deriveProjectName as t, createRealExecutor as u, note as v };
@@ -0,0 +1,119 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/utils/json.d.ts
4
+ declare const PackageJsonSchema: z.ZodObject<{
5
+ name: z.ZodOptional<z.ZodString>;
6
+ version: z.ZodOptional<z.ZodString>;
7
+ private: z.ZodOptional<z.ZodBoolean>;
8
+ type: z.ZodOptional<z.ZodString>;
9
+ scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
10
+ dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
11
+ devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
12
+ bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
13
+ exports: z.ZodOptional<z.ZodUnknown>;
14
+ main: z.ZodOptional<z.ZodString>;
15
+ types: z.ZodOptional<z.ZodString>;
16
+ typings: z.ZodOptional<z.ZodString>;
17
+ engines: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
18
+ repository: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
19
+ url: z.ZodString;
20
+ }, z.core.$loose>]>>;
21
+ packageManager: z.ZodOptional<z.ZodString>;
22
+ }, z.core.$loose>;
23
+ type PackageJson = z.infer<typeof PackageJsonSchema>;
24
+ //#endregion
25
+ //#region src/types.d.ts
26
+ type CiPlatform = "github" | "forgejo" | "none";
27
+ type ReleaseStrategy = "release-it" | "simple" | "changesets" | "none";
28
+ type Formatter = "oxfmt" | "prettier";
29
+ /** User's answers from the interactive prompt or CLI flags. */
30
+ interface ProjectConfig {
31
+ /** Project name (from package.json name or user input) */
32
+ name: string;
33
+ /** Project structure */
34
+ structure: "single" | "monorepo";
35
+ /** Include @bensandee/eslint-plugin oxlint plugin */
36
+ useEslintPlugin: boolean;
37
+ /** Formatter choice */
38
+ formatter: Formatter;
39
+ /** Set up vitest with a starter test */
40
+ setupVitest: boolean;
41
+ /** CI platform choice */
42
+ ci: CiPlatform;
43
+ /** Set up Renovate for automated dependency updates */
44
+ setupRenovate: boolean;
45
+ /** Release management strategy */
46
+ releaseStrategy: ReleaseStrategy;
47
+ /** Opt-in: publish packages to npm registry */
48
+ publishNpm: boolean;
49
+ /** Opt-in: publish Docker images to a registry */
50
+ publishDocker: boolean;
51
+ /** Project type determines tsconfig base configuration */
52
+ projectType: "default" | "node" | "react" | "library";
53
+ /** Auto-detect and configure tsconfig bases for monorepo packages */
54
+ detectPackageTypes: boolean;
55
+ /** Target directory (default: cwd) */
56
+ targetDir: string;
57
+ }
58
+ /** Result from a single generator: what file was written and how. */
59
+ interface GeneratorResult {
60
+ filePath: string;
61
+ action: "created" | "updated" | "skipped" | "archived" | "removed";
62
+ /** Human-readable description of what changed */
63
+ description: string;
64
+ }
65
+ /** Context passed to each generator function. */
66
+ interface GeneratorContext {
67
+ config: ProjectConfig;
68
+ /** Absolute path to target directory */
69
+ targetDir: string;
70
+ /** Pre-parsed package.json from the target directory, or undefined if missing/invalid */
71
+ packageJson: PackageJson | undefined;
72
+ /** Check whether a file exists in the target directory */
73
+ exists: (relativePath: string) => boolean;
74
+ /** Read an existing file from the target directory, returns undefined if not found */
75
+ read: (relativePath: string) => string | undefined;
76
+ /** Write a file to the target directory (creating directories as needed) */
77
+ write: (relativePath: string, content: string) => void;
78
+ /** Remove a file from the target directory (no-op if not found) */
79
+ remove: (relativePath: string) => void;
80
+ /** Prompt user for conflict resolution on non-mergeable files */
81
+ confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
82
+ }
83
+ /** Generator function signature. */
84
+ type Generator = (ctx: GeneratorContext) => Promise<GeneratorResult>;
85
+ /** State detected from an existing project directory. */
86
+ interface DetectedProjectState {
87
+ hasPackageJson: boolean;
88
+ hasTsconfig: boolean;
89
+ hasOxlintConfig: boolean;
90
+ /** Existing oxlint.config.ts already imports @bensandee/config/oxlint/recommended */
91
+ oxlintExtendsRecommended: boolean;
92
+ /** Legacy .oxlintrc.json found (should be migrated to oxlint.config.ts) */
93
+ hasLegacyOxlintJson: boolean;
94
+ hasGitignore: boolean;
95
+ hasVitestConfig: boolean;
96
+ hasTsdownConfig: boolean;
97
+ hasPnpmWorkspace: boolean;
98
+ hasKnipConfig: boolean;
99
+ hasRenovateConfig: boolean;
100
+ hasReleaseItConfig: boolean;
101
+ hasSimpleReleaseConfig: boolean;
102
+ hasChangesetsConfig: boolean;
103
+ /** Whether package.json has a repository field (needed for release workflows) */
104
+ hasRepositoryField: boolean;
105
+ /** Whether commit-and-tag-version is in root devDependencies (required for simple release strategy) */
106
+ hasCommitAndTagVersion: boolean;
107
+ /** Git remote origin URL (for inferring repository field) */
108
+ gitRemoteUrl: string | null;
109
+ /** Legacy tooling configs found */
110
+ legacyConfigs: LegacyConfig[];
111
+ }
112
+ declare const LEGACY_TOOLS: readonly ["eslint", "prettier", "jest", "webpack", "rollup"];
113
+ type LegacyTool = (typeof LEGACY_TOOLS)[number];
114
+ interface LegacyConfig {
115
+ tool: LegacyTool;
116
+ files: string[];
117
+ }
118
+ //#endregion
119
+ export { LegacyConfig as a, GeneratorResult as i, Generator as n, ProjectConfig as o, GeneratorContext as r, DetectedProjectState as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "release-it": "20.0.1",
56
56
  "tsdown": "0.22.0",
57
57
  "@bensandee/claude-hooks": "0.7.4",
58
- "@bensandee/config": "0.11.1"
58
+ "@bensandee/config": "0.11.2"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsdown",