@bensandee/tooling 0.27.1 → 0.28.1

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
@@ -113,6 +113,7 @@ const LEGACY_PATTERNS = {
113
113
  /** Detect existing project state in the target directory. */
114
114
  function detectProject(targetDir) {
115
115
  const exists = (rel) => existsSync(path.join(targetDir, rel));
116
+ const rootPkg = readPackageJson(targetDir);
116
117
  return {
117
118
  hasPackageJson: exists("package.json"),
118
119
  hasTsconfig: exists("tsconfig.json"),
@@ -127,7 +128,8 @@ function detectProject(targetDir) {
127
128
  hasReleaseItConfig: exists(".release-it.json") || exists(".release-it.yaml") || exists(".release-it.toml"),
128
129
  hasSimpleReleaseConfig: exists(".versionrc") || exists(".versionrc.json") || exists(".versionrc.js"),
129
130
  hasChangesetsConfig: exists(".changeset/config.json"),
130
- hasRepositoryField: !!readPackageJson(targetDir)?.repository,
131
+ hasRepositoryField: !!rootPkg?.repository,
132
+ hasCommitAndTagVersion: !!rootPkg?.devDependencies?.["commit-and-tag-version"],
131
133
  legacyConfigs: detectLegacyConfigs(targetDir)
132
134
  };
133
135
  }
@@ -278,14 +280,18 @@ function getMonorepoPackages(targetDir) {
278
280
  function isCancelled(value) {
279
281
  return clack.isCancel(value);
280
282
  }
283
+ function detectProjectInfo(targetDir) {
284
+ const existingPkg = readPackageJson(targetDir);
285
+ return {
286
+ detected: detectProject(targetDir),
287
+ defaults: computeDefaults(targetDir),
288
+ name: existingPkg?.name ?? path.basename(targetDir)
289
+ };
290
+ }
281
291
  async function runInitPrompts(targetDir, saved) {
282
292
  clack.intro("@bensandee/tooling repo:sync");
283
- const existingPkg = readPackageJson(targetDir);
284
- const detected = detectProject(targetDir);
285
- const defaults = computeDefaults(targetDir);
286
- const isExisting = detected.hasPackageJson;
293
+ const { detected, defaults, name } = detectProjectInfo(targetDir);
287
294
  const isFirstInit = !saved;
288
- const name = existingPkg?.name ?? path.basename(targetDir);
289
295
  const structure = saved?.structure ?? defaults.structure;
290
296
  const useEslintPlugin = saved?.useEslintPlugin ?? defaults.useEslintPlugin;
291
297
  let formatter = saved?.formatter ?? defaults.formatter;
@@ -404,7 +410,6 @@ async function runInitPrompts(targetDir, saved) {
404
410
  clack.outro("Configuration complete!");
405
411
  return {
406
412
  name,
407
- isNew: !isExisting,
408
413
  structure,
409
414
  useEslintPlugin,
410
415
  formatter,
@@ -421,12 +426,9 @@ async function runInitPrompts(targetDir, saved) {
421
426
  }
422
427
  /** Build a ProjectConfig from CLI flags for non-interactive mode. */
423
428
  function buildDefaultConfig(targetDir, flags) {
424
- const existingPkg = readPackageJson(targetDir);
425
- const detected = detectProject(targetDir);
426
- const defaults = computeDefaults(targetDir);
429
+ const { defaults, name } = detectProjectInfo(targetDir);
427
430
  return {
428
- name: existingPkg?.name ?? path.basename(targetDir),
429
- isNew: !detected.hasPackageJson,
431
+ name,
430
432
  ...defaults,
431
433
  ...flags.eslintPlugin !== void 0 && { useEslintPlugin: flags.eslintPlugin },
432
434
  ...flags.noCi && { ci: "none" },
@@ -572,7 +574,6 @@ const ToolingConfigSchema = z.strictObject({
572
574
  "library"
573
575
  ]).optional().meta({ description: "Project type (determines tsconfig base)" }),
574
576
  detectPackageTypes: z.boolean().optional().meta({ description: "Auto-detect project types for monorepo packages" }),
575
- setupDocker: z.boolean().optional().meta({ description: "Generate Docker build/check scripts" }),
576
577
  docker: z.record(z.string(), z.object({
577
578
  dockerfile: z.string().meta({ description: "Path to Dockerfile relative to package" }),
578
579
  context: z.string().default(".").meta({ description: "Docker build context relative to package" })
@@ -635,7 +636,6 @@ function saveToolingConfig(ctx, config) {
635
636
  function mergeWithSavedConfig(detected, saved) {
636
637
  return {
637
638
  name: detected.name,
638
- isNew: detected.isNew,
639
639
  targetDir: detected.targetDir,
640
640
  structure: saved.structure ?? detected.structure,
641
641
  useEslintPlugin: saved.useEslintPlugin ?? detected.useEslintPlugin,
@@ -651,173 +651,499 @@ function mergeWithSavedConfig(detected, saved) {
651
651
  };
652
652
  }
653
653
  //#endregion
654
- //#region src/utils/yaml-merge.ts
655
- const IGNORE_PATTERN = "@bensandee/tooling:ignore";
656
- const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
657
- /** Returns a yaml-language-server schema comment for Forgejo workflows, empty string otherwise. */
658
- function workflowSchemaComment(ci) {
659
- return ci === "forgejo" ? FORGEJO_SCHEMA_COMMENT : "";
660
- }
661
- /** Prepend the Forgejo schema comment if it's not already present. No-op for GitHub. */
662
- function ensureSchemaComment(content, ci) {
663
- if (ci !== "forgejo") return content;
664
- if (content.includes("yaml-language-server")) return content;
665
- return FORGEJO_SCHEMA_COMMENT + content;
666
- }
667
- /** Migrate content from old tooling binary name to new. */
668
- function migrateToolingBinary(content) {
669
- return content.replaceAll("pnpm exec tooling ", "pnpm exec bst ");
670
- }
671
- /** Check if a YAML file has an opt-out comment in the first 10 lines. */
672
- function isToolingIgnored(content) {
673
- return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
654
+ //#region src/release/docker.ts
655
+ const ToolingDockerMapSchema = z.record(z.string(), z.object({
656
+ dockerfile: z.string(),
657
+ context: z.string().default(".")
658
+ }));
659
+ const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
660
+ const PackageInfoSchema = z.object({
661
+ name: z.string().optional(),
662
+ version: z.string().optional()
663
+ });
664
+ /** Read the docker map from .tooling.json. Returns empty record if missing or invalid. */
665
+ function loadDockerMap(executor, cwd) {
666
+ const configPath = path.join(cwd, ".tooling.json");
667
+ const raw = executor.readFile(configPath);
668
+ if (!raw) return {};
669
+ try {
670
+ const result = ToolingConfigDockerSchema.safeParse(JSON.parse(raw));
671
+ if (!result.success || !result.data.docker) return {};
672
+ return result.data.docker;
673
+ } catch (_error) {
674
+ return {};
675
+ }
674
676
  }
675
- /**
676
- * Ensure required commands exist under `pre-commit.commands` in a lefthook config.
677
- * Only adds missing commands — never modifies existing ones.
678
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
679
- */
680
- function mergeLefthookCommands(existing, requiredCommands) {
681
- if (isToolingIgnored(existing)) return {
682
- content: existing,
683
- changed: false
677
+ /** Read name and version from a package's package.json. */
678
+ function readPackageInfo(executor, packageJsonPath) {
679
+ const raw = executor.readFile(packageJsonPath);
680
+ if (!raw) return {
681
+ name: void 0,
682
+ version: void 0
684
683
  };
685
684
  try {
686
- const doc = parseDocument(existing);
687
- let changed = false;
688
- if (!doc.hasIn(["pre-commit", "commands"])) {
689
- doc.setIn(["pre-commit", "commands"], requiredCommands);
690
- return {
691
- content: doc.toString(),
692
- changed: true
693
- };
694
- }
695
- const commands = doc.getIn(["pre-commit", "commands"]);
696
- if (!isMap(commands)) return {
697
- content: existing,
698
- changed: false
685
+ const result = PackageInfoSchema.safeParse(JSON.parse(raw));
686
+ if (!result.success) return {
687
+ name: void 0,
688
+ version: void 0
699
689
  };
700
- for (const [name, config] of Object.entries(requiredCommands)) if (!commands.has(name)) {
701
- commands.set(name, config);
702
- changed = true;
703
- }
704
690
  return {
705
- content: changed ? doc.toString() : existing,
706
- changed
691
+ name: result.data.name,
692
+ version: result.data.version
707
693
  };
708
- } catch {
694
+ } catch (_error) {
709
695
  return {
710
- content: existing,
711
- changed: false
696
+ name: void 0,
697
+ version: void 0
712
698
  };
713
699
  }
714
700
  }
701
+ /** Strip npm scope from a package name: "@scope/foo" → "foo", "foo" → "foo". */
702
+ function stripScope(name) {
703
+ const slashIndex = name.indexOf("/");
704
+ return name.startsWith("@") && slashIndex !== -1 ? name.slice(slashIndex + 1) : name;
705
+ }
706
+ /** Convention paths to check for Dockerfiles in a package directory. */
707
+ const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
715
708
  /**
716
- * Ensure required steps exist in a workflow job's steps array.
717
- * Only adds missing steps at the end — never modifies existing ones.
718
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
709
+ * Find a Dockerfile at convention paths for a monorepo package.
710
+ * Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
719
711
  */
720
- function mergeWorkflowSteps(existing, jobName, requiredSteps) {
721
- if (isToolingIgnored(existing)) return {
722
- content: existing,
723
- changed: false
724
- };
725
- try {
726
- const doc = parseDocument(existing);
727
- const steps = doc.getIn([
728
- "jobs",
729
- jobName,
730
- "steps"
731
- ]);
732
- if (!isSeq(steps)) return {
733
- content: existing,
734
- changed: false
735
- };
736
- let changed = false;
737
- for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
738
- if (!isMap(item)) return false;
739
- if (match.run) {
740
- const run = item.get("run");
741
- return typeof run === "string" && run.includes(match.run);
742
- }
743
- if (match.uses) {
744
- const uses = item.get("uses");
745
- return typeof uses === "string" && uses.startsWith(match.uses);
746
- }
747
- return false;
748
- })) {
749
- steps.add(doc.createNode(step));
750
- changed = true;
751
- }
752
- return {
753
- content: changed ? doc.toString() : existing,
754
- changed
755
- };
756
- } catch {
757
- return {
758
- content: existing,
759
- changed: false
712
+ function findConventionDockerfile(executor, cwd, dir) {
713
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) {
714
+ const dockerfilePath = `packages/${dir}/${rel}`;
715
+ if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
716
+ dockerfile: dockerfilePath,
717
+ context: "."
760
718
  };
761
719
  }
762
720
  }
763
721
  /**
764
- * Add a job to an existing workflow YAML if it doesn't already exist.
765
- * Returns unchanged content if the job already exists, the file has an opt-out comment,
766
- * or the document can't be parsed.
722
+ * Find a Dockerfile at convention paths for a single-package repo.
723
+ * Checks Dockerfile and docker/Dockerfile at the project root.
767
724
  */
725
+ function findRootDockerfile(executor, cwd) {
726
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
727
+ dockerfile: rel,
728
+ context: "."
729
+ };
730
+ }
768
731
  /**
769
- * Ensure a `concurrency` block exists at the workflow top level.
770
- * Adds it if missing — never modifies an existing one.
771
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
732
+ * Discover Docker packages by convention and merge with .tooling.json overrides.
733
+ *
734
+ * Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
735
+ * For monorepos, scans packages/{name}/. For single-package repos, scans the root.
736
+ * The docker map in .tooling.json overrides convention-discovered config and can add
737
+ * packages at non-standard locations.
738
+ *
739
+ * Image names are derived from {root-name}-{package-name} using each package's package.json name.
740
+ * Versions are read from each package's own package.json.
772
741
  */
773
- function ensureWorkflowConcurrency(existing, concurrency) {
774
- if (isToolingIgnored(existing)) return {
775
- content: existing,
776
- changed: false
777
- };
778
- try {
779
- const doc = parseDocument(existing);
780
- if (doc.has("concurrency")) return {
781
- content: existing,
782
- changed: false
783
- };
784
- doc.set("concurrency", concurrency);
785
- const contents = doc.contents;
786
- if (isMap(contents)) {
787
- const items = contents.items;
788
- const nameIdx = items.findIndex((p) => isScalar(p.key) && p.key.value === "name");
789
- const concPair = items.pop();
790
- if (concPair) items.splice(nameIdx + 1, 0, concPair);
742
+ function detectDockerPackages(executor, cwd, repoName) {
743
+ const overrides = loadDockerMap(executor, cwd);
744
+ const packageDirs = executor.listPackageDirs(cwd);
745
+ const packages = [];
746
+ const seen = /* @__PURE__ */ new Set();
747
+ if (packageDirs.length > 0) {
748
+ for (const dir of packageDirs) {
749
+ const convention = findConventionDockerfile(executor, cwd, dir);
750
+ const docker = overrides[dir] ?? convention;
751
+ if (docker) {
752
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
753
+ packages.push({
754
+ dir,
755
+ imageName: `${repoName}-${stripScope(name ?? dir)}`,
756
+ version,
757
+ docker
758
+ });
759
+ seen.add(dir);
760
+ }
761
+ }
762
+ for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
763
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
764
+ packages.push({
765
+ dir,
766
+ imageName: `${repoName}-${stripScope(name ?? dir)}`,
767
+ version,
768
+ docker
769
+ });
770
+ }
771
+ } else {
772
+ const convention = findRootDockerfile(executor, cwd);
773
+ const docker = overrides["."] ?? convention;
774
+ if (docker) {
775
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
776
+ packages.push({
777
+ dir: ".",
778
+ imageName: stripScope(name ?? repoName),
779
+ version,
780
+ docker
781
+ });
791
782
  }
792
- return {
793
- content: doc.toString(),
794
- changed: true
795
- };
796
- } catch {
797
- return {
798
- content: existing,
799
- changed: false
800
- };
801
783
  }
784
+ return packages;
802
785
  }
803
- //#endregion
804
- //#region src/generators/deploy-ci.ts
805
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
806
- function actionsExpr$2(expr) {
807
- return `\${{ ${expr} }}`;
808
- }
809
- function hasEnginesNode$2(ctx) {
810
- return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
811
- }
812
- function deployWorkflow(ci, nodeVersionYaml) {
813
- return `${workflowSchemaComment(ci)}name: Deploy
814
- on:
815
- push:
816
- tags:
817
- - "v[0-9]+.[0-9]+.[0-9]+"
786
+ /**
787
+ * Read docker config for a single package, checking convention paths first,
788
+ * then .tooling.json overrides. Used by the per-package image:build script.
789
+ */
790
+ function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
791
+ const dir = path.basename(path.resolve(cwd, packageDir));
792
+ const convention = findConventionDockerfile(executor, cwd, dir);
793
+ const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
794
+ if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
795
+ const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
796
+ return {
797
+ dir,
798
+ imageName: `${repoName}-${stripScope(name ?? dir)}`,
799
+ version,
800
+ docker
801
+ };
802
+ }
803
+ /** Parse semver version string into major, minor, patch components. */
804
+ function parseSemver(version) {
805
+ const clean = version.replace(/^v/, "");
806
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(clean);
807
+ if (!match?.[1] || !match[2] || !match[3]) throw new FatalError(`Invalid semver version: ${version}`);
808
+ return {
809
+ major: Number(match[1]),
810
+ minor: Number(match[2]),
811
+ patch: Number(match[3])
812
+ };
813
+ }
814
+ /** Generate semver tag variants: latest, vX.Y.Z, vX.Y, vX */
815
+ function generateTags(version) {
816
+ const { major, minor, patch } = parseSemver(version);
817
+ return [
818
+ "latest",
819
+ `v${major}.${minor}.${patch}`,
820
+ `v${major}.${minor}`,
821
+ `v${major}`
822
+ ];
823
+ }
824
+ /** Build the full image reference: namespace/imageName:tag */
825
+ function imageRef(namespace, imageName, tag) {
826
+ return `${namespace}/${imageName}:${tag}`;
827
+ }
828
+ function log$1(message) {
829
+ console.log(message);
830
+ }
831
+ /** Read the repo name from root package.json. */
832
+ function readRepoName(executor, cwd) {
833
+ const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
834
+ if (!rootPkgRaw) throw new FatalError("No package.json found in project root");
835
+ const repoName = parsePackageJson(rootPkgRaw)?.name;
836
+ if (!repoName) throw new FatalError("Root package.json must have a name field");
837
+ return repoName;
838
+ }
839
+ /** Build a single docker image from its config. Paths are resolved relative to cwd. */
840
+ function buildImage(executor, pkg, cwd, extraArgs) {
841
+ const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
842
+ const contextPath = path.resolve(cwd, pkg.docker.context);
843
+ const command = [
844
+ "docker build",
845
+ `-f ${dockerfilePath}`,
846
+ `-t ${pkg.imageName}:latest`,
847
+ ...extraArgs,
848
+ contextPath
849
+ ].join(" ");
850
+ executor.execInherit(command);
851
+ }
852
+ /**
853
+ * Detect packages with docker config in .tooling.json and build each one.
854
+ * Runs `docker build -f <dockerfile> -t <image-name>:latest <context>` for each package.
855
+ * Dockerfile and context paths are resolved relative to the project root.
856
+ *
857
+ * When `packageDir` is set, builds only that single package (for use as an image:build script).
858
+ */
859
+ function runDockerBuild(executor, config) {
860
+ const repoName = readRepoName(executor, config.cwd);
861
+ if (config.packageDir) {
862
+ const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
863
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
864
+ buildImage(executor, pkg, config.cwd, config.extraArgs);
865
+ log$1(`Built ${pkg.imageName}:latest`);
866
+ return { packages: [pkg] };
867
+ }
868
+ const packages = detectDockerPackages(executor, config.cwd, repoName);
869
+ if (packages.length === 0) {
870
+ log$1("No packages with docker config found");
871
+ return { packages: [] };
872
+ }
873
+ log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
874
+ for (const pkg of packages) {
875
+ log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
876
+ buildImage(executor, pkg, config.cwd, config.extraArgs);
877
+ }
878
+ log$1(`Built ${packages.length} image(s)`);
879
+ return { packages };
880
+ }
881
+ /**
882
+ * Run the full Docker publish pipeline:
883
+ * 1. Build all images via runDockerBuild
884
+ * 2. Login to registry
885
+ * 3. Tag each image with semver variants from its own package.json version
886
+ * 4. Push all tags
887
+ * 5. Logout from registry
888
+ */
889
+ function runDockerPublish(executor, config) {
890
+ const { packages } = runDockerBuild(executor, {
891
+ cwd: config.cwd,
892
+ packageDir: void 0,
893
+ extraArgs: []
894
+ });
895
+ if (packages.length === 0) return {
896
+ packages: [],
897
+ tags: []
898
+ };
899
+ for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
900
+ if (!config.dryRun) {
901
+ log$1(`Logging in to ${config.registryHost}...`);
902
+ const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
903
+ if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
904
+ } else log$1("[dry-run] Skipping docker login");
905
+ const allTags = [];
906
+ try {
907
+ for (const pkg of packages) {
908
+ const tags = generateTags(pkg.version ?? "");
909
+ log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
910
+ for (const tag of tags) {
911
+ const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
912
+ allTags.push(ref);
913
+ log$1(`Tagging ${pkg.imageName} → ${ref}`);
914
+ const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
915
+ if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
916
+ if (!config.dryRun) {
917
+ log$1(`Pushing ${ref}...`);
918
+ const pushResult = executor.exec(`docker push ${ref}`);
919
+ if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
920
+ } else log$1(`[dry-run] Skipping push for ${ref}`);
921
+ }
922
+ }
923
+ } finally {
924
+ if (!config.dryRun) {
925
+ log$1(`Logging out from ${config.registryHost}...`);
926
+ executor.exec(`docker logout ${config.registryHost}`);
927
+ }
928
+ }
929
+ log$1(`Published ${allTags.length} image tag(s)`);
930
+ return {
931
+ packages,
932
+ tags: allTags
933
+ };
934
+ }
935
+ //#endregion
936
+ //#region src/utils/yaml-merge.ts
937
+ const IGNORE_PATTERN = "@bensandee/tooling:ignore";
938
+ const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
939
+ /** Returns a yaml-language-server schema comment for Forgejo workflows, empty string otherwise. */
940
+ function workflowSchemaComment(ci) {
941
+ return ci === "forgejo" ? FORGEJO_SCHEMA_COMMENT : "";
942
+ }
943
+ /** Prepend the Forgejo schema comment if it's not already present. No-op for GitHub. */
944
+ function ensureSchemaComment(content, ci) {
945
+ if (ci !== "forgejo") return content;
946
+ if (content.includes("yaml-language-server")) return content;
947
+ return FORGEJO_SCHEMA_COMMENT + content;
948
+ }
949
+ /** Migrate content from old tooling binary name to new. */
950
+ function migrateToolingBinary(content) {
951
+ return content.replaceAll("pnpm exec tooling ", "pnpm exec bst ");
952
+ }
953
+ /** Check if a YAML file has an opt-out comment in the first 10 lines. */
954
+ function isToolingIgnored(content) {
955
+ return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
956
+ }
957
+ /**
958
+ * Ensure required commands exist under `pre-commit.commands` in a lefthook config.
959
+ * Only adds missing commands — never modifies existing ones.
960
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
961
+ */
962
+ function mergeLefthookCommands(existing, requiredCommands) {
963
+ if (isToolingIgnored(existing)) return {
964
+ content: existing,
965
+ changed: false
966
+ };
967
+ try {
968
+ const doc = parseDocument(existing);
969
+ let changed = false;
970
+ if (!doc.hasIn(["pre-commit", "commands"])) {
971
+ doc.setIn(["pre-commit", "commands"], requiredCommands);
972
+ return {
973
+ content: doc.toString(),
974
+ changed: true
975
+ };
976
+ }
977
+ const commands = doc.getIn(["pre-commit", "commands"]);
978
+ if (!isMap(commands)) return {
979
+ content: existing,
980
+ changed: false
981
+ };
982
+ for (const [name, config] of Object.entries(requiredCommands)) if (!commands.has(name)) {
983
+ commands.set(name, config);
984
+ changed = true;
985
+ }
986
+ return {
987
+ content: changed ? doc.toString() : existing,
988
+ changed
989
+ };
990
+ } catch {
991
+ return {
992
+ content: existing,
993
+ changed: false
994
+ };
995
+ }
996
+ }
997
+ /**
998
+ * Ensure required steps exist in a workflow job's steps array.
999
+ * Only adds missing steps at the end — never modifies existing ones.
1000
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1001
+ */
1002
+ function mergeWorkflowSteps(existing, jobName, requiredSteps) {
1003
+ if (isToolingIgnored(existing)) return {
1004
+ content: existing,
1005
+ changed: false
1006
+ };
1007
+ try {
1008
+ const doc = parseDocument(existing);
1009
+ const steps = doc.getIn([
1010
+ "jobs",
1011
+ jobName,
1012
+ "steps"
1013
+ ]);
1014
+ if (!isSeq(steps)) return {
1015
+ content: existing,
1016
+ changed: false
1017
+ };
1018
+ let changed = false;
1019
+ for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
1020
+ if (!isMap(item)) return false;
1021
+ if (match.run) {
1022
+ const run = item.get("run");
1023
+ return typeof run === "string" && run.includes(match.run);
1024
+ }
1025
+ if (match.uses) {
1026
+ const uses = item.get("uses");
1027
+ return typeof uses === "string" && uses.startsWith(match.uses);
1028
+ }
1029
+ return false;
1030
+ })) {
1031
+ steps.add(doc.createNode(step));
1032
+ changed = true;
1033
+ }
1034
+ return {
1035
+ content: changed ? doc.toString() : existing,
1036
+ changed
1037
+ };
1038
+ } catch {
1039
+ return {
1040
+ content: existing,
1041
+ changed: false
1042
+ };
1043
+ }
1044
+ }
1045
+ /**
1046
+ * Add a job to an existing workflow YAML if it doesn't already exist.
1047
+ * Returns unchanged content if the job already exists, the file has an opt-out comment,
1048
+ * or the document can't be parsed.
1049
+ */
1050
+ /**
1051
+ * Ensure a `concurrency` block exists at the workflow top level.
1052
+ * Adds it if missing — never modifies an existing one.
1053
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1054
+ */
1055
+ /**
1056
+ * Ensure `on.push` has `tags-ignore: ["**"]` so tag pushes don't trigger CI.
1057
+ * Only adds the filter when `on.push` exists and `tags-ignore` is absent.
1058
+ */
1059
+ function ensureWorkflowTagsIgnore(existing) {
1060
+ if (isToolingIgnored(existing)) return {
1061
+ content: existing,
1062
+ changed: false
1063
+ };
1064
+ try {
1065
+ const doc = parseDocument(existing);
1066
+ const on = doc.get("on");
1067
+ if (!isMap(on)) return {
1068
+ content: existing,
1069
+ changed: false
1070
+ };
1071
+ const push = on.get("push");
1072
+ if (!isMap(push)) return {
1073
+ content: existing,
1074
+ changed: false
1075
+ };
1076
+ if (push.has("tags-ignore")) return {
1077
+ content: existing,
1078
+ changed: false
1079
+ };
1080
+ push.set("tags-ignore", ["**"]);
1081
+ return {
1082
+ content: doc.toString(),
1083
+ changed: true
1084
+ };
1085
+ } catch {
1086
+ return {
1087
+ content: existing,
1088
+ changed: false
1089
+ };
1090
+ }
1091
+ }
1092
+ function ensureWorkflowConcurrency(existing, concurrency) {
1093
+ if (isToolingIgnored(existing)) return {
1094
+ content: existing,
1095
+ changed: false
1096
+ };
1097
+ try {
1098
+ const doc = parseDocument(existing);
1099
+ if (doc.has("concurrency")) return {
1100
+ content: existing,
1101
+ changed: false
1102
+ };
1103
+ doc.set("concurrency", concurrency);
1104
+ const contents = doc.contents;
1105
+ if (isMap(contents)) {
1106
+ const items = contents.items;
1107
+ const nameIdx = items.findIndex((p) => isScalar(p.key) && p.key.value === "name");
1108
+ const concPair = items.pop();
1109
+ if (concPair) items.splice(nameIdx + 1, 0, concPair);
1110
+ }
1111
+ return {
1112
+ content: doc.toString(),
1113
+ changed: true
1114
+ };
1115
+ } catch {
1116
+ return {
1117
+ content: existing,
1118
+ changed: false
1119
+ };
1120
+ }
1121
+ }
1122
+ //#endregion
1123
+ //#region src/generators/ci-utils.ts
1124
+ /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
1125
+ function actionsExpr(expr) {
1126
+ return `\${{ ${expr} }}`;
1127
+ }
1128
+ function hasEnginesNode(ctx) {
1129
+ const raw = ctx.read("package.json");
1130
+ if (!raw) return false;
1131
+ return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
1132
+ }
1133
+ function computeNodeVersionYaml(ctx) {
1134
+ return hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1135
+ }
1136
+ //#endregion
1137
+ //#region src/generators/publish-ci.ts
1138
+ function deployWorkflow(ci, nodeVersionYaml) {
1139
+ return `${workflowSchemaComment(ci)}name: Publish
1140
+ on:
1141
+ push:
1142
+ tags:
1143
+ - "v[0-9]+.[0-9]+.[0-9]+"
818
1144
 
819
1145
  jobs:
820
- deploy:
1146
+ publish:
821
1147
  runs-on: ubuntu-latest
822
1148
  steps:
823
1149
  - uses: actions/checkout@v4
@@ -828,10 +1154,10 @@ jobs:
828
1154
  - run: pnpm install --frozen-lockfile
829
1155
  - name: Publish Docker images
830
1156
  env:
831
- DOCKER_REGISTRY_HOST: ${actionsExpr$2("vars.DOCKER_REGISTRY_HOST")}
832
- DOCKER_REGISTRY_NAMESPACE: ${actionsExpr$2("vars.DOCKER_REGISTRY_NAMESPACE")}
833
- DOCKER_USERNAME: ${actionsExpr$2("secrets.DOCKER_USERNAME")}
834
- DOCKER_PASSWORD: ${actionsExpr$2("secrets.DOCKER_PASSWORD")}
1157
+ DOCKER_REGISTRY_HOST: ${actionsExpr("vars.DOCKER_REGISTRY_HOST")}
1158
+ DOCKER_REGISTRY_NAMESPACE: ${actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")}
1159
+ DOCKER_USERNAME: ${actionsExpr("secrets.DOCKER_USERNAME")}
1160
+ DOCKER_PASSWORD: ${actionsExpr("secrets.DOCKER_PASSWORD")}
835
1161
  run: pnpm exec bst docker:publish
836
1162
  `;
837
1163
  }
@@ -859,8 +1185,6 @@ function requiredDeploySteps() {
859
1185
  }
860
1186
  ];
861
1187
  }
862
- /** Convention paths to check for Dockerfiles. */
863
- const CONVENTION_DOCKERFILE_PATHS$1 = ["Dockerfile", "docker/Dockerfile"];
864
1188
  const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
865
1189
  /** Get names of packages that have Docker builds (by convention or .tooling.json config). */
866
1190
  function getDockerPackageNames(ctx) {
@@ -875,12 +1199,12 @@ function getDockerPackageNames(ctx) {
875
1199
  for (const pkg of packages) {
876
1200
  const dirName = pkg.name.split("/").pop() ?? pkg.name;
877
1201
  if (names.includes(dirName)) continue;
878
- for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(`packages/${dirName}/${rel}`)) {
1202
+ for (const rel of CONVENTION_DOCKERFILE_PATHS) if (ctx.exists(`packages/${dirName}/${rel}`)) {
879
1203
  names.push(dirName);
880
1204
  break;
881
1205
  }
882
1206
  }
883
- } else for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(rel)) {
1207
+ } else for (const rel of CONVENTION_DOCKERFILE_PATHS) if (ctx.exists(rel)) {
884
1208
  if (!names.includes(ctx.config.name)) names.push(ctx.config.name);
885
1209
  break;
886
1210
  }
@@ -899,7 +1223,7 @@ async function generateDeployCi(ctx) {
899
1223
  };
900
1224
  const isGitHub = ctx.config.ci === "github";
901
1225
  const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
902
- const nodeVersionYaml = hasEnginesNode$2(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1226
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
903
1227
  const content = deployWorkflow(ctx.config.ci, nodeVersionYaml);
904
1228
  if (ctx.exists(workflowPath)) {
905
1229
  const raw = ctx.read(workflowPath);
@@ -911,16 +1235,16 @@ async function generateDeployCi(ctx) {
911
1235
  return {
912
1236
  filePath: workflowPath,
913
1237
  action: "updated",
914
- description: "Migrated tooling binary name in deploy workflow"
1238
+ description: "Migrated tooling binary name in publish workflow"
915
1239
  };
916
1240
  }
917
1241
  return {
918
1242
  filePath: workflowPath,
919
1243
  action: "skipped",
920
- description: "Deploy workflow already up to date"
1244
+ description: "Publish workflow already up to date"
921
1245
  };
922
1246
  }
923
- const merged = mergeWorkflowSteps(existing, "deploy", requiredDeploySteps());
1247
+ const merged = mergeWorkflowSteps(existing, "publish", requiredDeploySteps());
924
1248
  const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
925
1249
  if (!merged.changed) {
926
1250
  if (withComment !== raw) {
@@ -928,33 +1252,33 @@ async function generateDeployCi(ctx) {
928
1252
  return {
929
1253
  filePath: workflowPath,
930
1254
  action: "updated",
931
- description: existing !== raw ? "Migrated tooling binary name in deploy workflow" : "Added schema comment to deploy workflow"
1255
+ description: existing !== raw ? "Migrated tooling binary name in publish workflow" : "Added schema comment to publish workflow"
932
1256
  };
933
1257
  }
934
1258
  return {
935
1259
  filePath: workflowPath,
936
1260
  action: "skipped",
937
- description: "Existing deploy workflow preserved"
1261
+ description: "Existing publish workflow preserved"
938
1262
  };
939
1263
  }
940
1264
  ctx.write(workflowPath, withComment);
941
1265
  return {
942
1266
  filePath: workflowPath,
943
1267
  action: "updated",
944
- description: "Added missing steps to deploy workflow"
1268
+ description: "Added missing steps to publish workflow"
945
1269
  };
946
1270
  }
947
1271
  return {
948
1272
  filePath: workflowPath,
949
1273
  action: "skipped",
950
- description: "Deploy workflow already up to date"
1274
+ description: "Publish workflow already up to date"
951
1275
  };
952
1276
  }
953
1277
  ctx.write(workflowPath, content);
954
1278
  return {
955
1279
  filePath: workflowPath,
956
1280
  action: "created",
957
- description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions deploy workflow`
1281
+ description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions publish workflow`
958
1282
  };
959
1283
  }
960
1284
  //#endregion
@@ -1054,7 +1378,7 @@ function getAddedDevDepNames(config) {
1054
1378
  const deps = { ...ROOT_DEV_DEPS };
1055
1379
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
1056
1380
  deps["@bensandee/config"] = "0.9.1";
1057
- deps["@bensandee/tooling"] = "0.27.1";
1381
+ deps["@bensandee/tooling"] = "0.28.1";
1058
1382
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
1059
1383
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
1060
1384
  addReleaseDeps(deps, config);
@@ -1079,7 +1403,7 @@ async function generatePackageJson(ctx) {
1079
1403
  const devDeps = { ...ROOT_DEV_DEPS };
1080
1404
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
1081
1405
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.9.1";
1082
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.27.1";
1406
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.28.1";
1083
1407
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
1084
1408
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
1085
1409
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1563,30 +1887,23 @@ async function generateGitignore(ctx) {
1563
1887
  }
1564
1888
  //#endregion
1565
1889
  //#region src/generators/ci.ts
1566
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
1567
- function actionsExpr$1(expr) {
1568
- return `\${{ ${expr} }}`;
1569
- }
1570
1890
  const CI_CONCURRENCY = {
1571
- group: `ci-${actionsExpr$1("github.ref")}`,
1572
- "cancel-in-progress": actionsExpr$1("github.ref != 'refs/heads/main'")
1891
+ group: `ci-${actionsExpr("github.ref")}`,
1892
+ "cancel-in-progress": actionsExpr("github.ref != 'refs/heads/main'")
1573
1893
  };
1574
- function hasEnginesNode$1(ctx) {
1575
- const raw = ctx.read("package.json");
1576
- if (!raw) return false;
1577
- return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
1578
- }
1579
1894
  function ciWorkflow(nodeVersionYaml, isForgejo, isChangesets) {
1580
1895
  const emailNotifications = isForgejo ? "\nenable-email-notifications: true\n" : "";
1581
1896
  const concurrencyBlock = isChangesets ? `
1582
1897
  concurrency:
1583
- group: ci-${actionsExpr$1("github.ref")}
1584
- cancel-in-progress: ${actionsExpr$1("github.ref != 'refs/heads/main'")}
1898
+ group: ci-${actionsExpr("github.ref")}
1899
+ cancel-in-progress: ${actionsExpr("github.ref != 'refs/heads/main'")}
1585
1900
  ` : "";
1586
1901
  return `${workflowSchemaComment(isForgejo ? "forgejo" : "github")}name: CI
1587
1902
  ${emailNotifications}on:
1588
1903
  push:
1589
1904
  branches: [main]
1905
+ tags-ignore:
1906
+ - "**"
1590
1907
  pull_request:
1591
1908
  ${concurrencyBlock}
1592
1909
  jobs:
@@ -1649,13 +1966,18 @@ async function generateCi(ctx) {
1649
1966
  };
1650
1967
  const isGitHub = ctx.config.ci === "github";
1651
1968
  const isChangesets = ctx.config.releaseStrategy === "changesets";
1652
- const nodeVersionYaml = hasEnginesNode$1(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1969
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
1653
1970
  const filePath = ciWorkflowPath(ctx.config.ci, ctx.config.releaseStrategy);
1654
1971
  const content = ciWorkflow(nodeVersionYaml, !isGitHub, isChangesets);
1655
1972
  if (ctx.exists(filePath)) {
1656
1973
  const existing = ctx.read(filePath);
1657
1974
  if (existing) {
1658
1975
  let result = mergeWorkflowSteps(existing, "check", requiredCheckSteps(nodeVersionYaml));
1976
+ const withTagsIgnore = ensureWorkflowTagsIgnore(result.content);
1977
+ result = {
1978
+ content: withTagsIgnore.content,
1979
+ changed: result.changed || withTagsIgnore.changed
1980
+ };
1659
1981
  if (isChangesets) {
1660
1982
  const withConcurrency = ensureWorkflowConcurrency(result.content, CI_CONCURRENCY);
1661
1983
  result = {
@@ -2155,13 +2477,6 @@ async function generateChangesets(ctx) {
2155
2477
  }
2156
2478
  //#endregion
2157
2479
  //#region src/generators/release-ci.ts
2158
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
2159
- function actionsExpr(expr) {
2160
- return `\${{ ${expr} }}`;
2161
- }
2162
- function hasEnginesNode(ctx) {
2163
- return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
2164
- }
2165
2480
  function commonSteps(nodeVersionYaml, publishesNpm) {
2166
2481
  return ` - uses: actions/checkout@v4
2167
2482
  with:
@@ -2171,8 +2486,7 @@ function commonSteps(nodeVersionYaml, publishesNpm) {
2171
2486
  with:
2172
2487
  ${nodeVersionYaml}
2173
2488
  cache: pnpm${publishesNpm ? `\n registry-url: "https://registry.npmjs.org"` : ""}
2174
- - run: pnpm install --frozen-lockfile
2175
- - run: pnpm build`;
2489
+ - run: pnpm install --frozen-lockfile`;
2176
2490
  }
2177
2491
  function releaseItWorkflow(ci, nodeVersionYaml, publishesNpm) {
2178
2492
  const isGitHub = ci === "github";
@@ -2289,10 +2603,6 @@ function requiredReleaseSteps(strategy, nodeVersionYaml, publishesNpm) {
2289
2603
  {
2290
2604
  match: { run: "pnpm install" },
2291
2605
  step: { run: "pnpm install --frozen-lockfile" }
2292
- },
2293
- {
2294
- match: { run: "build" },
2295
- step: { run: "pnpm build" }
2296
2606
  }
2297
2607
  ];
2298
2608
  switch (strategy) {
@@ -2369,7 +2679,7 @@ async function generateReleaseCi(ctx) {
2369
2679
  if (ctx.config.releaseStrategy === "changesets") return generateChangesetsReleaseCi(ctx, publishesNpm);
2370
2680
  const isGitHub = ctx.config.ci === "github";
2371
2681
  const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
2372
- const nodeVersionYaml = hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
2682
+ const nodeVersionYaml = computeNodeVersionYaml(ctx);
2373
2683
  const content = buildWorkflow(ctx.config.releaseStrategy, ctx.config.ci, nodeVersionYaml, publishesNpm);
2374
2684
  if (!content) return {
2375
2685
  filePath,
@@ -2589,411 +2899,129 @@ async function generateLefthook(ctx) {
2589
2899
  const SCHEMA_NPM_PATH = "@bensandee/config/schemas/forgejo-workflow.schema.json";
2590
2900
  const SCHEMA_LOCAL_PATH = ".vscode/forgejo-workflow.schema.json";
2591
2901
  const SETTINGS_PATH = ".vscode/settings.json";
2592
- const SCHEMA_GLOB = ".forgejo/workflows/*.{yml,yaml}";
2593
- const VscodeSettingsSchema = z.looseObject({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) });
2594
- function readSchemaFromNodeModules(targetDir) {
2595
- const candidate = path.join(targetDir, "node_modules", SCHEMA_NPM_PATH);
2596
- if (!existsSync(candidate)) return void 0;
2597
- return readFileSync(candidate, "utf-8");
2598
- }
2599
- function serializeSettings(settings) {
2600
- return JSON.stringify(settings, null, 2) + "\n";
2601
- }
2602
- const YamlSchemasSchema = z.record(z.string(), z.unknown());
2603
- /** Merge yaml.schemas into a settings object. Returns the result and whether anything changed. */
2604
- function mergeYamlSchemas(settings) {
2605
- const parsed = YamlSchemasSchema.safeParse(settings["yaml.schemas"]);
2606
- const yamlSchemas = parsed.success ? { ...parsed.data } : {};
2607
- if (SCHEMA_LOCAL_PATH in yamlSchemas) return {
2608
- merged: settings,
2609
- changed: false
2610
- };
2611
- yamlSchemas[SCHEMA_LOCAL_PATH] = SCHEMA_GLOB;
2612
- return {
2613
- merged: {
2614
- ...settings,
2615
- "yaml.schemas": yamlSchemas
2616
- },
2617
- changed: true
2618
- };
2619
- }
2620
- function writeSchemaToSettings(ctx) {
2621
- if (ctx.exists(SETTINGS_PATH)) {
2622
- const raw = ctx.read(SETTINGS_PATH);
2623
- if (!raw) return {
2624
- filePath: SETTINGS_PATH,
2625
- action: "skipped",
2626
- description: "Could not read existing settings"
2627
- };
2628
- const parsed = VscodeSettingsSchema.safeParse(parse(raw));
2629
- if (!parsed.success) return {
2630
- filePath: SETTINGS_PATH,
2631
- action: "skipped",
2632
- description: "Could not parse existing settings"
2633
- };
2634
- const { merged, changed } = mergeYamlSchemas(parsed.data);
2635
- if (!changed) return {
2636
- filePath: SETTINGS_PATH,
2637
- action: "skipped",
2638
- description: "Already has Forgejo schema mapping"
2639
- };
2640
- ctx.write(SETTINGS_PATH, serializeSettings(merged));
2641
- return {
2642
- filePath: SETTINGS_PATH,
2643
- action: "updated",
2644
- description: "Added Forgejo workflow schema mapping"
2645
- };
2646
- }
2647
- ctx.write(SETTINGS_PATH, serializeSettings({ "yaml.schemas": { [SCHEMA_LOCAL_PATH]: SCHEMA_GLOB } }));
2648
- return {
2649
- filePath: SETTINGS_PATH,
2650
- action: "created",
2651
- description: "Generated .vscode/settings.json with Forgejo workflow schema"
2652
- };
2653
- }
2654
- async function generateVscodeSettings(ctx) {
2655
- const results = [];
2656
- if (ctx.config.ci !== "forgejo") {
2657
- results.push({
2658
- filePath: SETTINGS_PATH,
2659
- action: "skipped",
2660
- description: "Not a Forgejo project"
2661
- });
2662
- return results;
2663
- }
2664
- const schemaContent = readSchemaFromNodeModules(ctx.targetDir);
2665
- if (!schemaContent) {
2666
- results.push({
2667
- filePath: SCHEMA_LOCAL_PATH,
2668
- action: "skipped",
2669
- description: "Could not find @bensandee/config schema in node_modules"
2670
- });
2671
- return results;
2672
- }
2673
- const existingSchema = ctx.read(SCHEMA_LOCAL_PATH);
2674
- if (existingSchema !== void 0 && contentEqual(SCHEMA_LOCAL_PATH, existingSchema, schemaContent)) results.push({
2675
- filePath: SCHEMA_LOCAL_PATH,
2676
- action: "skipped",
2677
- description: "Schema already up to date"
2678
- });
2679
- else {
2680
- ctx.write(SCHEMA_LOCAL_PATH, schemaContent);
2681
- results.push({
2682
- filePath: SCHEMA_LOCAL_PATH,
2683
- action: existingSchema ? "updated" : "created",
2684
- description: "Copied Forgejo workflow schema from @bensandee/config"
2685
- });
2686
- }
2687
- results.push(writeSchemaToSettings(ctx));
2688
- return results;
2689
- }
2690
- //#endregion
2691
- //#region src/generators/pipeline.ts
2692
- /** Run all generators sequentially and return their results. */
2693
- async function runGenerators(ctx) {
2694
- const results = [];
2695
- results.push(await generatePackageJson(ctx));
2696
- results.push(await generatePnpmWorkspace(ctx));
2697
- results.push(...await generateTsconfig(ctx));
2698
- results.push(await generateTsdown(ctx));
2699
- results.push(await generateOxlint(ctx));
2700
- results.push(await generateFormatter(ctx));
2701
- results.push(...await generateLefthook(ctx));
2702
- results.push(await generateGitignore(ctx));
2703
- results.push(await generateKnip(ctx));
2704
- results.push(await generateRenovate(ctx));
2705
- results.push(await generateCi(ctx));
2706
- results.push(...await generateClaudeSettings(ctx));
2707
- results.push(await generateReleaseIt(ctx));
2708
- results.push(await generateChangesets(ctx));
2709
- results.push(await generateReleaseCi(ctx));
2710
- results.push(await generateDeployCi(ctx));
2711
- results.push(...await generateVitest(ctx));
2712
- results.push(...await generateVscodeSettings(ctx));
2713
- results.push(saveToolingConfig(ctx, ctx.config));
2714
- return results;
2715
- }
2716
- //#endregion
2717
- //#region src/release/docker.ts
2718
- const ToolingDockerMapSchema = z.record(z.string(), z.object({
2719
- dockerfile: z.string(),
2720
- context: z.string().default(".")
2721
- }));
2722
- const ToolingConfigDockerSchema = z.object({ docker: ToolingDockerMapSchema.optional() });
2723
- const PackageInfoSchema = z.object({
2724
- name: z.string().optional(),
2725
- version: z.string().optional()
2726
- });
2727
- /** Read the docker map from .tooling.json. Returns empty record if missing or invalid. */
2728
- function loadDockerMap(executor, cwd) {
2729
- const configPath = path.join(cwd, ".tooling.json");
2730
- const raw = executor.readFile(configPath);
2731
- if (!raw) return {};
2732
- try {
2733
- const result = ToolingConfigDockerSchema.safeParse(JSON.parse(raw));
2734
- if (!result.success || !result.data.docker) return {};
2735
- return result.data.docker;
2736
- } catch (_error) {
2737
- return {};
2738
- }
2739
- }
2740
- /** Read name and version from a package's package.json. */
2741
- function readPackageInfo(executor, packageJsonPath) {
2742
- const raw = executor.readFile(packageJsonPath);
2743
- if (!raw) return {
2744
- name: void 0,
2745
- version: void 0
2746
- };
2747
- try {
2748
- const result = PackageInfoSchema.safeParse(JSON.parse(raw));
2749
- if (!result.success) return {
2750
- name: void 0,
2751
- version: void 0
2752
- };
2753
- return {
2754
- name: result.data.name,
2755
- version: result.data.version
2756
- };
2757
- } catch (_error) {
2758
- return {
2759
- name: void 0,
2760
- version: void 0
2761
- };
2762
- }
2763
- }
2764
- /** Strip npm scope from a package name: "@scope/foo" → "foo", "foo" → "foo". */
2765
- function stripScope(name) {
2766
- const slashIndex = name.indexOf("/");
2767
- return name.startsWith("@") && slashIndex !== -1 ? name.slice(slashIndex + 1) : name;
2768
- }
2769
- /** Convention paths to check for Dockerfiles in a package directory. */
2770
- const CONVENTION_DOCKERFILE_PATHS = ["Dockerfile", "docker/Dockerfile"];
2771
- /**
2772
- * Find a Dockerfile at convention paths for a monorepo package.
2773
- * Checks packages/{dir}/Dockerfile and packages/{dir}/docker/Dockerfile.
2774
- */
2775
- function findConventionDockerfile(executor, cwd, dir) {
2776
- for (const rel of CONVENTION_DOCKERFILE_PATHS) {
2777
- const dockerfilePath = `packages/${dir}/${rel}`;
2778
- if (executor.readFile(path.join(cwd, dockerfilePath)) !== null) return {
2779
- dockerfile: dockerfilePath,
2780
- context: "."
2781
- };
2782
- }
2783
- }
2784
- /**
2785
- * Find a Dockerfile at convention paths for a single-package repo.
2786
- * Checks Dockerfile and docker/Dockerfile at the project root.
2787
- */
2788
- function findRootDockerfile(executor, cwd) {
2789
- for (const rel of CONVENTION_DOCKERFILE_PATHS) if (executor.readFile(path.join(cwd, rel)) !== null) return {
2790
- dockerfile: rel,
2791
- context: "."
2792
- };
2793
- }
2794
- /**
2795
- * Discover Docker packages by convention and merge with .tooling.json overrides.
2796
- *
2797
- * Convention: any package with a Dockerfile or docker/Dockerfile is a Docker package.
2798
- * For monorepos, scans packages/{name}/. For single-package repos, scans the root.
2799
- * The docker map in .tooling.json overrides convention-discovered config and can add
2800
- * packages at non-standard locations.
2801
- *
2802
- * Image names are derived from {root-name}-{package-name} using each package's package.json name.
2803
- * Versions are read from each package's own package.json.
2804
- */
2805
- function detectDockerPackages(executor, cwd, repoName) {
2806
- const overrides = loadDockerMap(executor, cwd);
2807
- const packageDirs = executor.listPackageDirs(cwd);
2808
- const packages = [];
2809
- const seen = /* @__PURE__ */ new Set();
2810
- if (packageDirs.length > 0) {
2811
- for (const dir of packageDirs) {
2812
- const convention = findConventionDockerfile(executor, cwd, dir);
2813
- const docker = overrides[dir] ?? convention;
2814
- if (docker) {
2815
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2816
- packages.push({
2817
- dir,
2818
- imageName: `${repoName}-${stripScope(name ?? dir)}`,
2819
- version,
2820
- docker
2821
- });
2822
- seen.add(dir);
2823
- }
2824
- }
2825
- for (const [dir, docker] of Object.entries(overrides)) if (!seen.has(dir)) {
2826
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2827
- packages.push({
2828
- dir,
2829
- imageName: `${repoName}-${stripScope(name ?? dir)}`,
2830
- version,
2831
- docker
2832
- });
2833
- }
2834
- } else {
2835
- const convention = findRootDockerfile(executor, cwd);
2836
- const docker = overrides["."] ?? convention;
2837
- if (docker) {
2838
- const { name, version } = readPackageInfo(executor, path.join(cwd, "package.json"));
2839
- packages.push({
2840
- dir: ".",
2841
- imageName: stripScope(name ?? repoName),
2842
- version,
2843
- docker
2844
- });
2845
- }
2846
- }
2847
- return packages;
2902
+ const SCHEMA_GLOB = ".forgejo/workflows/*.{yml,yaml}";
2903
+ const VscodeSettingsSchema = z.looseObject({ "yaml.schemas": z.record(z.string(), z.unknown()).default({}) });
2904
+ function readSchemaFromNodeModules(targetDir) {
2905
+ const candidate = path.join(targetDir, "node_modules", SCHEMA_NPM_PATH);
2906
+ if (!existsSync(candidate)) return void 0;
2907
+ return readFileSync(candidate, "utf-8");
2848
2908
  }
2849
- /**
2850
- * Read docker config for a single package, checking convention paths first,
2851
- * then .tooling.json overrides. Used by the per-package image:build script.
2852
- */
2853
- function readSinglePackageDocker(executor, cwd, packageDir, repoName) {
2854
- const dir = path.basename(path.resolve(cwd, packageDir));
2855
- const convention = findConventionDockerfile(executor, cwd, dir);
2856
- const docker = loadDockerMap(executor, cwd)[dir] ?? convention;
2857
- if (!docker) throw new FatalError(`No Dockerfile found for package "${dir}" (checked convention paths and .tooling.json)`);
2858
- const { name, version } = readPackageInfo(executor, path.join(cwd, "packages", dir, "package.json"));
2909
+ function serializeSettings(settings) {
2910
+ return JSON.stringify(settings, null, 2) + "\n";
2911
+ }
2912
+ const YamlSchemasSchema = z.record(z.string(), z.unknown());
2913
+ /** Merge yaml.schemas into a settings object. Returns the result and whether anything changed. */
2914
+ function mergeYamlSchemas(settings) {
2915
+ const parsed = YamlSchemasSchema.safeParse(settings["yaml.schemas"]);
2916
+ const yamlSchemas = parsed.success ? { ...parsed.data } : {};
2917
+ if (SCHEMA_LOCAL_PATH in yamlSchemas) return {
2918
+ merged: settings,
2919
+ changed: false
2920
+ };
2921
+ yamlSchemas[SCHEMA_LOCAL_PATH] = SCHEMA_GLOB;
2859
2922
  return {
2860
- dir,
2861
- imageName: `${repoName}-${stripScope(name ?? dir)}`,
2862
- version,
2863
- docker
2923
+ merged: {
2924
+ ...settings,
2925
+ "yaml.schemas": yamlSchemas
2926
+ },
2927
+ changed: true
2864
2928
  };
2865
2929
  }
2866
- /** Parse semver version string into major, minor, patch components. */
2867
- function parseSemver(version) {
2868
- const clean = version.replace(/^v/, "");
2869
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(clean);
2870
- if (!match?.[1] || !match[2] || !match[3]) throw new FatalError(`Invalid semver version: ${version}`);
2930
+ function writeSchemaToSettings(ctx) {
2931
+ if (ctx.exists(SETTINGS_PATH)) {
2932
+ const raw = ctx.read(SETTINGS_PATH);
2933
+ if (!raw) return {
2934
+ filePath: SETTINGS_PATH,
2935
+ action: "skipped",
2936
+ description: "Could not read existing settings"
2937
+ };
2938
+ const parsed = VscodeSettingsSchema.safeParse(parse(raw));
2939
+ if (!parsed.success) return {
2940
+ filePath: SETTINGS_PATH,
2941
+ action: "skipped",
2942
+ description: "Could not parse existing settings"
2943
+ };
2944
+ const { merged, changed } = mergeYamlSchemas(parsed.data);
2945
+ if (!changed) return {
2946
+ filePath: SETTINGS_PATH,
2947
+ action: "skipped",
2948
+ description: "Already has Forgejo schema mapping"
2949
+ };
2950
+ ctx.write(SETTINGS_PATH, serializeSettings(merged));
2951
+ return {
2952
+ filePath: SETTINGS_PATH,
2953
+ action: "updated",
2954
+ description: "Added Forgejo workflow schema mapping"
2955
+ };
2956
+ }
2957
+ ctx.write(SETTINGS_PATH, serializeSettings({ "yaml.schemas": { [SCHEMA_LOCAL_PATH]: SCHEMA_GLOB } }));
2871
2958
  return {
2872
- major: Number(match[1]),
2873
- minor: Number(match[2]),
2874
- patch: Number(match[3])
2959
+ filePath: SETTINGS_PATH,
2960
+ action: "created",
2961
+ description: "Generated .vscode/settings.json with Forgejo workflow schema"
2875
2962
  };
2876
2963
  }
2877
- /** Generate semver tag variants: latest, vX.Y.Z, vX.Y, vX */
2878
- function generateTags(version) {
2879
- const { major, minor, patch } = parseSemver(version);
2880
- return [
2881
- "latest",
2882
- `v${major}.${minor}.${patch}`,
2883
- `v${major}.${minor}`,
2884
- `v${major}`
2885
- ];
2886
- }
2887
- /** Build the full image reference: namespace/imageName:tag */
2888
- function imageRef(namespace, imageName, tag) {
2889
- return `${namespace}/${imageName}:${tag}`;
2890
- }
2891
- function log$1(message) {
2892
- console.log(message);
2893
- }
2894
- /** Read the repo name from root package.json. */
2895
- function readRepoName(executor, cwd) {
2896
- const rootPkgRaw = executor.readFile(path.join(cwd, "package.json"));
2897
- if (!rootPkgRaw) throw new FatalError("No package.json found in project root");
2898
- const repoName = parsePackageJson(rootPkgRaw)?.name;
2899
- if (!repoName) throw new FatalError("Root package.json must have a name field");
2900
- return repoName;
2901
- }
2902
- /** Build a single docker image from its config. Paths are resolved relative to cwd. */
2903
- function buildImage(executor, pkg, cwd, extraArgs) {
2904
- const dockerfilePath = path.resolve(cwd, pkg.docker.dockerfile);
2905
- const contextPath = path.resolve(cwd, pkg.docker.context);
2906
- const command = [
2907
- "docker build",
2908
- `-f ${dockerfilePath}`,
2909
- `-t ${pkg.imageName}:latest`,
2910
- ...extraArgs,
2911
- contextPath
2912
- ].join(" ");
2913
- executor.execInherit(command);
2914
- }
2915
- /**
2916
- * Detect packages with docker config in .tooling.json and build each one.
2917
- * Runs `docker build -f <dockerfile> -t <image-name>:latest <context>` for each package.
2918
- * Dockerfile and context paths are resolved relative to the project root.
2919
- *
2920
- * When `packageDir` is set, builds only that single package (for use as an image:build script).
2921
- */
2922
- function runDockerBuild(executor, config) {
2923
- const repoName = readRepoName(executor, config.cwd);
2924
- if (config.packageDir) {
2925
- const pkg = readSinglePackageDocker(executor, config.cwd, config.packageDir, repoName);
2926
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
2927
- buildImage(executor, pkg, config.cwd, config.extraArgs);
2928
- log$1(`Built ${pkg.imageName}:latest`);
2929
- return { packages: [pkg] };
2930
- }
2931
- const packages = detectDockerPackages(executor, config.cwd, repoName);
2932
- if (packages.length === 0) {
2933
- log$1("No packages with docker config found");
2934
- return { packages: [] };
2964
+ async function generateVscodeSettings(ctx) {
2965
+ const results = [];
2966
+ if (ctx.config.ci !== "forgejo") {
2967
+ results.push({
2968
+ filePath: SETTINGS_PATH,
2969
+ action: "skipped",
2970
+ description: "Not a Forgejo project"
2971
+ });
2972
+ return results;
2935
2973
  }
2936
- log$1(`Found ${packages.length} Docker package(s): ${packages.map((p) => p.dir).join(", ")}`);
2937
- for (const pkg of packages) {
2938
- log$1(`Building image for ${pkg.dir} (${pkg.imageName}:latest)...`);
2939
- buildImage(executor, pkg, config.cwd, config.extraArgs);
2974
+ const schemaContent = readSchemaFromNodeModules(ctx.targetDir);
2975
+ if (!schemaContent) {
2976
+ results.push({
2977
+ filePath: SCHEMA_LOCAL_PATH,
2978
+ action: "skipped",
2979
+ description: "Could not find @bensandee/config schema in node_modules"
2980
+ });
2981
+ return results;
2940
2982
  }
2941
- log$1(`Built ${packages.length} image(s)`);
2942
- return { packages };
2943
- }
2944
- /**
2945
- * Run the full Docker publish pipeline:
2946
- * 1. Build all images via runDockerBuild
2947
- * 2. Login to registry
2948
- * 3. Tag each image with semver variants from its own package.json version
2949
- * 4. Push all tags
2950
- * 5. Logout from registry
2951
- */
2952
- function runDockerPublish(executor, config) {
2953
- const { packages } = runDockerBuild(executor, {
2954
- cwd: config.cwd,
2955
- packageDir: void 0,
2956
- extraArgs: []
2983
+ const existingSchema = ctx.read(SCHEMA_LOCAL_PATH);
2984
+ if (existingSchema !== void 0 && contentEqual(SCHEMA_LOCAL_PATH, existingSchema, schemaContent)) results.push({
2985
+ filePath: SCHEMA_LOCAL_PATH,
2986
+ action: "skipped",
2987
+ description: "Schema already up to date"
2957
2988
  });
2958
- if (packages.length === 0) return {
2959
- packages: [],
2960
- tags: []
2961
- };
2962
- for (const pkg of packages) if (!pkg.version) throw new FatalError(`Package ${pkg.dir} has docker config but no version in package.json`);
2963
- if (!config.dryRun) {
2964
- log$1(`Logging in to ${config.registryHost}...`);
2965
- const loginResult = executor.exec(`echo "${config.password}" | docker login ${config.registryHost} -u ${config.username} --password-stdin`);
2966
- if (loginResult.exitCode !== 0) throw new FatalError(`Docker login failed: ${loginResult.stderr}`);
2967
- } else log$1("[dry-run] Skipping docker login");
2968
- const allTags = [];
2969
- try {
2970
- for (const pkg of packages) {
2971
- const tags = generateTags(pkg.version ?? "");
2972
- log$1(`${pkg.dir} v${pkg.version} → tags: ${tags.join(", ")}`);
2973
- for (const tag of tags) {
2974
- const ref = imageRef(config.registryNamespace, pkg.imageName, tag);
2975
- allTags.push(ref);
2976
- log$1(`Tagging ${pkg.imageName} → ${ref}`);
2977
- const tagResult = executor.exec(`docker tag ${pkg.imageName} ${ref}`);
2978
- if (tagResult.exitCode !== 0) throw new FatalError(`docker tag failed: ${tagResult.stderr}`);
2979
- if (!config.dryRun) {
2980
- log$1(`Pushing ${ref}...`);
2981
- const pushResult = executor.exec(`docker push ${ref}`);
2982
- if (pushResult.exitCode !== 0) throw new FatalError(`docker push failed: ${pushResult.stderr}`);
2983
- } else log$1(`[dry-run] Skipping push for ${ref}`);
2984
- }
2985
- }
2986
- } finally {
2987
- if (!config.dryRun) {
2988
- log$1(`Logging out from ${config.registryHost}...`);
2989
- executor.exec(`docker logout ${config.registryHost}`);
2990
- }
2989
+ else {
2990
+ ctx.write(SCHEMA_LOCAL_PATH, schemaContent);
2991
+ results.push({
2992
+ filePath: SCHEMA_LOCAL_PATH,
2993
+ action: existingSchema ? "updated" : "created",
2994
+ description: "Copied Forgejo workflow schema from @bensandee/config"
2995
+ });
2991
2996
  }
2992
- log$1(`Published ${allTags.length} image tag(s)`);
2993
- return {
2994
- packages,
2995
- tags: allTags
2996
- };
2997
+ results.push(writeSchemaToSettings(ctx));
2998
+ return results;
2999
+ }
3000
+ //#endregion
3001
+ //#region src/generators/pipeline.ts
3002
+ /** Run all generators sequentially and return their results. */
3003
+ async function runGenerators(ctx) {
3004
+ const results = [];
3005
+ results.push(await generatePackageJson(ctx));
3006
+ results.push(await generatePnpmWorkspace(ctx));
3007
+ results.push(...await generateTsconfig(ctx));
3008
+ results.push(await generateTsdown(ctx));
3009
+ results.push(await generateOxlint(ctx));
3010
+ results.push(await generateFormatter(ctx));
3011
+ results.push(...await generateLefthook(ctx));
3012
+ results.push(await generateGitignore(ctx));
3013
+ results.push(await generateKnip(ctx));
3014
+ results.push(await generateRenovate(ctx));
3015
+ results.push(await generateCi(ctx));
3016
+ results.push(...await generateClaudeSettings(ctx));
3017
+ results.push(await generateReleaseIt(ctx));
3018
+ results.push(await generateChangesets(ctx));
3019
+ results.push(await generateReleaseCi(ctx));
3020
+ results.push(await generateDeployCi(ctx));
3021
+ results.push(...await generateVitest(ctx));
3022
+ results.push(...await generateVscodeSettings(ctx));
3023
+ results.push(saveToolingConfig(ctx, ctx.config));
3024
+ return results;
2997
3025
  }
2998
3026
  //#endregion
2999
3027
  //#region src/generators/migrate-prompt.ts
@@ -3003,8 +3031,11 @@ function runDockerPublish(executor, config) {
3003
3031
  */
3004
3032
  function generateMigratePrompt(results, config, detected) {
3005
3033
  const sections = [];
3034
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3006
3035
  sections.push("# Migration Prompt");
3007
3036
  sections.push("");
3037
+ sections.push(`_Generated by \`@bensandee/tooling@0.28.1 repo:sync\` on ${timestamp}_`);
3038
+ sections.push("");
3008
3039
  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.");
3009
3040
  sections.push("");
3010
3041
  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.");
@@ -3037,6 +3068,18 @@ function generateMigratePrompt(results, config, detected) {
3037
3068
  }
3038
3069
  sections.push("## Migration tasks");
3039
3070
  sections.push("");
3071
+ if (config.releaseStrategy === "simple" && !detected.hasCommitAndTagVersion) {
3072
+ sections.push("### Add commit-and-tag-version to devDependencies");
3073
+ sections.push("");
3074
+ 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.");
3075
+ sections.push("");
3076
+ sections.push("Run:");
3077
+ sections.push("");
3078
+ sections.push("```sh");
3079
+ sections.push("pnpm add -D -w commit-and-tag-version");
3080
+ sections.push("```");
3081
+ sections.push("");
3082
+ }
3040
3083
  if (config.releaseStrategy !== "none" && !detected.hasRepositoryField) {
3041
3084
  sections.push("### Add repository field to package.json");
3042
3085
  sections.push("");
@@ -4804,7 +4847,7 @@ const dockerCheckCommand = defineCommand({
4804
4847
  const main = defineCommand({
4805
4848
  meta: {
4806
4849
  name: "bst",
4807
- version: "0.27.1",
4850
+ version: "0.28.1",
4808
4851
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4809
4852
  },
4810
4853
  subCommands: {
@@ -4820,7 +4863,7 @@ const main = defineCommand({
4820
4863
  "docker:check": dockerCheckCommand
4821
4864
  }
4822
4865
  });
4823
- console.log(`@bensandee/tooling v0.27.1`);
4866
+ console.log(`@bensandee/tooling v0.28.1`);
4824
4867
  async function run() {
4825
4868
  await runMain(main);
4826
4869
  process.exit(process.exitCode ?? 0);