@bensandee/tooling 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { l as createRealExecutor$1, t as runDockerCheck, u as isExecSyncError } from "./check-D41R218h.mjs";
2
+ import { l as createRealExecutor$1, t as runDockerCheck, u as isExecSyncError } from "./check-DMDdHanG.mjs";
3
3
  import { defineCommand, runMain } from "citty";
4
4
  import * as clack from "@clack/prompts";
5
5
  import { isCancel, select } from "@clack/prompts";
6
6
  import path from "node:path";
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { execSync } from "node:child_process";
8
9
  import JSON5 from "json5";
9
10
  import { parse } from "jsonc-parser";
10
11
  import { z } from "zod";
11
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
12
13
  import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify } from "yaml";
13
- import { execSync } from "node:child_process";
14
14
  import picomatch from "picomatch";
15
15
  import { tmpdir } from "node:os";
16
16
  //#region src/utils/log.ts
@@ -130,9 +130,43 @@ function detectProject(targetDir) {
130
130
  hasChangesetsConfig: exists(".changeset/config.json"),
131
131
  hasRepositoryField: !!rootPkg?.repository,
132
132
  hasCommitAndTagVersion: !!rootPkg?.devDependencies?.["commit-and-tag-version"],
133
+ gitRemoteUrl: detectGitRemoteUrl(targetDir),
133
134
  legacyConfigs: detectLegacyConfigs(targetDir)
134
135
  };
135
136
  }
137
+ /** Read git remote origin URL and normalize to HTTPS. */
138
+ function detectGitRemoteUrl(targetDir) {
139
+ try {
140
+ return normalizeGitUrl(execSync("git remote get-url origin", {
141
+ cwd: targetDir,
142
+ stdio: [
143
+ "ignore",
144
+ "pipe",
145
+ "ignore"
146
+ ],
147
+ timeout: 5e3
148
+ }).toString().trim());
149
+ } catch (_error) {
150
+ return null;
151
+ }
152
+ }
153
+ /**
154
+ * Normalize a git remote URL to a clean HTTPS URL.
155
+ * Handles SSH (git@host:owner/repo.git) and HTTPS variants.
156
+ */
157
+ function normalizeGitUrl(raw) {
158
+ const sshMatch = raw.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
159
+ if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`;
160
+ try {
161
+ const url = new URL(raw);
162
+ url.pathname = url.pathname.replace(/\.git$/, "");
163
+ url.username = "";
164
+ url.password = "";
165
+ return url.toString().replace(/\/$/, "");
166
+ } catch (_error) {
167
+ return null;
168
+ }
169
+ }
136
170
  /** Scan for legacy/conflicting tooling config files. */
137
171
  function detectLegacyConfigs(targetDir) {
138
172
  let entries;
@@ -280,14 +314,18 @@ function getMonorepoPackages(targetDir) {
280
314
  function isCancelled(value) {
281
315
  return clack.isCancel(value);
282
316
  }
317
+ function detectProjectInfo(targetDir) {
318
+ const existingPkg = readPackageJson(targetDir);
319
+ return {
320
+ detected: detectProject(targetDir),
321
+ defaults: computeDefaults(targetDir),
322
+ name: existingPkg?.name ?? path.basename(targetDir)
323
+ };
324
+ }
283
325
  async function runInitPrompts(targetDir, saved) {
284
326
  clack.intro("@bensandee/tooling repo:sync");
285
- const existingPkg = readPackageJson(targetDir);
286
- const detected = detectProject(targetDir);
287
- const defaults = computeDefaults(targetDir);
288
- const isExisting = detected.hasPackageJson;
327
+ const { detected, defaults, name } = detectProjectInfo(targetDir);
289
328
  const isFirstInit = !saved;
290
- const name = existingPkg?.name ?? path.basename(targetDir);
291
329
  const structure = saved?.structure ?? defaults.structure;
292
330
  const useEslintPlugin = saved?.useEslintPlugin ?? defaults.useEslintPlugin;
293
331
  let formatter = saved?.formatter ?? defaults.formatter;
@@ -406,7 +444,6 @@ async function runInitPrompts(targetDir, saved) {
406
444
  clack.outro("Configuration complete!");
407
445
  return {
408
446
  name,
409
- isNew: !isExisting,
410
447
  structure,
411
448
  useEslintPlugin,
412
449
  formatter,
@@ -423,12 +460,9 @@ async function runInitPrompts(targetDir, saved) {
423
460
  }
424
461
  /** Build a ProjectConfig from CLI flags for non-interactive mode. */
425
462
  function buildDefaultConfig(targetDir, flags) {
426
- const existingPkg = readPackageJson(targetDir);
427
- const detected = detectProject(targetDir);
428
- const defaults = computeDefaults(targetDir);
463
+ const { defaults, name } = detectProjectInfo(targetDir);
429
464
  return {
430
- name: existingPkg?.name ?? path.basename(targetDir),
431
- isNew: !detected.hasPackageJson,
465
+ name,
432
466
  ...defaults,
433
467
  ...flags.eslintPlugin !== void 0 && { useEslintPlugin: flags.eslintPlugin },
434
468
  ...flags.noCi && { ci: "none" },
@@ -574,7 +608,6 @@ const ToolingConfigSchema = z.strictObject({
574
608
  "library"
575
609
  ]).optional().meta({ description: "Project type (determines tsconfig base)" }),
576
610
  detectPackageTypes: z.boolean().optional().meta({ description: "Auto-detect project types for monorepo packages" }),
577
- setupDocker: z.boolean().optional().meta({ description: "Generate Docker build/check scripts" }),
578
611
  docker: z.record(z.string(), z.object({
579
612
  dockerfile: z.string().meta({ description: "Path to Dockerfile relative to package" }),
580
613
  context: z.string().default(".").meta({ description: "Docker build context relative to package" })
@@ -637,7 +670,6 @@ function saveToolingConfig(ctx, config) {
637
670
  function mergeWithSavedConfig(detected, saved) {
638
671
  return {
639
672
  name: detected.name,
640
- isNew: detected.isNew,
641
673
  targetDir: detected.targetDir,
642
674
  structure: saved.structure ?? detected.structure,
643
675
  useEslintPlugin: saved.useEslintPlugin ?? detected.useEslintPlugin,
@@ -653,173 +685,499 @@ function mergeWithSavedConfig(detected, saved) {
653
685
  };
654
686
  }
655
687
  //#endregion
656
- //#region src/utils/yaml-merge.ts
657
- const IGNORE_PATTERN = "@bensandee/tooling:ignore";
658
- const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
659
- /** Returns a yaml-language-server schema comment for Forgejo workflows, empty string otherwise. */
660
- function workflowSchemaComment(ci) {
661
- return ci === "forgejo" ? FORGEJO_SCHEMA_COMMENT : "";
662
- }
663
- /** Prepend the Forgejo schema comment if it's not already present. No-op for GitHub. */
664
- function ensureSchemaComment(content, ci) {
665
- if (ci !== "forgejo") return content;
666
- if (content.includes("yaml-language-server")) return content;
667
- return FORGEJO_SCHEMA_COMMENT + content;
668
- }
669
- /** Migrate content from old tooling binary name to new. */
670
- function migrateToolingBinary(content) {
671
- return content.replaceAll("pnpm exec tooling ", "pnpm exec bst ");
672
- }
673
- /** Check if a YAML file has an opt-out comment in the first 10 lines. */
674
- function isToolingIgnored(content) {
675
- return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
688
+ //#region src/release/docker.ts
689
+ const ToolingDockerMapSchema = z.record(z.string(), z.object({
690
+ dockerfile: z.string(),
691
+ context: z.string().default(".")
692
+ }));
693
+ const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
694
+ const PackageInfoSchema = z.object({
695
+ name: z.string().optional(),
696
+ version: z.string().optional()
697
+ });
698
+ /** Read the docker map from .tooling.json. Returns empty record if missing or invalid. */
699
+ function loadDockerMap(executor, cwd) {
700
+ const configPath = path.join(cwd, ".tooling.json");
701
+ const raw = executor.readFile(configPath);
702
+ if (!raw) return {};
703
+ try {
704
+ const result = ToolingConfigDockerSchema.safeParse(JSON.parse(raw));
705
+ if (!result.success || !result.data.docker) return {};
706
+ return result.data.docker;
707
+ } catch (_error) {
708
+ return {};
709
+ }
676
710
  }
677
- /**
678
- * Ensure required commands exist under `pre-commit.commands` in a lefthook config.
679
- * Only adds missing commands — never modifies existing ones.
680
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
681
- */
682
- function mergeLefthookCommands(existing, requiredCommands) {
683
- if (isToolingIgnored(existing)) return {
684
- content: existing,
685
- changed: false
711
+ /** Read name and version from a package's package.json. */
712
+ function readPackageInfo(executor, packageJsonPath) {
713
+ const raw = executor.readFile(packageJsonPath);
714
+ if (!raw) return {
715
+ name: void 0,
716
+ version: void 0
686
717
  };
687
718
  try {
688
- const doc = parseDocument(existing);
689
- let changed = false;
690
- if (!doc.hasIn(["pre-commit", "commands"])) {
691
- doc.setIn(["pre-commit", "commands"], requiredCommands);
692
- return {
693
- content: doc.toString(),
694
- changed: true
695
- };
696
- }
697
- const commands = doc.getIn(["pre-commit", "commands"]);
698
- if (!isMap(commands)) return {
699
- content: existing,
700
- changed: false
719
+ const result = PackageInfoSchema.safeParse(JSON.parse(raw));
720
+ if (!result.success) return {
721
+ name: void 0,
722
+ version: void 0
701
723
  };
702
- for (const [name, config] of Object.entries(requiredCommands)) if (!commands.has(name)) {
703
- commands.set(name, config);
704
- changed = true;
705
- }
706
724
  return {
707
- content: changed ? doc.toString() : existing,
708
- changed
725
+ name: result.data.name,
726
+ version: result.data.version
709
727
  };
710
- } catch {
728
+ } catch (_error) {
711
729
  return {
712
- content: existing,
713
- changed: false
730
+ name: void 0,
731
+ version: void 0
714
732
  };
715
733
  }
716
734
  }
735
+ /** Strip npm scope from a package name: "@scope/foo" → "foo", "foo" → "foo". */
736
+ function stripScope(name) {
737
+ const slashIndex = name.indexOf("/");
738
+ return name.startsWith("@") && slashIndex !== -1 ? name.slice(slashIndex + 1) : name;
739
+ }
740
+ /** Convention paths to check for Dockerfiles in a package directory. */
741
+ const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
717
742
  /**
718
- * Ensure required steps exist in a workflow job's steps array.
719
- * Only adds missing steps at the end — never modifies existing ones.
720
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
743
+ * Find a Dockerfile at convention paths for a monorepo package.
744
+ * Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
721
745
  */
722
- function mergeWorkflowSteps(existing, jobName, requiredSteps) {
723
- if (isToolingIgnored(existing)) return {
724
- content: existing,
725
- changed: false
726
- };
727
- try {
728
- const doc = parseDocument(existing);
729
- const steps = doc.getIn([
730
- "jobs",
731
- jobName,
732
- "steps"
733
- ]);
734
- if (!isSeq(steps)) return {
735
- content: existing,
736
- changed: false
737
- };
738
- let changed = false;
739
- for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
740
- if (!isMap(item)) return false;
741
- if (match.run) {
742
- const run = item.get("run");
743
- return typeof run === "string" && run.includes(match.run);
744
- }
745
- if (match.uses) {
746
- const uses = item.get("uses");
747
- return typeof uses === "string" && uses.startsWith(match.uses);
748
- }
749
- return false;
750
- })) {
751
- steps.add(doc.createNode(step));
752
- changed = true;
753
- }
754
- return {
755
- content: changed ? doc.toString() : existing,
756
- changed
757
- };
758
- } catch {
759
- return {
760
- content: existing,
761
- changed: false
746
+ function findConventionDockerfile(executor, cwd, dir) {
747
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) {
748
+ const dockerfilePath = `packages/${dir}/${rel}`;
749
+ if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
750
+ dockerfile: dockerfilePath,
751
+ context: "."
762
752
  };
763
753
  }
764
754
  }
765
755
  /**
766
- * Add a job to an existing workflow YAML if it doesn't already exist.
767
- * Returns unchanged content if the job already exists, the file has an opt-out comment,
768
- * or the document can't be parsed.
756
+ * Find a Dockerfile at convention paths for a single-package repo.
757
+ * Checks Dockerfile and docker/Dockerfile at the project root.
769
758
  */
759
+ function findRootDockerfile(executor, cwd) {
760
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
761
+ dockerfile: rel,
762
+ context: "."
763
+ };
764
+ }
770
765
  /**
771
- * Ensure a `concurrency` block exists at the workflow top level.
772
- * Adds it if missing — never modifies an existing one.
773
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
766
+ * Discover Docker packages by convention and merge with .tooling.json overrides.
767
+ *
768
+ * Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
769
+ * For monorepos, scans packages/{name}/. For single-package repos, scans the root.
770
+ * The docker map in .tooling.json overrides convention-discovered config and can add
771
+ * packages at non-standard locations.
772
+ *
773
+ * Image names are derived from {root-name}-{package-name} using each package's package.json name.
774
+ * Versions are read from each package's own package.json.
774
775
  */
775
- function ensureWorkflowConcurrency(existing, concurrency) {
776
- if (isToolingIgnored(existing)) return {
777
- content: existing,
778
- changed: false
779
- };
780
- try {
781
- const doc = parseDocument(existing);
782
- if (doc.has("concurrency")) return {
783
- content: existing,
784
- changed: false
785
- };
786
- doc.set("concurrency", concurrency);
787
- const contents = doc.contents;
788
- if (isMap(contents)) {
789
- const items = contents.items;
790
- const nameIdx = items.findIndex((p) => isScalar(p.key) && p.key.value === "name");
791
- const concPair = items.pop();
792
- if (concPair) items.splice(nameIdx + 1, 0, concPair);
776
+ function detectDockerPackages(executor, cwd, repoName) {
777
+ const overrides = loadDockerMap(executor, cwd);
778
+ const packageDirs = executor.listPackageDirs(cwd);
779
+ const packages = [];
780
+ const seen = /* @__PURE__ */ new Set();
781
+ if (packageDirs.length > 0) {
782
+ for (const dir of packageDirs) {
783
+ const convention = findConventionDockerfile(executor, cwd, dir);
784
+ const docker = overrides[dir] ?? convention;
785
+ if (docker) {
786
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
787
+ packages.push({
788
+ dir,
789
+ imageName: `${repoName}-${stripScope(name ?? dir)}`,
790
+ version,
791
+ docker
792
+ });
793
+ seen.add(dir);
794
+ }
793
795
  }
794
- return {
795
- content: doc.toString(),
796
- changed: true
797
- };
798
- } catch {
799
- return {
800
- content: existing,
801
- changed: false
802
- };
803
- }
804
- }
805
- //#endregion
806
- //#region src/generators/publish-ci.ts
807
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
808
- function actionsExpr$2(expr) {
809
- return `\${{ ${expr} }}`;
810
- }
811
- function hasEnginesNode$2(ctx) {
812
- return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
813
- }
814
- function deployWorkflow(ci, nodeVersionYaml) {
815
- return `${workflowSchemaComment(ci)}name: Publish
816
- on:
817
- push:
818
- tags:
819
- - "v[0-9]+.[0-9]+.[0-9]+"
820
-
821
- jobs:
822
- publish:
796
+ for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
797
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
798
+ packages.push({
799
+ dir,
800
+ imageName: `${repoName}-${stripScope(name ?? dir)}`,
801
+ version,
802
+ docker
803
+ });
804
+ }
805
+ } else {
806
+ const convention = findRootDockerfile(executor, cwd);
807
+ const docker = overrides["."] ?? convention;
808
+ if (docker) {
809
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
810
+ packages.push({
811
+ dir: ".",
812
+ imageName: stripScope(name ?? repoName),
813
+ version,
814
+ docker
815
+ });
816
+ }
817
+ }
818
+ return packages;
819
+ }
820
+ /**
821
+ * Read docker config for a single package, checking convention paths first,
822
+ * then .tooling.json overrides. Used by the per-package image:build script.
823
+ */
824
+ function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
825
+ const dir = path.basename(path.resolve(cwd, packageDir));
826
+ const convention = findConventionDockerfile(executor, cwd, dir);
827
+ const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
828
+ if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
829
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
830
+ return {
831
+ dir,
832
+ imageName: `${repoName}-${stripScope(name ?? dir)}`,
833
+ version,
834
+ docker
835
+ };
836
+ }
837
+ /** Parse semver version string into major, minor, patch components. */
838
+ function parseSemver(version) {
839
+ const clean = version.replace(/^v/, "");
840
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(clean);
841
+ if (!match?.[1] || !match[2] || !match[3]) throw new FatalError(`Invalid semver version: ${version}`);
842
+ return {
843
+ major: Number(match[1]),
844
+ minor: Number(match[2]),
845
+ patch: Number(match[3])
846
+ };
847
+ }
848
+ /** Generate semver tag variants: latest, vX.Y.Z, vX.Y, vX */
849
+ function generateTags(version) {
850
+ const { major, minor, patch } = parseSemver(version);
851
+ return [
852
+ "latest",
853
+ `v${major}.${minor}.${patch}`,
854
+ `v${major}.${minor}`,
855
+ `v${major}`
856
+ ];
857
+ }
858
+ /** Build the full image reference: namespace/imageName:tag */
859
+ function imageRef(namespace, imageName, tag) {
860
+ return `${namespace}/${imageName}:${tag}`;
861
+ }
862
+ function log$1(message) {
863
+ console.log(message);
864
+ }
865
+ /** Read the repo name from root package.json. */
866
+ function readRepoName(executor, cwd) {
867
+ const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
868
+ if (!rootPkgRaw) throw new FatalError("No package.json found in project root");
869
+ const repoName = parsePackageJson(rootPkgRaw)?.name;
870
+ if (!repoName) throw new FatalError("Root package.json must have a name field");
871
+ return repoName;
872
+ }
873
+ /** Build a single docker image from its config. Paths are resolved relative to cwd. */
874
+ function buildImage(executor, pkg, cwd, extraArgs) {
875
+ const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
876
+ const contextPath = path.resolve(cwd, pkg.docker.context);
877
+ const command = [
878
+ "docker build",
879
+ `-f ${dockerfilePath}`,
880
+ `-t ${pkg.imageName}:latest`,
881
+ ...extraArgs,
882
+ contextPath
883
+ ].join(" ");
884
+ executor.execInherit(command);
885
+ }
886
+ /**
887
+ * Detect packages with docker config in .tooling.json and build each one.
888
+ * Runs `docker build -f <dockerfile> -t <image-name>:latest <context>` for each package.
889
+ * Dockerfile and context paths are resolved relative to the project root.
890
+ *
891
+ * When `packageDir` is set, builds only that single package (for use as an image:build script).
892
+ */
893
+ function runDockerBuild(executor, config) {
894
+ const repoName = readRepoName(executor, config.cwd);
895
+ if (config.packageDir) {
896
+ const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
897
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
898
+ buildImage(executor, pkg, config.cwd, config.extraArgs);
899
+ log$1(`Built ${pkg.imageName}:latest`);
900
+ return { packages: [pkg] };
901
+ }
902
+ const packages = detectDockerPackages(executor, config.cwd, repoName);
903
+ if (packages.length === 0) {
904
+ log$1("No packages with docker config found");
905
+ return { packages: [] };
906
+ }
907
+ log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
908
+ for (const pkg of packages) {
909
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
910
+ buildImage(executor, pkg, config.cwd, config.extraArgs);
911
+ }
912
+ log$1(`Built ${packages.length} image(s)`);
913
+ return { packages };
914
+ }
915
+ /**
916
+ * Run the full Docker publish pipeline:
917
+ * 1. Build all images via runDockerBuild
918
+ * 2. Login to registry
919
+ * 3. Tag each image with semver variants from its own package.json version
920
+ * 4. Push all tags
921
+ * 5. Logout from registry
922
+ */
923
+ function runDockerPublish(executor, config) {
924
+ const { packages } = runDockerBuild(executor, {
925
+ cwd: config.cwd,
926
+ packageDir: void 0,
927
+ extraArgs: []
928
+ });
929
+ if (packages.length === 0) return {
930
+ packages: [],
931
+ tags: []
932
+ };
933
+ for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
934
+ if (!config.dryRun) {
935
+ log$1(`Logging in to ${config.registryHost}...`);
936
+ const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
937
+ if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
938
+ } else log$1("[dry-run] Skipping docker login");
939
+ const allTags = [];
940
+ try {
941
+ for (const pkg of packages) {
942
+ const tags = generateTags(pkg.version ?? "");
943
+ log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
944
+ for (const tag of tags) {
945
+ const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
946
+ allTags.push(ref);
947
+ log$1(`Tagging ${pkg.imageName} → ${ref}`);
948
+ const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
949
+ if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
950
+ if (!config.dryRun) {
951
+ log$1(`Pushing ${ref}...`);
952
+ const pushResult = executor.exec(`docker push ${ref}`);
953
+ if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
954
+ } else log$1(`[dry-run] Skipping push for ${ref}`);
955
+ }
956
+ }
957
+ } finally {
958
+ if (!config.dryRun) {
959
+ log$1(`Logging out from ${config.registryHost}...`);
960
+ executor.exec(`docker logout ${config.registryHost}`);
961
+ }
962
+ }
963
+ log$1(`Published ${allTags.length} image tag(s)`);
964
+ return {
965
+ packages,
966
+ tags: allTags
967
+ };
968
+ }
969
+ //#endregion
970
+ //#region src/utils/yaml-merge.ts
971
+ const IGNORE_PATTERN = "@bensandee/tooling:ignore";
972
+ const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
973
+ /** Returns a yaml-language-server schema comment for Forgejo workflows, empty string otherwise. */
974
+ function workflowSchemaComment(ci) {
975
+ return ci === "forgejo" ? FORGEJO_SCHEMA_COMMENT : "";
976
+ }
977
+ /** Prepend the Forgejo schema comment if it's not already present. No-op for GitHub. */
978
+ function ensureSchemaComment(content, ci) {
979
+ if (ci !== "forgejo") return content;
980
+ if (content.includes("yaml-language-server")) return content;
981
+ return FORGEJO_SCHEMA_COMMENT + content;
982
+ }
983
+ /** Migrate content from old tooling binary name to new. */
984
+ function migrateToolingBinary(content) {
985
+ return content.replaceAll("pnpm exec tooling ", "pnpm exec bst ");
986
+ }
987
+ /** Check if a YAML file has an opt-out comment in the first 10 lines. */
988
+ function isToolingIgnored(content) {
989
+ return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
990
+ }
991
+ /**
992
+ * Ensure required commands exist under `pre-commit.commands` in a lefthook config.
993
+ * Only adds missing commands — never modifies existing ones.
994
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
995
+ */
996
+ function mergeLefthookCommands(existing, requiredCommands) {
997
+ if (isToolingIgnored(existing)) return {
998
+ content: existing,
999
+ changed: false
1000
+ };
1001
+ try {
1002
+ const doc = parseDocument(existing);
1003
+ let changed = false;
1004
+ if (!doc.hasIn(["pre-commit", "commands"])) {
1005
+ doc.setIn(["pre-commit", "commands"], requiredCommands);
1006
+ return {
1007
+ content: doc.toString(),
1008
+ changed: true
1009
+ };
1010
+ }
1011
+ const commands = doc.getIn(["pre-commit", "commands"]);
1012
+ if (!isMap(commands)) return {
1013
+ content: existing,
1014
+ changed: false
1015
+ };
1016
+ for (const [name, config] of Object.entries(requiredCommands)) if (!commands.has(name)) {
1017
+ commands.set(name, config);
1018
+ changed = true;
1019
+ }
1020
+ return {
1021
+ content: changed ? doc.toString() : existing,
1022
+ changed
1023
+ };
1024
+ } catch {
1025
+ return {
1026
+ content: existing,
1027
+ changed: false
1028
+ };
1029
+ }
1030
+ }
1031
+ /**
1032
+ * Ensure required steps exist in a workflow job's steps array.
1033
+ * Only adds missing steps at the end — never modifies existing ones.
1034
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1035
+ */
1036
+ function mergeWorkflowSteps(existing, jobName, requiredSteps) {
1037
+ if (isToolingIgnored(existing)) return {
1038
+ content: existing,
1039
+ changed: false
1040
+ };
1041
+ try {
1042
+ const doc = parseDocument(existing);
1043
+ const steps = doc.getIn([
1044
+ "jobs",
1045
+ jobName,
1046
+ "steps"
1047
+ ]);
1048
+ if (!isSeq(steps)) return {
1049
+ content: existing,
1050
+ changed: false
1051
+ };
1052
+ let changed = false;
1053
+ for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
1054
+ if (!isMap(item)) return false;
1055
+ if (match.run) {
1056
+ const run = item.get("run");
1057
+ return typeof run === "string" && run.includes(match.run);
1058
+ }
1059
+ if (match.uses) {
1060
+ const uses = item.get("uses");
1061
+ return typeof uses === "string" && uses.startsWith(match.uses);
1062
+ }
1063
+ return false;
1064
+ })) {
1065
+ steps.add(doc.createNode(step));
1066
+ changed = true;
1067
+ }
1068
+ return {
1069
+ content: changed ? doc.toString() : existing,
1070
+ changed
1071
+ };
1072
+ } catch {
1073
+ return {
1074
+ content: existing,
1075
+ changed: false
1076
+ };
1077
+ }
1078
+ }
1079
+ /**
1080
+ * Add a job to an existing workflow YAML if it doesn't already exist.
1081
+ * Returns unchanged content if the job already exists, the file has an opt-out comment,
1082
+ * or the document can't be parsed.
1083
+ */
1084
+ /**
1085
+ * Ensure a `concurrency` block exists at the workflow top level.
1086
+ * Adds it if missing — never modifies an existing one.
1087
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1088
+ */
1089
+ /**
1090
+ * Ensure `on.push` has `tags-ignore: ["**"]` so tag pushes don't trigger CI.
1091
+ * Only adds the filter when `on.push` exists and `tags-ignore` is absent.
1092
+ */
1093
+ function ensureWorkflowTagsIgnore(existing) {
1094
+ if (isToolingIgnored(existing)) return {
1095
+ content: existing,
1096
+ changed: false
1097
+ };
1098
+ try {
1099
+ const doc = parseDocument(existing);
1100
+ const on = doc.get("on");
1101
+ if (!isMap(on)) return {
1102
+ content: existing,
1103
+ changed: false
1104
+ };
1105
+ const push = on.get("push");
1106
+ if (!isMap(push)) return {
1107
+ content: existing,
1108
+ changed: false
1109
+ };
1110
+ if (push.has("tags-ignore")) return {
1111
+ content: existing,
1112
+ changed: false
1113
+ };
1114
+ push.set("tags-ignore", ["**"]);
1115
+ return {
1116
+ content: doc.toString(),
1117
+ changed: true
1118
+ };
1119
+ } catch {
1120
+ return {
1121
+ content: existing,
1122
+ changed: false
1123
+ };
1124
+ }
1125
+ }
1126
+ function ensureWorkflowConcurrency(existing, concurrency) {
1127
+ if (isToolingIgnored(existing)) return {
1128
+ content: existing,
1129
+ changed: false
1130
+ };
1131
+ try {
1132
+ const doc = parseDocument(existing);
1133
+ if (doc.has("concurrency")) return {
1134
+ content: existing,
1135
+ changed: false
1136
+ };
1137
+ doc.set("concurrency", concurrency);
1138
+ const contents = doc.contents;
1139
+ if (isMap(contents)) {
1140
+ const items = contents.items;
1141
+ const nameIdx = items.findIndex((p) => isScalar(p.key) && p.key.value === "name");
1142
+ const concPair = items.pop();
1143
+ if (concPair) items.splice(nameIdx + 1, 0, concPair);
1144
+ }
1145
+ return {
1146
+ content: doc.toString(),
1147
+ changed: true
1148
+ };
1149
+ } catch {
1150
+ return {
1151
+ content: existing,
1152
+ changed: false
1153
+ };
1154
+ }
1155
+ }
1156
+ //#endregion
1157
+ //#region src/generators/ci-utils.ts
1158
+ /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
1159
+ function actionsExpr(expr) {
1160
+ return `\${{ ${expr} }}`;
1161
+ }
1162
+ function hasEnginesNode(ctx) {
1163
+ const raw = ctx.read("package.json");
1164
+ if (!raw) return false;
1165
+ return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
1166
+ }
1167
+ function computeNodeVersionYaml(ctx) {
1168
+ return hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1169
+ }
1170
+ //#endregion
1171
+ //#region src/generators/publish-ci.ts
1172
+ function deployWorkflow(ci, nodeVersionYaml) {
1173
+ return `${workflowSchemaComment(ci)}name: Publish
1174
+ on:
1175
+ push:
1176
+ tags:
1177
+ - "v[0-9]+.[0-9]+.[0-9]+"
1178
+
1179
+ jobs:
1180
+ publish:
823
1181
  runs-on: ubuntu-latest
824
1182
  steps:
825
1183
  - uses: actions/checkout@v4
@@ -830,10 +1188,10 @@ jobs:
830
1188
  - run: pnpm install --frozen-lockfile
831
1189
  - name: Publish Docker images
832
1190
  env:
833
- DOCKER_REGISTRY_HOST: ${actionsExpr$2("vars.DOCKER_REGISTRY_HOST")}
834
- DOCKER_REGISTRY_NAMESPACE: ${actionsExpr$2("vars.DOCKER_REGISTRY_NAMESPACE")}
835
- DOCKER_USERNAME: ${actionsExpr$2("secrets.DOCKER_USERNAME")}
836
- DOCKER_PASSWORD: ${actionsExpr$2("secrets.DOCKER_PASSWORD")}
1191
+ DOCKER_REGISTRY_HOST: ${actionsExpr("vars.DOCKER_REGISTRY_HOST")}
1192
+ DOCKER_REGISTRY_NAMESPACE: ${actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")}
1193
+ DOCKER_USERNAME: ${actionsExpr("secrets.DOCKER_USERNAME")}
1194
+ DOCKER_PASSWORD: ${actionsExpr("secrets.DOCKER_PASSWORD")}
837
1195
  run: pnpm exec bst docker:publish
838
1196
  `;
839
1197
  }
@@ -861,8 +1219,6 @@ function requiredDeploySteps() {
861
1219
  }
862
1220
  ];
863
1221
  }
864
- /** Convention paths to check for Dockerfiles. */
865
- const CONVENTION_DOCKERFILE_PATHS$1 = ["Dockerfile", "docker/Dockerfile"];
866
1222
  const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
867
1223
  /** Get names of packages that have Docker builds (by convention or .tooling.json config). */
868
1224
  function getDockerPackageNames(ctx) {
@@ -877,12 +1233,12 @@ function getDockerPackageNames(ctx) {
877
1233
  for (const pkg of packages) {
878
1234
  const dirName = pkg.name.split("/").pop() ?? pkg.name;
879
1235
  if (names.includes(dirName)) continue;
880
- for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(`packages/${dirName}/${rel}`)) {
1236
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) if (ctx.exists(`packages/${dirName}/${rel}`)) {
881
1237
  names.push(dirName);
882
1238
  break;
883
1239
  }
884
1240
  }
885
- } else for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(rel)) {
1241
+ } else for (const rel of CONVENTION_DOCKERFILE_PATHS) if (ctx.exists(rel)) {
886
1242
  if (!names.includes(ctx.config.name)) names.push(ctx.config.name);
887
1243
  break;
888
1244
  }
@@ -901,7 +1257,7 @@ async function generateDeployCi(ctx) {
901
1257
  };
902
1258
  const isGitHub = ctx.config.ci === "github";
903
1259
  const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
904
- const nodeVersionYaml = hasEnginesNode$2(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1260
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
905
1261
  const content = deployWorkflow(ctx.config.ci, nodeVersionYaml);
906
1262
  if (ctx.exists(workflowPath)) {
907
1263
  const raw = ctx.read(workflowPath);
@@ -1005,16 +1361,121 @@ function matchesManagedScript(scriptValue, expectedFragment) {
1005
1361
  const DEPRECATED_SCRIPTS = ["tooling:init", "tooling:update"];
1006
1362
  /** DevDeps that belong in every project (single repo) or per-package (monorepo). */
1007
1363
  const PER_PACKAGE_DEV_DEPS = {
1008
- "@types/node": "25.3.2",
1009
- tsdown: "0.20.3",
1010
- typescript: "5.9.3",
1011
- vitest: "4.0.18"
1364
+ "@types/node": {
1365
+ "@changesets/cli": "2.30.0",
1366
+ "@release-it/bumper": "7.0.5",
1367
+ "commit-and-tag-version": "12.7.0",
1368
+ "knip": "5.87.0",
1369
+ "lefthook": "2.1.4",
1370
+ "oxfmt": "0.41.0",
1371
+ "oxlint": "1.56.0",
1372
+ "prettier": "3.8.1",
1373
+ "release-it": "19.2.4",
1374
+ "@types/node": "24.12.0",
1375
+ "tsdown": "0.21.4",
1376
+ "typescript": "5.9.3",
1377
+ "vitest": "4.1.0",
1378
+ "pnpm": "10.32.1"
1379
+ }["@types/node"],
1380
+ tsdown: {
1381
+ "@changesets/cli": "2.30.0",
1382
+ "@release-it/bumper": "7.0.5",
1383
+ "commit-and-tag-version": "12.7.0",
1384
+ "knip": "5.87.0",
1385
+ "lefthook": "2.1.4",
1386
+ "oxfmt": "0.41.0",
1387
+ "oxlint": "1.56.0",
1388
+ "prettier": "3.8.1",
1389
+ "release-it": "19.2.4",
1390
+ "@types/node": "24.12.0",
1391
+ "tsdown": "0.21.4",
1392
+ "typescript": "5.9.3",
1393
+ "vitest": "4.1.0",
1394
+ "pnpm": "10.32.1"
1395
+ }["tsdown"],
1396
+ typescript: {
1397
+ "@changesets/cli": "2.30.0",
1398
+ "@release-it/bumper": "7.0.5",
1399
+ "commit-and-tag-version": "12.7.0",
1400
+ "knip": "5.87.0",
1401
+ "lefthook": "2.1.4",
1402
+ "oxfmt": "0.41.0",
1403
+ "oxlint": "1.56.0",
1404
+ "prettier": "3.8.1",
1405
+ "release-it": "19.2.4",
1406
+ "@types/node": "24.12.0",
1407
+ "tsdown": "0.21.4",
1408
+ "typescript": "5.9.3",
1409
+ "vitest": "4.1.0",
1410
+ "pnpm": "10.32.1"
1411
+ }["typescript"],
1412
+ vitest: {
1413
+ "@changesets/cli": "2.30.0",
1414
+ "@release-it/bumper": "7.0.5",
1415
+ "commit-and-tag-version": "12.7.0",
1416
+ "knip": "5.87.0",
1417
+ "lefthook": "2.1.4",
1418
+ "oxfmt": "0.41.0",
1419
+ "oxlint": "1.56.0",
1420
+ "prettier": "3.8.1",
1421
+ "release-it": "19.2.4",
1422
+ "@types/node": "24.12.0",
1423
+ "tsdown": "0.21.4",
1424
+ "typescript": "5.9.3",
1425
+ "vitest": "4.1.0",
1426
+ "pnpm": "10.32.1"
1427
+ }["vitest"]
1012
1428
  };
1013
1429
  /** DevDeps that belong at the root regardless of structure. */
1014
1430
  const ROOT_DEV_DEPS = {
1015
- knip: "5.85.0",
1016
- lefthook: "2.1.2",
1017
- oxlint: "1.50.0"
1431
+ knip: {
1432
+ "@changesets/cli": "2.30.0",
1433
+ "@release-it/bumper": "7.0.5",
1434
+ "commit-and-tag-version": "12.7.0",
1435
+ "knip": "5.87.0",
1436
+ "lefthook": "2.1.4",
1437
+ "oxfmt": "0.41.0",
1438
+ "oxlint": "1.56.0",
1439
+ "prettier": "3.8.1",
1440
+ "release-it": "19.2.4",
1441
+ "@types/node": "24.12.0",
1442
+ "tsdown": "0.21.4",
1443
+ "typescript": "5.9.3",
1444
+ "vitest": "4.1.0",
1445
+ "pnpm": "10.32.1"
1446
+ }["knip"],
1447
+ lefthook: {
1448
+ "@changesets/cli": "2.30.0",
1449
+ "@release-it/bumper": "7.0.5",
1450
+ "commit-and-tag-version": "12.7.0",
1451
+ "knip": "5.87.0",
1452
+ "lefthook": "2.1.4",
1453
+ "oxfmt": "0.41.0",
1454
+ "oxlint": "1.56.0",
1455
+ "prettier": "3.8.1",
1456
+ "release-it": "19.2.4",
1457
+ "@types/node": "24.12.0",
1458
+ "tsdown": "0.21.4",
1459
+ "typescript": "5.9.3",
1460
+ "vitest": "4.1.0",
1461
+ "pnpm": "10.32.1"
1462
+ }["lefthook"],
1463
+ oxlint: {
1464
+ "@changesets/cli": "2.30.0",
1465
+ "@release-it/bumper": "7.0.5",
1466
+ "commit-and-tag-version": "12.7.0",
1467
+ "knip": "5.87.0",
1468
+ "lefthook": "2.1.4",
1469
+ "oxfmt": "0.41.0",
1470
+ "oxlint": "1.56.0",
1471
+ "prettier": "3.8.1",
1472
+ "release-it": "19.2.4",
1473
+ "@types/node": "24.12.0",
1474
+ "tsdown": "0.21.4",
1475
+ "typescript": "5.9.3",
1476
+ "vitest": "4.1.0",
1477
+ "pnpm": "10.32.1"
1478
+ }["oxlint"]
1018
1479
  };
1019
1480
  /**
1020
1481
  * Check if a package name is available as a workspace dependency.
@@ -1042,12 +1503,74 @@ const UPDATE_EXCLUDE = new Set(["@types/node"]);
1042
1503
  function addReleaseDeps(deps, config) {
1043
1504
  switch (config.releaseStrategy) {
1044
1505
  case "release-it":
1045
- deps["release-it"] = "18.1.2";
1046
- if (config.structure === "monorepo") deps["@release-it/bumper"] = "7.0.2";
1506
+ deps["release-it"] = {
1507
+ "@changesets/cli": "2.30.0",
1508
+ "@release-it/bumper": "7.0.5",
1509
+ "commit-and-tag-version": "12.7.0",
1510
+ "knip": "5.87.0",
1511
+ "lefthook": "2.1.4",
1512
+ "oxfmt": "0.41.0",
1513
+ "oxlint": "1.56.0",
1514
+ "prettier": "3.8.1",
1515
+ "release-it": "19.2.4",
1516
+ "@types/node": "24.12.0",
1517
+ "tsdown": "0.21.4",
1518
+ "typescript": "5.9.3",
1519
+ "vitest": "4.1.0",
1520
+ "pnpm": "10.32.1"
1521
+ }["release-it"];
1522
+ if (config.structure === "monorepo") deps["@release-it/bumper"] = {
1523
+ "@changesets/cli": "2.30.0",
1524
+ "@release-it/bumper": "7.0.5",
1525
+ "commit-and-tag-version": "12.7.0",
1526
+ "knip": "5.87.0",
1527
+ "lefthook": "2.1.4",
1528
+ "oxfmt": "0.41.0",
1529
+ "oxlint": "1.56.0",
1530
+ "prettier": "3.8.1",
1531
+ "release-it": "19.2.4",
1532
+ "@types/node": "24.12.0",
1533
+ "tsdown": "0.21.4",
1534
+ "typescript": "5.9.3",
1535
+ "vitest": "4.1.0",
1536
+ "pnpm": "10.32.1"
1537
+ }["@release-it/bumper"];
1538
+ break;
1539
+ case "simple":
1540
+ deps["commit-and-tag-version"] = {
1541
+ "@changesets/cli": "2.30.0",
1542
+ "@release-it/bumper": "7.0.5",
1543
+ "commit-and-tag-version": "12.7.0",
1544
+ "knip": "5.87.0",
1545
+ "lefthook": "2.1.4",
1546
+ "oxfmt": "0.41.0",
1547
+ "oxlint": "1.56.0",
1548
+ "prettier": "3.8.1",
1549
+ "release-it": "19.2.4",
1550
+ "@types/node": "24.12.0",
1551
+ "tsdown": "0.21.4",
1552
+ "typescript": "5.9.3",
1553
+ "vitest": "4.1.0",
1554
+ "pnpm": "10.32.1"
1555
+ }["commit-and-tag-version"];
1047
1556
  break;
1048
- case "simple": break;
1049
1557
  case "changesets":
1050
- deps["@changesets/cli"] = "2.29.4";
1558
+ deps["@changesets/cli"] = {
1559
+ "@changesets/cli": "2.30.0",
1560
+ "@release-it/bumper": "7.0.5",
1561
+ "commit-and-tag-version": "12.7.0",
1562
+ "knip": "5.87.0",
1563
+ "lefthook": "2.1.4",
1564
+ "oxfmt": "0.41.0",
1565
+ "oxlint": "1.56.0",
1566
+ "prettier": "3.8.1",
1567
+ "release-it": "19.2.4",
1568
+ "@types/node": "24.12.0",
1569
+ "tsdown": "0.21.4",
1570
+ "typescript": "5.9.3",
1571
+ "vitest": "4.1.0",
1572
+ "pnpm": "10.32.1"
1573
+ }["@changesets/cli"];
1051
1574
  break;
1052
1575
  }
1053
1576
  }
@@ -1056,9 +1579,39 @@ function getAddedDevDepNames(config) {
1056
1579
  const deps = { ...ROOT_DEV_DEPS };
1057
1580
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
1058
1581
  deps["@bensandee/config"] = "0.9.1";
1059
- deps["@bensandee/tooling"] = "0.28.0";
1060
- if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
1061
- if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
1582
+ deps["@bensandee/tooling"] = "0.29.0";
1583
+ if (config.formatter === "oxfmt") deps["oxfmt"] = {
1584
+ "@changesets/cli": "2.30.0",
1585
+ "@release-it/bumper": "7.0.5",
1586
+ "commit-and-tag-version": "12.7.0",
1587
+ "knip": "5.87.0",
1588
+ "lefthook": "2.1.4",
1589
+ "oxfmt": "0.41.0",
1590
+ "oxlint": "1.56.0",
1591
+ "prettier": "3.8.1",
1592
+ "release-it": "19.2.4",
1593
+ "@types/node": "24.12.0",
1594
+ "tsdown": "0.21.4",
1595
+ "typescript": "5.9.3",
1596
+ "vitest": "4.1.0",
1597
+ "pnpm": "10.32.1"
1598
+ }["oxfmt"];
1599
+ if (config.formatter === "prettier") deps["prettier"] = {
1600
+ "@changesets/cli": "2.30.0",
1601
+ "@release-it/bumper": "7.0.5",
1602
+ "commit-and-tag-version": "12.7.0",
1603
+ "knip": "5.87.0",
1604
+ "lefthook": "2.1.4",
1605
+ "oxfmt": "0.41.0",
1606
+ "oxlint": "1.56.0",
1607
+ "prettier": "3.8.1",
1608
+ "release-it": "19.2.4",
1609
+ "@types/node": "24.12.0",
1610
+ "tsdown": "0.21.4",
1611
+ "typescript": "5.9.3",
1612
+ "vitest": "4.1.0",
1613
+ "pnpm": "10.32.1"
1614
+ }["prettier"];
1062
1615
  addReleaseDeps(deps, config);
1063
1616
  return Object.keys(deps).filter((name) => !UPDATE_EXCLUDE.has(name));
1064
1617
  }
@@ -1081,10 +1634,40 @@ async function generatePackageJson(ctx) {
1081
1634
  const devDeps = { ...ROOT_DEV_DEPS };
1082
1635
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1083
1636
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1084
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.28.0";
1637
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.29.0";
1085
1638
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1086
- if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
1087
- if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
1639
+ if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
1640
+ "@changesets/cli": "2.30.0",
1641
+ "@release-it/bumper": "7.0.5",
1642
+ "commit-and-tag-version": "12.7.0",
1643
+ "knip": "5.87.0",
1644
+ "lefthook": "2.1.4",
1645
+ "oxfmt": "0.41.0",
1646
+ "oxlint": "1.56.0",
1647
+ "prettier": "3.8.1",
1648
+ "release-it": "19.2.4",
1649
+ "@types/node": "24.12.0",
1650
+ "tsdown": "0.21.4",
1651
+ "typescript": "5.9.3",
1652
+ "vitest": "4.1.0",
1653
+ "pnpm": "10.32.1"
1654
+ }["oxfmt"];
1655
+ if (ctx.config.formatter === "prettier") devDeps["prettier"] = {
1656
+ "@changesets/cli": "2.30.0",
1657
+ "@release-it/bumper": "7.0.5",
1658
+ "commit-and-tag-version": "12.7.0",
1659
+ "knip": "5.87.0",
1660
+ "lefthook": "2.1.4",
1661
+ "oxfmt": "0.41.0",
1662
+ "oxlint": "1.56.0",
1663
+ "prettier": "3.8.1",
1664
+ "release-it": "19.2.4",
1665
+ "@types/node": "24.12.0",
1666
+ "tsdown": "0.21.4",
1667
+ "typescript": "5.9.3",
1668
+ "vitest": "4.1.0",
1669
+ "pnpm": "10.32.1"
1670
+ }["prettier"];
1088
1671
  addReleaseDeps(devDeps, ctx.config);
1089
1672
  if (existing) {
1090
1673
  const pkg = parsePackageJson(existing);
@@ -1124,6 +1707,13 @@ async function generatePackageJson(ctx) {
1124
1707
  changes.push(`updated devDependency: ${key} to ${value}`);
1125
1708
  }
1126
1709
  pkg.devDependencies = existingDevDeps;
1710
+ if (!pkg.repository) {
1711
+ const repoUrl = detectGitRemoteUrl(ctx.targetDir);
1712
+ if (repoUrl) {
1713
+ pkg.repository = repoUrl;
1714
+ changes.push("added repository from git remote");
1715
+ }
1716
+ }
1127
1717
  if (!pkg["engines"]) {
1128
1718
  pkg["engines"] = { node: ">=24.13.0" };
1129
1719
  changes.push("set engines.node >= 24.13.0");
@@ -1140,15 +1730,32 @@ async function generatePackageJson(ctx) {
1140
1730
  description: changes.join(", ")
1141
1731
  };
1142
1732
  }
1733
+ const repoUrl = detectGitRemoteUrl(ctx.targetDir);
1143
1734
  const pkg = {
1144
1735
  name: ctx.config.name,
1145
1736
  version: "0.1.0",
1146
1737
  private: true,
1738
+ ...repoUrl && { repository: repoUrl },
1147
1739
  type: "module",
1148
1740
  scripts: allScripts,
1149
1741
  devDependencies: devDeps,
1150
1742
  engines: { node: ">=24.13.0" },
1151
- packageManager: "pnpm@10.29.3"
1743
+ packageManager: `pnpm@${{
1744
+ "@changesets/cli": "2.30.0",
1745
+ "@release-it/bumper": "7.0.5",
1746
+ "commit-and-tag-version": "12.7.0",
1747
+ "knip": "5.87.0",
1748
+ "lefthook": "2.1.4",
1749
+ "oxfmt": "0.41.0",
1750
+ "oxlint": "1.56.0",
1751
+ "prettier": "3.8.1",
1752
+ "release-it": "19.2.4",
1753
+ "@types/node": "24.12.0",
1754
+ "tsdown": "0.21.4",
1755
+ "typescript": "5.9.3",
1756
+ "vitest": "4.1.0",
1757
+ "pnpm": "10.32.1"
1758
+ }["pnpm"]}`
1152
1759
  };
1153
1760
  ctx.write(filePath, JSON.stringify(pkg, null, 2) + "\n");
1154
1761
  return {
@@ -1565,30 +2172,23 @@ async function generateGitignore(ctx) {
1565
2172
  }
1566
2173
  //#endregion
1567
2174
  //#region src/generators/ci.ts
1568
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
1569
- function actionsExpr$1(expr) {
1570
- return `\${{ ${expr} }}`;
1571
- }
1572
2175
  const CI_CONCURRENCY = {
1573
- group: `ci-${actionsExpr$1("github.ref")}`,
1574
- "cancel-in-progress": actionsExpr$1("github.ref != 'refs/heads/main'")
2176
+ group: `ci-${actionsExpr("github.ref")}`,
2177
+ "cancel-in-progress": actionsExpr("github.ref != 'refs/heads/main'")
1575
2178
  };
1576
- function hasEnginesNode$1(ctx) {
1577
- const raw = ctx.read("package.json");
1578
- if (!raw) return false;
1579
- return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
1580
- }
1581
2179
  function ciWorkflow(nodeVersionYaml, isForgejo, isChangesets) {
1582
2180
  const emailNotifications = isForgejo ? "\nenable-email-notifications: true\n" : "";
1583
2181
  const concurrencyBlock = isChangesets ? `
1584
2182
  concurrency:
1585
- group: ci-${actionsExpr$1("github.ref")}
1586
- cancel-in-progress: ${actionsExpr$1("github.ref != 'refs/heads/main'")}
2183
+ group: ci-${actionsExpr("github.ref")}
2184
+ cancel-in-progress: ${actionsExpr("github.ref != 'refs/heads/main'")}
1587
2185
  ` : "";
1588
2186
  return `${workflowSchemaComment(isForgejo ? "forgejo" : "github")}name: CI
1589
2187
  ${emailNotifications}on:
1590
2188
  push:
1591
2189
  branches: [main]
2190
+ tags-ignore:
2191
+ - "**"
1592
2192
  pull_request:
1593
2193
  ${concurrencyBlock}
1594
2194
  jobs:
@@ -1651,13 +2251,18 @@ async function generateCi(ctx) {
1651
2251
  };
1652
2252
  const isGitHub = ctx.config.ci === "github";
1653
2253
  const isChangesets = ctx.config.releaseStrategy === "changesets";
1654
- const nodeVersionYaml = hasEnginesNode$1(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
2254
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
1655
2255
  const filePath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
1656
2256
  const content = ciWorkflow(nodeVersionYaml, !isGitHub, isChangesets);
1657
2257
  if (ctx.exists(filePath)) {
1658
2258
  const existing = ctx.read(filePath);
1659
2259
  if (existing) {
1660
2260
  let result = mergeWorkflowSteps(existing, "check", requiredCheckSteps(nodeVersionYaml));
2261
+ const withTagsIgnore = ensureWorkflowTagsIgnore(result.content);
2262
+ result = {
2263
+ content: withTagsIgnore.content,
2264
+ changed: result.changed || withTagsIgnore.changed
2265
+ };
1661
2266
  if (isChangesets) {
1662
2267
  const withConcurrency = ensureWorkflowConcurrency(result.content, CI_CONCURRENCY);
1663
2268
  result = {
@@ -2155,16 +2760,9 @@ async function generateChangesets(ctx) {
2155
2760
  description: "Generated .changeset/config.json"
2156
2761
  };
2157
2762
  }
2158
- //#endregion
2159
- //#region src/generators/release-ci.ts
2160
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
2161
- function actionsExpr(expr) {
2162
- return `\${{ ${expr} }}`;
2163
- }
2164
- function hasEnginesNode(ctx) {
2165
- return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
2166
- }
2167
- function commonSteps(nodeVersionYaml, publishesNpm, { build = true } = {}) {
2763
+ //#endregion
2764
+ //#region src/generators/release-ci.ts
2765
+ function commonSteps(nodeVersionYaml, publishesNpm) {
2168
2766
  return ` - uses: actions/checkout@v4
2169
2767
  with:
2170
2768
  fetch-depth: 0
@@ -2173,7 +2771,7 @@ function commonSteps(nodeVersionYaml, publishesNpm, { build = true } = {}) {
2173
2771
  with:
2174
2772
  ${nodeVersionYaml}
2175
2773
  cache: pnpm${publishesNpm ? `\n registry-url: "https://registry.npmjs.org"` : ""}
2176
- - run: pnpm install --frozen-lockfile${build ? `\n - run: pnpm build` : ""}`;
2774
+ - run: pnpm install --frozen-lockfile`;
2177
2775
  }
2178
2776
  function releaseItWorkflow(ci, nodeVersionYaml, publishesNpm) {
2179
2777
  const isGitHub = ci === "github";
@@ -2227,7 +2825,7 @@ jobs:
2227
2825
  release:
2228
2826
  runs-on: ubuntu-latest
2229
2827
  steps:
2230
- ${commonSteps(nodeVersionYaml, publishesNpm, { build: false })}${gitConfigStep}${releaseStep}
2828
+ ${commonSteps(nodeVersionYaml, publishesNpm)}${gitConfigStep}${releaseStep}
2231
2829
  `;
2232
2830
  }
2233
2831
  /** Build the required release step for the check job (changesets). */
@@ -2290,11 +2888,7 @@ function requiredReleaseSteps(strategy, nodeVersionYaml, publishesNpm) {
2290
2888
  {
2291
2889
  match: { run: "pnpm install" },
2292
2890
  step: { run: "pnpm install --frozen-lockfile" }
2293
- },
2294
- ...strategy !== "simple" ? [{
2295
- match: { run: "build" },
2296
- step: { run: "pnpm build" }
2297
- }] : []
2891
+ }
2298
2892
  ];
2299
2893
  switch (strategy) {
2300
2894
  case "release-it":
@@ -2370,7 +2964,7 @@ async function generateReleaseCi(ctx) {
2370
2964
  if (ctx.config.releaseStrategy === "changesets") return generateChangesetsReleaseCi(ctx, publishesNpm);
2371
2965
  const isGitHub = ctx.config.ci === "github";
2372
2966
  const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
2373
- const nodeVersionYaml = hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
2967
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
2374
2968
  const content = buildWorkflow(ctx.config.releaseStrategy, ctx.config.ci, nodeVersionYaml, publishesNpm);
2375
2969
  if (!content) return {
2376
2970
  filePath,
@@ -2594,407 +3188,125 @@ const SCHEMA_GLOB = ".forgejo/workflows/*.{yml,yaml}";
2594
3188
  const VscodeSettingsSchema = z.looseObject({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) });
2595
3189
  function readSchemaFromNodeModules(targetDir) {
2596
3190
  const candidate = path.join(targetDir, "node_modules", SCHEMA_NPM_PATH);
2597
- if (!existsSync(candidate)) return void 0;
2598
- return readFileSync(candidate, "utf-8");
2599
- }
2600
- function serializeSettings(settings) {
2601
- return JSON.stringify(settings, null, 2) + "\n";
2602
- }
2603
- const YamlSchemasSchema = z.record(z.string(), z.unknown());
2604
- /** Merge yaml.schemas into a settings object. Returns the result and whether anything changed. */
2605
- function mergeYamlSchemas(settings) {
2606
- const parsed = YamlSchemasSchema.safeParse(settings["yaml.schemas"]);
2607
- const yamlSchemas = parsed.success ? { ...parsed.data } : {};
2608
- if (SCHEMA_LOCAL_PATH in yamlSchemas) return {
2609
- merged: settings,
2610
- changed: false
2611
- };
2612
- yamlSchemas[SCHEMA_LOCAL_PATH] = SCHEMA_GLOB;
2613
- return {
2614
- merged: {
2615
- ...settings,
2616
- "yaml.schemas": yamlSchemas
2617
- },
2618
- changed: true
2619
- };
2620
- }
2621
- function writeSchemaToSettings(ctx) {
2622
- if (ctx.exists(SETTINGS_PATH)) {
2623
- const raw = ctx.read(SETTINGS_PATH);
2624
- if (!raw) return {
2625
- filePath: SETTINGS_PATH,
2626
- action: "skipped",
2627
- description: "Could not read existing settings"
2628
- };
2629
- const parsed = VscodeSettingsSchema.safeParse(parse(raw));
2630
- if (!parsed.success) return {
2631
- filePath: SETTINGS_PATH,
2632
- action: "skipped",
2633
- description: "Could not parse existing settings"
2634
- };
2635
- const { merged, changed } = mergeYamlSchemas(parsed.data);
2636
- if (!changed) return {
2637
- filePath: SETTINGS_PATH,
2638
- action: "skipped",
2639
- description: "Already has Forgejo schema mapping"
2640
- };
2641
- ctx.write(SETTINGS_PATH, serializeSettings(merged));
2642
- return {
2643
- filePath: SETTINGS_PATH,
2644
- action: "updated",
2645
- description: "Added Forgejo workflow schema mapping"
2646
- };
2647
- }
2648
- ctx.write(SETTINGS_PATH, serializeSettings({ "yaml.schemas": { [SCHEMA_LOCAL_PATH]: SCHEMA_GLOB } }));
2649
- return {
2650
- filePath: SETTINGS_PATH,
2651
- action: "created",
2652
- description: "Generated .vscode/settings.json with Forgejo workflow schema"
2653
- };
2654
- }
2655
- async function generateVscodeSettings(ctx) {
2656
- const results = [];
2657
- if (ctx.config.ci !== "forgejo") {
2658
- results.push({
2659
- filePath: SETTINGS_PATH,
2660
- action: "skipped",
2661
- description: "Not a Forgejo project"
2662
- });
2663
- return results;
2664
- }
2665
- const schemaContent = readSchemaFromNodeModules(ctx.targetDir);
2666
- if (!schemaContent) {
2667
- results.push({
2668
- filePath: SCHEMA_LOCAL_PATH,
2669
- action: "skipped",
2670
- description: "Could not find @bensandee/config schema in node_modules"
2671
- });
2672
- return results;
2673
- }
2674
- const existingSchema = ctx.read(SCHEMA_LOCAL_PATH);
2675
- if (existingSchema !== void 0 && contentEqual(SCHEMA_LOCAL_PATH, existingSchema, schemaContent)) results.push({
2676
- filePath: SCHEMA_LOCAL_PATH,
2677
- action: "skipped",
2678
- description: "Schema already up to date"
2679
- });
2680
- else {
2681
- ctx.write(SCHEMA_LOCAL_PATH, schemaContent);
2682
- results.push({
2683
- filePath: SCHEMA_LOCAL_PATH,
2684
- action: existingSchema ? "updated" : "created",
2685
- description: "Copied Forgejo workflow schema from @bensandee/config"
2686
- });
2687
- }
2688
- results.push(writeSchemaToSettings(ctx));
2689
- return results;
2690
- }
2691
- //#endregion
2692
- //#region src/generators/pipeline.ts
2693
- /** Run all generators sequentially and return their results. */
2694
- async function runGenerators(ctx) {
2695
- const results = [];
2696
- results.push(await generatePackageJson(ctx));
2697
- results.push(await generatePnpmWorkspace(ctx));
2698
- results.push(...await generateTsconfig(ctx));
2699
- results.push(await generateTsdown(ctx));
2700
- results.push(await generateOxlint(ctx));
2701
- results.push(await generateFormatter(ctx));
2702
- results.push(...await generateLefthook(ctx));
2703
- results.push(await generateGitignore(ctx));
2704
- results.push(await generateKnip(ctx));
2705
- results.push(await generateRenovate(ctx));
2706
- results.push(await generateCi(ctx));
2707
- results.push(...await generateClaudeSettings(ctx));
2708
- results.push(await generateReleaseIt(ctx));
2709
- results.push(await generateChangesets(ctx));
2710
- results.push(await generateReleaseCi(ctx));
2711
- results.push(await generateDeployCi(ctx));
2712
- results.push(...await generateVitest(ctx));
2713
- results.push(...await generateVscodeSettings(ctx));
2714
- results.push(saveToolingConfig(ctx, ctx.config));
2715
- return results;
2716
- }
2717
- //#endregion
2718
- //#region src/release/docker.ts
2719
- const ToolingDockerMapSchema = z.record(z.string(), z.object({
2720
- dockerfile: z.string(),
2721
- context: z.string().default(".")
2722
- }));
2723
- const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
2724
- const PackageInfoSchema = z.object({
2725
- name: z.string().optional(),
2726
- version: z.string().optional()
2727
- });
2728
- /** Read the docker map from .tooling.json. Returns empty record if missing or invalid. */
2729
- function loadDockerMap(executor, cwd) {
2730
- const configPath = path.join(cwd, ".tooling.json");
2731
- const raw = executor.readFile(configPath);
2732
- if (!raw) return {};
2733
- try {
2734
- const result = ToolingConfigDockerSchema.safeParse(JSON.parse(raw));
2735
- if (!result.success || !result.data.docker) return {};
2736
- return result.data.docker;
2737
- } catch (_error) {
2738
- return {};
2739
- }
2740
- }
2741
- /** Read name and version from a package's package.json. */
2742
- function readPackageInfo(executor, packageJsonPath) {
2743
- const raw = executor.readFile(packageJsonPath);
2744
- if (!raw) return {
2745
- name: void 0,
2746
- version: void 0
2747
- };
2748
- try {
2749
- const result = PackageInfoSchema.safeParse(JSON.parse(raw));
2750
- if (!result.success) return {
2751
- name: void 0,
2752
- version: void 0
2753
- };
2754
- return {
2755
- name: result.data.name,
2756
- version: result.data.version
2757
- };
2758
- } catch (_error) {
2759
- return {
2760
- name: void 0,
2761
- version: void 0
2762
- };
2763
- }
2764
- }
2765
- /** Strip npm scope from a package name: "@scope/foo" → "foo", "foo" → "foo". */
2766
- function stripScope(name) {
2767
- const slashIndex = name.indexOf("/");
2768
- return name.startsWith("@") && slashIndex !== -1 ? name.slice(slashIndex + 1) : name;
2769
- }
2770
- /** Convention paths to check for Dockerfiles in a package directory. */
2771
- const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
2772
- /**
2773
- * Find a Dockerfile at convention paths for a monorepo package.
2774
- * Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
2775
- */
2776
- function findConventionDockerfile(executor, cwd, dir) {
2777
- for (const rel of CONVENTION_DOCKERFILE_PATHS) {
2778
- const dockerfilePath = `packages/${dir}/${rel}`;
2779
- if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
2780
- dockerfile: dockerfilePath,
2781
- context: "."
2782
- };
2783
- }
2784
- }
2785
- /**
2786
- * Find a Dockerfile at convention paths for a single-package repo.
2787
- * Checks Dockerfile and docker/Dockerfile at the project root.
2788
- */
2789
- function findRootDockerfile(executor, cwd) {
2790
- for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
2791
- dockerfile: rel,
2792
- context: "."
2793
- };
2794
- }
2795
- /**
2796
- * Discover Docker packages by convention and merge with .tooling.json overrides.
2797
- *
2798
- * Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
2799
- * For monorepos, scans packages/{name}/. For single-package repos, scans the root.
2800
- * The docker map in .tooling.json overrides convention-discovered config and can add
2801
- * packages at non-standard locations.
2802
- *
2803
- * Image names are derived from {root-name}-{package-name} using each package's package.json name.
2804
- * Versions are read from each package's own package.json.
2805
- */
2806
- function detectDockerPackages(executor, cwd, repoName) {
2807
- const overrides = loadDockerMap(executor, cwd);
2808
- const packageDirs = executor.listPackageDirs(cwd);
2809
- const packages = [];
2810
- const seen = /* @__PURE__ */ new Set();
2811
- if (packageDirs.length > 0) {
2812
- for (const dir of packageDirs) {
2813
- const convention = findConventionDockerfile(executor, cwd, dir);
2814
- const docker = overrides[dir] ?? convention;
2815
- if (docker) {
2816
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2817
- packages.push({
2818
- dir,
2819
- imageName: `${repoName}-${stripScope(name ?? dir)}`,
2820
- version,
2821
- docker
2822
- });
2823
- seen.add(dir);
2824
- }
2825
- }
2826
- for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
2827
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2828
- packages.push({
2829
- dir,
2830
- imageName: `${repoName}-${stripScope(name ?? dir)}`,
2831
- version,
2832
- docker
2833
- });
2834
- }
2835
- } else {
2836
- const convention = findRootDockerfile(executor, cwd);
2837
- const docker = overrides["."] ?? convention;
2838
- if (docker) {
2839
- const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
2840
- packages.push({
2841
- dir: ".",
2842
- imageName: stripScope(name ?? repoName),
2843
- version,
2844
- docker
2845
- });
2846
- }
2847
- }
2848
- return packages;
3191
+ if (!existsSync(candidate)) return void 0;
3192
+ return readFileSync(candidate, "utf-8");
2849
3193
  }
2850
- /**
2851
- * Read docker config for a single package, checking convention paths first,
2852
- * then .tooling.json overrides. Used by the per-package image:build script.
2853
- */
2854
- function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
2855
- const dir = path.basename(path.resolve(cwd, packageDir));
2856
- const convention = findConventionDockerfile(executor, cwd, dir);
2857
- const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
2858
- if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
2859
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
3194
+ function serializeSettings(settings) {
3195
+ return JSON.stringify(settings, null, 2) + "\n";
3196
+ }
3197
+ const YamlSchemasSchema = z.record(z.string(), z.unknown());
3198
+ /** Merge yaml.schemas into a settings object. Returns the result and whether anything changed. */
3199
+ function mergeYamlSchemas(settings) {
3200
+ const parsed = YamlSchemasSchema.safeParse(settings["yaml.schemas"]);
3201
+ const yamlSchemas = parsed.success ? { ...parsed.data } : {};
3202
+ if (SCHEMA_LOCAL_PATH in yamlSchemas) return {
3203
+ merged: settings,
3204
+ changed: false
3205
+ };
3206
+ yamlSchemas[SCHEMA_LOCAL_PATH] = SCHEMA_GLOB;
2860
3207
  return {
2861
- dir,
2862
- imageName: `${repoName}-${stripScope(name ?? dir)}`,
2863
- version,
2864
- docker
3208
+ merged: {
3209
+ ...settings,
3210
+ "yaml.schemas": yamlSchemas
3211
+ },
3212
+ changed: true
2865
3213
  };
2866
3214
  }
2867
- /** Parse semver version string into major, minor, patch components. */
2868
- function parseSemver(version) {
2869
- const clean = version.replace(/^v/, "");
2870
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(clean);
2871
- if (!match?.[1] || !match[2] || !match[3]) throw new FatalError(`Invalid semver version: ${version}`);
3215
+ function writeSchemaToSettings(ctx) {
3216
+ if (ctx.exists(SETTINGS_PATH)) {
3217
+ const raw = ctx.read(SETTINGS_PATH);
3218
+ if (!raw) return {
3219
+ filePath: SETTINGS_PATH,
3220
+ action: "skipped",
3221
+ description: "Could not read existing settings"
3222
+ };
3223
+ const parsed = VscodeSettingsSchema.safeParse(parse(raw));
3224
+ if (!parsed.success) return {
3225
+ filePath: SETTINGS_PATH,
3226
+ action: "skipped",
3227
+ description: "Could not parse existing settings"
3228
+ };
3229
+ const { merged, changed } = mergeYamlSchemas(parsed.data);
3230
+ if (!changed) return {
3231
+ filePath: SETTINGS_PATH,
3232
+ action: "skipped",
3233
+ description: "Already has Forgejo schema mapping"
3234
+ };
3235
+ ctx.write(SETTINGS_PATH, serializeSettings(merged));
3236
+ return {
3237
+ filePath: SETTINGS_PATH,
3238
+ action: "updated",
3239
+ description: "Added Forgejo workflow schema mapping"
3240
+ };
3241
+ }
3242
+ ctx.write(SETTINGS_PATH, serializeSettings({ "yaml.schemas": { [SCHEMA_LOCAL_PATH]: SCHEMA_GLOB } }));
2872
3243
  return {
2873
- major: Number(match[1]),
2874
- minor: Number(match[2]),
2875
- patch: Number(match[3])
3244
+ filePath: SETTINGS_PATH,
3245
+ action: "created",
3246
+ description: "Generated .vscode/settings.json with Forgejo workflow schema"
2876
3247
  };
2877
3248
  }
2878
- /** Generate semver tag variants: latest, vX.Y.Z, vX.Y, vX */
2879
- function generateTags(version) {
2880
- const { major, minor, patch } = parseSemver(version);
2881
- return [
2882
- "latest",
2883
- `v${major}.${minor}.${patch}`,
2884
- `v${major}.${minor}`,
2885
- `v${major}`
2886
- ];
2887
- }
2888
- /** Build the full image reference: namespace/imageName:tag */
2889
- function imageRef(namespace, imageName, tag) {
2890
- return `${namespace}/${imageName}:${tag}`;
2891
- }
2892
- function log$1(message) {
2893
- console.log(message);
2894
- }
2895
- /** Read the repo name from root package.json. */
2896
- function readRepoName(executor, cwd) {
2897
- const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
2898
- if (!rootPkgRaw) throw new FatalError("No package.json found in project root");
2899
- const repoName = parsePackageJson(rootPkgRaw)?.name;
2900
- if (!repoName) throw new FatalError("Root package.json must have a name field");
2901
- return repoName;
2902
- }
2903
- /** Build a single docker image from its config. Paths are resolved relative to cwd. */
2904
- function buildImage(executor, pkg, cwd, extraArgs) {
2905
- const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
2906
- const contextPath = path.resolve(cwd, pkg.docker.context);
2907
- const command = [
2908
- "docker build",
2909
- `-f ${dockerfilePath}`,
2910
- `-t ${pkg.imageName}:latest`,
2911
- ...extraArgs,
2912
- contextPath
2913
- ].join(" ");
2914
- executor.execInherit(command);
2915
- }
2916
- /**
2917
- * Detect packages with docker config in .tooling.json and build each one.
2918
- * Runs `docker build -f <dockerfile> -t <image-name>:latest <context>` for each package.
2919
- * Dockerfile and context paths are resolved relative to the project root.
2920
- *
2921
- * When `packageDir` is set, builds only that single package (for use as an image:build script).
2922
- */
2923
- function runDockerBuild(executor, config) {
2924
- const repoName = readRepoName(executor, config.cwd);
2925
- if (config.packageDir) {
2926
- const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
2927
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
2928
- buildImage(executor, pkg, config.cwd, config.extraArgs);
2929
- log$1(`Built ${pkg.imageName}:latest`);
2930
- return { packages: [pkg] };
2931
- }
2932
- const packages = detectDockerPackages(executor, config.cwd, repoName);
2933
- if (packages.length === 0) {
2934
- log$1("No packages with docker config found");
2935
- return { packages: [] };
3249
+ async function generateVscodeSettings(ctx) {
3250
+ const results = [];
3251
+ if (ctx.config.ci !== "forgejo") {
3252
+ results.push({
3253
+ filePath: SETTINGS_PATH,
3254
+ action: "skipped",
3255
+ description: "Not a Forgejo project"
3256
+ });
3257
+ return results;
2936
3258
  }
2937
- log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
2938
- for (const pkg of packages) {
2939
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
2940
- buildImage(executor, pkg, config.cwd, config.extraArgs);
3259
+ const schemaContent = readSchemaFromNodeModules(ctx.targetDir);
3260
+ if (!schemaContent) {
3261
+ results.push({
3262
+ filePath: SCHEMA_LOCAL_PATH,
3263
+ action: "skipped",
3264
+ description: "Could not find @bensandee/config schema in node_modules"
3265
+ });
3266
+ return results;
2941
3267
  }
2942
- log$1(`Built ${packages.length} image(s)`);
2943
- return { packages };
2944
- }
2945
- /**
2946
- * Run the full Docker publish pipeline:
2947
- * 1. Build all images via runDockerBuild
2948
- * 2. Login to registry
2949
- * 3. Tag each image with semver variants from its own package.json version
2950
- * 4. Push all tags
2951
- * 5. Logout from registry
2952
- */
2953
- function runDockerPublish(executor, config) {
2954
- const { packages } = runDockerBuild(executor, {
2955
- cwd: config.cwd,
2956
- packageDir: void 0,
2957
- extraArgs: []
3268
+ const existingSchema = ctx.read(SCHEMA_LOCAL_PATH);
3269
+ if (existingSchema !== void 0 && contentEqual(SCHEMA_LOCAL_PATH, existingSchema, schemaContent)) results.push({
3270
+ filePath: SCHEMA_LOCAL_PATH,
3271
+ action: "skipped",
3272
+ description: "Schema already up to date"
2958
3273
  });
2959
- if (packages.length === 0) return {
2960
- packages: [],
2961
- tags: []
2962
- };
2963
- for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
2964
- if (!config.dryRun) {
2965
- log$1(`Logging in to ${config.registryHost}...`);
2966
- const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
2967
- if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
2968
- } else log$1("[dry-run] Skipping docker login");
2969
- const allTags = [];
2970
- try {
2971
- for (const pkg of packages) {
2972
- const tags = generateTags(pkg.version ?? "");
2973
- log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
2974
- for (const tag of tags) {
2975
- const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
2976
- allTags.push(ref);
2977
- log$1(`Tagging ${pkg.imageName} → ${ref}`);
2978
- const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
2979
- if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
2980
- if (!config.dryRun) {
2981
- log$1(`Pushing ${ref}...`);
2982
- const pushResult = executor.exec(`docker push ${ref}`);
2983
- if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
2984
- } else log$1(`[dry-run] Skipping push for ${ref}`);
2985
- }
2986
- }
2987
- } finally {
2988
- if (!config.dryRun) {
2989
- log$1(`Logging out from ${config.registryHost}...`);
2990
- executor.exec(`docker logout ${config.registryHost}`);
2991
- }
3274
+ else {
3275
+ ctx.write(SCHEMA_LOCAL_PATH, schemaContent);
3276
+ results.push({
3277
+ filePath: SCHEMA_LOCAL_PATH,
3278
+ action: existingSchema ? "updated" : "created",
3279
+ description: "Copied Forgejo workflow schema from @bensandee/config"
3280
+ });
2992
3281
  }
2993
- log$1(`Published ${allTags.length} image tag(s)`);
2994
- return {
2995
- packages,
2996
- tags: allTags
2997
- };
3282
+ results.push(writeSchemaToSettings(ctx));
3283
+ return results;
3284
+ }
3285
+ //#endregion
3286
+ //#region src/generators/pipeline.ts
3287
+ /** Run all generators sequentially and return their results. */
3288
+ async function runGenerators(ctx) {
3289
+ const results = [];
3290
+ results.push(await generatePackageJson(ctx));
3291
+ results.push(await generatePnpmWorkspace(ctx));
3292
+ results.push(...await generateTsconfig(ctx));
3293
+ results.push(await generateTsdown(ctx));
3294
+ results.push(await generateOxlint(ctx));
3295
+ results.push(await generateFormatter(ctx));
3296
+ results.push(...await generateLefthook(ctx));
3297
+ results.push(await generateGitignore(ctx));
3298
+ results.push(await generateKnip(ctx));
3299
+ results.push(await generateRenovate(ctx));
3300
+ results.push(await generateCi(ctx));
3301
+ results.push(...await generateClaudeSettings(ctx));
3302
+ results.push(await generateReleaseIt(ctx));
3303
+ results.push(await generateChangesets(ctx));
3304
+ results.push(await generateReleaseCi(ctx));
3305
+ results.push(await generateDeployCi(ctx));
3306
+ results.push(...await generateVitest(ctx));
3307
+ results.push(...await generateVscodeSettings(ctx));
3308
+ results.push(saveToolingConfig(ctx, ctx.config));
3309
+ return results;
2998
3310
  }
2999
3311
  //#endregion
3000
3312
  //#region src/generators/migrate-prompt.ts
@@ -3007,7 +3319,7 @@ function generateMigratePrompt(results, config, detected) {
3007
3319
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3008
3320
  sections.push("# Migration Prompt");
3009
3321
  sections.push("");
3010
- sections.push(`_Generated by \`@bensandee/tooling@0.28.0 repo:sync\` on ${timestamp}_`);
3322
+ sections.push(`_Generated by \`@bensandee/tooling@0.29.0 repo:sync\` on ${timestamp}_`);
3011
3323
  sections.push("");
3012
3324
  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.");
3013
3325
  sections.push("");
@@ -3041,29 +3353,6 @@ function generateMigratePrompt(results, config, detected) {
3041
3353
  }
3042
3354
  sections.push("## Migration tasks");
3043
3355
  sections.push("");
3044
- if (config.releaseStrategy === "simple" && !detected.hasCommitAndTagVersion) {
3045
- sections.push("### Add commit-and-tag-version to devDependencies");
3046
- sections.push("");
3047
- sections.push("The `simple` release strategy requires `commit-and-tag-version` as a root devDependency so that `pnpm exec commit-and-tag-version` resolves correctly.");
3048
- sections.push("");
3049
- sections.push("Run:");
3050
- sections.push("");
3051
- sections.push("```sh");
3052
- sections.push("pnpm add -D -w commit-and-tag-version");
3053
- sections.push("```");
3054
- sections.push("");
3055
- }
3056
- if (config.releaseStrategy !== "none" && !detected.hasRepositoryField) {
3057
- sections.push("### Add repository field to package.json");
3058
- sections.push("");
3059
- sections.push(`The release strategy \`${config.releaseStrategy}\` requires a \`repository\` field in \`package.json\` so that \`release:trigger\` can determine the correct hosting platform (Forgejo vs GitHub).`);
3060
- sections.push("");
3061
- sections.push("Add the appropriate repository URL to `package.json`:");
3062
- sections.push("");
3063
- sections.push("- For Forgejo: `\"repository\": \"https://your-forgejo-instance.com/owner/repo\"`");
3064
- sections.push("- For GitHub: `\"repository\": \"https://github.com/owner/repo\"`");
3065
- sections.push("");
3066
- }
3067
3356
  const legacyToRemove = detected.legacyConfigs.filter((legacy) => !(legacy.tool === "prettier" && config.formatter === "prettier"));
3068
3357
  if (legacyToRemove.length > 0) {
3069
3358
  sections.push("### Remove legacy tooling");
@@ -4820,7 +5109,7 @@ const dockerCheckCommand = defineCommand({
4820
5109
  const main = defineCommand({
4821
5110
  meta: {
4822
5111
  name: "bst",
4823
- version: "0.28.0",
5112
+ version: "0.29.0",
4824
5113
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4825
5114
  },
4826
5115
  subCommands: {
@@ -4836,7 +5125,7 @@ const main = defineCommand({
4836
5125
  "docker:check": dockerCheckCommand
4837
5126
  }
4838
5127
  });
4839
- console.log(`@bensandee/tooling v0.28.0`);
5128
+ console.log(`@bensandee/tooling v0.29.0`);
4840
5129
  async function run() {
4841
5130
  await runMain(main);
4842
5131
  process.exit(process.exitCode ?? 0);