@bensandee/tooling 0.23.0 → 0.25.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
@@ -22,7 +22,7 @@ const LEGACY_TOOLS = [
22
22
  //#endregion
23
23
  //#region src/utils/json.ts
24
24
  const StringRecord = z.record(z.string(), z.string());
25
- const PackageJsonSchema = z.object({
25
+ const PackageJsonSchema = z.looseObject({
26
26
  name: z.string().optional(),
27
27
  version: z.string().optional(),
28
28
  private: z.boolean().optional(),
@@ -35,20 +35,21 @@ const PackageJsonSchema = z.object({
35
35
  main: z.string().optional(),
36
36
  types: z.string().optional(),
37
37
  typings: z.string().optional(),
38
- engines: StringRecord.optional()
39
- }).loose();
40
- const TsconfigSchema = z.object({
38
+ engines: StringRecord.optional(),
39
+ repository: z.union([z.string(), z.looseObject({ url: z.string() })]).optional()
40
+ });
41
+ const TsconfigSchema = z.looseObject({
41
42
  extends: z.union([z.string(), z.array(z.string())]).optional(),
42
43
  include: z.array(z.string()).optional(),
43
44
  exclude: z.array(z.string()).optional(),
44
45
  files: z.array(z.string()).optional(),
45
- references: z.array(z.object({ path: z.string() }).loose()).optional(),
46
+ references: z.array(z.looseObject({ path: z.string() })).optional(),
46
47
  compilerOptions: z.record(z.string(), z.unknown()).optional()
47
- }).loose();
48
- const RenovateSchema = z.object({
48
+ });
49
+ const RenovateSchema = z.looseObject({
49
50
  $schema: z.string().optional(),
50
51
  extends: z.array(z.string()).optional()
51
- }).loose();
52
+ });
52
53
  /** Parse a JSONC string as a tsconfig.json. Returns a typed object with `{}` fallback on failure. */
53
54
  function parseTsconfig(raw) {
54
55
  const result = TsconfigSchema.safeParse(parse(raw));
@@ -63,7 +64,7 @@ function parseRenovateJson(raw) {
63
64
  return {};
64
65
  }
65
66
  }
66
- const ChangesetConfigSchema = z.object({ commit: z.union([z.boolean(), z.string()]).optional() }).loose();
67
+ const ChangesetConfigSchema = z.looseObject({ commit: z.union([z.boolean(), z.string()]).optional() });
67
68
  /** Parse a JSON string as a .changeset/config.json. Returns `undefined` on failure. */
68
69
  function parseChangesetConfig(raw) {
69
70
  try {
@@ -108,6 +109,7 @@ function detectProject(targetDir) {
108
109
  hasReleaseItConfig: exists(".release-it.json") || exists(".release-it.yaml") || exists(".release-it.toml"),
109
110
  hasSimpleReleaseConfig: exists(".versionrc") || exists(".versionrc.json") || exists(".versionrc.js"),
110
111
  hasChangesetsConfig: exists(".changeset/config.json"),
112
+ hasRepositoryField: !!readPackageJson(targetDir)?.repository,
111
113
  legacyConfigs: detectLegacyConfigs(targetDir)
112
114
  };
113
115
  }
@@ -981,7 +983,7 @@ function getAddedDevDepNames(config) {
981
983
  const deps = { ...ROOT_DEV_DEPS };
982
984
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
983
985
  deps["@bensandee/config"] = "0.8.2";
984
- deps["@bensandee/tooling"] = "0.23.0";
986
+ deps["@bensandee/tooling"] = "0.25.0";
985
987
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
986
988
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
987
989
  addReleaseDeps(deps, config);
@@ -1006,7 +1008,7 @@ async function generatePackageJson(ctx) {
1006
1008
  const devDeps = { ...ROOT_DEV_DEPS };
1007
1009
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1008
1010
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.2";
1009
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.23.0";
1011
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.25.0";
1010
1012
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1011
1013
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
1012
1014
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1894,7 +1896,8 @@ function buildSettings(ctx) {
1894
1896
  instructions: [
1895
1897
  "Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
1896
1898
  "No typecasts (as/any). Use zod schemas, type guards, or narrowing instead.",
1897
- "Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option."
1899
+ "Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option.",
1900
+ "Prefer extensionless imports; if an extension is required, use .ts over .js."
1898
1901
  ],
1899
1902
  enabledPlugins,
1900
1903
  extraKnownMarketplaces
@@ -2475,7 +2478,7 @@ const SCHEMA_NPM_PATH = "@bensandee/config/schemas/forgejo-workflow.schema.json"
2475
2478
  const SCHEMA_LOCAL_PATH = ".vscode/forgejo-workflow.schema.json";
2476
2479
  const SETTINGS_PATH = ".vscode/settings.json";
2477
2480
  const SCHEMA_GLOB = ".forgejo/workflows/*.{yml,yaml}";
2478
- const VscodeSettingsSchema = z.object({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) }).loose();
2481
+ const VscodeSettingsSchema = z.looseObject({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) });
2479
2482
  function readSchemaFromNodeModules(targetDir) {
2480
2483
  const candidate = path.join(targetDir, "node_modules", SCHEMA_NPM_PATH);
2481
2484
  if (!existsSync(candidate)) return void 0;
@@ -2599,54 +2602,349 @@ async function runGenerators(ctx) {
2599
2602
  return results;
2600
2603
  }
2601
2604
  //#endregion
2602
- //#region src/generators/migrate-prompt.ts
2605
+ //#region src/release/docker.ts
2606
+ const ToolingDockerMapSchema = z.record(z.string(), z.object({
2607
+ dockerfile: z.string(),
2608
+ context: z.string().default(".")
2609
+ }));
2610
+ const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
2611
+ const PackageInfoSchema = z.object({
2612
+ name: z.string().optional(),
2613
+ version: z.string().optional()
2614
+ });
2615
+ /** Read the docker map from .tooling.json. Returns empty record if missing or invalid. */
2616
+ function loadDockerMap(executor, cwd) {
2617
+ const configPath = path.join(cwd, ".tooling.json");
2618
+ const raw = executor.readFile(configPath);
2619
+ if (!raw) return {};
2620
+ try {
2621
+ const result = ToolingConfigDockerSchema.safeParse(JSON.parse(raw));
2622
+ if (!result.success || !result.data.docker) return {};
2623
+ return result.data.docker;
2624
+ } catch (_error) {
2625
+ return {};
2626
+ }
2627
+ }
2628
+ /** Read name and version from a package's package.json. */
2629
+ function readPackageInfo(executor, packageJsonPath) {
2630
+ const raw = executor.readFile(packageJsonPath);
2631
+ if (!raw) return {
2632
+ name: void 0,
2633
+ version: void 0
2634
+ };
2635
+ try {
2636
+ const result = PackageInfoSchema.safeParse(JSON.parse(raw));
2637
+ if (!result.success) return {
2638
+ name: void 0,
2639
+ version: void 0
2640
+ };
2641
+ return {
2642
+ name: result.data.name,
2643
+ version: result.data.version
2644
+ };
2645
+ } catch (_error) {
2646
+ return {
2647
+ name: void 0,
2648
+ version: void 0
2649
+ };
2650
+ }
2651
+ }
2652
+ /** Convention paths to check for Dockerfiles in a package directory. */
2653
+ const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
2603
2654
  /**
2604
- * Generate a context-aware AI migration prompt based on what the CLI did.
2605
- * This prompt can be pasted into Claude Code (or similar) to finish the migration.
2655
+ * Find a Dockerfile at convention paths for a monorepo package.
2656
+ * Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
2606
2657
  */
2607
- function generateMigratePrompt(results, config, detected) {
2608
- const sections = [];
2609
- sections.push("# Migration Prompt");
2610
- sections.push("");
2611
- sections.push("The following prompt was generated by `@bensandee/tooling repo:sync`. Paste it into Claude Code or another AI assistant to finish migrating this repository.");
2612
- sections.push("");
2613
- sections.push("> **Tip:** Before starting, run `/init` in Claude Code to generate a `CLAUDE.md` that gives the AI a complete picture of your repository's structure, conventions, and build commands.");
2614
- sections.push("");
2615
- sections.push("## What was changed");
2616
- sections.push("");
2617
- const created = results.filter((r) => r.action === "created");
2618
- const updated = results.filter((r) => r.action === "updated");
2619
- const skipped = results.filter((r) => r.action === "skipped");
2620
- const archived = results.filter((r) => r.action === "archived");
2621
- if (created.length > 0) {
2622
- sections.push("**Created:**");
2623
- for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2624
- sections.push("");
2658
+ function findConventionDockerfile(executor, cwd, dir) {
2659
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) {
2660
+ const dockerfilePath = `packages/${dir}/${rel}`;
2661
+ if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
2662
+ dockerfile: dockerfilePath,
2663
+ context: "."
2664
+ };
2625
2665
  }
2626
- if (updated.length > 0) {
2627
- sections.push("**Updated:**");
2628
- for (const r of updated) sections.push(`- \`${r.filePath}\` ${r.description}`);
2629
- sections.push("");
2666
+ }
2667
+ /**
2668
+ * Find a Dockerfile at convention paths for a single-package repo.
2669
+ * Checks Dockerfile and docker/Dockerfile at the project root.
2670
+ */
2671
+ function findRootDockerfile(executor, cwd) {
2672
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
2673
+ dockerfile: rel,
2674
+ context: "."
2675
+ };
2676
+ }
2677
+ /**
2678
+ * Discover Docker packages by convention and merge with .tooling.json overrides.
2679
+ *
2680
+ * Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
2681
+ * For monorepos, scans packages/{name}/. For single-package repos, scans the root.
2682
+ * The docker map in .tooling.json overrides convention-discovered config and can add
2683
+ * packages at non-standard locations.
2684
+ *
2685
+ * Image names are derived from {root-name}-{package-name} using each package's package.json name.
2686
+ * Versions are read from each package's own package.json.
2687
+ */
2688
+ function detectDockerPackages(executor, cwd, repoName) {
2689
+ const overrides = loadDockerMap(executor, cwd);
2690
+ const packageDirs = executor.listPackageDirs(cwd);
2691
+ const packages = [];
2692
+ const seen = /* @__PURE__ */ new Set();
2693
+ if (packageDirs.length > 0) {
2694
+ for (const dir of packageDirs) {
2695
+ const convention = findConventionDockerfile(executor, cwd, dir);
2696
+ const docker = overrides[dir] ?? convention;
2697
+ if (docker) {
2698
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2699
+ packages.push({
2700
+ dir,
2701
+ imageName: `${repoName}-${name ?? dir}`,
2702
+ version,
2703
+ docker
2704
+ });
2705
+ seen.add(dir);
2706
+ }
2707
+ }
2708
+ for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
2709
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2710
+ packages.push({
2711
+ dir,
2712
+ imageName: `${repoName}-${name ?? dir}`,
2713
+ version,
2714
+ docker
2715
+ });
2716
+ }
2717
+ } else {
2718
+ const convention = findRootDockerfile(executor, cwd);
2719
+ const docker = overrides["."] ?? convention;
2720
+ if (docker) {
2721
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
2722
+ packages.push({
2723
+ dir: ".",
2724
+ imageName: name ?? repoName,
2725
+ version,
2726
+ docker
2727
+ });
2728
+ }
2630
2729
  }
2631
- if (archived.length > 0) {
2632
- sections.push("**Archived:**");
2633
- for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2634
- sections.push("");
2730
+ return packages;
2731
+ }
2732
+ /**
2733
+ * Read docker config for a single package, checking convention paths first,
2734
+ * then .tooling.json overrides. Used by the per-package image:build script.
2735
+ */
2736
+ function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
2737
+ const dir = path.basename(path.resolve(cwd, packageDir));
2738
+ const convention = findConventionDockerfile(executor, cwd, dir);
2739
+ const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
2740
+ if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
2741
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2742
+ return {
2743
+ dir,
2744
+ imageName: `${repoName}-${name ?? dir}`,
2745
+ version,
2746
+ docker
2747
+ };
2748
+ }
2749
+ /** Parse semver version string into major, minor, patch components. */
2750
+ function parseSemver(version) {
2751
+ const clean = version.replace(/^v/, "");
2752
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(clean);
2753
+ if (!match?.[1] || !match[2] || !match[3]) throw new FatalError(`Invalid semver version: ${version}`);
2754
+ return {
2755
+ major: Number(match[1]),
2756
+ minor: Number(match[2]),
2757
+ patch: Number(match[3])
2758
+ };
2759
+ }
2760
+ /** Generate semver tag variants: latest, vX.Y.Z, vX.Y, vX */
2761
+ function generateTags(version) {
2762
+ const { major, minor, patch } = parseSemver(version);
2763
+ return [
2764
+ "latest",
2765
+ `v${major}.${minor}.${patch}`,
2766
+ `v${major}.${minor}`,
2767
+ `v${major}`
2768
+ ];
2769
+ }
2770
+ /** Build the full image reference: namespace/imageName:tag */
2771
+ function imageRef(namespace, imageName, tag) {
2772
+ return `${namespace}/${imageName}:${tag}`;
2773
+ }
2774
+ function log$1(message) {
2775
+ console.log(message);
2776
+ }
2777
+ function debug$1(verbose, message) {
2778
+ if (verbose) console.log(`[debug] ${message}`);
2779
+ }
2780
+ /** Read the repo name from root package.json. */
2781
+ function readRepoName(executor, cwd) {
2782
+ const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
2783
+ if (!rootPkgRaw) throw new FatalError("No package.json found in project root");
2784
+ const repoName = parsePackageJson(rootPkgRaw)?.name;
2785
+ if (!repoName) throw new FatalError("Root package.json must have a name field");
2786
+ return repoName;
2787
+ }
2788
+ /** Build a single docker image from its config. Paths are resolved relative to cwd. */
2789
+ function buildImage(executor, pkg, cwd, verbose, extraArgs) {
2790
+ const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
2791
+ const contextPath = path.resolve(cwd, pkg.docker.context);
2792
+ const command = [
2793
+ "docker build",
2794
+ `-f ${dockerfilePath}`,
2795
+ `-t ${pkg.imageName}:latest`,
2796
+ ...extraArgs,
2797
+ contextPath
2798
+ ].join(" ");
2799
+ debug$1(verbose, `Running: ${command}`);
2800
+ const buildResult = executor.exec(command);
2801
+ debug$1(verbose, `Build stdout: ${buildResult.stdout}`);
2802
+ if (buildResult.exitCode !== 0) throw new FatalError(`docker build failed for ${pkg.dir} (exit ${buildResult.exitCode}): ${buildResult.stderr}`);
2803
+ }
2804
+ /**
2805
+ * Detect packages with docker config in .tooling.json and build each one.
2806
+ * Runs `docker build -f <dockerfile> -t <image-name>:latest <context>` for each package.
2807
+ * Dockerfile and context paths are resolved relative to the project root.
2808
+ *
2809
+ * When `packageDir` is set, builds only that single package (for use as an image:build script).
2810
+ */
2811
+ function runDockerBuild(executor, config) {
2812
+ const repoName = readRepoName(executor, config.cwd);
2813
+ if (config.packageDir) {
2814
+ const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
2815
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
2816
+ buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
2817
+ log$1(`Built ${pkg.imageName}:latest`);
2818
+ return { packages: [pkg] };
2635
2819
  }
2636
- if (skipped.length > 0) {
2637
- sections.push("**Skipped (review these):**");
2638
- for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2639
- sections.push("");
2820
+ const packages = detectDockerPackages(executor, config.cwd, repoName);
2821
+ if (packages.length === 0) {
2822
+ log$1("No packages with docker config found");
2823
+ return { packages: [] };
2640
2824
  }
2641
- sections.push("## Migration tasks");
2642
- sections.push("");
2643
- const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
2644
- if (legacyToRemove.length > 0) {
2645
- sections.push("### Remove legacy tooling");
2646
- sections.push("");
2647
- for (const legacy of legacyToRemove) {
2648
- const replacement = {
2649
- eslint: "oxlint",
2825
+ log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
2826
+ for (const pkg of packages) {
2827
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
2828
+ buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
2829
+ }
2830
+ log$1(`Built ${packages.length} image(s)`);
2831
+ return { packages };
2832
+ }
2833
+ /**
2834
+ * Run the full Docker publish pipeline:
2835
+ * 1. Build all images via runDockerBuild
2836
+ * 2. Login to registry
2837
+ * 3. Tag each image with semver variants from its own package.json version
2838
+ * 4. Push all tags
2839
+ * 5. Logout from registry
2840
+ */
2841
+ function runDockerPublish(executor, config) {
2842
+ const { packages } = runDockerBuild(executor, {
2843
+ cwd: config.cwd,
2844
+ packageDir: void 0,
2845
+ verbose: config.verbose,
2846
+ extraArgs: []
2847
+ });
2848
+ if (packages.length === 0) return {
2849
+ packages: [],
2850
+ tags: []
2851
+ };
2852
+ for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
2853
+ if (!config.dryRun) {
2854
+ log$1(`Logging in to ${config.registryHost}...`);
2855
+ const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
2856
+ if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
2857
+ } else log$1("[dry-run] Skipping docker login");
2858
+ const allTags = [];
2859
+ try {
2860
+ for (const pkg of packages) {
2861
+ const tags = generateTags(pkg.version ?? "");
2862
+ log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
2863
+ for (const tag of tags) {
2864
+ const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
2865
+ allTags.push(ref);
2866
+ log$1(`Tagging ${pkg.imageName} → ${ref}`);
2867
+ const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
2868
+ if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
2869
+ if (!config.dryRun) {
2870
+ log$1(`Pushing ${ref}...`);
2871
+ const pushResult = executor.exec(`docker push ${ref}`);
2872
+ if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
2873
+ } else log$1(`[dry-run] Skipping push for ${ref}`);
2874
+ }
2875
+ }
2876
+ } finally {
2877
+ if (!config.dryRun) {
2878
+ log$1(`Logging out from ${config.registryHost}...`);
2879
+ executor.exec(`docker logout ${config.registryHost}`);
2880
+ }
2881
+ }
2882
+ log$1(`Published ${allTags.length} image tag(s)`);
2883
+ return {
2884
+ packages,
2885
+ tags: allTags
2886
+ };
2887
+ }
2888
+ //#endregion
2889
+ //#region src/generators/migrate-prompt.ts
2890
+ /**
2891
+ * Generate a context-aware AI migration prompt based on what the CLI did.
2892
+ * This prompt can be pasted into Claude Code (or similar) to finish the migration.
2893
+ */
2894
+ function generateMigratePrompt(results, config, detected) {
2895
+ const sections = [];
2896
+ sections.push("# Migration Prompt");
2897
+ sections.push("");
2898
+ sections.push("The following prompt was generated by `@bensandee/tooling repo:sync`. Paste it into Claude Code or another AI assistant to finish migrating this repository.");
2899
+ sections.push("");
2900
+ sections.push("> **Tip:** Before starting, run `/init` in Claude Code to generate a `CLAUDE.md` that gives the AI a complete picture of your repository's structure, conventions, and build commands.");
2901
+ sections.push("");
2902
+ sections.push("## What was changed");
2903
+ sections.push("");
2904
+ const created = results.filter((r) => r.action === "created");
2905
+ const updated = results.filter((r) => r.action === "updated");
2906
+ const skipped = results.filter((r) => r.action === "skipped");
2907
+ const archived = results.filter((r) => r.action === "archived");
2908
+ if (created.length > 0) {
2909
+ sections.push("**Created:**");
2910
+ for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2911
+ sections.push("");
2912
+ }
2913
+ if (updated.length > 0) {
2914
+ sections.push("**Updated:**");
2915
+ for (const r of updated) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2916
+ sections.push("");
2917
+ }
2918
+ if (archived.length > 0) {
2919
+ sections.push("**Archived:**");
2920
+ for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2921
+ sections.push("");
2922
+ }
2923
+ if (skipped.length > 0) {
2924
+ sections.push("**Skipped (review these):**");
2925
+ for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2926
+ sections.push("");
2927
+ }
2928
+ sections.push("## Migration tasks");
2929
+ sections.push("");
2930
+ if (config.releaseStrategy !== "none" && !detected.hasRepositoryField) {
2931
+ sections.push("### Add repository field to package.json");
2932
+ sections.push("");
2933
+ sections.push(`The release strategy \`${config.releaseStrategy}\` requires a \`repository\` field in \`package.json\` so that \`release:trigger\` can determine the correct hosting platform (Forgejo vs GitHub).`);
2934
+ sections.push("");
2935
+ sections.push("Add the appropriate repository URL to `package.json`:");
2936
+ sections.push("");
2937
+ sections.push("- For Forgejo: `\"repository\": \"https://your-forgejo-instance.com/owner/repo\"`");
2938
+ sections.push("- For GitHub: `\"repository\": \"https://github.com/owner/repo\"`");
2939
+ sections.push("");
2940
+ }
2941
+ const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
2942
+ if (legacyToRemove.length > 0) {
2943
+ sections.push("### Remove legacy tooling");
2944
+ sections.push("");
2945
+ for (const legacy of legacyToRemove) {
2946
+ const replacement = {
2947
+ eslint: "oxlint",
2650
2948
  prettier: "oxfmt",
2651
2949
  jest: "vitest",
2652
2950
  webpack: "tsdown",
@@ -2769,15 +3067,29 @@ function generateMigratePrompt(results, config, detected) {
2769
3067
  }
2770
3068
  //#endregion
2771
3069
  //#region src/commands/repo-init.ts
3070
+ /** Adapt a GeneratorContext to the DockerFileReader interface used by detectDockerPackages. */
3071
+ function contextAsDockerReader(ctx) {
3072
+ return {
3073
+ listPackageDirs(cwd) {
3074
+ const packagesDir = path.join(cwd, "packages");
3075
+ try {
3076
+ return readdirSync(packagesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
3077
+ } catch (_error) {
3078
+ return [];
3079
+ }
3080
+ },
3081
+ readFile(filePath) {
3082
+ const rel = path.relative(ctx.targetDir, filePath);
3083
+ return ctx.read(rel) ?? null;
3084
+ }
3085
+ };
3086
+ }
2772
3087
  /** Log what was detected so the user understands generator decisions. */
2773
3088
  function logDetectionSummary(ctx) {
2774
- const dockerNames = getDockerPackageNames(ctx);
2775
- if (dockerNames.length > 0) p.log.info(`Detected Docker packages: ${dockerNames.join(", ")}`);
2776
- if (ctx.config.releaseStrategy !== "none") {
2777
- const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
2778
- if (publishable.length > 0) p.log.info(`Will publish npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
2779
- else p.log.info("No publishable npm packages — npm registry setup will be skipped");
2780
- }
3089
+ const dockerPackages = detectDockerPackages(contextAsDockerReader(ctx), ctx.targetDir, ctx.config.name);
3090
+ if (dockerPackages.length > 0) p.log.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3091
+ const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
3092
+ if (publishable.length > 0) p.log.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
2781
3093
  }
2782
3094
  async function runInit(config, options = {}) {
2783
3095
  const detected = detectProject(config.targetDir);
@@ -2798,9 +3110,16 @@ async function runInit(config, options = {}) {
2798
3110
  if (p.isCancel(result)) return "skip";
2799
3111
  return result;
2800
3112
  }));
3113
+ if (config.releaseStrategy !== "none" && !ctx.packageJson?.repository) p.log.warn(`package.json is missing a "repository" field — required for release strategy "${config.releaseStrategy}"`);
2801
3114
  logDetectionSummary(ctx);
2802
3115
  s.start("Generating configuration files...");
2803
- const results = await runGenerators(ctx);
3116
+ let results;
3117
+ try {
3118
+ results = await runGenerators(ctx);
3119
+ } catch (error) {
3120
+ s.stop("Generation failed!");
3121
+ throw error;
3122
+ }
2804
3123
  const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
2805
3124
  for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
2806
3125
  filePath: rel,
@@ -2817,7 +3136,8 @@ async function runInit(config, options = {}) {
2817
3136
  if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
2818
3137
  execSync("git config --unset core.hooksPath", {
2819
3138
  cwd: config.targetDir,
2820
- stdio: "ignore"
3139
+ stdio: "ignore",
3140
+ timeout: 5e3
2821
3141
  });
2822
3142
  } catch (_error) {}
2823
3143
  const summaryLines = [];
@@ -2838,7 +3158,8 @@ async function runInit(config, options = {}) {
2838
3158
  try {
2839
3159
  execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
2840
3160
  cwd: config.targetDir,
2841
- stdio: "ignore"
3161
+ stdio: "ignore",
3162
+ timeout: 6e4
2842
3163
  });
2843
3164
  s.stop("Updated @bensandee/* packages");
2844
3165
  } catch (_error) {
@@ -2847,10 +3168,8 @@ async function runInit(config, options = {}) {
2847
3168
  }
2848
3169
  p.note([
2849
3170
  "1. Run: pnpm install",
2850
- "2. Run: pnpm typecheck",
2851
- "3. Run: pnpm build",
2852
- "4. Run: pnpm test",
2853
- ...options.noPrompt ? [] : ["5. In Claude Code, run: \"Execute the steps in .tooling-migrate.md\""]
3171
+ "2. Run: pnpm check",
3172
+ ...options.noPrompt ? [] : ["3. In Claude Code, run: \"Execute the steps in .tooling-migrate.md\""]
2854
3173
  ].join("\n"), "Next steps");
2855
3174
  return results;
2856
3175
  }
@@ -3180,7 +3499,7 @@ async function createRelease(executor, conn, tag) {
3180
3499
  //#endregion
3181
3500
  //#region src/release/log.ts
3182
3501
  /** Log a debug message when verbose mode is enabled. */
3183
- function debug$1(config, message) {
3502
+ function debug(config, message) {
3184
3503
  if (config.verbose) p.log.info(`[debug] ${message}`);
3185
3504
  }
3186
3505
  /** Log the result of an exec call when verbose mode is enabled. */
@@ -3263,7 +3582,7 @@ function buildPrContent(executor, cwd, packagesBefore) {
3263
3582
  async function runVersionMode(executor, config) {
3264
3583
  p.log.info("Changesets detected — versioning packages");
3265
3584
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
3266
- debug$1(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
3585
+ debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
3267
3586
  const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
3268
3587
  const originalConfig = executor.readFile(changesetConfigPath);
3269
3588
  if (originalConfig) {
@@ -3274,7 +3593,7 @@ async function runVersionMode(executor, config) {
3274
3593
  commit: false
3275
3594
  };
3276
3595
  executor.writeFile(changesetConfigPath, JSON.stringify(patched, null, 2) + "\n");
3277
- debug$1(config, "Temporarily disabled changeset commit:true");
3596
+ debug(config, "Temporarily disabled changeset commit:true");
3278
3597
  }
3279
3598
  }
3280
3599
  const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
@@ -3283,11 +3602,12 @@ async function runVersionMode(executor, config) {
3283
3602
  if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
3284
3603
  debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
3285
3604
  const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
3286
- debug$1(config, `PR title: ${title}`);
3287
- executor.exec("git add -A", { cwd: config.cwd });
3605
+ debug(config, `PR title: ${title}`);
3606
+ const addResult = executor.exec("git add -A", { cwd: config.cwd });
3607
+ if (addResult.exitCode !== 0) throw new FatalError(`git add failed: ${addResult.stderr || addResult.stdout}`);
3288
3608
  const remainingChangesets = executor.listChangesetFiles(config.cwd);
3289
3609
  if (remainingChangesets.length > 0) p.log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
3290
- debug$1(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
3610
+ debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
3291
3611
  const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
3292
3612
  debugExec(config, "git commit", commitResult);
3293
3613
  if (commitResult.exitCode !== 0) {
@@ -3304,14 +3624,16 @@ async function runVersionMode(executor, config) {
3304
3624
  pr: "none"
3305
3625
  };
3306
3626
  }
3307
- debugExec(config, "git push", executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd }));
3627
+ const pushResult = executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd });
3628
+ debugExec(config, "git push", pushResult);
3629
+ if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
3308
3630
  const conn = {
3309
3631
  serverUrl: config.serverUrl,
3310
3632
  repository: config.repository,
3311
3633
  token: config.token
3312
3634
  };
3313
3635
  const existingPr = await findOpenPr(executor, conn, BRANCH);
3314
- debug$1(config, `Existing open PR for ${BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
3636
+ debug(config, `Existing open PR for ${BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
3315
3637
  if (existingPr === null) {
3316
3638
  await createPr(executor, conn, {
3317
3639
  title,
@@ -3359,14 +3681,14 @@ async function runPublishMode(executor, config) {
3359
3681
  debugExec(config, "pnpm changeset publish", publishResult);
3360
3682
  if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
3361
3683
  const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
3362
- debug$1(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
3684
+ debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
3363
3685
  const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
3364
- debug$1(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
3686
+ debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
3365
3687
  const remoteTags = parseRemoteTags(executor.exec("git ls-remote --tags origin", { cwd: config.cwd }).stdout);
3366
- debug$1(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
3688
+ debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
3367
3689
  const remoteSet = new Set(remoteTags);
3368
3690
  const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
3369
- debug$1(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
3691
+ debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
3370
3692
  if (config.dryRun) {
3371
3693
  if (tagsToPush.length === 0) {
3372
3694
  p.log.info("No packages were published");
@@ -3396,8 +3718,12 @@ async function runPublishMode(executor, config) {
3396
3718
  const errors = [];
3397
3719
  for (const tag of allTags) try {
3398
3720
  if (!remoteSet.has(tag)) {
3399
- if (executor.exec(`git tag -l ${JSON.stringify(tag)}`, { cwd: config.cwd }).stdout.trim() === "") executor.exec(`git tag ${JSON.stringify(tag)}`, { cwd: config.cwd });
3400
- executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
3721
+ if (executor.exec(`git tag -l ${JSON.stringify(tag)}`, { cwd: config.cwd }).stdout.trim() === "") {
3722
+ const tagResult = executor.exec(`git tag ${JSON.stringify(tag)}`, { cwd: config.cwd });
3723
+ if (tagResult.exitCode !== 0) throw new FatalError(`Failed to create tag ${tag}: ${tagResult.stderr || tagResult.stdout}`);
3724
+ }
3725
+ const pushTagResult = executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
3726
+ if (pushTagResult.exitCode !== 0) throw new FatalError(`Failed to push tag ${tag}: ${pushTagResult.stderr || pushTagResult.stdout}`);
3401
3727
  }
3402
3728
  if (await findRelease(executor, conn, tag)) p.log.warn(`Release for ${tag} already exists — skipping`);
3403
3729
  else {
@@ -3542,18 +3868,18 @@ function getCurrentBranch(executor, cwd) {
3542
3868
  async function runRelease(config, executor) {
3543
3869
  const branch = getCurrentBranch(executor, config.cwd);
3544
3870
  if (branch !== "main") {
3545
- debug$1(config, `Skipping release on non-main branch: ${branch}`);
3871
+ debug(config, `Skipping release on non-main branch: ${branch}`);
3546
3872
  return { mode: "none" };
3547
3873
  }
3548
3874
  executor.exec("git config user.name \"forgejo-actions[bot]\"", { cwd: config.cwd });
3549
3875
  executor.exec("git config user.email \"forgejo-actions[bot]@noreply.localhost\"", { cwd: config.cwd });
3550
3876
  const changesetFiles = executor.listChangesetFiles(config.cwd);
3551
- debug$1(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
3877
+ debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
3552
3878
  if (changesetFiles.length > 0) {
3553
- debug$1(config, "Entering version mode");
3879
+ debug(config, "Entering version mode");
3554
3880
  return runVersionMode(executor, config);
3555
3881
  }
3556
- debug$1(config, "Entering publish mode");
3882
+ debug(config, "Entering publish mode");
3557
3883
  return runPublishMode(executor, config);
3558
3884
  }
3559
3885
  //#endregion
@@ -3589,7 +3915,8 @@ async function triggerForgejo(conn, ref) {
3589
3915
  p.log.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
3590
3916
  }
3591
3917
  function triggerGitHub(ref) {
3592
- createRealExecutor().exec(`gh workflow run release.yml --ref ${ref}`, { cwd: process.cwd() });
3918
+ const result = createRealExecutor().exec(`gh workflow run release.yml --ref ${ref}`, { cwd: process.cwd() });
3919
+ if (result.exitCode !== 0) throw new FatalError(`Failed to trigger GitHub workflow: ${result.stderr || result.stdout || "unknown error"}`);
3593
3920
  p.log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
3594
3921
  }
3595
3922
  //#endregion
@@ -3658,7 +3985,8 @@ function mergeGitHub(dryRun) {
3658
3985
  p.log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
3659
3986
  return;
3660
3987
  }
3661
- executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
3988
+ const result = executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
3989
+ if (result.exitCode !== 0) throw new FatalError(`Failed to merge PR: ${result.stderr || result.stdout || "unknown error"}`);
3662
3990
  p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
3663
3991
  }
3664
3992
  //#endregion
@@ -3697,7 +4025,7 @@ async function runSimpleRelease(executor, config) {
3697
4025
  debugExec(config, "commit-and-tag-version", versionResult);
3698
4026
  if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
3699
4027
  const version = readVersion(executor, config.cwd);
3700
- debug$1(config, `New version: ${version}`);
4028
+ debug(config, `New version: ${version}`);
3701
4029
  const tagResult = executor.exec("git describe --tags --abbrev=0", { cwd: config.cwd });
3702
4030
  debugExec(config, "git describe", tagResult);
3703
4031
  const tag = tagResult.stdout.trim();
@@ -3719,7 +4047,7 @@ async function runSimpleRelease(executor, config) {
3719
4047
  let pushed = false;
3720
4048
  if (!config.noPush) {
3721
4049
  const branch = executor.exec("git rev-parse --abbrev-ref HEAD", { cwd: config.cwd }).stdout.trim() || "main";
3722
- debug$1(config, `Pushing to origin/${branch}`);
4050
+ debug(config, `Pushing to origin/${branch}`);
3723
4051
  const pushResult = executor.exec(`git push --follow-tags origin ${branch}`, { cwd: config.cwd });
3724
4052
  debugExec(config, "git push", pushResult);
3725
4053
  if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
@@ -3729,7 +4057,10 @@ async function runSimpleRelease(executor, config) {
3729
4057
  let slidingTags = [];
3730
4058
  if (!config.noSlidingTags && pushed) {
3731
4059
  slidingTags = computeSlidingTags(version);
3732
- for (const slidingTag of slidingTags) executor.exec(`git tag -f ${slidingTag}`, { cwd: config.cwd });
4060
+ for (const slidingTag of slidingTags) {
4061
+ const slidingTagResult = executor.exec(`git tag -f ${slidingTag}`, { cwd: config.cwd });
4062
+ if (slidingTagResult.exitCode !== 0) throw new FatalError(`Failed to create sliding tag ${slidingTag}: ${slidingTagResult.stderr || slidingTagResult.stdout}`);
4063
+ }
3733
4064
  const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
3734
4065
  debugExec(config, "force-push sliding tags", forcePushResult);
3735
4066
  if (forcePushResult.exitCode !== 0) p.log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
@@ -3749,7 +4080,7 @@ async function createPlatformRelease(executor, config, tag) {
3749
4080
  if (!config.platform) return false;
3750
4081
  if (config.platform.type === "forgejo") {
3751
4082
  if (await findRelease(executor, config.platform.conn, tag)) {
3752
- debug$1(config, `Release for ${tag} already exists, skipping`);
4083
+ debug(config, `Release for ${tag} already exists, skipping`);
3753
4084
  return false;
3754
4085
  }
3755
4086
  await createRelease(executor, config.platform.conn, tag);
@@ -3948,290 +4279,6 @@ const runChecksCommand = defineCommand({
3948
4279
  }
3949
4280
  });
3950
4281
  //#endregion
3951
- //#region src/release/docker.ts
3952
- const ToolingDockerMapSchema = z.record(z.string(), z.object({
3953
- dockerfile: z.string(),
3954
- context: z.string().default(".")
3955
- }));
3956
- const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
3957
- const PackageInfoSchema = z.object({
3958
- name: z.string().optional(),
3959
- version: z.string().optional()
3960
- });
3961
- /** Read the docker map from .tooling.json. Returns empty record if missing or invalid. */
3962
- function loadDockerMap(executor, cwd) {
3963
- const configPath = path.join(cwd, ".tooling.json");
3964
- const raw = executor.readFile(configPath);
3965
- if (!raw) return {};
3966
- try {
3967
- const result = ToolingConfigDockerSchema.safeParse(JSON.parse(raw));
3968
- if (!result.success || !result.data.docker) return {};
3969
- return result.data.docker;
3970
- } catch (_error) {
3971
- return {};
3972
- }
3973
- }
3974
- /** Read name and version from a package's package.json. */
3975
- function readPackageInfo(executor, packageJsonPath) {
3976
- const raw = executor.readFile(packageJsonPath);
3977
- if (!raw) return {
3978
- name: void 0,
3979
- version: void 0
3980
- };
3981
- try {
3982
- const result = PackageInfoSchema.safeParse(JSON.parse(raw));
3983
- if (!result.success) return {
3984
- name: void 0,
3985
- version: void 0
3986
- };
3987
- return {
3988
- name: result.data.name,
3989
- version: result.data.version
3990
- };
3991
- } catch (_error) {
3992
- return {
3993
- name: void 0,
3994
- version: void 0
3995
- };
3996
- }
3997
- }
3998
- /** Convention paths to check for Dockerfiles in a package directory. */
3999
- const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
4000
- /**
4001
- * Find a Dockerfile at convention paths for a monorepo package.
4002
- * Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
4003
- */
4004
- function findConventionDockerfile(executor, cwd, dir) {
4005
- for (const rel of CONVENTION_DOCKERFILE_PATHS) {
4006
- const dockerfilePath = `packages/${dir}/${rel}`;
4007
- if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
4008
- dockerfile: dockerfilePath,
4009
- context: "."
4010
- };
4011
- }
4012
- }
4013
- /**
4014
- * Find a Dockerfile at convention paths for a single-package repo.
4015
- * Checks Dockerfile and docker/Dockerfile at the project root.
4016
- */
4017
- function findRootDockerfile(executor, cwd) {
4018
- for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
4019
- dockerfile: rel,
4020
- context: "."
4021
- };
4022
- }
4023
- /**
4024
- * Discover Docker packages by convention and merge with .tooling.json overrides.
4025
- *
4026
- * Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
4027
- * For monorepos, scans packages/{name}/. For single-package repos, scans the root.
4028
- * The docker map in .tooling.json overrides convention-discovered config and can add
4029
- * packages at non-standard locations.
4030
- *
4031
- * Image names are derived from {root-name}-{package-name} using each package's package.json name.
4032
- * Versions are read from each package's own package.json.
4033
- */
4034
- function detectDockerPackages(executor, cwd, repoName) {
4035
- const overrides = loadDockerMap(executor, cwd);
4036
- const packageDirs = executor.listPackageDirs(cwd);
4037
- const packages = [];
4038
- const seen = /* @__PURE__ */ new Set();
4039
- if (packageDirs.length > 0) {
4040
- for (const dir of packageDirs) {
4041
- const convention = findConventionDockerfile(executor, cwd, dir);
4042
- const docker = overrides[dir] ?? convention;
4043
- if (docker) {
4044
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
4045
- packages.push({
4046
- dir,
4047
- imageName: `${repoName}-${name ?? dir}`,
4048
- version,
4049
- docker
4050
- });
4051
- seen.add(dir);
4052
- }
4053
- }
4054
- for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
4055
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
4056
- packages.push({
4057
- dir,
4058
- imageName: `${repoName}-${name ?? dir}`,
4059
- version,
4060
- docker
4061
- });
4062
- }
4063
- } else {
4064
- const convention = findRootDockerfile(executor, cwd);
4065
- const docker = overrides["."] ?? convention;
4066
- if (docker) {
4067
- const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
4068
- packages.push({
4069
- dir: ".",
4070
- imageName: name ?? repoName,
4071
- version,
4072
- docker
4073
- });
4074
- }
4075
- }
4076
- return packages;
4077
- }
4078
- /**
4079
- * Read docker config for a single package, checking convention paths first,
4080
- * then .tooling.json overrides. Used by the per-package image:build script.
4081
- */
4082
- function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
4083
- const dir = path.basename(path.resolve(cwd, packageDir));
4084
- const convention = findConventionDockerfile(executor, cwd, dir);
4085
- const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
4086
- if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
4087
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
4088
- return {
4089
- dir,
4090
- imageName: `${repoName}-${name ?? dir}`,
4091
- version,
4092
- docker
4093
- };
4094
- }
4095
- /** Parse semver version string into major, minor, patch components. */
4096
- function parseSemver(version) {
4097
- const clean = version.replace(/^v/, "");
4098
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(clean);
4099
- if (!match?.[1] || !match[2] || !match[3]) throw new FatalError(`Invalid semver version: ${version}`);
4100
- return {
4101
- major: Number(match[1]),
4102
- minor: Number(match[2]),
4103
- patch: Number(match[3])
4104
- };
4105
- }
4106
- /** Generate semver tag variants: latest, vX.Y.Z, vX.Y, vX */
4107
- function generateTags(version) {
4108
- const { major, minor, patch } = parseSemver(version);
4109
- return [
4110
- "latest",
4111
- `v${major}.${minor}.${patch}`,
4112
- `v${major}.${minor}`,
4113
- `v${major}`
4114
- ];
4115
- }
4116
- /** Build the full image reference: namespace/imageName:tag */
4117
- function imageRef(namespace, imageName, tag) {
4118
- return `${namespace}/${imageName}:${tag}`;
4119
- }
4120
- function log$1(message) {
4121
- console.log(message);
4122
- }
4123
- function debug(verbose, message) {
4124
- if (verbose) console.log(`[debug] ${message}`);
4125
- }
4126
- /** Read the repo name from root package.json. */
4127
- function readRepoName(executor, cwd) {
4128
- const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
4129
- if (!rootPkgRaw) throw new FatalError("No package.json found in project root");
4130
- const repoName = parsePackageJson(rootPkgRaw)?.name;
4131
- if (!repoName) throw new FatalError("Root package.json must have a name field");
4132
- return repoName;
4133
- }
4134
- /** Build a single docker image from its config. Paths are resolved relative to cwd. */
4135
- function buildImage(executor, pkg, cwd, verbose, extraArgs) {
4136
- const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
4137
- const contextPath = path.resolve(cwd, pkg.docker.context);
4138
- const command = [
4139
- "docker build",
4140
- `-f ${dockerfilePath}`,
4141
- `-t ${pkg.imageName}:latest`,
4142
- ...extraArgs,
4143
- contextPath
4144
- ].join(" ");
4145
- debug(verbose, `Running: ${command}`);
4146
- const buildResult = executor.exec(command);
4147
- debug(verbose, `Build stdout: ${buildResult.stdout}`);
4148
- if (buildResult.exitCode !== 0) throw new FatalError(`docker build failed for ${pkg.dir} (exit ${buildResult.exitCode}): ${buildResult.stderr}`);
4149
- }
4150
- /**
4151
- * Detect packages with docker config in .tooling.json and build each one.
4152
- * Runs `docker build -f <dockerfile> -t <image-name>:latest <context>` for each package.
4153
- * Dockerfile and context paths are resolved relative to the project root.
4154
- *
4155
- * When `packageDir` is set, builds only that single package (for use as an image:build script).
4156
- */
4157
- function runDockerBuild(executor, config) {
4158
- const repoName = readRepoName(executor, config.cwd);
4159
- if (config.packageDir) {
4160
- const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
4161
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
4162
- buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
4163
- log$1(`Built ${pkg.imageName}:latest`);
4164
- return { packages: [pkg] };
4165
- }
4166
- const packages = detectDockerPackages(executor, config.cwd, repoName);
4167
- if (packages.length === 0) {
4168
- log$1("No packages with docker config found");
4169
- return { packages: [] };
4170
- }
4171
- log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
4172
- for (const pkg of packages) {
4173
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
4174
- buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
4175
- }
4176
- log$1(`Built ${packages.length} image(s)`);
4177
- return { packages };
4178
- }
4179
- /**
4180
- * Run the full Docker publish pipeline:
4181
- * 1. Build all images via runDockerBuild
4182
- * 2. Login to registry
4183
- * 3. Tag each image with semver variants from its own package.json version
4184
- * 4. Push all tags
4185
- * 5. Logout from registry
4186
- */
4187
- function runDockerPublish(executor, config) {
4188
- const { packages } = runDockerBuild(executor, {
4189
- cwd: config.cwd,
4190
- packageDir: void 0,
4191
- verbose: config.verbose,
4192
- extraArgs: []
4193
- });
4194
- if (packages.length === 0) return {
4195
- packages: [],
4196
- tags: []
4197
- };
4198
- for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
4199
- if (!config.dryRun) {
4200
- log$1(`Logging in to ${config.registryHost}...`);
4201
- const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
4202
- if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
4203
- } else log$1("[dry-run] Skipping docker login");
4204
- const allTags = [];
4205
- try {
4206
- for (const pkg of packages) {
4207
- const tags = generateTags(pkg.version ?? "");
4208
- log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
4209
- for (const tag of tags) {
4210
- const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
4211
- allTags.push(ref);
4212
- log$1(`Tagging ${pkg.imageName} → ${ref}`);
4213
- const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
4214
- if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
4215
- if (!config.dryRun) {
4216
- log$1(`Pushing ${ref}...`);
4217
- const pushResult = executor.exec(`docker push ${ref}`);
4218
- if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
4219
- } else log$1(`[dry-run] Skipping push for ${ref}`);
4220
- }
4221
- }
4222
- } finally {
4223
- if (!config.dryRun) {
4224
- log$1(`Logging out from ${config.registryHost}...`);
4225
- executor.exec(`docker logout ${config.registryHost}`);
4226
- }
4227
- }
4228
- log$1(`Published ${allTags.length} image tag(s)`);
4229
- return {
4230
- packages,
4231
- tags: allTags
4232
- };
4233
- }
4234
- //#endregion
4235
4282
  //#region src/commands/publish-docker.ts
4236
4283
  function requireEnv(name) {
4237
4284
  const value = process.env[name];
@@ -4268,6 +4315,22 @@ const publishDockerCommand = defineCommand({
4268
4315
  });
4269
4316
  //#endregion
4270
4317
  //#region src/commands/docker-build.ts
4318
+ /**
4319
+ * Detect if cwd is inside a packages/ subdirectory.
4320
+ * If so, return the project root and the relative package dir.
4321
+ * Otherwise return cwd as-is with no packageDir.
4322
+ */
4323
+ function detectSubpackage(cwd) {
4324
+ const parent = path.dirname(cwd);
4325
+ if (path.basename(parent) === "packages" && existsSync(path.join(parent, "..", "package.json"))) return {
4326
+ root: path.dirname(parent),
4327
+ packageDir: `packages/${path.basename(cwd)}`
4328
+ };
4329
+ return {
4330
+ root: cwd,
4331
+ packageDir: void 0
4332
+ };
4333
+ }
4271
4334
  const dockerBuildCommand = defineCommand({
4272
4335
  meta: {
4273
4336
  name: "docker:build",
@@ -4292,9 +4355,16 @@ const dockerBuildCommand = defineCommand({
4292
4355
  const executor = createRealExecutor();
4293
4356
  const rawExtra = args._ ?? [];
4294
4357
  const extraArgs = Array.isArray(rawExtra) ? rawExtra.map(String) : [String(rawExtra)];
4358
+ let cwd = process.cwd();
4359
+ let packageDir = args.package;
4360
+ if (!packageDir) {
4361
+ const detected = detectSubpackage(cwd);
4362
+ cwd = detected.root;
4363
+ packageDir = detected.packageDir;
4364
+ }
4295
4365
  runDockerBuild(executor, {
4296
- cwd: process.cwd(),
4297
- packageDir: args.package,
4366
+ cwd,
4367
+ packageDir,
4298
4368
  verbose: args.verbose === true,
4299
4369
  extraArgs: extraArgs.filter((a) => a.length > 0)
4300
4370
  });
@@ -4310,16 +4380,16 @@ const COMPOSE_FILE_CANDIDATES = [
4310
4380
  "compose.yml"
4311
4381
  ];
4312
4382
  /** Zod schema for the subset of compose YAML we care about. */
4313
- const ComposePortSchema = z.union([z.string(), z.object({
4383
+ const ComposePortSchema = z.union([z.string(), z.looseObject({
4314
4384
  published: z.union([z.string(), z.number()]),
4315
4385
  target: z.union([z.string(), z.number()]).optional()
4316
- }).loose()]);
4317
- const ComposeServiceSchema = z.object({
4386
+ })]);
4387
+ const ComposeServiceSchema = z.looseObject({
4318
4388
  image: z.string().optional(),
4319
4389
  ports: z.array(ComposePortSchema).optional(),
4320
4390
  healthcheck: z.unknown().optional()
4321
- }).loose();
4322
- const ComposeFileSchema = z.object({ services: z.record(z.string(), ComposeServiceSchema).optional() }).loose();
4391
+ });
4392
+ const ComposeFileSchema = z.looseObject({ services: z.record(z.string(), ComposeServiceSchema).optional() });
4323
4393
  /** Directories to scan for compose files, in priority order. */
4324
4394
  const COMPOSE_DIR_CANDIDATES = [".", "docker"];
4325
4395
  /** Detect which compose files exist at conventional paths.
@@ -4398,11 +4468,20 @@ function parseComposeServices(cwd, composeFiles) {
4398
4468
  }
4399
4469
  return [...serviceMap.values()];
4400
4470
  }
4471
+ /**
4472
+ * Strip Docker Compose variable substitutions from an image string.
4473
+ * Handles `${VAR:-default}`, `${VAR-default}`, `${VAR:+alt}`, `${VAR+alt}`,
4474
+ * `${VAR:?err}`, `${VAR?err}`, and plain `${VAR}`.
4475
+ * Nested braces are not supported.
4476
+ */
4477
+ function stripComposeVariables(image) {
4478
+ return image.replace(/\$\{[^}]*\}/g, "");
4479
+ }
4401
4480
  /** Extract deduplicated bare image names (without tags) from parsed services. */
4402
4481
  function extractComposeImageNames(services) {
4403
4482
  const names = /* @__PURE__ */ new Set();
4404
4483
  for (const service of services) if (service.image) {
4405
- const bare = service.image.split(":")[0];
4484
+ const bare = stripComposeVariables(service.image).split(":")[0];
4406
4485
  if (bare) names.add(bare);
4407
4486
  }
4408
4487
  return [...names];
@@ -4587,7 +4666,7 @@ const dockerCheckCommand = defineCommand({
4587
4666
  const main = defineCommand({
4588
4667
  meta: {
4589
4668
  name: "tooling",
4590
- version: "0.23.0",
4669
+ version: "0.25.0",
4591
4670
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4592
4671
  },
4593
4672
  subCommands: {
@@ -4603,7 +4682,11 @@ const main = defineCommand({
4603
4682
  "docker:check": dockerCheckCommand
4604
4683
  }
4605
4684
  });
4606
- console.log(`@bensandee/tooling v0.23.0`);
4607
- runMain(main);
4685
+ console.log(`@bensandee/tooling v0.25.0`);
4686
+ async function run() {
4687
+ await runMain(main);
4688
+ process.exit(process.exitCode ?? 0);
4689
+ }
4690
+ run();
4608
4691
  //#endregion
4609
4692
  export {};
package/dist/index.d.mts CHANGED
@@ -15,6 +15,9 @@ declare const PackageJsonSchema: z.ZodObject<{
15
15
  types: z.ZodOptional<z.ZodString>;
16
16
  typings: z.ZodOptional<z.ZodString>;
17
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>]>>;
18
21
  }, z.core.$loose>;
19
22
  type PackageJson = z.infer<typeof PackageJsonSchema>;
20
23
  //#endregion
@@ -91,6 +94,8 @@ interface DetectedProjectState {
91
94
  hasReleaseItConfig: boolean;
92
95
  hasSimpleReleaseConfig: boolean;
93
96
  hasChangesetsConfig: boolean;
97
+ /** Whether package.json has a repository field (needed for release workflows) */
98
+ hasRepositoryField: boolean;
94
99
  /** Legacy tooling configs found */
95
100
  legacyConfigs: LegacyConfig[];
96
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "tooling": "./dist/bin.mjs"