@bensandee/tooling 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.mjs +1573 -1539
  2. package/package.json +5 -3
  3. package/tooling.schema.json +148 -0
package/dist/bin.mjs CHANGED
@@ -7,9 +7,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, w
7
7
  import JSON5 from "json5";
8
8
  import { parse } from "jsonc-parser";
9
9
  import { z } from "zod";
10
+ import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
10
11
  import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify } from "yaml";
11
12
  import { execSync } from "node:child_process";
12
- import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
13
  import { tmpdir } from "node:os";
14
14
  //#region src/types.ts
15
15
  const LEGACY_TOOLS = [
@@ -201,7 +201,7 @@ function computeDefaults(targetDir) {
201
201
  setupVitest: !isMonorepo && !detected.hasVitestConfig,
202
202
  ci: detectCiPlatform(targetDir),
203
203
  setupRenovate: true,
204
- releaseStrategy: isMonorepo ? "changesets" : "simple",
204
+ releaseStrategy: "none",
205
205
  projectType: isMonorepo ? "default" : detectProjectType(targetDir),
206
206
  detectPackageTypes: true
207
207
  };
@@ -493,7 +493,8 @@ const DockerCheckConfigSchema = z.object({
493
493
  timeoutMs: z.number().int().positive().optional(),
494
494
  pollIntervalMs: z.number().int().positive().optional()
495
495
  });
496
- const ToolingConfigSchema = z.object({
496
+ const ToolingConfigSchema = z.strictObject({
497
+ $schema: z.string().optional(),
497
498
  structure: z.enum(["single", "monorepo"]).optional(),
498
499
  useEslintPlugin: z.boolean().optional(),
499
500
  formatter: z.enum(["oxfmt", "prettier"]).optional(),
@@ -524,17 +525,14 @@ const ToolingConfigSchema = z.object({
524
525
  })).optional(),
525
526
  dockerCheck: z.union([z.literal(false), DockerCheckConfigSchema]).optional()
526
527
  });
527
- /** Load saved tooling config from the target directory. Returns undefined if missing or invalid. */
528
+ /** Load saved tooling config from the target directory. Returns undefined if missing, throws on invalid. */
528
529
  function loadToolingConfig(targetDir) {
529
530
  const fullPath = path.join(targetDir, CONFIG_FILE);
530
531
  if (!existsSync(fullPath)) return void 0;
531
- try {
532
- const raw = readFileSync(fullPath, "utf-8");
533
- const result = ToolingConfigSchema.safeParse(JSON.parse(raw));
534
- return result.success ? result.data : void 0;
535
- } catch {
536
- return;
537
- }
532
+ const raw = readFileSync(fullPath, "utf-8");
533
+ const result = ToolingConfigSchema.safeParse(JSON.parse(raw));
534
+ if (!result.success) throw new FatalError(`Invalid .tooling.json:\n${result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`);
535
+ return result.data;
538
536
  }
539
537
  /** Config fields that can be overridden in .tooling.json. */
540
538
  const OVERRIDE_KEYS = [
@@ -558,7 +556,7 @@ const MONOREPO_IGNORED_KEYS = new Set(["setupVitest", "projectType"]);
558
556
  function saveToolingConfig(ctx, config) {
559
557
  const defaults = computeDefaults(config.targetDir);
560
558
  const isMonorepo = config.structure === "monorepo";
561
- const overrides = {};
559
+ const overrides = { $schema: "node_modules/@bensandee/tooling/tooling.schema.json" };
562
560
  for (const key of OVERRIDE_KEYS) {
563
561
  if (isMonorepo && MONOREPO_IGNORED_KEYS.has(key)) continue;
564
562
  if (config[key] !== defaults[key]) overrides[key] = config[key];
@@ -983,7 +981,7 @@ function getAddedDevDepNames(config) {
983
981
  const deps = { ...ROOT_DEV_DEPS };
984
982
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
985
983
  deps["@bensandee/config"] = "0.8.2";
986
- deps["@bensandee/tooling"] = "0.22.0";
984
+ deps["@bensandee/tooling"] = "0.24.0";
987
985
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
988
986
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
989
987
  addReleaseDeps(deps, config);
@@ -1008,7 +1006,7 @@ async function generatePackageJson(ctx) {
1008
1006
  const devDeps = { ...ROOT_DEV_DEPS };
1009
1007
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1010
1008
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.2";
1011
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.22.0";
1009
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.24.0";
1012
1010
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1013
1011
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
1014
1012
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1494,25 +1492,26 @@ function actionsExpr$1(expr) {
1494
1492
  }
1495
1493
  const CI_CONCURRENCY = {
1496
1494
  group: `ci-${actionsExpr$1("github.ref")}`,
1497
- "cancel-in-progress": true
1495
+ "cancel-in-progress": actionsExpr$1("github.ref != 'refs/heads/main'")
1498
1496
  };
1499
1497
  function hasEnginesNode$1(ctx) {
1500
1498
  const raw = ctx.read("package.json");
1501
1499
  if (!raw) return false;
1502
1500
  return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
1503
1501
  }
1504
- function ciWorkflow(nodeVersionYaml, isForgejo) {
1502
+ function ciWorkflow(nodeVersionYaml, isForgejo, isChangesets) {
1505
1503
  const emailNotifications = isForgejo ? "\nenable-email-notifications: true\n" : "";
1504
+ const concurrencyBlock = isChangesets ? `
1505
+ concurrency:
1506
+ group: ci-${actionsExpr$1("github.ref")}
1507
+ cancel-in-progress: ${actionsExpr$1("github.ref != 'refs/heads/main'")}
1508
+ ` : "";
1506
1509
  return `${workflowSchemaComment(isForgejo ? "forgejo" : "github")}name: CI
1507
1510
  ${emailNotifications}on:
1508
1511
  push:
1509
1512
  branches: [main]
1510
1513
  pull_request:
1511
-
1512
- concurrency:
1513
- group: ci-${actionsExpr$1("github.ref")}
1514
- cancel-in-progress: true
1515
-
1514
+ ${concurrencyBlock}
1516
1515
  jobs:
1517
1516
  check:
1518
1517
  runs-on: ubuntu-latest
@@ -1561,6 +1560,10 @@ function requiredCheckSteps(nodeVersionYaml) {
1561
1560
  }
1562
1561
  ];
1563
1562
  }
1563
+ /** Resolve the CI workflow filename based on release strategy. */
1564
+ function ciWorkflowPath(ci, releaseStrategy) {
1565
+ return `${ci === "github" ? ".github/workflows" : ".forgejo/workflows"}/${releaseStrategy === "changesets" ? "ci.yml" : "check.yml"}`;
1566
+ }
1564
1567
  async function generateCi(ctx) {
1565
1568
  if (ctx.config.ci === "none") return {
1566
1569
  filePath: "ci",
@@ -1568,16 +1571,23 @@ async function generateCi(ctx) {
1568
1571
  description: "CI workflow not requested"
1569
1572
  };
1570
1573
  const isGitHub = ctx.config.ci === "github";
1574
+ const isChangesets = ctx.config.releaseStrategy === "changesets";
1571
1575
  const nodeVersionYaml = hasEnginesNode$1(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1572
- const filePath = isGitHub ? ".github/workflows/ci.yml" : ".forgejo/workflows/ci.yml";
1573
- const content = ciWorkflow(nodeVersionYaml, !isGitHub);
1576
+ const filePath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
1577
+ const content = ciWorkflow(nodeVersionYaml, !isGitHub, isChangesets);
1574
1578
  if (ctx.exists(filePath)) {
1575
1579
  const existing = ctx.read(filePath);
1576
1580
  if (existing) {
1577
- const merged = mergeWorkflowSteps(existing, "check", requiredCheckSteps(nodeVersionYaml));
1578
- const withConcurrency = ensureWorkflowConcurrency(merged.content, CI_CONCURRENCY);
1579
- const withComment = ensureSchemaComment(withConcurrency.content, isGitHub ? "github" : "forgejo");
1580
- if (merged.changed || withConcurrency.changed || withComment !== withConcurrency.content) {
1581
+ let result = mergeWorkflowSteps(existing, "check", requiredCheckSteps(nodeVersionYaml));
1582
+ if (isChangesets) {
1583
+ const withConcurrency = ensureWorkflowConcurrency(result.content, CI_CONCURRENCY);
1584
+ result = {
1585
+ content: withConcurrency.content,
1586
+ changed: result.changed || withConcurrency.changed
1587
+ };
1588
+ }
1589
+ const withComment = ensureSchemaComment(result.content, isGitHub ? "github" : "forgejo");
1590
+ if (result.changed || withComment !== result.content) {
1581
1591
  ctx.write(filePath, withComment);
1582
1592
  return {
1583
1593
  filePath,
@@ -2215,7 +2225,7 @@ function buildWorkflow(strategy, ci, nodeVersionYaml, publishesNpm) {
2215
2225
  }
2216
2226
  }
2217
2227
  function generateChangesetsReleaseCi(ctx, publishesNpm) {
2218
- const ciPath = ctx.config.ci === "github" ? ".github/workflows/ci.yml" : ".forgejo/workflows/ci.yml";
2228
+ const ciPath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
2219
2229
  const existing = ctx.read(ciPath);
2220
2230
  if (!existing) return {
2221
2231
  filePath: ciPath,
@@ -2589,1638 +2599,1658 @@ async function runGenerators(ctx) {
2589
2599
  return results;
2590
2600
  }
2591
2601
  //#endregion
2592
- //#region src/generators/migrate-prompt.ts
2593
- /**
2594
- * Generate a context-aware AI migration prompt based on what the CLI did.
2595
- * This prompt can be pasted into Claude Code (or similar) to finish the migration.
2596
- */
2597
- function generateMigratePrompt(results, config, detected) {
2598
- const sections = [];
2599
- sections.push("# Migration Prompt");
2600
- sections.push("");
2601
- 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.");
2602
- sections.push("");
2603
- 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.");
2604
- sections.push("");
2605
- sections.push("## What was changed");
2606
- sections.push("");
2607
- const created = results.filter((r) => r.action === "created");
2608
- const updated = results.filter((r) => r.action === "updated");
2609
- const skipped = results.filter((r) => r.action === "skipped");
2610
- const archived = results.filter((r) => r.action === "archived");
2611
- if (created.length > 0) {
2612
- sections.push("**Created:**");
2613
- for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2614
- sections.push("");
2615
- }
2616
- if (updated.length > 0) {
2617
- sections.push("**Updated:**");
2618
- for (const r of updated) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2619
- sections.push("");
2620
- }
2621
- if (archived.length > 0) {
2622
- sections.push("**Archived:**");
2623
- for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2624
- sections.push("");
2625
- }
2626
- if (skipped.length > 0) {
2627
- sections.push("**Skipped (review these):**");
2628
- for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2629
- sections.push("");
2630
- }
2631
- sections.push("## Migration tasks");
2632
- sections.push("");
2633
- const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
2634
- if (legacyToRemove.length > 0) {
2635
- sections.push("### Remove legacy tooling");
2636
- sections.push("");
2637
- for (const legacy of legacyToRemove) {
2638
- const replacement = {
2639
- eslint: "oxlint",
2640
- prettier: "oxfmt",
2641
- jest: "vitest",
2642
- webpack: "tsdown",
2643
- rollup: "tsdown"
2644
- }[legacy.tool];
2645
- sections.push(`- Remove ${legacy.tool} config files (${legacy.files.map((f) => `\`${f}\``).join(", ")}). This project now uses **${replacement}**.`);
2646
- sections.push(` - Uninstall ${legacy.tool}-related packages from devDependencies`);
2647
- if (legacy.tool === "eslint") sections.push(" - Migrate any custom ESLint rules that don't have oxlint equivalents");
2648
- if (legacy.tool === "jest") sections.push(" - Migrate any jest-specific test utilities (jest.mock, jest.fn) to vitest equivalents (vi.mock, vi.fn)");
2649
- }
2650
- sections.push("");
2651
- }
2652
- if (archived.length > 0) {
2653
- sections.push("### Review archived files");
2654
- sections.push("");
2655
- sections.push("The following files were modified or replaced. The originals have been saved to `.tooling-archived/`:");
2656
- sections.push("");
2657
- for (const r of archived) sections.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
2658
- sections.push("");
2659
- sections.push("For each archived file, **diff the old version against the new one** and look for features, categories, or modules that were enabled in the original but are missing from the replacement. Focus on broad capability gaps rather than individual rule strictness (in general, being stricter is fine). Examples of what to look for:");
2660
- sections.push("");
2661
- sections.push("- **Lint configs**: enabled plugin categories (e.g. `jsx-a11y`, `import`, `react`, `nextjs`), custom `plugins` or `overrides`, file-scoped rule blocks");
2662
- sections.push("- **TypeScript configs**: compiler features like `jsx`, `paths`, `baseUrl`, or `references` that affect build behavior");
2663
- sections.push("- **Other configs**: feature flags, custom presets, or integrations that go beyond the default template");
2664
- sections.push("");
2665
- sections.push("If the old config had capabilities the new one lacks, port them into the new file. Then:");
2666
- sections.push("");
2667
- sections.push("1. If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`");
2668
- sections.push("2. Delete the `.tooling-archived/` directory when migration is complete");
2669
- sections.push("");
2670
- }
2671
- const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
2672
- if (detected.hasLegacyOxlintJson) {
2673
- sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
2674
- sections.push("");
2675
- sections.push("A new `oxlint.config.ts` has been generated using `defineConfig` from the `oxlint` package. The existing `.oxlintrc.json` needs to be migrated:");
2676
- sections.push("");
2677
- sections.push("1. Read `.oxlintrc.json` and compare its `rules` against the rules provided by `@bensandee/config/oxlint/recommended` (check `node_modules/@bensandee/config`). Most standard rules are already included in the recommended config.");
2678
- sections.push("2. If there are any custom rules, overrides, settings, or `jsPlugins` not covered by the recommended config, add them to `oxlint.config.ts` alongside the `extends`.");
2679
- sections.push("3. Delete `.oxlintrc.json`.");
2680
- sections.push("4. Run `pnpm lint` to verify the new config works correctly.");
2681
- sections.push("");
2682
- } else if (oxlintWasSkipped && detected.hasOxlintConfig) {
2683
- sections.push("### Verify oxlint.config.ts includes recommended rules");
2684
- sections.push("");
2685
- sections.push("The existing `oxlint.config.ts` was kept as-is. Verify that it extends the recommended config from `@bensandee/config/oxlint`:");
2686
- sections.push("");
2687
- sections.push("1. Open `oxlint.config.ts` and check that it imports and extends `@bensandee/config/oxlint/recommended`.");
2688
- sections.push("2. The expected pattern is:");
2689
- sections.push(" ```ts");
2690
- sections.push(" import recommended from \"@bensandee/config/oxlint/recommended\";");
2691
- sections.push(" import { defineConfig } from \"oxlint\";");
2692
- sections.push("");
2693
- sections.push(" export default defineConfig({ extends: [recommended] });");
2694
- sections.push(" ```");
2695
- sections.push("3. If it uses a different pattern, update it to extend the recommended config while preserving any project-specific customizations.");
2696
- sections.push("4. Run `pnpm lint` to verify the config works correctly.");
2697
- sections.push("");
2698
- }
2699
- if (config.structure === "monorepo" && !detected.hasPnpmWorkspace) {
2700
- sections.push("### Migrate to monorepo structure");
2701
- sections.push("");
2702
- sections.push("This project was converted from a single repo to a monorepo. Complete the migration:");
2703
- sections.push("");
2704
- sections.push("1. Move existing source into `packages/<name>/` (using the existing package name)");
2705
- sections.push("2. Split the root `package.json` into a root workspace manifest + package-level `package.json`");
2706
- sections.push("3. Move the existing `tsconfig.json` into the package and update the root tsconfig with project references");
2707
- sections.push("4. Create a package-level `tsdown.config.ts` in the new package");
2708
- sections.push("5. Update any import paths or build scripts affected by the move");
2709
- sections.push("");
2710
- }
2711
- const skippedConfigs = skipped.filter((r) => r.filePath !== "ci" && r.description !== "Not a monorepo");
2712
- if (skippedConfigs.length > 0) {
2713
- sections.push("### Review skipped files");
2714
- sections.push("");
2715
- sections.push("The following files were left unchanged. Review them for compatibility:");
2716
- sections.push("");
2717
- for (const r of skippedConfigs) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2718
- sections.push("");
2602
+ //#region src/release/docker.ts
2603
+ const ToolingDockerMapSchema = z.record(z.string(), z.object({
2604
+ dockerfile: z.string(),
2605
+ context: z.string().default(".")
2606
+ }));
2607
+ const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
2608
+ const PackageInfoSchema = z.object({
2609
+ name: z.string().optional(),
2610
+ version: z.string().optional()
2611
+ });
2612
+ /** Read the docker map from .tooling.json. Returns empty record if missing or invalid. */
2613
+ function loadDockerMap(executor, cwd) {
2614
+ const configPath = path.join(cwd, ".tooling.json");
2615
+ const raw = executor.readFile(configPath);
2616
+ if (!raw) return {};
2617
+ try {
2618
+ const result = ToolingConfigDockerSchema.safeParse(JSON.parse(raw));
2619
+ if (!result.success || !result.data.docker) return {};
2620
+ return result.data.docker;
2621
+ } catch (_error) {
2622
+ return {};
2719
2623
  }
2720
- if (results.some((r) => r.filePath === "test/example.test.ts" && r.action === "created")) {
2721
- sections.push("### Generate tests");
2722
- sections.push("");
2723
- sections.push("A starter test was created at `test/example.test.ts`. Now:");
2724
- sections.push("");
2725
- sections.push("1. Review the existing source code in `src/`");
2726
- sections.push("2. Create additional test files following the starter test's patterns (import style, describe/it structure)");
2727
- sections.push("3. Focus on edge cases and core business logic");
2728
- sections.push("4. Aim for meaningful coverage of exported functions and key code paths");
2729
- sections.push("");
2624
+ }
2625
+ /** Read name and version from a package's package.json. */
2626
+ function readPackageInfo(executor, packageJsonPath) {
2627
+ const raw = executor.readFile(packageJsonPath);
2628
+ if (!raw) return {
2629
+ name: void 0,
2630
+ version: void 0
2631
+ };
2632
+ try {
2633
+ const result = PackageInfoSchema.safeParse(JSON.parse(raw));
2634
+ if (!result.success) return {
2635
+ name: void 0,
2636
+ version: void 0
2637
+ };
2638
+ return {
2639
+ name: result.data.name,
2640
+ version: result.data.version
2641
+ };
2642
+ } catch (_error) {
2643
+ return {
2644
+ name: void 0,
2645
+ version: void 0
2646
+ };
2730
2647
  }
2731
- sections.push("## Ground rules");
2732
- sections.push("");
2733
- sections.push("It is OK to add new packages (e.g. `zod`, `@bensandee/common`) if they are needed to resolve errors.");
2734
- sections.push("");
2735
- sections.push("When resolving errors from the checklist below, prefer fixing the root cause over suppressing the issue. For example:");
2736
- sections.push("");
2737
- sections.push("- **Lint errors**: fix the code rather than adding disable comments or rule exceptions");
2738
- sections.push("- **Test failures**: update the test or fix the underlying bug rather than skipping or deleting the test");
2739
- sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.config.ts`");
2740
- sections.push("- **Type errors**: add proper types rather than using `any` or `@ts-expect-error`");
2741
- sections.push("");
2742
- sections.push("Only suppress an issue if there is a clear, documented reason why the fix is not feasible (e.g. a third-party type mismatch). Leave a comment explaining why.");
2743
- sections.push("");
2744
- sections.push("## Verification checklist");
2745
- sections.push("");
2746
- sections.push("Run each of these commands and fix any errors before moving on:");
2747
- sections.push("");
2748
- sections.push("1. `pnpm install`");
2749
- const updateCmd = `pnpm update --latest ${getAddedDevDepNames(config).join(" ")}`;
2750
- sections.push(`2. \`${updateCmd}\` — bump added dependencies to their latest versions`);
2751
- sections.push("3. `pnpm typecheck` — fix any type errors");
2752
- sections.push("4. `pnpm build` — fix any build errors");
2753
- sections.push("5. `pnpm test` — fix any test failures");
2754
- sections.push("6. `pnpm lint` — fix the code to satisfy lint rules");
2755
- sections.push("7. `pnpm knip` — remove unused exports, dependencies, and dead code");
2756
- sections.push("8. `pnpm format` — fix any formatting issues");
2757
- sections.push("");
2758
- return sections.join("\n");
2759
2648
  }
2760
- //#endregion
2761
- //#region src/commands/repo-init.ts
2762
- /** Log what was detected so the user understands generator decisions. */
2763
- function logDetectionSummary(ctx) {
2764
- const dockerNames = getDockerPackageNames(ctx);
2765
- if (dockerNames.length > 0) p.log.info(`Detected Docker packages: ${dockerNames.join(", ")}`);
2766
- if (ctx.config.releaseStrategy !== "none") {
2767
- const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
2768
- if (publishable.length > 0) p.log.info(`Will publish npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
2769
- else p.log.info("No publishable npm packages npm registry setup will be skipped");
2649
+ /** Convention paths to check for Dockerfiles in a package directory. */
2650
+ const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
2651
+ /**
2652
+ * Find a Dockerfile at convention paths for a monorepo package.
2653
+ * Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
2654
+ */
2655
+ function findConventionDockerfile(executor, cwd, dir) {
2656
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) {
2657
+ const dockerfilePath = `packages/${dir}/${rel}`;
2658
+ if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
2659
+ dockerfile: dockerfilePath,
2660
+ context: "."
2661
+ };
2770
2662
  }
2771
2663
  }
2772
- async function runInit(config, options = {}) {
2773
- const detected = detectProject(config.targetDir);
2774
- const s = p.spinner();
2775
- const { ctx, archivedFiles } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
2776
- s.stop("Paused");
2777
- const result = await p.select({
2778
- message: `${relativePath} already exists. What do you want to do?`,
2779
- options: [{
2780
- value: "overwrite",
2781
- label: "Overwrite"
2782
- }, {
2783
- value: "skip",
2784
- label: "Skip"
2785
- }]
2786
- });
2787
- s.start("Generating configuration files...");
2788
- if (p.isCancel(result)) return "skip";
2789
- return result;
2790
- }));
2791
- logDetectionSummary(ctx);
2792
- s.start("Generating configuration files...");
2793
- const results = await runGenerators(ctx);
2794
- const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
2795
- for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
2796
- filePath: rel,
2797
- action: "archived",
2798
- description: `Original saved to .tooling-archived/${rel}`
2799
- });
2800
- const created = results.filter((r) => r.action === "created");
2801
- const updated = results.filter((r) => r.action === "updated");
2802
- if (!(created.length > 0 || updated.length > 0 || archivedFiles.length > 0) && options.noPrompt) {
2803
- s.stop("Repository is up to date.");
2804
- return results;
2805
- }
2806
- s.stop("Done!");
2807
- if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
2808
- execSync("git config --unset core.hooksPath", {
2809
- cwd: config.targetDir,
2810
- stdio: "ignore"
2811
- });
2812
- } catch (_error) {}
2813
- const summaryLines = [];
2814
- if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
2815
- if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
2816
- p.note(summaryLines.join("\n"), "Summary");
2817
- if (!options.noPrompt) {
2818
- const prompt = generateMigratePrompt(results, config, detected);
2819
- const promptPath = ".tooling-migrate.md";
2820
- ctx.write(promptPath, prompt);
2821
- p.log.info(`Migration prompt written to ${promptPath}`);
2822
- p.log.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
2823
- }
2824
- const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
2825
- const hasLockfile = ctx.exists("pnpm-lock.yaml");
2826
- if (bensandeeDeps.length > 0 && hasLockfile) {
2827
- s.start("Updating @bensandee/* packages...");
2828
- try {
2829
- execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
2830
- cwd: config.targetDir,
2831
- stdio: "ignore"
2832
- });
2833
- s.stop("Updated @bensandee/* packages");
2834
- } catch (_error) {
2835
- s.stop("Could not update @bensandee/* packages — run pnpm install first");
2836
- }
2837
- }
2838
- p.note([
2839
- "1. Run: pnpm install",
2840
- "2. Run: pnpm typecheck",
2841
- "3. Run: pnpm build",
2842
- "4. Run: pnpm test",
2843
- ...options.noPrompt ? [] : ["5. In Claude Code, run: \"Execute the steps in .tooling-migrate.md\""]
2844
- ].join("\n"), "Next steps");
2845
- return results;
2846
- }
2847
- //#endregion
2848
- //#region src/commands/repo-sync.ts
2849
- const syncCommand = defineCommand({
2850
- meta: {
2851
- name: "repo:sync",
2852
- description: "Detect, generate, and sync project tooling (idempotent)"
2853
- },
2854
- args: {
2855
- dir: {
2856
- type: "positional",
2857
- description: "Target directory (default: current directory)",
2858
- required: false
2859
- },
2860
- check: {
2861
- type: "boolean",
2862
- description: "Dry-run mode: report drift without writing files"
2863
- },
2864
- yes: {
2865
- type: "boolean",
2866
- alias: "y",
2867
- description: "Accept all defaults (non-interactive)"
2868
- },
2869
- "eslint-plugin": {
2870
- type: "boolean",
2871
- description: "Include @bensandee/eslint-plugin (default: true)"
2872
- },
2873
- "no-ci": {
2874
- type: "boolean",
2875
- description: "Skip CI workflow generation"
2876
- },
2877
- "no-prompt": {
2878
- type: "boolean",
2879
- description: "Skip migration prompt generation"
2880
- }
2881
- },
2882
- async run({ args }) {
2883
- const targetDir = path.resolve(args.dir ?? ".");
2884
- if (args.check) {
2885
- const exitCode = await runCheck(targetDir);
2886
- process.exitCode = exitCode;
2887
- return;
2888
- }
2889
- const saved = loadToolingConfig(targetDir);
2890
- const isFirstRun = !saved;
2891
- let config;
2892
- if (args.yes || !isFirstRun) {
2893
- const detected = buildDefaultConfig(targetDir, {
2894
- eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
2895
- noCi: args["no-ci"] === true ? true : void 0
2896
- });
2897
- config = saved ? mergeWithSavedConfig(detected, saved) : detected;
2898
- } else config = await runInitPrompts(targetDir, saved);
2899
- await runInit(config, {
2900
- noPrompt: args["no-prompt"] === true || !isFirstRun,
2901
- ...!isFirstRun && { confirmOverwrite: async () => "overwrite" }
2902
- });
2903
- }
2904
- });
2905
- /** Run sync in check mode: dry-run drift detection. */
2906
- async function runCheck(targetDir) {
2907
- const saved = loadToolingConfig(targetDir);
2908
- const detected = buildDefaultConfig(targetDir, {});
2909
- const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
2910
- logDetectionSummary(ctx);
2911
- const actionable = (await runGenerators(ctx)).filter((r) => {
2912
- if (r.action !== "created" && r.action !== "updated") return false;
2913
- const newContent = pendingWrites.get(r.filePath);
2914
- if (newContent && r.action === "updated") {
2915
- const existingPath = path.join(targetDir, r.filePath);
2916
- const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
2917
- if (existing && contentEqual(r.filePath, existing, newContent)) return false;
2918
- }
2919
- return true;
2920
- });
2921
- if (actionable.length === 0) {
2922
- p.log.success("Repository is up to date.");
2923
- return 0;
2924
- }
2925
- p.log.warn(`${actionable.length} file(s) would be changed by repo:sync`);
2926
- for (const r of actionable) {
2927
- p.log.info(` ${r.action}: ${r.filePath} — ${r.description}`);
2928
- const newContent = pendingWrites.get(r.filePath);
2929
- if (!newContent) continue;
2930
- const existingPath = path.join(targetDir, r.filePath);
2931
- const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
2932
- if (!existing) {
2933
- const lineCount = newContent.split("\n").length - 1;
2934
- p.log.info(` + ${lineCount} new lines`);
2935
- } else {
2936
- const diff = lineDiff(existing, newContent);
2937
- for (const line of diff) p.log.info(` ${line}`);
2938
- }
2939
- }
2940
- return 1;
2941
- }
2942
- const normalize = (line) => line.trimEnd();
2943
- function lineDiff(oldText, newText) {
2944
- const oldLines = oldText.split("\n").map(normalize);
2945
- const newLines = newText.split("\n").map(normalize);
2946
- const oldSet = new Set(oldLines);
2947
- const newSet = new Set(newLines);
2948
- const removed = oldLines.filter((l) => l.trim() !== "" && !newSet.has(l));
2949
- const added = newLines.filter((l) => l.trim() !== "" && !oldSet.has(l));
2950
- const lines = [];
2951
- for (const l of removed) lines.push(`- ${l.trim()}`);
2952
- for (const l of added) lines.push(`+ ${l.trim()}`);
2953
- return lines;
2954
- }
2955
- //#endregion
2956
- //#region src/release/executor.ts
2957
- /** Create an executor that runs real commands, fetches, and reads the filesystem. */
2958
- function createRealExecutor() {
2959
- return {
2960
- exec(command, options) {
2961
- try {
2962
- return {
2963
- stdout: execSync(command, {
2964
- cwd: options?.cwd,
2965
- env: {
2966
- ...process.env,
2967
- ...options?.env
2968
- },
2969
- encoding: "utf-8",
2970
- stdio: [
2971
- "pipe",
2972
- "pipe",
2973
- "pipe"
2974
- ]
2975
- }),
2976
- stderr: "",
2977
- exitCode: 0
2978
- };
2979
- } catch (err) {
2980
- if (isExecSyncError(err)) return {
2981
- stdout: err.stdout,
2982
- stderr: err.stderr,
2983
- exitCode: err.status
2984
- };
2985
- return {
2986
- stdout: "",
2987
- stderr: "",
2988
- exitCode: 1
2989
- };
2990
- }
2991
- },
2992
- fetch: globalThis.fetch,
2993
- listChangesetFiles(cwd) {
2994
- const dir = path.join(cwd, ".changeset");
2995
- try {
2996
- return readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "README.md");
2997
- } catch {
2998
- return [];
2999
- }
3000
- },
3001
- listWorkspacePackages(cwd) {
3002
- const packagesDir = path.join(cwd, "packages");
3003
- const packages = [];
3004
- try {
3005
- for (const entry of readdirSync(packagesDir)) {
3006
- const pkgPath = path.join(packagesDir, entry, "package.json");
3007
- try {
3008
- const pkg = parsePackageJson(readFileSync(pkgPath, "utf-8"));
3009
- if (pkg?.name && pkg.version && !pkg.private) packages.push({
3010
- name: pkg.name,
3011
- version: pkg.version,
3012
- dir: entry
3013
- });
3014
- } catch (_error) {}
3015
- }
3016
- } catch (_error) {}
3017
- return packages;
3018
- },
3019
- listPackageDirs(cwd) {
3020
- const packagesDir = path.join(cwd, "packages");
3021
- try {
3022
- return readdirSync(packagesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
3023
- } catch {
3024
- return [];
3025
- }
3026
- },
3027
- readFile(filePath) {
3028
- try {
3029
- return readFileSync(filePath, "utf-8");
3030
- } catch {
3031
- return null;
3032
- }
3033
- },
3034
- writeFile(filePath, content) {
3035
- mkdirSync(path.dirname(filePath), { recursive: true });
3036
- writeFileSync(filePath, content);
3037
- }
3038
- };
3039
- }
3040
- /** Parse "New tag:" lines from changeset publish output. */
3041
- function parseNewTags(output) {
3042
- const tags = [];
3043
- for (const line of output.split("\n")) {
3044
- const match = /New tag:\s+(\S+)/.exec(line);
3045
- if (match?.[1]) tags.push(match[1]);
3046
- }
3047
- return tags;
3048
- }
3049
- /** Map workspace packages to their expected tag strings (name@version). */
3050
- function computeExpectedTags(packages) {
3051
- return packages.map((p) => `${p.name}@${p.version}`);
3052
- }
3053
- /** Parse `git ls-remote --tags` output into tag names, filtering out `^{}` dereference entries. */
3054
- function parseRemoteTags(output) {
3055
- const tags = [];
3056
- for (const line of output.split("\n")) {
3057
- const match = /refs\/tags\/(.+)/.exec(line);
3058
- if (match?.[1] && !match[1].endsWith("^{}")) tags.push(match[1]);
3059
- }
3060
- return tags;
3061
- }
3062
2664
  /**
3063
- * Reconcile expected tags with what already exists on the remote.
3064
- * Returns `(expected - remote) stdoutTags`, deduplicated.
2665
+ * Find a Dockerfile at convention paths for a single-package repo.
2666
+ * Checks Dockerfile and docker/Dockerfile at the project root.
3065
2667
  */
3066
- function reconcileTags(expectedTags, remoteTags, stdoutTags) {
3067
- const remoteSet = new Set(remoteTags);
3068
- const result = /* @__PURE__ */ new Set();
3069
- for (const tag of expectedTags) if (!remoteSet.has(tag)) result.add(tag);
3070
- for (const tag of stdoutTags) result.add(tag);
3071
- return [...result];
2668
+ function findRootDockerfile(executor, cwd) {
2669
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
2670
+ dockerfile: rel,
2671
+ context: "."
2672
+ };
3072
2673
  }
3073
- //#endregion
3074
- //#region src/release/forgejo.ts
3075
- const PullRequestSchema = z.array(z.object({
3076
- number: z.number(),
3077
- head: z.object({ ref: z.string() })
3078
- }));
3079
2674
  /**
3080
- * Find an open PR with the given head branch. Returns the PR number or null.
2675
+ * Discover Docker packages by convention and merge with .tooling.json overrides.
3081
2676
  *
3082
- * Fetches all open PRs and filters client-side by head.ref rather than relying
3083
- * on Forgejo's query parameter filtering, which behaves inconsistently.
2677
+ * Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
2678
+ * For monorepos, scans packages/{name}/. For single-package repos, scans the root.
2679
+ * The docker map in .tooling.json overrides convention-discovered config and can add
2680
+ * packages at non-standard locations.
2681
+ *
2682
+ * Image names are derived from {root-name}-{package-name} using each package's package.json name.
2683
+ * Versions are read from each package's own package.json.
3084
2684
  */
3085
- async function findOpenPr(executor, conn, head) {
3086
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?state=open`;
3087
- const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3088
- if (!res.ok) throw new TransientError(`Failed to list PRs: ${res.status} ${res.statusText}`);
3089
- const parsed = PullRequestSchema.safeParse(await res.json());
3090
- if (!parsed.success) throw new UnexpectedError(`Unexpected PR list response: ${parsed.error.message}`);
3091
- return parsed.data.find((pr) => pr.head.ref === head)?.number ?? null;
3092
- }
3093
- /** Create a new pull request. */
3094
- async function createPr(executor, conn, options) {
3095
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls`;
3096
- const payload = {
3097
- title: options.title,
3098
- head: options.head,
3099
- base: options.base
3100
- };
3101
- if (options.body) payload["body"] = options.body;
3102
- const res = await executor.fetch(url, {
3103
- method: "POST",
3104
- headers: {
3105
- Authorization: `token ${conn.token}`,
3106
- "Content-Type": "application/json"
3107
- },
3108
- body: JSON.stringify(payload)
3109
- });
3110
- if (!res.ok) throw new TransientError(`Failed to create PR: ${res.status} ${res.statusText}`);
3111
- }
3112
- /** Update an existing pull request's title and body. */
3113
- async function updatePr(executor, conn, prNumber, options) {
3114
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
3115
- const res = await executor.fetch(url, {
3116
- method: "PATCH",
3117
- headers: {
3118
- Authorization: `token ${conn.token}`,
3119
- "Content-Type": "application/json"
3120
- },
3121
- body: JSON.stringify({
3122
- title: options.title,
3123
- body: options.body
3124
- })
3125
- });
3126
- if (!res.ok) throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
3127
- }
3128
- /** Merge a pull request by number. */
3129
- async function mergePr(executor, conn, prNumber, options) {
3130
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}/merge`;
3131
- const res = await executor.fetch(url, {
3132
- method: "POST",
3133
- headers: {
3134
- Authorization: `token ${conn.token}`,
3135
- "Content-Type": "application/json"
3136
- },
3137
- body: JSON.stringify({
3138
- Do: options?.method ?? "merge",
3139
- delete_branch_after_merge: options?.deleteBranch ?? true
3140
- })
3141
- });
3142
- if (!res.ok) throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
3143
- }
3144
- /** Check whether a Forgejo release already exists for a given tag. */
3145
- async function findRelease(executor, conn, tag) {
3146
- const encodedTag = encodeURIComponent(tag);
3147
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
3148
- const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3149
- if (res.status === 200) return true;
3150
- if (res.status === 404) return false;
3151
- throw new TransientError(`Failed to check release for ${tag}: ${res.status} ${res.statusText}`);
3152
- }
3153
- /** Create a Forgejo release for a given tag. */
3154
- async function createRelease(executor, conn, tag) {
3155
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
3156
- const res = await executor.fetch(url, {
3157
- method: "POST",
3158
- headers: {
3159
- Authorization: `token ${conn.token}`,
3160
- "Content-Type": "application/json"
3161
- },
3162
- body: JSON.stringify({
3163
- tag_name: tag,
3164
- name: tag,
3165
- body: `Published ${tag}`
3166
- })
3167
- });
3168
- if (!res.ok) throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}`);
3169
- }
3170
- //#endregion
3171
- //#region src/release/log.ts
3172
- /** Log a debug message when verbose mode is enabled. */
3173
- function debug$1(config, message) {
3174
- if (config.verbose) p.log.info(`[debug] ${message}`);
3175
- }
3176
- /** Log the result of an exec call when verbose mode is enabled. */
3177
- function debugExec(config, label, result) {
3178
- if (!config.verbose) return;
3179
- const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
3180
- if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
3181
- if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
3182
- p.log.info(lines.join("\n"));
3183
- }
3184
- //#endregion
3185
- //#region src/release/version.ts
3186
- const BRANCH = "changeset-release/main";
3187
- /** Extract the latest changelog entry (content between first and second `## ` heading). */
3188
- function extractLatestEntry(changelog) {
3189
- const lines = changelog.split("\n");
3190
- let start = -1;
3191
- let end = lines.length;
3192
- for (let i = 0; i < lines.length; i++) if (lines[i]?.startsWith("## ")) if (start === -1) start = i;
3193
- else {
3194
- end = i;
3195
- break;
2685
+ function detectDockerPackages(executor, cwd, repoName) {
2686
+ const overrides = loadDockerMap(executor, cwd);
2687
+ const packageDirs = executor.listPackageDirs(cwd);
2688
+ const packages = [];
2689
+ const seen = /* @__PURE__ */ new Set();
2690
+ if (packageDirs.length > 0) {
2691
+ for (const dir of packageDirs) {
2692
+ const convention = findConventionDockerfile(executor, cwd, dir);
2693
+ const docker = overrides[dir] ?? convention;
2694
+ if (docker) {
2695
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2696
+ packages.push({
2697
+ dir,
2698
+ imageName: `${repoName}-${name ?? dir}`,
2699
+ version,
2700
+ docker
2701
+ });
2702
+ seen.add(dir);
2703
+ }
2704
+ }
2705
+ for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
2706
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2707
+ packages.push({
2708
+ dir,
2709
+ imageName: `${repoName}-${name ?? dir}`,
2710
+ version,
2711
+ docker
2712
+ });
2713
+ }
2714
+ } else {
2715
+ const convention = findRootDockerfile(executor, cwd);
2716
+ const docker = overrides["."] ?? convention;
2717
+ if (docker) {
2718
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
2719
+ packages.push({
2720
+ dir: ".",
2721
+ imageName: name ?? repoName,
2722
+ version,
2723
+ docker
2724
+ });
2725
+ }
3196
2726
  }
3197
- if (start === -1) return null;
3198
- return lines.slice(start, end).join("\n").trim();
2727
+ return packages;
3199
2728
  }
3200
- /** Read the root package.json name and version. */
3201
- function readRootPackage(executor, cwd) {
3202
- const content = executor.readFile(path.join(cwd, "package.json"));
3203
- if (!content) return null;
3204
- const pkg = parsePackageJson(content);
3205
- if (!pkg?.name || !pkg.version) return null;
3206
- if (pkg.private) return null;
2729
+ /**
2730
+ * Read docker config for a single package, checking convention paths first,
2731
+ * then .tooling.json overrides. Used by the per-package image:build script.
2732
+ */
2733
+ function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
2734
+ const dir = path.basename(path.resolve(cwd, packageDir));
2735
+ const convention = findConventionDockerfile(executor, cwd, dir);
2736
+ const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
2737
+ if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
2738
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
3207
2739
  return {
3208
- name: pkg.name,
3209
- version: pkg.version
2740
+ dir,
2741
+ imageName: `${repoName}-${name ?? dir}`,
2742
+ version,
2743
+ docker
3210
2744
  };
3211
2745
  }
3212
- /** Determine which packages changed and collect their changelog entries. */
3213
- function buildPrContent(executor, cwd, packagesBefore) {
3214
- const packagesAfter = executor.listWorkspacePackages(cwd);
3215
- if (!(packagesBefore.length > 0 || packagesAfter.length > 0)) {
3216
- const rootPkg = readRootPackage(executor, cwd);
3217
- if (rootPkg) {
3218
- const changelog = executor.readFile(path.join(cwd, "CHANGELOG.md"));
3219
- const entry = changelog ? extractLatestEntry(changelog) : null;
3220
- return {
3221
- title: `chore: release ${rootPkg.name}@${rootPkg.version}`,
3222
- body: entry ?? ""
3223
- };
3224
- }
3225
- return {
3226
- title: "chore: version packages",
3227
- body: ""
3228
- };
3229
- }
3230
- const beforeMap = new Map(packagesBefore.map((pkg) => [pkg.name, pkg.version]));
3231
- const changed = packagesAfter.filter((pkg) => beforeMap.get(pkg.name) !== pkg.version);
3232
- if (changed.length === 0) return {
3233
- title: "chore: version packages",
3234
- body: ""
3235
- };
3236
- const title = `chore: release ${changed.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ")}`;
3237
- const entries = [];
3238
- for (const pkg of changed) {
3239
- const changelogPath = path.join(cwd, "packages", pkg.dir, "CHANGELOG.md");
3240
- const changelog = executor.readFile(changelogPath);
3241
- const entry = changelog ? extractLatestEntry(changelog) : null;
3242
- if (entry) {
3243
- const labeled = entry.replace(/^## .+/, `## ${pkg.name}@${pkg.version}`);
3244
- entries.push(labeled);
3245
- }
3246
- }
2746
+ /** Parse semver version string into major, minor, patch components. */
2747
+ function parseSemver(version) {
2748
+ const clean = version.replace(/^v/, "");
2749
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(clean);
2750
+ if (!match?.[1] || !match[2] || !match[3]) throw new FatalError(`Invalid semver version: ${version}`);
3247
2751
  return {
3248
- title,
3249
- body: entries.join("\n\n")
2752
+ major: Number(match[1]),
2753
+ minor: Number(match[2]),
2754
+ patch: Number(match[3])
3250
2755
  };
3251
2756
  }
3252
- /** Mode 1: version packages and create/update a PR. */
3253
- async function runVersionMode(executor, config) {
3254
- p.log.info("Changesets detected versioning packages");
3255
- const packagesBefore = executor.listWorkspacePackages(config.cwd);
3256
- debug$1(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
3257
- const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
3258
- const originalConfig = executor.readFile(changesetConfigPath);
3259
- if (originalConfig) {
3260
- const parsed = parseChangesetConfig(originalConfig);
3261
- if (parsed?.commit) {
3262
- const patched = {
3263
- ...parsed,
3264
- commit: false
3265
- };
3266
- executor.writeFile(changesetConfigPath, JSON.stringify(patched, null, 2) + "\n");
3267
- debug$1(config, "Temporarily disabled changeset commit:true");
3268
- }
2757
+ /** Generate semver tag variants: latest, vX.Y.Z, vX.Y, vX */
2758
+ function generateTags(version) {
2759
+ const { major, minor, patch } = parseSemver(version);
2760
+ return [
2761
+ "latest",
2762
+ `v${major}.${minor}.${patch}`,
2763
+ `v${major}.${minor}`,
2764
+ `v${major}`
2765
+ ];
2766
+ }
2767
+ /** Build the full image reference: namespace/imageName:tag */
2768
+ function imageRef(namespace, imageName, tag) {
2769
+ return `${namespace}/${imageName}:${tag}`;
2770
+ }
2771
+ function log$1(message) {
2772
+ console.log(message);
2773
+ }
2774
+ function debug$1(verbose, message) {
2775
+ if (verbose) console.log(`[debug] ${message}`);
2776
+ }
2777
+ /** Read the repo name from root package.json. */
2778
+ function readRepoName(executor, cwd) {
2779
+ const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
2780
+ if (!rootPkgRaw) throw new FatalError("No package.json found in project root");
2781
+ const repoName = parsePackageJson(rootPkgRaw)?.name;
2782
+ if (!repoName) throw new FatalError("Root package.json must have a name field");
2783
+ return repoName;
2784
+ }
2785
+ /** Build a single docker image from its config. Paths are resolved relative to cwd. */
2786
+ function buildImage(executor, pkg, cwd, verbose, extraArgs) {
2787
+ const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
2788
+ const contextPath = path.resolve(cwd, pkg.docker.context);
2789
+ const command = [
2790
+ "docker build",
2791
+ `-f ${dockerfilePath}`,
2792
+ `-t ${pkg.imageName}:latest`,
2793
+ ...extraArgs,
2794
+ contextPath
2795
+ ].join(" ");
2796
+ debug$1(verbose, `Running: ${command}`);
2797
+ const buildResult = executor.exec(command);
2798
+ debug$1(verbose, `Build stdout: ${buildResult.stdout}`);
2799
+ if (buildResult.exitCode !== 0) throw new FatalError(`docker build failed for ${pkg.dir} (exit ${buildResult.exitCode}): ${buildResult.stderr}`);
2800
+ }
2801
+ /**
2802
+ * Detect packages with docker config in .tooling.json and build each one.
2803
+ * Runs `docker build -f <dockerfile> -t <image-name>:latest <context>` for each package.
2804
+ * Dockerfile and context paths are resolved relative to the project root.
2805
+ *
2806
+ * When `packageDir` is set, builds only that single package (for use as an image:build script).
2807
+ */
2808
+ function runDockerBuild(executor, config) {
2809
+ const repoName = readRepoName(executor, config.cwd);
2810
+ if (config.packageDir) {
2811
+ const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
2812
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
2813
+ buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
2814
+ log$1(`Built ${pkg.imageName}:latest`);
2815
+ return { packages: [pkg] };
3269
2816
  }
3270
- const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
3271
- debugExec(config, "pnpm changeset version", versionResult);
3272
- if (originalConfig) executor.writeFile(changesetConfigPath, originalConfig);
3273
- if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
3274
- debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
3275
- const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
3276
- debug$1(config, `PR title: ${title}`);
3277
- executor.exec("git add -A", { cwd: config.cwd });
3278
- const remainingChangesets = executor.listChangesetFiles(config.cwd);
3279
- if (remainingChangesets.length > 0) p.log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
3280
- debug$1(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
3281
- const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
3282
- debugExec(config, "git commit", commitResult);
3283
- if (commitResult.exitCode !== 0) {
3284
- p.log.info("Nothing to commit after versioning");
3285
- return {
3286
- mode: "version",
3287
- pr: "none"
3288
- };
2817
+ const packages = detectDockerPackages(executor, config.cwd, repoName);
2818
+ if (packages.length === 0) {
2819
+ log$1("No packages with docker config found");
2820
+ return { packages: [] };
3289
2821
  }
3290
- if (config.dryRun) {
3291
- p.log.info("[dry-run] Would push and create/update PR");
3292
- return {
3293
- mode: "version",
3294
- pr: "none"
3295
- };
2822
+ log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
2823
+ for (const pkg of packages) {
2824
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
2825
+ buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
3296
2826
  }
3297
- debugExec(config, "git push", executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd }));
3298
- const conn = {
3299
- serverUrl: config.serverUrl,
3300
- repository: config.repository,
3301
- token: config.token
2827
+ log$1(`Built ${packages.length} image(s)`);
2828
+ return { packages };
2829
+ }
2830
+ /**
2831
+ * Run the full Docker publish pipeline:
2832
+ * 1. Build all images via runDockerBuild
2833
+ * 2. Login to registry
2834
+ * 3. Tag each image with semver variants from its own package.json version
2835
+ * 4. Push all tags
2836
+ * 5. Logout from registry
2837
+ */
2838
+ function runDockerPublish(executor, config) {
2839
+ const { packages } = runDockerBuild(executor, {
2840
+ cwd: config.cwd,
2841
+ packageDir: void 0,
2842
+ verbose: config.verbose,
2843
+ extraArgs: []
2844
+ });
2845
+ if (packages.length === 0) return {
2846
+ packages: [],
2847
+ tags: []
3302
2848
  };
3303
- const existingPr = await findOpenPr(executor, conn, BRANCH);
3304
- debug$1(config, `Existing open PR for ${BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
3305
- if (existingPr === null) {
3306
- await createPr(executor, conn, {
3307
- title,
3308
- head: BRANCH,
3309
- base: "main",
3310
- body
3311
- });
3312
- p.log.info("Created version PR");
3313
- return {
3314
- mode: "version",
3315
- pr: "created"
3316
- };
2849
+ for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
2850
+ if (!config.dryRun) {
2851
+ log$1(`Logging in to ${config.registryHost}...`);
2852
+ const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
2853
+ if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
2854
+ } else log$1("[dry-run] Skipping docker login");
2855
+ const allTags = [];
2856
+ try {
2857
+ for (const pkg of packages) {
2858
+ const tags = generateTags(pkg.version ?? "");
2859
+ log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
2860
+ for (const tag of tags) {
2861
+ const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
2862
+ allTags.push(ref);
2863
+ log$1(`Tagging ${pkg.imageName} → ${ref}`);
2864
+ const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
2865
+ if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
2866
+ if (!config.dryRun) {
2867
+ log$1(`Pushing ${ref}...`);
2868
+ const pushResult = executor.exec(`docker push ${ref}`);
2869
+ if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
2870
+ } else log$1(`[dry-run] Skipping push for ${ref}`);
2871
+ }
2872
+ }
2873
+ } finally {
2874
+ if (!config.dryRun) {
2875
+ log$1(`Logging out from ${config.registryHost}...`);
2876
+ executor.exec(`docker logout ${config.registryHost}`);
2877
+ }
3317
2878
  }
3318
- await updatePr(executor, conn, existingPr, {
3319
- title,
3320
- body
3321
- });
3322
- p.log.info(`Updated version PR #${String(existingPr)}`);
2879
+ log$1(`Published ${allTags.length} image tag(s)`);
3323
2880
  return {
3324
- mode: "version",
3325
- pr: "updated"
2881
+ packages,
2882
+ tags: allTags
3326
2883
  };
3327
2884
  }
3328
2885
  //#endregion
3329
- //#region src/release/publish.ts
3330
- const RETRY_ATTEMPTS = 3;
3331
- const RETRY_BASE_DELAY_MS = 1e3;
3332
- async function retryAsync(fn) {
3333
- let lastError;
3334
- for (let attempt = 0; attempt <= RETRY_ATTEMPTS; attempt++) try {
3335
- return await fn();
3336
- } catch (error) {
3337
- lastError = error;
3338
- if (attempt < RETRY_ATTEMPTS) {
3339
- const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
3340
- await new Promise((resolve) => setTimeout(resolve, delay));
3341
- }
2886
+ //#region src/generators/migrate-prompt.ts
2887
+ /**
2888
+ * Generate a context-aware AI migration prompt based on what the CLI did.
2889
+ * This prompt can be pasted into Claude Code (or similar) to finish the migration.
2890
+ */
2891
+ function generateMigratePrompt(results, config, detected) {
2892
+ const sections = [];
2893
+ sections.push("# Migration Prompt");
2894
+ sections.push("");
2895
+ 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.");
2896
+ sections.push("");
2897
+ 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.");
2898
+ sections.push("");
2899
+ sections.push("## What was changed");
2900
+ sections.push("");
2901
+ const created = results.filter((r) => r.action === "created");
2902
+ const updated = results.filter((r) => r.action === "updated");
2903
+ const skipped = results.filter((r) => r.action === "skipped");
2904
+ const archived = results.filter((r) => r.action === "archived");
2905
+ if (created.length > 0) {
2906
+ sections.push("**Created:**");
2907
+ for (const r of created) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2908
+ sections.push("");
3342
2909
  }
3343
- throw lastError;
3344
- }
3345
- /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
3346
- async function runPublishMode(executor, config) {
3347
- p.log.info("No changesets — publishing packages");
3348
- const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
3349
- debugExec(config, "pnpm changeset publish", publishResult);
3350
- if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
3351
- const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
3352
- debug$1(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
3353
- const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
3354
- debug$1(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
3355
- const remoteTags = parseRemoteTags(executor.exec("git ls-remote --tags origin", { cwd: config.cwd }).stdout);
3356
- debug$1(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
3357
- const remoteSet = new Set(remoteTags);
3358
- const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
3359
- debug$1(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
3360
- if (config.dryRun) {
3361
- if (tagsToPush.length === 0) {
3362
- p.log.info("No packages were published");
3363
- return { mode: "none" };
3364
- }
3365
- p.log.info(`Tags to process: ${tagsToPush.join(", ")}`);
3366
- p.log.info("[dry-run] Would push tags and create releases");
3367
- return {
3368
- mode: "publish",
3369
- tags: tagsToPush
3370
- };
2910
+ if (updated.length > 0) {
2911
+ sections.push("**Updated:**");
2912
+ for (const r of updated) sections.push(`- \`${r.filePath}\` ${r.description}`);
2913
+ sections.push("");
3371
2914
  }
3372
- const conn = {
3373
- serverUrl: config.serverUrl,
3374
- repository: config.repository,
3375
- token: config.token
3376
- };
3377
- const remoteExpectedTags = expectedTags.filter((t) => remoteSet.has(t) && !tagsToPush.includes(t));
3378
- const tagsWithMissingReleases = [];
3379
- for (const tag of remoteExpectedTags) if (!await findRelease(executor, conn, tag)) tagsWithMissingReleases.push(tag);
3380
- const allTags = [...tagsToPush, ...tagsWithMissingReleases];
3381
- if (allTags.length === 0) {
3382
- p.log.info("No packages were published");
3383
- return { mode: "none" };
2915
+ if (archived.length > 0) {
2916
+ sections.push("**Archived:**");
2917
+ for (const r of archived) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2918
+ sections.push("");
3384
2919
  }
3385
- p.log.info(`Tags to process: ${allTags.join(", ")}`);
3386
- const errors = [];
3387
- for (const tag of allTags) try {
3388
- if (!remoteSet.has(tag)) {
3389
- if (executor.exec(`git tag -l ${JSON.stringify(tag)}`, { cwd: config.cwd }).stdout.trim() === "") executor.exec(`git tag ${JSON.stringify(tag)}`, { cwd: config.cwd });
3390
- executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
3391
- }
3392
- if (await findRelease(executor, conn, tag)) p.log.warn(`Release for ${tag} already exists skipping`);
3393
- else {
3394
- await retryAsync(async () => {
3395
- try {
3396
- await createRelease(executor, conn, tag);
3397
- } catch (error) {
3398
- if (await findRelease(executor, conn, tag)) return;
3399
- throw error;
3400
- }
3401
- });
3402
- p.log.info(`Created release for ${tag}`);
2920
+ if (skipped.length > 0) {
2921
+ sections.push("**Skipped (review these):**");
2922
+ for (const r of skipped) sections.push(`- \`${r.filePath}\` — ${r.description}`);
2923
+ sections.push("");
2924
+ }
2925
+ sections.push("## Migration tasks");
2926
+ sections.push("");
2927
+ const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
2928
+ if (legacyToRemove.length > 0) {
2929
+ sections.push("### Remove legacy tooling");
2930
+ sections.push("");
2931
+ for (const legacy of legacyToRemove) {
2932
+ const replacement = {
2933
+ eslint: "oxlint",
2934
+ prettier: "oxfmt",
2935
+ jest: "vitest",
2936
+ webpack: "tsdown",
2937
+ rollup: "tsdown"
2938
+ }[legacy.tool];
2939
+ sections.push(`- Remove ${legacy.tool} config files (${legacy.files.map((f) => `\`${f}\``).join(", ")}). This project now uses **${replacement}**.`);
2940
+ sections.push(` - Uninstall ${legacy.tool}-related packages from devDependencies`);
2941
+ if (legacy.tool === "eslint") sections.push(" - Migrate any custom ESLint rules that don't have oxlint equivalents");
2942
+ if (legacy.tool === "jest") sections.push(" - Migrate any jest-specific test utilities (jest.mock, jest.fn) to vitest equivalents (vi.mock, vi.fn)");
3403
2943
  }
3404
- } catch (error) {
3405
- errors.push({
3406
- tag,
3407
- error
3408
- });
3409
- p.log.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
2944
+ sections.push("");
3410
2945
  }
3411
- if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
3412
- return {
3413
- mode: "publish",
3414
- tags: allTags
3415
- };
2946
+ if (archived.length > 0) {
2947
+ sections.push("### Review archived files");
2948
+ sections.push("");
2949
+ sections.push("The following files were modified or replaced. The originals have been saved to `.tooling-archived/`:");
2950
+ sections.push("");
2951
+ for (const r of archived) sections.push(`- \`${r.filePath}\` → \`.tooling-archived/${r.filePath}\``);
2952
+ sections.push("");
2953
+ sections.push("For each archived file, **diff the old version against the new one** and look for features, categories, or modules that were enabled in the original but are missing from the replacement. Focus on broad capability gaps rather than individual rule strictness (in general, being stricter is fine). Examples of what to look for:");
2954
+ sections.push("");
2955
+ sections.push("- **Lint configs**: enabled plugin categories (e.g. `jsx-a11y`, `import`, `react`, `nextjs`), custom `plugins` or `overrides`, file-scoped rule blocks");
2956
+ sections.push("- **TypeScript configs**: compiler features like `jsx`, `paths`, `baseUrl`, or `references` that affect build behavior");
2957
+ sections.push("- **Other configs**: feature flags, custom presets, or integrations that go beyond the default template");
2958
+ sections.push("");
2959
+ sections.push("If the old config had capabilities the new one lacks, port them into the new file. Then:");
2960
+ sections.push("");
2961
+ sections.push("1. If the project previously used `husky` and `lint-staged`, remove them from `devDependencies`");
2962
+ sections.push("2. Delete the `.tooling-archived/` directory when migration is complete");
2963
+ sections.push("");
2964
+ }
2965
+ const oxlintWasSkipped = results.find((r) => r.filePath === "oxlint.config.ts")?.action === "skipped";
2966
+ if (detected.hasLegacyOxlintJson) {
2967
+ sections.push("### Migrate .oxlintrc.json to oxlint.config.ts");
2968
+ sections.push("");
2969
+ sections.push("A new `oxlint.config.ts` has been generated using `defineConfig` from the `oxlint` package. The existing `.oxlintrc.json` needs to be migrated:");
2970
+ sections.push("");
2971
+ sections.push("1. Read `.oxlintrc.json` and compare its `rules` against the rules provided by `@bensandee/config/oxlint/recommended` (check `node_modules/@bensandee/config`). Most standard rules are already included in the recommended config.");
2972
+ sections.push("2. If there are any custom rules, overrides, settings, or `jsPlugins` not covered by the recommended config, add them to `oxlint.config.ts` alongside the `extends`.");
2973
+ sections.push("3. Delete `.oxlintrc.json`.");
2974
+ sections.push("4. Run `pnpm lint` to verify the new config works correctly.");
2975
+ sections.push("");
2976
+ } else if (oxlintWasSkipped && detected.hasOxlintConfig) {
2977
+ sections.push("### Verify oxlint.config.ts includes recommended rules");
2978
+ sections.push("");
2979
+ sections.push("The existing `oxlint.config.ts` was kept as-is. Verify that it extends the recommended config from `@bensandee/config/oxlint`:");
2980
+ sections.push("");
2981
+ sections.push("1. Open `oxlint.config.ts` and check that it imports and extends `@bensandee/config/oxlint/recommended`.");
2982
+ sections.push("2. The expected pattern is:");
2983
+ sections.push(" ```ts");
2984
+ sections.push(" import recommended from \"@bensandee/config/oxlint/recommended\";");
2985
+ sections.push(" import { defineConfig } from \"oxlint\";");
2986
+ sections.push("");
2987
+ sections.push(" export default defineConfig({ extends: [recommended] });");
2988
+ sections.push(" ```");
2989
+ sections.push("3. If it uses a different pattern, update it to extend the recommended config while preserving any project-specific customizations.");
2990
+ sections.push("4. Run `pnpm lint` to verify the config works correctly.");
2991
+ sections.push("");
2992
+ }
2993
+ if (config.structure === "monorepo" && !detected.hasPnpmWorkspace) {
2994
+ sections.push("### Migrate to monorepo structure");
2995
+ sections.push("");
2996
+ sections.push("This project was converted from a single repo to a monorepo. Complete the migration:");
2997
+ sections.push("");
2998
+ sections.push("1. Move existing source into `packages/<name>/` (using the existing package name)");
2999
+ sections.push("2. Split the root `package.json` into a root workspace manifest + package-level `package.json`");
3000
+ sections.push("3. Move the existing `tsconfig.json` into the package and update the root tsconfig with project references");
3001
+ sections.push("4. Create a package-level `tsdown.config.ts` in the new package");
3002
+ sections.push("5. Update any import paths or build scripts affected by the move");
3003
+ sections.push("");
3004
+ }
3005
+ const skippedConfigs = skipped.filter((r) => r.filePath !== "ci" && r.description !== "Not a monorepo");
3006
+ if (skippedConfigs.length > 0) {
3007
+ sections.push("### Review skipped files");
3008
+ sections.push("");
3009
+ sections.push("The following files were left unchanged. Review them for compatibility:");
3010
+ sections.push("");
3011
+ for (const r of skippedConfigs) sections.push(`- \`${r.filePath}\` — ${r.description}`);
3012
+ sections.push("");
3013
+ }
3014
+ if (results.some((r) => r.filePath === "test/example.test.ts" && r.action === "created")) {
3015
+ sections.push("### Generate tests");
3016
+ sections.push("");
3017
+ sections.push("A starter test was created at `test/example.test.ts`. Now:");
3018
+ sections.push("");
3019
+ sections.push("1. Review the existing source code in `src/`");
3020
+ sections.push("2. Create additional test files following the starter test's patterns (import style, describe/it structure)");
3021
+ sections.push("3. Focus on edge cases and core business logic");
3022
+ sections.push("4. Aim for meaningful coverage of exported functions and key code paths");
3023
+ sections.push("");
3024
+ }
3025
+ sections.push("## Ground rules");
3026
+ sections.push("");
3027
+ sections.push("It is OK to add new packages (e.g. `zod`, `@bensandee/common`) if they are needed to resolve errors.");
3028
+ sections.push("");
3029
+ sections.push("When resolving errors from the checklist below, prefer fixing the root cause over suppressing the issue. For example:");
3030
+ sections.push("");
3031
+ sections.push("- **Lint errors**: fix the code rather than adding disable comments or rule exceptions");
3032
+ sections.push("- **Test failures**: update the test or fix the underlying bug rather than skipping or deleting the test");
3033
+ sections.push("- **Knip findings**: remove genuinely unused code/exports/dependencies rather than adding ignores to `knip.config.ts`");
3034
+ sections.push("- **Type errors**: add proper types rather than using `any` or `@ts-expect-error`");
3035
+ sections.push("");
3036
+ sections.push("Only suppress an issue if there is a clear, documented reason why the fix is not feasible (e.g. a third-party type mismatch). Leave a comment explaining why.");
3037
+ sections.push("");
3038
+ sections.push("## Verification checklist");
3039
+ sections.push("");
3040
+ sections.push("Run each of these commands and fix any errors before moving on:");
3041
+ sections.push("");
3042
+ sections.push("1. `pnpm install`");
3043
+ const updateCmd = `pnpm update --latest ${getAddedDevDepNames(config).join(" ")}`;
3044
+ sections.push(`2. \`${updateCmd}\` — bump added dependencies to their latest versions`);
3045
+ sections.push("3. `pnpm typecheck` — fix any type errors");
3046
+ sections.push("4. `pnpm build` — fix any build errors");
3047
+ sections.push("5. `pnpm test` — fix any test failures");
3048
+ sections.push("6. `pnpm lint` — fix the code to satisfy lint rules");
3049
+ sections.push("7. `pnpm knip` — remove unused exports, dependencies, and dead code");
3050
+ sections.push("8. `pnpm format` — fix any formatting issues");
3051
+ sections.push("");
3052
+ return sections.join("\n");
3416
3053
  }
3417
3054
  //#endregion
3418
- //#region src/release/connection.ts
3419
- const RepositorySchema = z.union([z.string(), z.object({ url: z.string() })]);
3420
- /**
3421
- * Resolve the hosting platform and connection details.
3422
- *
3423
- * Priority:
3424
- * 1. Environment variables (FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, FORGEJO_TOKEN)
3425
- * 2. `repository` field in package.json (server URL and owner/repo parsed from the URL)
3426
- *
3427
- * For Forgejo, FORGEJO_TOKEN is always required (either from env or explicitly).
3428
- * If the repository URL hostname is `github.com`, returns `{ type: "github" }`.
3429
- */
3430
- function resolveConnection(cwd) {
3431
- const serverUrl = process.env["FORGEJO_SERVER_URL"];
3432
- const repository = process.env["FORGEJO_REPOSITORY"];
3433
- const token = process.env["FORGEJO_TOKEN"];
3434
- if (serverUrl && repository && token) return {
3435
- type: "forgejo",
3436
- conn: {
3437
- serverUrl,
3438
- repository,
3439
- token
3440
- }
3441
- };
3442
- const parsed = parseRepositoryUrl(cwd);
3443
- if (parsed === null) {
3444
- if (serverUrl) {
3445
- if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
3446
- if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
3447
- }
3448
- return { type: "github" };
3449
- }
3450
- if (parsed.hostname === "github.com") return { type: "github" };
3451
- const resolvedToken = token;
3452
- if (!resolvedToken) throw new FatalError("FORGEJO_TOKEN environment variable is required (server URL and repository were resolved from package.json)");
3055
+ //#region src/commands/repo-init.ts
3056
+ /** Adapt a GeneratorContext to the DockerFileReader interface used by detectDockerPackages. */
3057
+ function contextAsDockerReader(ctx) {
3453
3058
  return {
3454
- type: "forgejo",
3455
- conn: {
3456
- serverUrl: serverUrl ?? `${parsed.protocol}//${parsed.hostname}`,
3457
- repository: repository ?? parsed.repository,
3458
- token: resolvedToken
3059
+ listPackageDirs(cwd) {
3060
+ const packagesDir = path.join(cwd, "packages");
3061
+ try {
3062
+ return readdirSync(packagesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
3063
+ } catch (_error) {
3064
+ return [];
3065
+ }
3066
+ },
3067
+ readFile(filePath) {
3068
+ const rel = path.relative(ctx.targetDir, filePath);
3069
+ return ctx.read(rel) ?? null;
3459
3070
  }
3460
3071
  };
3461
3072
  }
3462
- function parseRepositoryUrl(cwd) {
3463
- const pkgPath = path.join(cwd, "package.json");
3464
- let raw;
3465
- try {
3466
- raw = readFileSync(pkgPath, "utf-8");
3467
- } catch {
3468
- return null;
3469
- }
3470
- const pkg = z.object({ repository: RepositorySchema.optional() }).safeParse(JSON.parse(raw));
3471
- if (!pkg.success) return null;
3472
- const repo = pkg.data.repository;
3473
- if (!repo) return null;
3474
- return parseGitUrl(typeof repo === "string" ? repo : repo.url);
3073
+ /** Log what was detected so the user understands generator decisions. */
3074
+ function logDetectionSummary(ctx) {
3075
+ const dockerPackages = detectDockerPackages(contextAsDockerReader(ctx), ctx.targetDir, ctx.config.name);
3076
+ if (dockerPackages.length > 0) p.log.info(`Docker images: ${dockerPackages.map((pkg) => pkg.imageName).join(", ")}`);
3077
+ const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
3078
+ if (publishable.length > 0) p.log.info(`npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
3475
3079
  }
3476
- function parseGitUrl(urlStr) {
3080
+ async function runInit(config, options = {}) {
3081
+ const detected = detectProject(config.targetDir);
3082
+ const s = p.spinner();
3083
+ const { ctx, archivedFiles } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
3084
+ s.stop("Paused");
3085
+ const result = await p.select({
3086
+ message: `${relativePath} already exists. What do you want to do?`,
3087
+ options: [{
3088
+ value: "overwrite",
3089
+ label: "Overwrite"
3090
+ }, {
3091
+ value: "skip",
3092
+ label: "Skip"
3093
+ }]
3094
+ });
3095
+ s.start("Generating configuration files...");
3096
+ if (p.isCancel(result)) return "skip";
3097
+ return result;
3098
+ }));
3099
+ logDetectionSummary(ctx);
3100
+ s.start("Generating configuration files...");
3101
+ let results;
3477
3102
  try {
3478
- const url = new URL(urlStr);
3479
- const pathname = url.pathname.replace(/\.git$/, "").replace(/^\//, "");
3480
- if (!pathname.includes("/")) return null;
3481
- return {
3482
- protocol: url.protocol,
3483
- hostname: url.hostname,
3484
- repository: pathname
3485
- };
3486
- } catch {
3487
- return null;
3103
+ results = await runGenerators(ctx);
3104
+ } catch (error) {
3105
+ s.stop("Generation failed!");
3106
+ throw error;
3107
+ }
3108
+ const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
3109
+ for (const rel of archivedFiles) if (!alreadyArchived.has(rel)) results.push({
3110
+ filePath: rel,
3111
+ action: "archived",
3112
+ description: `Original saved to .tooling-archived/${rel}`
3113
+ });
3114
+ const created = results.filter((r) => r.action === "created");
3115
+ const updated = results.filter((r) => r.action === "updated");
3116
+ if (!(created.length > 0 || updated.length > 0 || archivedFiles.length > 0) && options.noPrompt) {
3117
+ s.stop("Repository is up to date.");
3118
+ return results;
3119
+ }
3120
+ s.stop("Done!");
3121
+ if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
3122
+ execSync("git config --unset core.hooksPath", {
3123
+ cwd: config.targetDir,
3124
+ stdio: "ignore",
3125
+ timeout: 5e3
3126
+ });
3127
+ } catch (_error) {}
3128
+ const summaryLines = [];
3129
+ if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
3130
+ if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
3131
+ p.note(summaryLines.join("\n"), "Summary");
3132
+ if (!options.noPrompt) {
3133
+ const prompt = generateMigratePrompt(results, config, detected);
3134
+ const promptPath = ".tooling-migrate.md";
3135
+ ctx.write(promptPath, prompt);
3136
+ p.log.info(`Migration prompt written to ${promptPath}`);
3137
+ p.log.info("In Claude Code, run: \"Execute the steps in .tooling-migrate.md\"");
3138
+ }
3139
+ const bensandeeDeps = getAddedDevDepNames(config).filter((name) => name.startsWith("@bensandee/"));
3140
+ const hasLockfile = ctx.exists("pnpm-lock.yaml");
3141
+ if (bensandeeDeps.length > 0 && hasLockfile) {
3142
+ s.start("Updating @bensandee/* packages...");
3143
+ try {
3144
+ execSync(`pnpm update --latest ${bensandeeDeps.join(" ")}`, {
3145
+ cwd: config.targetDir,
3146
+ stdio: "ignore",
3147
+ timeout: 6e4
3148
+ });
3149
+ s.stop("Updated @bensandee/* packages");
3150
+ } catch (_error) {
3151
+ s.stop("Could not update @bensandee/* packages — run pnpm install first");
3152
+ }
3488
3153
  }
3154
+ p.note([
3155
+ "1. Run: pnpm install",
3156
+ "2. Run: pnpm check",
3157
+ ...options.noPrompt ? [] : ["3. In Claude Code, run: \"Execute the steps in .tooling-migrate.md\""]
3158
+ ].join("\n"), "Next steps");
3159
+ return results;
3489
3160
  }
3490
3161
  //#endregion
3491
- //#region src/commands/release-changesets.ts
3492
- const releaseForgejoCommand = defineCommand({
3162
+ //#region src/commands/repo-sync.ts
3163
+ const syncCommand = defineCommand({
3493
3164
  meta: {
3494
- name: "release:changesets",
3495
- description: "Changesets version/publish for Forgejo CI"
3165
+ name: "repo:sync",
3166
+ description: "Detect, generate, and sync project tooling (idempotent)"
3496
3167
  },
3497
3168
  args: {
3498
- "dry-run": {
3169
+ dir: {
3170
+ type: "positional",
3171
+ description: "Target directory (default: current directory)",
3172
+ required: false
3173
+ },
3174
+ check: {
3499
3175
  type: "boolean",
3500
- description: "Skip push, API calls, and publishing side effects"
3176
+ description: "Dry-run mode: report drift without writing files"
3501
3177
  },
3502
- verbose: {
3178
+ yes: {
3503
3179
  type: "boolean",
3504
- description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
3505
- }
3506
- },
3507
- async run({ args }) {
3508
- if ((await runRelease(buildReleaseConfig({
3509
- dryRun: args["dry-run"] === true,
3510
- verbose: args.verbose === true || process.env["RELEASE_DEBUG"] === "true"
3511
- }), createRealExecutor())).mode === "none") process.exitCode = 0;
3512
- }
3513
- });
3514
- /** Build release config from environment / package.json and CLI flags. */
3515
- function buildReleaseConfig(flags) {
3516
- const resolved = resolveConnection(process.cwd());
3517
- if (resolved.type !== "forgejo") throw new FatalError("release:changesets requires a Forgejo repository");
3518
- return {
3519
- ...resolved.conn,
3520
- cwd: process.cwd(),
3521
- dryRun: flags.dryRun ?? false,
3522
- verbose: flags.verbose ?? false
3523
- };
3524
- }
3525
- /** Resolve the current branch from CI env vars or git. */
3526
- function getCurrentBranch(executor, cwd) {
3527
- const ref = process.env["GITHUB_REF"];
3528
- if (ref?.startsWith("refs/heads/")) return ref.slice(11);
3529
- return executor.exec("git rev-parse --abbrev-ref HEAD", { cwd }).stdout.trim();
3530
- }
3531
- /** Core release logic — testable with a mock executor. */
3532
- async function runRelease(config, executor) {
3533
- const branch = getCurrentBranch(executor, config.cwd);
3534
- if (branch !== "main") {
3535
- debug$1(config, `Skipping release on non-main branch: ${branch}`);
3536
- return { mode: "none" };
3537
- }
3538
- executor.exec("git config user.name \"forgejo-actions[bot]\"", { cwd: config.cwd });
3539
- executor.exec("git config user.email \"forgejo-actions[bot]@noreply.localhost\"", { cwd: config.cwd });
3540
- const changesetFiles = executor.listChangesetFiles(config.cwd);
3541
- debug$1(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
3542
- if (changesetFiles.length > 0) {
3543
- debug$1(config, "Entering version mode");
3544
- return runVersionMode(executor, config);
3545
- }
3546
- debug$1(config, "Entering publish mode");
3547
- return runPublishMode(executor, config);
3548
- }
3549
- //#endregion
3550
- //#region src/commands/release-trigger.ts
3551
- const releaseTriggerCommand = defineCommand({
3552
- meta: {
3553
- name: "release:trigger",
3554
- description: "Trigger the release CI workflow"
3555
- },
3556
- args: { ref: {
3557
- type: "string",
3558
- description: "Git ref to trigger on (default: main)",
3559
- required: false
3560
- } },
3561
- async run({ args }) {
3562
- const ref = args.ref ?? "main";
3563
- const resolved = resolveConnection(process.cwd());
3564
- if (resolved.type === "forgejo") await triggerForgejo(resolved.conn, ref);
3565
- else triggerGitHub(ref);
3566
- }
3567
- });
3568
- async function triggerForgejo(conn, ref) {
3569
- const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/workflows/release.yml/dispatches`;
3570
- const res = await fetch(url, {
3571
- method: "POST",
3572
- headers: {
3573
- Authorization: `token ${conn.token}`,
3574
- "Content-Type": "application/json"
3180
+ alias: "y",
3181
+ description: "Accept all defaults (non-interactive)"
3575
3182
  },
3576
- body: JSON.stringify({ ref })
3577
- });
3578
- if (!res.ok) throw new FatalError(`Failed to trigger Forgejo workflow: ${res.status} ${res.statusText}`);
3579
- p.log.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
3580
- }
3581
- function triggerGitHub(ref) {
3582
- createRealExecutor().exec(`gh workflow run release.yml --ref ${ref}`, { cwd: process.cwd() });
3583
- p.log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
3584
- }
3585
- //#endregion
3586
- //#region src/commands/forgejo-create-release.ts
3587
- const createForgejoReleaseCommand = defineCommand({
3588
- meta: {
3589
- name: "forgejo:create-release",
3590
- description: "Create a Forgejo release for a given tag"
3183
+ "eslint-plugin": {
3184
+ type: "boolean",
3185
+ description: "Include @bensandee/eslint-plugin (default: true)"
3186
+ },
3187
+ "no-ci": {
3188
+ type: "boolean",
3189
+ description: "Skip CI workflow generation"
3190
+ },
3191
+ "no-prompt": {
3192
+ type: "boolean",
3193
+ description: "Skip migration prompt generation"
3194
+ }
3591
3195
  },
3592
- args: { tag: {
3593
- type: "string",
3594
- description: "Git tag to create a release for",
3595
- required: true
3596
- } },
3597
3196
  async run({ args }) {
3598
- const resolved = resolveConnection(process.cwd());
3599
- if (resolved.type !== "forgejo") throw new FatalError("forgejo:create-release requires a Forgejo repository");
3600
- const executor = createRealExecutor();
3601
- const conn = resolved.conn;
3602
- if (await findRelease(executor, conn, args.tag)) {
3603
- p.log.info(`Release for ${args.tag} already exists — skipping`);
3197
+ const targetDir = path.resolve(args.dir ?? ".");
3198
+ if (args.check) {
3199
+ const exitCode = await runCheck(targetDir);
3200
+ process.exitCode = exitCode;
3604
3201
  return;
3605
3202
  }
3606
- await createRelease(executor, conn, args.tag);
3607
- p.log.info(`Created Forgejo release for ${args.tag}`);
3203
+ const saved = loadToolingConfig(targetDir);
3204
+ const isFirstRun = !saved;
3205
+ let config;
3206
+ if (args.yes || !isFirstRun) {
3207
+ const detected = buildDefaultConfig(targetDir, {
3208
+ eslintPlugin: args["eslint-plugin"] === true ? true : void 0,
3209
+ noCi: args["no-ci"] === true ? true : void 0
3210
+ });
3211
+ config = saved ? mergeWithSavedConfig(detected, saved) : detected;
3212
+ } else config = await runInitPrompts(targetDir, saved);
3213
+ await runInit(config, {
3214
+ noPrompt: args["no-prompt"] === true || !isFirstRun,
3215
+ ...!isFirstRun && { confirmOverwrite: async () => "overwrite" }
3216
+ });
3608
3217
  }
3609
3218
  });
3610
- //#endregion
3611
- //#region src/commands/changesets-merge.ts
3612
- const HEAD_BRANCH = "changeset-release/main";
3613
- const releaseMergeCommand = defineCommand({
3614
- meta: {
3615
- name: "changesets:merge",
3616
- description: "Merge the open changesets version PR"
3617
- },
3618
- args: { "dry-run": {
3619
- type: "boolean",
3620
- description: "Show what would be merged without actually merging"
3621
- } },
3622
- async run({ args }) {
3623
- const dryRun = args["dry-run"] === true;
3624
- const resolved = resolveConnection(process.cwd());
3625
- if (resolved.type === "forgejo") await mergeForgejo(resolved.conn, dryRun);
3626
- else mergeGitHub(dryRun);
3219
+ /** Run sync in check mode: dry-run drift detection. */
3220
+ async function runCheck(targetDir) {
3221
+ const saved = loadToolingConfig(targetDir);
3222
+ const detected = buildDefaultConfig(targetDir, {});
3223
+ const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
3224
+ logDetectionSummary(ctx);
3225
+ const actionable = (await runGenerators(ctx)).filter((r) => {
3226
+ if (r.action !== "created" && r.action !== "updated") return false;
3227
+ const newContent = pendingWrites.get(r.filePath);
3228
+ if (newContent && r.action === "updated") {
3229
+ const existingPath = path.join(targetDir, r.filePath);
3230
+ const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
3231
+ if (existing && contentEqual(r.filePath, existing, newContent)) return false;
3232
+ }
3233
+ return true;
3234
+ });
3235
+ if (actionable.length === 0) {
3236
+ p.log.success("Repository is up to date.");
3237
+ return 0;
3627
3238
  }
3628
- });
3629
- async function mergeForgejo(conn, dryRun) {
3630
- const executor = createRealExecutor();
3631
- const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
3632
- if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
3633
- if (dryRun) {
3634
- p.log.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
3635
- return;
3239
+ p.log.warn(`${actionable.length} file(s) would be changed by repo:sync`);
3240
+ for (const r of actionable) {
3241
+ p.log.info(` ${r.action}: ${r.filePath} ${r.description}`);
3242
+ const newContent = pendingWrites.get(r.filePath);
3243
+ if (!newContent) continue;
3244
+ const existingPath = path.join(targetDir, r.filePath);
3245
+ const existing = existsSync(existingPath) ? readFileSync(existingPath, "utf-8") : void 0;
3246
+ if (!existing) {
3247
+ const lineCount = newContent.split("\n").length - 1;
3248
+ p.log.info(` + ${lineCount} new lines`);
3249
+ } else {
3250
+ const diff = lineDiff(existing, newContent);
3251
+ for (const line of diff) p.log.info(` ${line}`);
3252
+ }
3636
3253
  }
3637
- await mergePr(executor, conn, prNumber, {
3638
- method: "merge",
3639
- deleteBranch: true
3640
- });
3641
- p.log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
3254
+ return 1;
3642
3255
  }
3643
- function mergeGitHub(dryRun) {
3644
- const executor = createRealExecutor();
3645
- if (dryRun) {
3646
- const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
3647
- if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
3648
- p.log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
3649
- return;
3650
- }
3651
- executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
3652
- p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
3256
+ const normalize = (line) => line.trimEnd();
3257
+ function lineDiff(oldText, newText) {
3258
+ const oldLines = oldText.split("\n").map(normalize);
3259
+ const newLines = newText.split("\n").map(normalize);
3260
+ const oldSet = new Set(oldLines);
3261
+ const newSet = new Set(newLines);
3262
+ const removed = oldLines.filter((l) => l.trim() !== "" && !newSet.has(l));
3263
+ const added = newLines.filter((l) => l.trim() !== "" && !oldSet.has(l));
3264
+ const lines = [];
3265
+ for (const l of removed) lines.push(`- ${l.trim()}`);
3266
+ for (const l of added) lines.push(`+ ${l.trim()}`);
3267
+ return lines;
3653
3268
  }
3654
3269
  //#endregion
3655
- //#region src/release/simple.ts
3656
- /**
3657
- * Compute sliding version tags from a semver version string.
3658
- * For "1.2.3" returns ["v1", "v1.2"]. Strips prerelease suffixes.
3659
- */
3660
- function computeSlidingTags(version) {
3661
- const parts = (version.split("-")[0] ?? version).split(".");
3662
- if (parts.length < 2 || !parts[0] || !parts[1]) throw new FatalError(`Invalid version format "${version}". Expected semver (X.Y.Z)`);
3663
- return [`v${parts[0]}`, `v${parts[0]}.${parts[1]}`];
3664
- }
3665
- /** Build the commit-and-tag-version command with appropriate flags. */
3666
- function buildCommand(config) {
3667
- const args = ["pnpm exec commit-and-tag-version"];
3668
- if (config.dryRun) args.push("--dry-run");
3669
- if (config.firstRelease) args.push("--first-release");
3670
- if (config.releaseAs) args.push(`--release-as ${config.releaseAs}`);
3671
- if (config.prerelease) args.push(`--prerelease ${config.prerelease}`);
3672
- return args.join(" ");
3673
- }
3674
- /** Read the current version from package.json. */
3675
- function readVersion(executor, cwd) {
3676
- const raw = executor.readFile(path.join(cwd, "package.json"));
3677
- if (!raw) throw new FatalError("Could not read package.json");
3678
- const pkg = parsePackageJson(raw);
3679
- if (!pkg?.version) throw new FatalError("No version field found in package.json");
3680
- return pkg.version;
3681
- }
3682
- /** Run the full commit-and-tag-version release flow. */
3683
- async function runSimpleRelease(executor, config) {
3684
- const command = buildCommand(config);
3685
- p.log.info(`Running: ${command}`);
3686
- const versionResult = executor.exec(command, { cwd: config.cwd });
3687
- debugExec(config, "commit-and-tag-version", versionResult);
3688
- if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
3689
- const version = readVersion(executor, config.cwd);
3690
- debug$1(config, `New version: ${version}`);
3691
- const tagResult = executor.exec("git describe --tags --abbrev=0", { cwd: config.cwd });
3692
- debugExec(config, "git describe", tagResult);
3693
- const tag = tagResult.stdout.trim();
3694
- if (!tag) throw new FatalError("Could not determine the new tag from git describe");
3695
- p.log.info(`Version ${version} tagged as ${tag}`);
3696
- if (config.dryRun) {
3697
- const slidingTags = config.noSlidingTags ? [] : computeSlidingTags(version);
3698
- p.log.info(`[dry-run] Would push to origin with --follow-tags`);
3699
- if (slidingTags.length > 0) p.log.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
3700
- if (!config.noRelease && config.platform) p.log.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
3701
- return {
3702
- version,
3703
- tag,
3704
- slidingTags,
3705
- pushed: false,
3706
- releaseCreated: false
3707
- };
3708
- }
3709
- let pushed = false;
3710
- if (!config.noPush) {
3711
- const branch = executor.exec("git rev-parse --abbrev-ref HEAD", { cwd: config.cwd }).stdout.trim() || "main";
3712
- debug$1(config, `Pushing to origin/${branch}`);
3713
- const pushResult = executor.exec(`git push --follow-tags origin ${branch}`, { cwd: config.cwd });
3714
- debugExec(config, "git push", pushResult);
3715
- if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
3716
- pushed = true;
3717
- p.log.info("Pushed to origin");
3718
- }
3719
- let slidingTags = [];
3720
- if (!config.noSlidingTags && pushed) {
3721
- slidingTags = computeSlidingTags(version);
3722
- for (const slidingTag of slidingTags) executor.exec(`git tag -f ${slidingTag}`, { cwd: config.cwd });
3723
- const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
3724
- debugExec(config, "force-push sliding tags", forcePushResult);
3725
- if (forcePushResult.exitCode !== 0) p.log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
3726
- else p.log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
3727
- }
3728
- let releaseCreated = false;
3729
- if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
3270
+ //#region src/release/executor.ts
3271
+ /** Create an executor that runs real commands, fetches, and reads the filesystem. */
3272
+ function createRealExecutor() {
3730
3273
  return {
3731
- version,
3732
- tag,
3733
- slidingTags,
3734
- pushed,
3735
- releaseCreated
3274
+ exec(command, options) {
3275
+ try {
3276
+ return {
3277
+ stdout: execSync(command, {
3278
+ cwd: options?.cwd,
3279
+ env: {
3280
+ ...process.env,
3281
+ ...options?.env
3282
+ },
3283
+ encoding: "utf-8",
3284
+ stdio: [
3285
+ "pipe",
3286
+ "pipe",
3287
+ "pipe"
3288
+ ]
3289
+ }),
3290
+ stderr: "",
3291
+ exitCode: 0
3292
+ };
3293
+ } catch (err) {
3294
+ if (isExecSyncError(err)) return {
3295
+ stdout: err.stdout,
3296
+ stderr: err.stderr,
3297
+ exitCode: err.status
3298
+ };
3299
+ return {
3300
+ stdout: "",
3301
+ stderr: "",
3302
+ exitCode: 1
3303
+ };
3304
+ }
3305
+ },
3306
+ fetch: globalThis.fetch,
3307
+ listChangesetFiles(cwd) {
3308
+ const dir = path.join(cwd, ".changeset");
3309
+ try {
3310
+ return readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "README.md");
3311
+ } catch {
3312
+ return [];
3313
+ }
3314
+ },
3315
+ listWorkspacePackages(cwd) {
3316
+ const packagesDir = path.join(cwd, "packages");
3317
+ const packages = [];
3318
+ try {
3319
+ for (const entry of readdirSync(packagesDir)) {
3320
+ const pkgPath = path.join(packagesDir, entry, "package.json");
3321
+ try {
3322
+ const pkg = parsePackageJson(readFileSync(pkgPath, "utf-8"));
3323
+ if (pkg?.name && pkg.version && !pkg.private) packages.push({
3324
+ name: pkg.name,
3325
+ version: pkg.version,
3326
+ dir: entry
3327
+ });
3328
+ } catch (_error) {}
3329
+ }
3330
+ } catch (_error) {}
3331
+ return packages;
3332
+ },
3333
+ listPackageDirs(cwd) {
3334
+ const packagesDir = path.join(cwd, "packages");
3335
+ try {
3336
+ return readdirSync(packagesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
3337
+ } catch {
3338
+ return [];
3339
+ }
3340
+ },
3341
+ readFile(filePath) {
3342
+ try {
3343
+ return readFileSync(filePath, "utf-8");
3344
+ } catch {
3345
+ return null;
3346
+ }
3347
+ },
3348
+ writeFile(filePath, content) {
3349
+ mkdirSync(path.dirname(filePath), { recursive: true });
3350
+ writeFileSync(filePath, content);
3351
+ }
3736
3352
  };
3737
3353
  }
3738
- async function createPlatformRelease(executor, config, tag) {
3739
- if (!config.platform) return false;
3740
- if (config.platform.type === "forgejo") {
3741
- if (await findRelease(executor, config.platform.conn, tag)) {
3742
- debug$1(config, `Release for ${tag} already exists, skipping`);
3743
- return false;
3744
- }
3745
- await createRelease(executor, config.platform.conn, tag);
3746
- p.log.info(`Created Forgejo release for ${tag}`);
3747
- return true;
3354
+ /** Parse "New tag:" lines from changeset publish output. */
3355
+ function parseNewTags(output) {
3356
+ const tags = [];
3357
+ for (const line of output.split("\n")) {
3358
+ const match = /New tag:\s+(\S+)/.exec(line);
3359
+ if (match?.[1]) tags.push(match[1]);
3748
3360
  }
3749
- const ghResult = executor.exec(`gh release create ${tag} --generate-notes`, { cwd: config.cwd });
3750
- debugExec(config, "gh release create", ghResult);
3751
- if (ghResult.exitCode !== 0) {
3752
- p.log.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
3753
- return false;
3361
+ return tags;
3362
+ }
3363
+ /** Map workspace packages to their expected tag strings (name@version). */
3364
+ function computeExpectedTags(packages) {
3365
+ return packages.map((p) => `${p.name}@${p.version}`);
3366
+ }
3367
+ /** Parse `git ls-remote --tags` output into tag names, filtering out `^{}` dereference entries. */
3368
+ function parseRemoteTags(output) {
3369
+ const tags = [];
3370
+ for (const line of output.split("\n")) {
3371
+ const match = /refs\/tags\/(.+)/.exec(line);
3372
+ if (match?.[1] && !match[1].endsWith("^{}")) tags.push(match[1]);
3754
3373
  }
3755
- p.log.info(`Created GitHub release for ${tag}`);
3756
- return true;
3374
+ return tags;
3375
+ }
3376
+ /**
3377
+ * Reconcile expected tags with what already exists on the remote.
3378
+ * Returns `(expected - remote) ∪ stdoutTags`, deduplicated.
3379
+ */
3380
+ function reconcileTags(expectedTags, remoteTags, stdoutTags) {
3381
+ const remoteSet = new Set(remoteTags);
3382
+ const result = /* @__PURE__ */ new Set();
3383
+ for (const tag of expectedTags) if (!remoteSet.has(tag)) result.add(tag);
3384
+ for (const tag of stdoutTags) result.add(tag);
3385
+ return [...result];
3757
3386
  }
3758
3387
  //#endregion
3759
- //#region src/commands/release-simple.ts
3760
- const releaseSimpleCommand = defineCommand({
3761
- meta: {
3762
- name: "release:simple",
3763
- description: "Run commit-and-tag-version, push, create sliding tags, and create a platform release"
3764
- },
3765
- args: {
3766
- "dry-run": {
3767
- type: "boolean",
3768
- description: "Pass --dry-run to commit-and-tag-version and skip all remote operations"
3769
- },
3770
- verbose: {
3771
- type: "boolean",
3772
- description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
3773
- },
3774
- "no-push": {
3775
- type: "boolean",
3776
- description: "Run commit-and-tag-version but skip push and remote operations"
3777
- },
3778
- "no-sliding-tags": {
3779
- type: "boolean",
3780
- description: "Skip creating sliding major/minor version tags (vX, vX.Y)"
3388
+ //#region src/release/forgejo.ts
3389
+ const PullRequestSchema = z.array(z.object({
3390
+ number: z.number(),
3391
+ head: z.object({ ref: z.string() })
3392
+ }));
3393
+ /**
3394
+ * Find an open PR with the given head branch. Returns the PR number or null.
3395
+ *
3396
+ * Fetches all open PRs and filters client-side by head.ref rather than relying
3397
+ * on Forgejo's query parameter filtering, which behaves inconsistently.
3398
+ */
3399
+ async function findOpenPr(executor, conn, head) {
3400
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls?state=open`;
3401
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3402
+ if (!res.ok) throw new TransientError(`Failed to list PRs: ${res.status} ${res.statusText}`);
3403
+ const parsed = PullRequestSchema.safeParse(await res.json());
3404
+ if (!parsed.success) throw new UnexpectedError(`Unexpected PR list response: ${parsed.error.message}`);
3405
+ return parsed.data.find((pr) => pr.head.ref === head)?.number ?? null;
3406
+ }
3407
+ /** Create a new pull request. */
3408
+ async function createPr(executor, conn, options) {
3409
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls`;
3410
+ const payload = {
3411
+ title: options.title,
3412
+ head: options.head,
3413
+ base: options.base
3414
+ };
3415
+ if (options.body) payload["body"] = options.body;
3416
+ const res = await executor.fetch(url, {
3417
+ method: "POST",
3418
+ headers: {
3419
+ Authorization: `token ${conn.token}`,
3420
+ "Content-Type": "application/json"
3781
3421
  },
3782
- "no-release": {
3783
- type: "boolean",
3784
- description: "Skip Forgejo/GitHub release creation"
3422
+ body: JSON.stringify(payload)
3423
+ });
3424
+ if (!res.ok) throw new TransientError(`Failed to create PR: ${res.status} ${res.statusText}`);
3425
+ }
3426
+ /** Update an existing pull request's title and body. */
3427
+ async function updatePr(executor, conn, prNumber, options) {
3428
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}`;
3429
+ const res = await executor.fetch(url, {
3430
+ method: "PATCH",
3431
+ headers: {
3432
+ Authorization: `token ${conn.token}`,
3433
+ "Content-Type": "application/json"
3785
3434
  },
3786
- "first-release": {
3787
- type: "boolean",
3788
- description: "Pass --first-release to commit-and-tag-version (skip version bump)"
3435
+ body: JSON.stringify({
3436
+ title: options.title,
3437
+ body: options.body
3438
+ })
3439
+ });
3440
+ if (!res.ok) throw new TransientError(`Failed to update PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
3441
+ }
3442
+ /** Merge a pull request by number. */
3443
+ async function mergePr(executor, conn, prNumber, options) {
3444
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/pulls/${String(prNumber)}/merge`;
3445
+ const res = await executor.fetch(url, {
3446
+ method: "POST",
3447
+ headers: {
3448
+ Authorization: `token ${conn.token}`,
3449
+ "Content-Type": "application/json"
3789
3450
  },
3790
- "release-as": {
3791
- type: "string",
3792
- description: "Force a specific version (passed to commit-and-tag-version --release-as)"
3451
+ body: JSON.stringify({
3452
+ Do: options?.method ?? "merge",
3453
+ delete_branch_after_merge: options?.deleteBranch ?? true
3454
+ })
3455
+ });
3456
+ if (!res.ok) throw new TransientError(`Failed to merge PR #${String(prNumber)}: ${res.status} ${res.statusText}`);
3457
+ }
3458
+ /** Check whether a Forgejo release already exists for a given tag. */
3459
+ async function findRelease(executor, conn, tag) {
3460
+ const encodedTag = encodeURIComponent(tag);
3461
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases/tags/${encodedTag}`;
3462
+ const res = await executor.fetch(url, { headers: { Authorization: `token ${conn.token}` } });
3463
+ if (res.status === 200) return true;
3464
+ if (res.status === 404) return false;
3465
+ throw new TransientError(`Failed to check release for ${tag}: ${res.status} ${res.statusText}`);
3466
+ }
3467
+ /** Create a Forgejo release for a given tag. */
3468
+ async function createRelease(executor, conn, tag) {
3469
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/releases`;
3470
+ const res = await executor.fetch(url, {
3471
+ method: "POST",
3472
+ headers: {
3473
+ Authorization: `token ${conn.token}`,
3474
+ "Content-Type": "application/json"
3793
3475
  },
3794
- prerelease: {
3795
- type: "string",
3796
- description: "Create a prerelease with the given tag (e.g., beta, alpha)"
3797
- }
3798
- },
3799
- async run({ args }) {
3800
- const cwd = process.cwd();
3801
- const verbose = args.verbose === true || process.env["RELEASE_DEBUG"] === "true";
3802
- const noRelease = args["no-release"] === true;
3803
- let platform;
3804
- if (!noRelease) {
3805
- const resolved = resolveConnection(cwd);
3806
- if (resolved.type === "forgejo") platform = {
3807
- type: "forgejo",
3808
- conn: resolved.conn
3809
- };
3810
- else platform = { type: "github" };
3811
- }
3812
- const config = {
3813
- cwd,
3814
- dryRun: args["dry-run"] === true,
3815
- verbose,
3816
- noPush: args["no-push"] === true,
3817
- noSlidingTags: args["no-sliding-tags"] === true,
3818
- noRelease,
3819
- firstRelease: args["first-release"] === true,
3820
- releaseAs: args["release-as"],
3821
- prerelease: args.prerelease,
3822
- platform
3823
- };
3824
- await runSimpleRelease(createRealExecutor(), config);
3825
- }
3826
- });
3476
+ body: JSON.stringify({
3477
+ tag_name: tag,
3478
+ name: tag,
3479
+ body: `Published ${tag}`
3480
+ })
3481
+ });
3482
+ if (!res.ok) throw new TransientError(`Failed to create release for ${tag}: ${res.status} ${res.statusText}`);
3483
+ }
3827
3484
  //#endregion
3828
- //#region src/commands/repo-run-checks.ts
3829
- const CHECKS = [
3830
- { name: "build" },
3831
- { name: "typecheck" },
3832
- { name: "lint" },
3833
- { name: "test" },
3834
- {
3835
- name: "format",
3836
- args: "--check"
3837
- },
3838
- { name: "knip" },
3839
- { name: "tooling:check" },
3840
- { name: "docker:check" }
3841
- ];
3842
- function defaultGetScripts(targetDir) {
3843
- try {
3844
- const pkg = parsePackageJson(readFileSync(path.join(targetDir, "package.json"), "utf-8"));
3845
- return new Set(Object.keys(pkg?.scripts ?? {}));
3846
- } catch {
3847
- return /* @__PURE__ */ new Set();
3848
- }
3485
+ //#region src/release/log.ts
3486
+ /** Log a debug message when verbose mode is enabled. */
3487
+ function debug(config, message) {
3488
+ if (config.verbose) p.log.info(`[debug] ${message}`);
3849
3489
  }
3850
- function defaultExecCommand(cmd, cwd) {
3851
- try {
3852
- execSync(cmd, {
3853
- cwd,
3854
- stdio: "inherit"
3855
- });
3856
- return 0;
3857
- } catch (err) {
3858
- if (isExecSyncError(err)) return err.status;
3859
- return 1;
3490
+ /** Log the result of an exec call when verbose mode is enabled. */
3491
+ function debugExec(config, label, result) {
3492
+ if (!config.verbose) return;
3493
+ const lines = [`[debug] ${label} (exit code ${String(result.exitCode)})`];
3494
+ if (result.stdout.trim()) lines.push(` stdout: ${result.stdout.trim()}`);
3495
+ if (result.stderr.trim()) lines.push(` stderr: ${result.stderr.trim()}`);
3496
+ p.log.info(lines.join("\n"));
3497
+ }
3498
+ //#endregion
3499
+ //#region src/release/version.ts
3500
+ const BRANCH = "changeset-release/main";
3501
+ /** Extract the latest changelog entry (content between first and second `## ` heading). */
3502
+ function extractLatestEntry(changelog) {
3503
+ const lines = changelog.split("\n");
3504
+ let start = -1;
3505
+ let end = lines.length;
3506
+ for (let i = 0; i < lines.length; i++) if (lines[i]?.startsWith("## ")) if (start === -1) start = i;
3507
+ else {
3508
+ end = i;
3509
+ break;
3860
3510
  }
3511
+ if (start === -1) return null;
3512
+ return lines.slice(start, end).join("\n").trim();
3861
3513
  }
3862
- const ciLog = (msg) => console.log(msg);
3863
- function runRunChecks(targetDir, options = {}) {
3864
- const exec = options.execCommand ?? defaultExecCommand;
3865
- const getScripts = options.getScripts ?? defaultGetScripts;
3866
- const skip = options.skip ?? /* @__PURE__ */ new Set();
3867
- const add = options.add ?? [];
3868
- const isCI = Boolean(process.env["CI"]);
3869
- const failFast = options.failFast ?? !isCI;
3870
- const definedScripts = getScripts(targetDir);
3871
- const addedNames = new Set(add);
3872
- const allChecks = [...CHECKS, ...add.map((name) => ({ name }))];
3873
- const failures = [];
3874
- const notDefined = [];
3875
- for (const check of allChecks) {
3876
- if (skip.has(check.name)) continue;
3877
- if (!definedScripts.has(check.name)) {
3878
- if (addedNames.has(check.name)) {
3879
- p.log.error(`${check.name} not defined in package.json`);
3880
- failures.push(check.name);
3881
- } else notDefined.push(check.name);
3882
- continue;
3883
- }
3884
- const cmd = check.args ? `pnpm run ${check.name} ${check.args}` : `pnpm run ${check.name}`;
3885
- if (isCI) ciLog(`::group::${check.name}`);
3886
- const exitCode = exec(cmd, targetDir);
3887
- if (isCI) ciLog("::endgroup::");
3888
- if (exitCode === 0) p.log.success(check.name);
3889
- else {
3890
- if (isCI) ciLog(`::error::${check.name} failed`);
3891
- p.log.error(`${check.name} failed`);
3892
- failures.push(check.name);
3893
- if (failFast) return 1;
3514
+ /** Read the root package.json name and version. */
3515
+ function readRootPackage(executor, cwd) {
3516
+ const content = executor.readFile(path.join(cwd, "package.json"));
3517
+ if (!content) return null;
3518
+ const pkg = parsePackageJson(content);
3519
+ if (!pkg?.name || !pkg.version) return null;
3520
+ if (pkg.private) return null;
3521
+ return {
3522
+ name: pkg.name,
3523
+ version: pkg.version
3524
+ };
3525
+ }
3526
+ /** Determine which packages changed and collect their changelog entries. */
3527
+ function buildPrContent(executor, cwd, packagesBefore) {
3528
+ const packagesAfter = executor.listWorkspacePackages(cwd);
3529
+ if (!(packagesBefore.length > 0 || packagesAfter.length > 0)) {
3530
+ const rootPkg = readRootPackage(executor, cwd);
3531
+ if (rootPkg) {
3532
+ const changelog = executor.readFile(path.join(cwd, "CHANGELOG.md"));
3533
+ const entry = changelog ? extractLatestEntry(changelog) : null;
3534
+ return {
3535
+ title: `chore: release ${rootPkg.name}@${rootPkg.version}`,
3536
+ body: entry ?? ""
3537
+ };
3894
3538
  }
3539
+ return {
3540
+ title: "chore: version packages",
3541
+ body: ""
3542
+ };
3895
3543
  }
3896
- if (notDefined.length > 0) p.log.info(`Skipped (not defined): ${notDefined.join(", ")}`);
3897
- if (failures.length > 0) {
3898
- p.log.error(`Failed checks: ${failures.join(", ")}`);
3899
- return 1;
3544
+ const beforeMap = new Map(packagesBefore.map((pkg) => [pkg.name, pkg.version]));
3545
+ const changed = packagesAfter.filter((pkg) => beforeMap.get(pkg.name) !== pkg.version);
3546
+ if (changed.length === 0) return {
3547
+ title: "chore: version packages",
3548
+ body: ""
3549
+ };
3550
+ const title = `chore: release ${changed.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ")}`;
3551
+ const entries = [];
3552
+ for (const pkg of changed) {
3553
+ const changelogPath = path.join(cwd, "packages", pkg.dir, "CHANGELOG.md");
3554
+ const changelog = executor.readFile(changelogPath);
3555
+ const entry = changelog ? extractLatestEntry(changelog) : null;
3556
+ if (entry) {
3557
+ const labeled = entry.replace(/^## .+/, `## ${pkg.name}@${pkg.version}`);
3558
+ entries.push(labeled);
3559
+ }
3900
3560
  }
3901
- p.log.success("All checks passed");
3902
- return 0;
3561
+ return {
3562
+ title,
3563
+ body: entries.join("\n\n")
3564
+ };
3903
3565
  }
3904
- const runChecksCommand = defineCommand({
3905
- meta: {
3906
- name: "checks:run",
3907
- description: "Run all standard checks (build, typecheck, lint, test, format, knip, tooling:check, docker:check)"
3908
- },
3909
- args: {
3910
- dir: {
3911
- type: "positional",
3912
- description: "Target directory (default: current directory)",
3913
- required: false
3914
- },
3915
- skip: {
3916
- type: "string",
3917
- description: "Comma-separated list of checks to skip (build, typecheck, lint, test, format, knip, tooling:check, docker:check)",
3918
- required: false
3919
- },
3920
- add: {
3921
- type: "string",
3922
- description: "Comma-separated list of additional check names to run (uses pnpm run <name>)",
3923
- required: false
3924
- },
3925
- "fail-fast": {
3926
- type: "boolean",
3927
- description: "Stop on first failure (default: true in dev, false in CI)",
3928
- required: false
3566
+ /** Mode 1: version packages and create/update a PR. */
3567
+ async function runVersionMode(executor, config) {
3568
+ p.log.info("Changesets detected — versioning packages");
3569
+ const packagesBefore = executor.listWorkspacePackages(config.cwd);
3570
+ debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
3571
+ const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
3572
+ const originalConfig = executor.readFile(changesetConfigPath);
3573
+ if (originalConfig) {
3574
+ const parsed = parseChangesetConfig(originalConfig);
3575
+ if (parsed?.commit) {
3576
+ const patched = {
3577
+ ...parsed,
3578
+ commit: false
3579
+ };
3580
+ executor.writeFile(changesetConfigPath, JSON.stringify(patched, null, 2) + "\n");
3581
+ debug(config, "Temporarily disabled changeset commit:true");
3929
3582
  }
3930
- },
3931
- run({ args }) {
3932
- const exitCode = runRunChecks(path.resolve(args.dir ?? "."), {
3933
- skip: args.skip ? new Set(args.skip.split(",").map((s) => s.trim())) : void 0,
3934
- add: args.add ? args.add.split(",").map((s) => s.trim()) : void 0,
3935
- failFast: args["fail-fast"] === true ? true : args["fail-fast"] === false ? false : void 0
3936
- });
3937
- process.exitCode = exitCode;
3938
- }
3939
- });
3940
- //#endregion
3941
- //#region src/release/docker.ts
3942
- const ToolingDockerMapSchema = z.record(z.string(), z.object({
3943
- dockerfile: z.string(),
3944
- context: z.string().default(".")
3945
- }));
3946
- const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
3947
- const PackageInfoSchema = z.object({
3948
- name: z.string().optional(),
3949
- version: z.string().optional()
3950
- });
3951
- /** Read the docker map from .tooling.json. Returns empty record if missing or invalid. */
3952
- function loadDockerMap(executor, cwd) {
3953
- const configPath = path.join(cwd, ".tooling.json");
3954
- const raw = executor.readFile(configPath);
3955
- if (!raw) return {};
3956
- try {
3957
- const result = ToolingConfigDockerSchema.safeParse(JSON.parse(raw));
3958
- if (!result.success || !result.data.docker) return {};
3959
- return result.data.docker;
3960
- } catch (_error) {
3961
- return {};
3962
3583
  }
3963
- }
3964
- /** Read name and version from a package's package.json. */
3965
- function readPackageInfo(executor, packageJsonPath) {
3966
- const raw = executor.readFile(packageJsonPath);
3967
- if (!raw) return {
3968
- name: void 0,
3969
- version: void 0
3970
- };
3971
- try {
3972
- const result = PackageInfoSchema.safeParse(JSON.parse(raw));
3973
- if (!result.success) return {
3974
- name: void 0,
3975
- version: void 0
3584
+ const versionResult = executor.exec("pnpm changeset version", { cwd: config.cwd });
3585
+ debugExec(config, "pnpm changeset version", versionResult);
3586
+ if (originalConfig) executor.writeFile(changesetConfigPath, originalConfig);
3587
+ if (versionResult.exitCode !== 0) throw new FatalError(`pnpm changeset version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr}`);
3588
+ debugExec(config, "pnpm install --no-frozen-lockfile", executor.exec("pnpm install --no-frozen-lockfile", { cwd: config.cwd }));
3589
+ const { title, body } = buildPrContent(executor, config.cwd, packagesBefore);
3590
+ debug(config, `PR title: ${title}`);
3591
+ executor.exec("git add -A", { cwd: config.cwd });
3592
+ const remainingChangesets = executor.listChangesetFiles(config.cwd);
3593
+ if (remainingChangesets.length > 0) p.log.warn(`Changeset files still present after versioning: ${remainingChangesets.join(", ")}`);
3594
+ debug(config, `Changeset files after versioning: ${remainingChangesets.length > 0 ? remainingChangesets.join(", ") : "(none — all consumed)"}`);
3595
+ const commitResult = executor.exec("git commit -m \"chore: version packages\"", { cwd: config.cwd });
3596
+ debugExec(config, "git commit", commitResult);
3597
+ if (commitResult.exitCode !== 0) {
3598
+ p.log.info("Nothing to commit after versioning");
3599
+ return {
3600
+ mode: "version",
3601
+ pr: "none"
3976
3602
  };
3603
+ }
3604
+ if (config.dryRun) {
3605
+ p.log.info("[dry-run] Would push and create/update PR");
3977
3606
  return {
3978
- name: result.data.name,
3979
- version: result.data.version
3607
+ mode: "version",
3608
+ pr: "none"
3980
3609
  };
3981
- } catch (_error) {
3610
+ }
3611
+ debugExec(config, "git push", executor.exec(`git push origin "HEAD:refs/heads/${BRANCH}" --force`, { cwd: config.cwd }));
3612
+ const conn = {
3613
+ serverUrl: config.serverUrl,
3614
+ repository: config.repository,
3615
+ token: config.token
3616
+ };
3617
+ const existingPr = await findOpenPr(executor, conn, BRANCH);
3618
+ debug(config, `Existing open PR for ${BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
3619
+ if (existingPr === null) {
3620
+ await createPr(executor, conn, {
3621
+ title,
3622
+ head: BRANCH,
3623
+ base: "main",
3624
+ body
3625
+ });
3626
+ p.log.info("Created version PR");
3982
3627
  return {
3983
- name: void 0,
3984
- version: void 0
3628
+ mode: "version",
3629
+ pr: "created"
3985
3630
  };
3986
3631
  }
3632
+ await updatePr(executor, conn, existingPr, {
3633
+ title,
3634
+ body
3635
+ });
3636
+ p.log.info(`Updated version PR #${String(existingPr)}`);
3637
+ return {
3638
+ mode: "version",
3639
+ pr: "updated"
3640
+ };
3641
+ }
3642
+ //#endregion
3643
+ //#region src/release/publish.ts
3644
+ const RETRY_ATTEMPTS = 3;
3645
+ const RETRY_BASE_DELAY_MS = 1e3;
3646
+ async function retryAsync(fn) {
3647
+ let lastError;
3648
+ for (let attempt = 0; attempt <= RETRY_ATTEMPTS; attempt++) try {
3649
+ return await fn();
3650
+ } catch (error) {
3651
+ lastError = error;
3652
+ if (attempt < RETRY_ATTEMPTS) {
3653
+ const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
3654
+ await new Promise((resolve) => setTimeout(resolve, delay));
3655
+ }
3656
+ }
3657
+ throw lastError;
3987
3658
  }
3988
- /** Convention paths to check for Dockerfiles in a package directory. */
3989
- const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
3990
- /**
3991
- * Find a Dockerfile at convention paths for a monorepo package.
3992
- * Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
3993
- */
3994
- function findConventionDockerfile(executor, cwd, dir) {
3995
- for (const rel of CONVENTION_DOCKERFILE_PATHS) {
3996
- const dockerfilePath = `packages/${dir}/${rel}`;
3997
- if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
3998
- dockerfile: dockerfilePath,
3999
- context: "."
3659
+ /** Mode 2: publish to npm, push tags, and create Forgejo releases. */
3660
+ async function runPublishMode(executor, config) {
3661
+ p.log.info("No changesets — publishing packages");
3662
+ const publishResult = executor.exec("pnpm changeset publish", { cwd: config.cwd });
3663
+ debugExec(config, "pnpm changeset publish", publishResult);
3664
+ if (publishResult.exitCode !== 0) throw new FatalError(`pnpm changeset publish failed (exit code ${String(publishResult.exitCode)}):\n${publishResult.stderr}`);
3665
+ const stdoutTags = parseNewTags(publishResult.stdout + "\n" + publishResult.stderr);
3666
+ debug(config, `Tags from publish stdout: ${stdoutTags.length > 0 ? stdoutTags.join(", ") : "(none)"}`);
3667
+ const expectedTags = computeExpectedTags(executor.listWorkspacePackages(config.cwd));
3668
+ debug(config, `Expected tags from workspace packages: ${expectedTags.length > 0 ? expectedTags.join(", ") : "(none)"}`);
3669
+ const remoteTags = parseRemoteTags(executor.exec("git ls-remote --tags origin", { cwd: config.cwd }).stdout);
3670
+ debug(config, `Remote tags: ${remoteTags.length > 0 ? remoteTags.join(", ") : "(none)"}`);
3671
+ const remoteSet = new Set(remoteTags);
3672
+ const tagsToPush = reconcileTags(expectedTags, remoteTags, stdoutTags);
3673
+ debug(config, `Reconciled tags to push: ${tagsToPush.length > 0 ? tagsToPush.join(", ") : "(none)"}`);
3674
+ if (config.dryRun) {
3675
+ if (tagsToPush.length === 0) {
3676
+ p.log.info("No packages were published");
3677
+ return { mode: "none" };
3678
+ }
3679
+ p.log.info(`Tags to process: ${tagsToPush.join(", ")}`);
3680
+ p.log.info("[dry-run] Would push tags and create releases");
3681
+ return {
3682
+ mode: "publish",
3683
+ tags: tagsToPush
4000
3684
  };
4001
3685
  }
4002
- }
4003
- /**
4004
- * Find a Dockerfile at convention paths for a single-package repo.
4005
- * Checks Dockerfile and docker/Dockerfile at the project root.
4006
- */
4007
- function findRootDockerfile(executor, cwd) {
4008
- for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
4009
- dockerfile: rel,
4010
- context: "."
3686
+ const conn = {
3687
+ serverUrl: config.serverUrl,
3688
+ repository: config.repository,
3689
+ token: config.token
3690
+ };
3691
+ const remoteExpectedTags = expectedTags.filter((t) => remoteSet.has(t) && !tagsToPush.includes(t));
3692
+ const tagsWithMissingReleases = [];
3693
+ for (const tag of remoteExpectedTags) if (!await findRelease(executor, conn, tag)) tagsWithMissingReleases.push(tag);
3694
+ const allTags = [...tagsToPush, ...tagsWithMissingReleases];
3695
+ if (allTags.length === 0) {
3696
+ p.log.info("No packages were published");
3697
+ return { mode: "none" };
3698
+ }
3699
+ p.log.info(`Tags to process: ${allTags.join(", ")}`);
3700
+ const errors = [];
3701
+ for (const tag of allTags) try {
3702
+ if (!remoteSet.has(tag)) {
3703
+ if (executor.exec(`git tag -l ${JSON.stringify(tag)}`, { cwd: config.cwd }).stdout.trim() === "") executor.exec(`git tag ${JSON.stringify(tag)}`, { cwd: config.cwd });
3704
+ executor.exec(`git push origin refs/tags/${tag}`, { cwd: config.cwd });
3705
+ }
3706
+ if (await findRelease(executor, conn, tag)) p.log.warn(`Release for ${tag} already exists — skipping`);
3707
+ else {
3708
+ await retryAsync(async () => {
3709
+ try {
3710
+ await createRelease(executor, conn, tag);
3711
+ } catch (error) {
3712
+ if (await findRelease(executor, conn, tag)) return;
3713
+ throw error;
3714
+ }
3715
+ });
3716
+ p.log.info(`Created release for ${tag}`);
3717
+ }
3718
+ } catch (error) {
3719
+ errors.push({
3720
+ tag,
3721
+ error
3722
+ });
3723
+ p.log.warn(`Failed to process ${tag}: ${error instanceof Error ? error.message : String(error)}`);
3724
+ }
3725
+ if (errors.length > 0) throw new TransientError(`Failed to create releases for: ${errors.map((e) => e.tag).join(", ")}`);
3726
+ return {
3727
+ mode: "publish",
3728
+ tags: allTags
4011
3729
  };
4012
3730
  }
3731
+ //#endregion
3732
+ //#region src/release/connection.ts
3733
+ const RepositorySchema = z.union([z.string(), z.object({ url: z.string() })]);
4013
3734
  /**
4014
- * Discover Docker packages by convention and merge with .tooling.json overrides.
3735
+ * Resolve the hosting platform and connection details.
4015
3736
  *
4016
- * Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
4017
- * For monorepos, scans packages/{name}/. For single-package repos, scans the root.
4018
- * The docker map in .tooling.json overrides convention-discovered config and can add
4019
- * packages at non-standard locations.
3737
+ * Priority:
3738
+ * 1. Environment variables (FORGEJO_SERVER_URL, FORGEJO_REPOSITORY, FORGEJO_TOKEN)
3739
+ * 2. `repository` field in package.json (server URL and owner/repo parsed from the URL)
4020
3740
  *
4021
- * Image names are derived from {root-name}-{package-name} using each package's package.json name.
4022
- * Versions are read from each package's own package.json.
3741
+ * For Forgejo, FORGEJO_TOKEN is always required (either from env or explicitly).
3742
+ * If the repository URL hostname is `github.com`, returns `{ type: "github" }`.
4023
3743
  */
4024
- function detectDockerPackages(executor, cwd, repoName) {
4025
- const overrides = loadDockerMap(executor, cwd);
4026
- const packageDirs = executor.listPackageDirs(cwd);
4027
- const packages = [];
4028
- const seen = /* @__PURE__ */ new Set();
4029
- if (packageDirs.length > 0) {
4030
- for (const dir of packageDirs) {
4031
- const convention = findConventionDockerfile(executor, cwd, dir);
4032
- const docker = overrides[dir] ?? convention;
4033
- if (docker) {
4034
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
4035
- packages.push({
4036
- dir,
4037
- imageName: `${repoName}-${name ?? dir}`,
4038
- version,
4039
- docker
4040
- });
4041
- seen.add(dir);
4042
- }
4043
- }
4044
- for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
4045
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
4046
- packages.push({
4047
- dir,
4048
- imageName: `${repoName}-${name ?? dir}`,
4049
- version,
4050
- docker
4051
- });
3744
+ function resolveConnection(cwd) {
3745
+ const serverUrl = process.env["FORGEJO_SERVER_URL"];
3746
+ const repository = process.env["FORGEJO_REPOSITORY"];
3747
+ const token = process.env["FORGEJO_TOKEN"];
3748
+ if (serverUrl && repository && token) return {
3749
+ type: "forgejo",
3750
+ conn: {
3751
+ serverUrl,
3752
+ repository,
3753
+ token
4052
3754
  }
4053
- } else {
4054
- const convention = findRootDockerfile(executor, cwd);
4055
- const docker = overrides["."] ?? convention;
4056
- if (docker) {
4057
- const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
4058
- packages.push({
4059
- dir: ".",
4060
- imageName: name ?? repoName,
4061
- version,
4062
- docker
4063
- });
3755
+ };
3756
+ const parsed = parseRepositoryUrl(cwd);
3757
+ if (parsed === null) {
3758
+ if (serverUrl) {
3759
+ if (!repository) throw new FatalError("FORGEJO_REPOSITORY environment variable is required");
3760
+ if (!token) throw new FatalError("FORGEJO_TOKEN environment variable is required");
4064
3761
  }
3762
+ return { type: "github" };
4065
3763
  }
4066
- return packages;
4067
- }
4068
- /**
4069
- * Read docker config for a single package, checking convention paths first,
4070
- * then .tooling.json overrides. Used by the per-package image:build script.
4071
- */
4072
- function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
4073
- const dir = path.basename(path.resolve(cwd, packageDir));
4074
- const convention = findConventionDockerfile(executor, cwd, dir);
4075
- const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
4076
- if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
4077
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
3764
+ if (parsed.hostname === "github.com") return { type: "github" };
3765
+ const resolvedToken = token;
3766
+ if (!resolvedToken) throw new FatalError("FORGEJO_TOKEN environment variable is required (server URL and repository were resolved from package.json)");
4078
3767
  return {
4079
- dir,
4080
- imageName: `${repoName}-${name ?? dir}`,
4081
- version,
4082
- docker
3768
+ type: "forgejo",
3769
+ conn: {
3770
+ serverUrl: serverUrl ?? `${parsed.protocol}//${parsed.hostname}`,
3771
+ repository: repository ?? parsed.repository,
3772
+ token: resolvedToken
3773
+ }
4083
3774
  };
4084
3775
  }
4085
- /** Parse semver version string into major, minor, patch components. */
4086
- function parseSemver(version) {
4087
- const clean = version.replace(/^v/, "");
4088
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(clean);
4089
- if (!match?.[1] || !match[2] || !match[3]) throw new FatalError(`Invalid semver version: ${version}`);
3776
+ function parseRepositoryUrl(cwd) {
3777
+ const pkgPath = path.join(cwd, "package.json");
3778
+ let raw;
3779
+ try {
3780
+ raw = readFileSync(pkgPath, "utf-8");
3781
+ } catch {
3782
+ return null;
3783
+ }
3784
+ const pkg = z.object({ repository: RepositorySchema.optional() }).safeParse(JSON.parse(raw));
3785
+ if (!pkg.success) return null;
3786
+ const repo = pkg.data.repository;
3787
+ if (!repo) return null;
3788
+ return parseGitUrl(typeof repo === "string" ? repo : repo.url);
3789
+ }
3790
+ function parseGitUrl(urlStr) {
3791
+ try {
3792
+ const url = new URL(urlStr);
3793
+ const pathname = url.pathname.replace(/\.git$/, "").replace(/^\//, "");
3794
+ if (!pathname.includes("/")) return null;
3795
+ return {
3796
+ protocol: url.protocol,
3797
+ hostname: url.hostname,
3798
+ repository: pathname
3799
+ };
3800
+ } catch {
3801
+ return null;
3802
+ }
3803
+ }
3804
+ //#endregion
3805
+ //#region src/commands/release-changesets.ts
3806
+ const releaseForgejoCommand = defineCommand({
3807
+ meta: {
3808
+ name: "release:changesets",
3809
+ description: "Changesets version/publish for Forgejo CI"
3810
+ },
3811
+ args: {
3812
+ "dry-run": {
3813
+ type: "boolean",
3814
+ description: "Skip push, API calls, and publishing side effects"
3815
+ },
3816
+ verbose: {
3817
+ type: "boolean",
3818
+ description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
3819
+ }
3820
+ },
3821
+ async run({ args }) {
3822
+ if ((await runRelease(buildReleaseConfig({
3823
+ dryRun: args["dry-run"] === true,
3824
+ verbose: args.verbose === true || process.env["RELEASE_DEBUG"] === "true"
3825
+ }), createRealExecutor())).mode === "none") process.exitCode = 0;
3826
+ }
3827
+ });
3828
+ /** Build release config from environment / package.json and CLI flags. */
3829
+ function buildReleaseConfig(flags) {
3830
+ const resolved = resolveConnection(process.cwd());
3831
+ if (resolved.type !== "forgejo") throw new FatalError("release:changesets requires a Forgejo repository");
4090
3832
  return {
4091
- major: Number(match[1]),
4092
- minor: Number(match[2]),
4093
- patch: Number(match[3])
3833
+ ...resolved.conn,
3834
+ cwd: process.cwd(),
3835
+ dryRun: flags.dryRun ?? false,
3836
+ verbose: flags.verbose ?? false
4094
3837
  };
4095
3838
  }
4096
- /** Generate semver tag variants: latest, vX.Y.Z, vX.Y, vX */
4097
- function generateTags(version) {
4098
- const { major, minor, patch } = parseSemver(version);
4099
- return [
4100
- "latest",
4101
- `v${major}.${minor}.${patch}`,
4102
- `v${major}.${minor}`,
4103
- `v${major}`
4104
- ];
3839
+ /** Resolve the current branch from CI env vars or git. */
3840
+ function getCurrentBranch(executor, cwd) {
3841
+ const ref = process.env["GITHUB_REF"];
3842
+ if (ref?.startsWith("refs/heads/")) return ref.slice(11);
3843
+ return executor.exec("git rev-parse --abbrev-ref HEAD", { cwd }).stdout.trim();
4105
3844
  }
4106
- /** Build the full image reference: namespace/imageName:tag */
4107
- function imageRef(namespace, imageName, tag) {
4108
- return `${namespace}/${imageName}:${tag}`;
3845
+ /** Core release logic testable with a mock executor. */
3846
+ async function runRelease(config, executor) {
3847
+ const branch = getCurrentBranch(executor, config.cwd);
3848
+ if (branch !== "main") {
3849
+ debug(config, `Skipping release on non-main branch: ${branch}`);
3850
+ return { mode: "none" };
3851
+ }
3852
+ executor.exec("git config user.name \"forgejo-actions[bot]\"", { cwd: config.cwd });
3853
+ executor.exec("git config user.email \"forgejo-actions[bot]@noreply.localhost\"", { cwd: config.cwd });
3854
+ const changesetFiles = executor.listChangesetFiles(config.cwd);
3855
+ debug(config, `Changeset files found: ${changesetFiles.length > 0 ? changesetFiles.join(", ") : "(none)"}`);
3856
+ if (changesetFiles.length > 0) {
3857
+ debug(config, "Entering version mode");
3858
+ return runVersionMode(executor, config);
3859
+ }
3860
+ debug(config, "Entering publish mode");
3861
+ return runPublishMode(executor, config);
4109
3862
  }
4110
- function log$1(message) {
4111
- console.log(message);
3863
+ //#endregion
3864
+ //#region src/commands/release-trigger.ts
3865
+ const releaseTriggerCommand = defineCommand({
3866
+ meta: {
3867
+ name: "release:trigger",
3868
+ description: "Trigger the release CI workflow"
3869
+ },
3870
+ args: { ref: {
3871
+ type: "string",
3872
+ description: "Git ref to trigger on (default: main)",
3873
+ required: false
3874
+ } },
3875
+ async run({ args }) {
3876
+ const ref = args.ref ?? "main";
3877
+ const resolved = resolveConnection(process.cwd());
3878
+ if (resolved.type === "forgejo") await triggerForgejo(resolved.conn, ref);
3879
+ else triggerGitHub(ref);
3880
+ }
3881
+ });
3882
+ async function triggerForgejo(conn, ref) {
3883
+ const url = `${conn.serverUrl}/api/v1/repos/${conn.repository}/actions/workflows/release.yml/dispatches`;
3884
+ const res = await fetch(url, {
3885
+ method: "POST",
3886
+ headers: {
3887
+ Authorization: `token ${conn.token}`,
3888
+ "Content-Type": "application/json"
3889
+ },
3890
+ body: JSON.stringify({ ref })
3891
+ });
3892
+ if (!res.ok) throw new FatalError(`Failed to trigger Forgejo workflow: ${res.status} ${res.statusText}`);
3893
+ p.log.info(`Triggered release workflow on Forgejo (ref: ${ref})`);
4112
3894
  }
4113
- function debug(verbose, message) {
4114
- if (verbose) console.log(`[debug] ${message}`);
3895
+ function triggerGitHub(ref) {
3896
+ createRealExecutor().exec(`gh workflow run release.yml --ref ${ref}`, { cwd: process.cwd() });
3897
+ p.log.info(`Triggered release workflow on GitHub (ref: ${ref})`);
4115
3898
  }
4116
- /** Read the repo name from root package.json. */
4117
- function readRepoName(executor, cwd) {
4118
- const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
4119
- if (!rootPkgRaw) throw new FatalError("No package.json found in project root");
4120
- const repoName = parsePackageJson(rootPkgRaw)?.name;
4121
- if (!repoName) throw new FatalError("Root package.json must have a name field");
4122
- return repoName;
3899
+ //#endregion
3900
+ //#region src/commands/forgejo-create-release.ts
3901
+ const createForgejoReleaseCommand = defineCommand({
3902
+ meta: {
3903
+ name: "forgejo:create-release",
3904
+ description: "Create a Forgejo release for a given tag"
3905
+ },
3906
+ args: { tag: {
3907
+ type: "string",
3908
+ description: "Git tag to create a release for",
3909
+ required: true
3910
+ } },
3911
+ async run({ args }) {
3912
+ const resolved = resolveConnection(process.cwd());
3913
+ if (resolved.type !== "forgejo") throw new FatalError("forgejo:create-release requires a Forgejo repository");
3914
+ const executor = createRealExecutor();
3915
+ const conn = resolved.conn;
3916
+ if (await findRelease(executor, conn, args.tag)) {
3917
+ p.log.info(`Release for ${args.tag} already exists — skipping`);
3918
+ return;
3919
+ }
3920
+ await createRelease(executor, conn, args.tag);
3921
+ p.log.info(`Created Forgejo release for ${args.tag}`);
3922
+ }
3923
+ });
3924
+ //#endregion
3925
+ //#region src/commands/changesets-merge.ts
3926
+ const HEAD_BRANCH = "changeset-release/main";
3927
+ const releaseMergeCommand = defineCommand({
3928
+ meta: {
3929
+ name: "changesets:merge",
3930
+ description: "Merge the open changesets version PR"
3931
+ },
3932
+ args: { "dry-run": {
3933
+ type: "boolean",
3934
+ description: "Show what would be merged without actually merging"
3935
+ } },
3936
+ async run({ args }) {
3937
+ const dryRun = args["dry-run"] === true;
3938
+ const resolved = resolveConnection(process.cwd());
3939
+ if (resolved.type === "forgejo") await mergeForgejo(resolved.conn, dryRun);
3940
+ else mergeGitHub(dryRun);
3941
+ }
3942
+ });
3943
+ async function mergeForgejo(conn, dryRun) {
3944
+ const executor = createRealExecutor();
3945
+ const prNumber = await findOpenPr(executor, conn, HEAD_BRANCH);
3946
+ if (prNumber === null) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
3947
+ if (dryRun) {
3948
+ p.log.info(`[dry-run] Would merge PR #${String(prNumber)} and delete branch ${HEAD_BRANCH}`);
3949
+ return;
3950
+ }
3951
+ await mergePr(executor, conn, prNumber, {
3952
+ method: "merge",
3953
+ deleteBranch: true
3954
+ });
3955
+ p.log.info(`Merged PR #${String(prNumber)} and deleted branch ${HEAD_BRANCH}`);
4123
3956
  }
4124
- /** Build a single docker image from its config. Paths are resolved relative to cwd. */
4125
- function buildImage(executor, pkg, cwd, verbose, extraArgs) {
4126
- const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
4127
- const contextPath = path.resolve(cwd, pkg.docker.context);
4128
- const command = [
4129
- "docker build",
4130
- `-f ${dockerfilePath}`,
4131
- `-t ${pkg.imageName}:latest`,
4132
- ...extraArgs,
4133
- contextPath
4134
- ].join(" ");
4135
- debug(verbose, `Running: ${command}`);
4136
- const buildResult = executor.exec(command);
4137
- debug(verbose, `Build stdout: ${buildResult.stdout}`);
4138
- if (buildResult.exitCode !== 0) throw new FatalError(`docker build failed for ${pkg.dir} (exit ${buildResult.exitCode}): ${buildResult.stderr}`);
3957
+ function mergeGitHub(dryRun) {
3958
+ const executor = createRealExecutor();
3959
+ if (dryRun) {
3960
+ const prNum = executor.exec(`gh pr view ${HEAD_BRANCH} --json number --jq .number`, { cwd: process.cwd() }).stdout.trim();
3961
+ if (!prNum) throw new FatalError(`No open PR found for branch ${HEAD_BRANCH}`);
3962
+ p.log.info(`[dry-run] Would merge PR #${prNum} and delete branch ${HEAD_BRANCH}`);
3963
+ return;
3964
+ }
3965
+ executor.exec(`gh pr merge ${HEAD_BRANCH} --merge --delete-branch`, { cwd: process.cwd() });
3966
+ p.log.info(`Merged changesets PR and deleted branch ${HEAD_BRANCH}`);
4139
3967
  }
3968
+ //#endregion
3969
+ //#region src/release/simple.ts
4140
3970
  /**
4141
- * Detect packages with docker config in .tooling.json and build each one.
4142
- * Runs `docker build -f <dockerfile> -t <image-name>:latest <context>` for each package.
4143
- * Dockerfile and context paths are resolved relative to the project root.
4144
- *
4145
- * When `packageDir` is set, builds only that single package (for use as an image:build script).
3971
+ * Compute sliding version tags from a semver version string.
3972
+ * For "1.2.3" returns ["v1", "v1.2"]. Strips prerelease suffixes.
4146
3973
  */
4147
- function runDockerBuild(executor, config) {
4148
- const repoName = readRepoName(executor, config.cwd);
4149
- if (config.packageDir) {
4150
- const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
4151
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
4152
- buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
4153
- log$1(`Built ${pkg.imageName}:latest`);
4154
- return { packages: [pkg] };
3974
+ function computeSlidingTags(version) {
3975
+ const parts = (version.split("-")[0] ?? version).split(".");
3976
+ if (parts.length < 2 || !parts[0] || !parts[1]) throw new FatalError(`Invalid version format "${version}". Expected semver (X.Y.Z)`);
3977
+ return [`v${parts[0]}`, `v${parts[0]}.${parts[1]}`];
3978
+ }
3979
+ /** Build the commit-and-tag-version command with appropriate flags. */
3980
+ function buildCommand(config) {
3981
+ const args = ["pnpm exec commit-and-tag-version"];
3982
+ if (config.dryRun) args.push("--dry-run");
3983
+ if (config.firstRelease) args.push("--first-release");
3984
+ if (config.releaseAs) args.push(`--release-as ${config.releaseAs}`);
3985
+ if (config.prerelease) args.push(`--prerelease ${config.prerelease}`);
3986
+ return args.join(" ");
3987
+ }
3988
+ /** Read the current version from package.json. */
3989
+ function readVersion(executor, cwd) {
3990
+ const raw = executor.readFile(path.join(cwd, "package.json"));
3991
+ if (!raw) throw new FatalError("Could not read package.json");
3992
+ const pkg = parsePackageJson(raw);
3993
+ if (!pkg?.version) throw new FatalError("No version field found in package.json");
3994
+ return pkg.version;
3995
+ }
3996
+ /** Run the full commit-and-tag-version release flow. */
3997
+ async function runSimpleRelease(executor, config) {
3998
+ const command = buildCommand(config);
3999
+ p.log.info(`Running: ${command}`);
4000
+ const versionResult = executor.exec(command, { cwd: config.cwd });
4001
+ debugExec(config, "commit-and-tag-version", versionResult);
4002
+ if (versionResult.exitCode !== 0) throw new FatalError(`commit-and-tag-version failed (exit code ${String(versionResult.exitCode)}):\n${versionResult.stderr || versionResult.stdout}`);
4003
+ const version = readVersion(executor, config.cwd);
4004
+ debug(config, `New version: ${version}`);
4005
+ const tagResult = executor.exec("git describe --tags --abbrev=0", { cwd: config.cwd });
4006
+ debugExec(config, "git describe", tagResult);
4007
+ const tag = tagResult.stdout.trim();
4008
+ if (!tag) throw new FatalError("Could not determine the new tag from git describe");
4009
+ p.log.info(`Version ${version} tagged as ${tag}`);
4010
+ if (config.dryRun) {
4011
+ const slidingTags = config.noSlidingTags ? [] : computeSlidingTags(version);
4012
+ p.log.info(`[dry-run] Would push to origin with --follow-tags`);
4013
+ if (slidingTags.length > 0) p.log.info(`[dry-run] Would create sliding tags: ${slidingTags.join(", ")}`);
4014
+ if (!config.noRelease && config.platform) p.log.info(`[dry-run] Would create ${config.platform.type} release for ${tag}`);
4015
+ return {
4016
+ version,
4017
+ tag,
4018
+ slidingTags,
4019
+ pushed: false,
4020
+ releaseCreated: false
4021
+ };
4022
+ }
4023
+ let pushed = false;
4024
+ if (!config.noPush) {
4025
+ const branch = executor.exec("git rev-parse --abbrev-ref HEAD", { cwd: config.cwd }).stdout.trim() || "main";
4026
+ debug(config, `Pushing to origin/${branch}`);
4027
+ const pushResult = executor.exec(`git push --follow-tags origin ${branch}`, { cwd: config.cwd });
4028
+ debugExec(config, "git push", pushResult);
4029
+ if (pushResult.exitCode !== 0) throw new FatalError(`git push failed (exit code ${String(pushResult.exitCode)}):\n${pushResult.stderr || pushResult.stdout}`);
4030
+ pushed = true;
4031
+ p.log.info("Pushed to origin");
4032
+ }
4033
+ let slidingTags = [];
4034
+ if (!config.noSlidingTags && pushed) {
4035
+ slidingTags = computeSlidingTags(version);
4036
+ for (const slidingTag of slidingTags) executor.exec(`git tag -f ${slidingTag}`, { cwd: config.cwd });
4037
+ const forcePushResult = executor.exec(`git push origin ${slidingTags.join(" ")} --force`, { cwd: config.cwd });
4038
+ debugExec(config, "force-push sliding tags", forcePushResult);
4039
+ if (forcePushResult.exitCode !== 0) p.log.warn(`Warning: Failed to push sliding tags: ${forcePushResult.stderr || forcePushResult.stdout}`);
4040
+ else p.log.info(`Created sliding tags: ${slidingTags.join(", ")}`);
4041
+ }
4042
+ let releaseCreated = false;
4043
+ if (!config.noRelease && config.platform) releaseCreated = await createPlatformRelease(executor, config, tag);
4044
+ return {
4045
+ version,
4046
+ tag,
4047
+ slidingTags,
4048
+ pushed,
4049
+ releaseCreated
4050
+ };
4051
+ }
4052
+ async function createPlatformRelease(executor, config, tag) {
4053
+ if (!config.platform) return false;
4054
+ if (config.platform.type === "forgejo") {
4055
+ if (await findRelease(executor, config.platform.conn, tag)) {
4056
+ debug(config, `Release for ${tag} already exists, skipping`);
4057
+ return false;
4058
+ }
4059
+ await createRelease(executor, config.platform.conn, tag);
4060
+ p.log.info(`Created Forgejo release for ${tag}`);
4061
+ return true;
4155
4062
  }
4156
- const packages = detectDockerPackages(executor, config.cwd, repoName);
4157
- if (packages.length === 0) {
4158
- log$1("No packages with docker config found");
4159
- return { packages: [] };
4063
+ const ghResult = executor.exec(`gh release create ${tag} --generate-notes`, { cwd: config.cwd });
4064
+ debugExec(config, "gh release create", ghResult);
4065
+ if (ghResult.exitCode !== 0) {
4066
+ p.log.warn(`Warning: Failed to create GitHub release: ${ghResult.stderr || ghResult.stdout}`);
4067
+ return false;
4160
4068
  }
4161
- log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
4162
- for (const pkg of packages) {
4163
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
4164
- buildImage(executor, pkg, config.cwd, config.verbose, config.extraArgs);
4069
+ p.log.info(`Created GitHub release for ${tag}`);
4070
+ return true;
4071
+ }
4072
+ //#endregion
4073
+ //#region src/commands/release-simple.ts
4074
+ const releaseSimpleCommand = defineCommand({
4075
+ meta: {
4076
+ name: "release:simple",
4077
+ description: "Run commit-and-tag-version, push, create sliding tags, and create a platform release"
4078
+ },
4079
+ args: {
4080
+ "dry-run": {
4081
+ type: "boolean",
4082
+ description: "Pass --dry-run to commit-and-tag-version and skip all remote operations"
4083
+ },
4084
+ verbose: {
4085
+ type: "boolean",
4086
+ description: "Enable detailed debug logging (also enabled by RELEASE_DEBUG env var)"
4087
+ },
4088
+ "no-push": {
4089
+ type: "boolean",
4090
+ description: "Run commit-and-tag-version but skip push and remote operations"
4091
+ },
4092
+ "no-sliding-tags": {
4093
+ type: "boolean",
4094
+ description: "Skip creating sliding major/minor version tags (vX, vX.Y)"
4095
+ },
4096
+ "no-release": {
4097
+ type: "boolean",
4098
+ description: "Skip Forgejo/GitHub release creation"
4099
+ },
4100
+ "first-release": {
4101
+ type: "boolean",
4102
+ description: "Pass --first-release to commit-and-tag-version (skip version bump)"
4103
+ },
4104
+ "release-as": {
4105
+ type: "string",
4106
+ description: "Force a specific version (passed to commit-and-tag-version --release-as)"
4107
+ },
4108
+ prerelease: {
4109
+ type: "string",
4110
+ description: "Create a prerelease with the given tag (e.g., beta, alpha)"
4111
+ }
4112
+ },
4113
+ async run({ args }) {
4114
+ const cwd = process.cwd();
4115
+ const verbose = args.verbose === true || process.env["RELEASE_DEBUG"] === "true";
4116
+ const noRelease = args["no-release"] === true;
4117
+ let platform;
4118
+ if (!noRelease) {
4119
+ const resolved = resolveConnection(cwd);
4120
+ if (resolved.type === "forgejo") platform = {
4121
+ type: "forgejo",
4122
+ conn: resolved.conn
4123
+ };
4124
+ else platform = { type: "github" };
4125
+ }
4126
+ const config = {
4127
+ cwd,
4128
+ dryRun: args["dry-run"] === true,
4129
+ verbose,
4130
+ noPush: args["no-push"] === true,
4131
+ noSlidingTags: args["no-sliding-tags"] === true,
4132
+ noRelease,
4133
+ firstRelease: args["first-release"] === true,
4134
+ releaseAs: args["release-as"],
4135
+ prerelease: args.prerelease,
4136
+ platform
4137
+ };
4138
+ await runSimpleRelease(createRealExecutor(), config);
4139
+ }
4140
+ });
4141
+ //#endregion
4142
+ //#region src/commands/repo-run-checks.ts
4143
+ const CHECKS = [
4144
+ { name: "build" },
4145
+ { name: "typecheck" },
4146
+ { name: "lint" },
4147
+ { name: "test" },
4148
+ {
4149
+ name: "format",
4150
+ args: "--check"
4151
+ },
4152
+ { name: "knip" },
4153
+ { name: "tooling:check" },
4154
+ { name: "docker:check" }
4155
+ ];
4156
+ function defaultGetScripts(targetDir) {
4157
+ try {
4158
+ const pkg = parsePackageJson(readFileSync(path.join(targetDir, "package.json"), "utf-8"));
4159
+ return new Set(Object.keys(pkg?.scripts ?? {}));
4160
+ } catch {
4161
+ return /* @__PURE__ */ new Set();
4165
4162
  }
4166
- log$1(`Built ${packages.length} image(s)`);
4167
- return { packages };
4168
4163
  }
4169
- /**
4170
- * Run the full Docker publish pipeline:
4171
- * 1. Build all images via runDockerBuild
4172
- * 2. Login to registry
4173
- * 3. Tag each image with semver variants from its own package.json version
4174
- * 4. Push all tags
4175
- * 5. Logout from registry
4176
- */
4177
- function runDockerPublish(executor, config) {
4178
- const { packages } = runDockerBuild(executor, {
4179
- cwd: config.cwd,
4180
- packageDir: void 0,
4181
- verbose: config.verbose,
4182
- extraArgs: []
4183
- });
4184
- if (packages.length === 0) return {
4185
- packages: [],
4186
- tags: []
4187
- };
4188
- for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
4189
- if (!config.dryRun) {
4190
- log$1(`Logging in to ${config.registryHost}...`);
4191
- const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
4192
- if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
4193
- } else log$1("[dry-run] Skipping docker login");
4194
- const allTags = [];
4164
+ function defaultExecCommand(cmd, cwd) {
4195
4165
  try {
4196
- for (const pkg of packages) {
4197
- const tags = generateTags(pkg.version ?? "");
4198
- log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
4199
- for (const tag of tags) {
4200
- const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
4201
- allTags.push(ref);
4202
- log$1(`Tagging ${pkg.imageName} → ${ref}`);
4203
- const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
4204
- if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
4205
- if (!config.dryRun) {
4206
- log$1(`Pushing ${ref}...`);
4207
- const pushResult = executor.exec(`docker push ${ref}`);
4208
- if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
4209
- } else log$1(`[dry-run] Skipping push for ${ref}`);
4210
- }
4166
+ execSync(cmd, {
4167
+ cwd,
4168
+ stdio: "inherit"
4169
+ });
4170
+ return 0;
4171
+ } catch (err) {
4172
+ if (isExecSyncError(err)) return err.status;
4173
+ return 1;
4174
+ }
4175
+ }
4176
+ const ciLog = (msg) => console.log(msg);
4177
+ function runRunChecks(targetDir, options = {}) {
4178
+ const exec = options.execCommand ?? defaultExecCommand;
4179
+ const getScripts = options.getScripts ?? defaultGetScripts;
4180
+ const skip = options.skip ?? /* @__PURE__ */ new Set();
4181
+ const add = options.add ?? [];
4182
+ const isCI = Boolean(process.env["CI"]);
4183
+ const failFast = options.failFast ?? !isCI;
4184
+ const definedScripts = getScripts(targetDir);
4185
+ const addedNames = new Set(add);
4186
+ const allChecks = [...CHECKS, ...add.map((name) => ({ name }))];
4187
+ const failures = [];
4188
+ const notDefined = [];
4189
+ for (const check of allChecks) {
4190
+ if (skip.has(check.name)) continue;
4191
+ if (!definedScripts.has(check.name)) {
4192
+ if (addedNames.has(check.name)) {
4193
+ p.log.error(`${check.name} not defined in package.json`);
4194
+ failures.push(check.name);
4195
+ } else notDefined.push(check.name);
4196
+ continue;
4211
4197
  }
4212
- } finally {
4213
- if (!config.dryRun) {
4214
- log$1(`Logging out from ${config.registryHost}...`);
4215
- executor.exec(`docker logout ${config.registryHost}`);
4198
+ const cmd = check.args ? `pnpm run ${check.name} ${check.args}` : `pnpm run ${check.name}`;
4199
+ if (isCI) ciLog(`::group::${check.name}`);
4200
+ const exitCode = exec(cmd, targetDir);
4201
+ if (isCI) ciLog("::endgroup::");
4202
+ if (exitCode === 0) p.log.success(check.name);
4203
+ else {
4204
+ if (isCI) ciLog(`::error::${check.name} failed`);
4205
+ p.log.error(`${check.name} failed`);
4206
+ failures.push(check.name);
4207
+ if (failFast) return 1;
4216
4208
  }
4217
4209
  }
4218
- log$1(`Published ${allTags.length} image tag(s)`);
4219
- return {
4220
- packages,
4221
- tags: allTags
4222
- };
4210
+ if (notDefined.length > 0) p.log.info(`Skipped (not defined): ${notDefined.join(", ")}`);
4211
+ if (failures.length > 0) {
4212
+ p.log.error(`Failed checks: ${failures.join(", ")}`);
4213
+ return 1;
4214
+ }
4215
+ p.log.success("All checks passed");
4216
+ return 0;
4223
4217
  }
4218
+ const runChecksCommand = defineCommand({
4219
+ meta: {
4220
+ name: "checks:run",
4221
+ description: "Run all standard checks (build, typecheck, lint, test, format, knip, tooling:check, docker:check)"
4222
+ },
4223
+ args: {
4224
+ dir: {
4225
+ type: "positional",
4226
+ description: "Target directory (default: current directory)",
4227
+ required: false
4228
+ },
4229
+ skip: {
4230
+ type: "string",
4231
+ description: "Comma-separated list of checks to skip (build, typecheck, lint, test, format, knip, tooling:check, docker:check)",
4232
+ required: false
4233
+ },
4234
+ add: {
4235
+ type: "string",
4236
+ description: "Comma-separated list of additional check names to run (uses pnpm run <name>)",
4237
+ required: false
4238
+ },
4239
+ "fail-fast": {
4240
+ type: "boolean",
4241
+ description: "Stop on first failure (default: true in dev, false in CI)",
4242
+ required: false
4243
+ }
4244
+ },
4245
+ run({ args }) {
4246
+ const exitCode = runRunChecks(path.resolve(args.dir ?? "."), {
4247
+ skip: args.skip ? new Set(args.skip.split(",").map((s) => s.trim())) : void 0,
4248
+ add: args.add ? args.add.split(",").map((s) => s.trim()) : void 0,
4249
+ failFast: args["fail-fast"] === true ? true : args["fail-fast"] === false ? false : void 0
4250
+ });
4251
+ process.exitCode = exitCode;
4252
+ }
4253
+ });
4224
4254
  //#endregion
4225
4255
  //#region src/commands/publish-docker.ts
4226
4256
  function requireEnv(name) {
@@ -4577,7 +4607,7 @@ const dockerCheckCommand = defineCommand({
4577
4607
  const main = defineCommand({
4578
4608
  meta: {
4579
4609
  name: "tooling",
4580
- version: "0.22.0",
4610
+ version: "0.24.0",
4581
4611
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4582
4612
  },
4583
4613
  subCommands: {
@@ -4593,7 +4623,11 @@ const main = defineCommand({
4593
4623
  "docker:check": dockerCheckCommand
4594
4624
  }
4595
4625
  });
4596
- console.log(`@bensandee/tooling v0.22.0`);
4597
- runMain(main);
4626
+ console.log(`@bensandee/tooling v0.24.0`);
4627
+ async function run() {
4628
+ await runMain(main);
4629
+ process.exit(process.exitCode ?? 0);
4630
+ }
4631
+ run();
4598
4632
  //#endregion
4599
4633
  export {};