@bensandee/tooling 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/bin.mjs +533 -420
  2. package/package.json +1 -1
package/dist/bin.mjs CHANGED
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, w
7
7
  import JSON5 from "json5";
8
8
  import { parse } from "jsonc-parser";
9
9
  import { z } from "zod";
10
- import { isMap, isSeq, parse as parse$1, parseDocument, stringify } from "yaml";
10
+ import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify } from "yaml";
11
11
  import { execSync } from "node:child_process";
12
12
  import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
13
13
  import { tmpdir } from "node:os";
@@ -206,6 +206,32 @@ function computeDefaults(targetDir) {
206
206
  detectPackageTypes: true
207
207
  };
208
208
  }
209
+ /**
210
+ * List packages that would be published to npm (non-private, have a name).
211
+ * For monorepos, scans packages/ subdirectories. For single repos, checks root package.json.
212
+ * An optional pre-parsed root package.json can be passed to avoid re-reading from disk.
213
+ */
214
+ function getPublishablePackages(targetDir, structure, rootPackageJson) {
215
+ if (structure === "monorepo") {
216
+ const packages = getMonorepoPackages(targetDir);
217
+ const results = [];
218
+ for (const pkg of packages) {
219
+ const pkgJson = readPackageJson(pkg.dir);
220
+ if (!pkgJson || pkgJson.private || !pkgJson.name) continue;
221
+ results.push({
222
+ name: pkgJson.name,
223
+ dir: pkg.dir
224
+ });
225
+ }
226
+ return results;
227
+ }
228
+ const pkg = rootPackageJson ?? readPackageJson(targetDir);
229
+ if (!pkg || pkg.private || !pkg.name) return [];
230
+ return [{
231
+ name: pkg.name,
232
+ dir: targetDir
233
+ }];
234
+ }
209
235
  /** List packages in a monorepo's packages/ directory. */
210
236
  function getMonorepoPackages(targetDir) {
211
237
  const packagesDir = path.join(targetDir, "packages");
@@ -569,6 +595,334 @@ function mergeWithSavedConfig(detected, saved) {
569
595
  };
570
596
  }
571
597
  //#endregion
598
+ //#region src/utils/yaml-merge.ts
599
+ const IGNORE_PATTERN = "@bensandee/tooling:ignore";
600
+ const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
601
+ /** Returns a yaml-language-server schema comment for Forgejo workflows, empty string otherwise. */
602
+ function workflowSchemaComment(ci) {
603
+ return ci === "forgejo" ? FORGEJO_SCHEMA_COMMENT : "";
604
+ }
605
+ /** Prepend the Forgejo schema comment if it's not already present. No-op for GitHub. */
606
+ function ensureSchemaComment(content, ci) {
607
+ if (ci !== "forgejo") return content;
608
+ if (content.includes("yaml-language-server")) return content;
609
+ return FORGEJO_SCHEMA_COMMENT + content;
610
+ }
611
+ /** Check if a YAML file has an opt-out comment in the first 10 lines. */
612
+ function isToolingIgnored(content) {
613
+ return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
614
+ }
615
+ /**
616
+ * Ensure required commands exist under `pre-commit.commands` in a lefthook config.
617
+ * Only adds missing commands — never modifies existing ones.
618
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
619
+ */
620
+ function mergeLefthookCommands(existing, requiredCommands) {
621
+ if (isToolingIgnored(existing)) return {
622
+ content: existing,
623
+ changed: false
624
+ };
625
+ try {
626
+ const doc = parseDocument(existing);
627
+ let changed = false;
628
+ if (!doc.hasIn(["pre-commit", "commands"])) {
629
+ doc.setIn(["pre-commit", "commands"], requiredCommands);
630
+ return {
631
+ content: doc.toString(),
632
+ changed: true
633
+ };
634
+ }
635
+ const commands = doc.getIn(["pre-commit", "commands"]);
636
+ if (!isMap(commands)) return {
637
+ content: existing,
638
+ changed: false
639
+ };
640
+ for (const [name, config] of Object.entries(requiredCommands)) if (!commands.has(name)) {
641
+ commands.set(name, config);
642
+ changed = true;
643
+ }
644
+ return {
645
+ content: changed ? doc.toString() : existing,
646
+ changed
647
+ };
648
+ } catch {
649
+ return {
650
+ content: existing,
651
+ changed: false
652
+ };
653
+ }
654
+ }
655
+ /**
656
+ * Ensure required steps exist in a workflow job's steps array.
657
+ * Only adds missing steps at the end — never modifies existing ones.
658
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
659
+ */
660
+ function mergeWorkflowSteps(existing, jobName, requiredSteps) {
661
+ if (isToolingIgnored(existing)) return {
662
+ content: existing,
663
+ changed: false
664
+ };
665
+ try {
666
+ const doc = parseDocument(existing);
667
+ const steps = doc.getIn([
668
+ "jobs",
669
+ jobName,
670
+ "steps"
671
+ ]);
672
+ if (!isSeq(steps)) return {
673
+ content: existing,
674
+ changed: false
675
+ };
676
+ let changed = false;
677
+ for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
678
+ if (!isMap(item)) return false;
679
+ if (match.run) {
680
+ const run = item.get("run");
681
+ return typeof run === "string" && run.includes(match.run);
682
+ }
683
+ if (match.uses) {
684
+ const uses = item.get("uses");
685
+ return typeof uses === "string" && uses.startsWith(match.uses);
686
+ }
687
+ return false;
688
+ })) {
689
+ steps.add(doc.createNode(step));
690
+ changed = true;
691
+ }
692
+ return {
693
+ content: changed ? doc.toString() : existing,
694
+ changed
695
+ };
696
+ } catch {
697
+ return {
698
+ content: existing,
699
+ changed: false
700
+ };
701
+ }
702
+ }
703
+ /**
704
+ * Add a job to an existing workflow YAML if it doesn't already exist.
705
+ * Returns unchanged content if the job already exists, the file has an opt-out comment,
706
+ * or the document can't be parsed.
707
+ */
708
+ /**
709
+ * Ensure a `concurrency` block exists at the workflow top level.
710
+ * Adds it if missing — never modifies an existing one.
711
+ * Returns unchanged content if the file has an opt-out comment or can't be parsed.
712
+ */
713
+ function ensureWorkflowConcurrency(existing, concurrency) {
714
+ if (isToolingIgnored(existing)) return {
715
+ content: existing,
716
+ changed: false
717
+ };
718
+ try {
719
+ const doc = parseDocument(existing);
720
+ if (doc.has("concurrency")) return {
721
+ content: existing,
722
+ changed: false
723
+ };
724
+ doc.set("concurrency", concurrency);
725
+ const contents = doc.contents;
726
+ if (isMap(contents)) {
727
+ const items = contents.items;
728
+ const nameIdx = items.findIndex((p) => isScalar(p.key) && p.key.value === "name");
729
+ const concPair = items.pop();
730
+ if (concPair) items.splice(nameIdx + 1, 0, concPair);
731
+ }
732
+ return {
733
+ content: doc.toString(),
734
+ changed: true
735
+ };
736
+ } catch {
737
+ return {
738
+ content: existing,
739
+ changed: false
740
+ };
741
+ }
742
+ }
743
+ function addWorkflowJob(existing, jobName, jobConfig) {
744
+ if (isToolingIgnored(existing)) return {
745
+ content: existing,
746
+ changed: false
747
+ };
748
+ try {
749
+ const doc = parseDocument(existing);
750
+ const jobs = doc.getIn(["jobs"]);
751
+ if (!isMap(jobs)) return {
752
+ content: existing,
753
+ changed: false
754
+ };
755
+ if (jobs.has(jobName)) return {
756
+ content: existing,
757
+ changed: false
758
+ };
759
+ jobs.set(jobName, doc.createNode(jobConfig));
760
+ return {
761
+ content: doc.toString(),
762
+ changed: true
763
+ };
764
+ } catch {
765
+ return {
766
+ content: existing,
767
+ changed: false
768
+ };
769
+ }
770
+ }
771
+ //#endregion
772
+ //#region src/generators/deploy-ci.ts
773
+ /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
774
+ function actionsExpr$2(expr) {
775
+ return `\${{ ${expr} }}`;
776
+ }
777
+ function hasEnginesNode$2(ctx) {
778
+ return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
779
+ }
780
+ function deployWorkflow(ci, nodeVersionYaml) {
781
+ return `${workflowSchemaComment(ci)}name: Deploy
782
+ on:
783
+ push:
784
+ tags:
785
+ - "v[0-9]+.[0-9]+.[0-9]+"
786
+
787
+ jobs:
788
+ deploy:
789
+ runs-on: ubuntu-latest
790
+ steps:
791
+ - uses: actions/checkout@v4
792
+ - uses: pnpm/action-setup@v4
793
+ - uses: actions/setup-node@v4
794
+ with:
795
+ ${nodeVersionYaml}
796
+ - run: pnpm install --frozen-lockfile
797
+ - name: Publish Docker images
798
+ env:
799
+ DOCKER_REGISTRY_HOST: ${actionsExpr$2("vars.DOCKER_REGISTRY_HOST")}
800
+ DOCKER_REGISTRY_NAMESPACE: ${actionsExpr$2("vars.DOCKER_REGISTRY_NAMESPACE")}
801
+ DOCKER_USERNAME: ${actionsExpr$2("secrets.DOCKER_USERNAME")}
802
+ DOCKER_PASSWORD: ${actionsExpr$2("secrets.DOCKER_PASSWORD")}
803
+ run: pnpm exec tooling docker:publish
804
+ `;
805
+ }
806
+ function requiredDeploySteps() {
807
+ return [
808
+ {
809
+ match: { uses: "actions/checkout" },
810
+ step: { uses: "actions/checkout@v4" }
811
+ },
812
+ {
813
+ match: { uses: "pnpm/action-setup" },
814
+ step: { uses: "pnpm/action-setup@v4" }
815
+ },
816
+ {
817
+ match: { uses: "actions/setup-node" },
818
+ step: { uses: "actions/setup-node@v4" }
819
+ },
820
+ {
821
+ match: { run: "pnpm install" },
822
+ step: { run: "pnpm install --frozen-lockfile" }
823
+ },
824
+ {
825
+ match: { run: "docker:publish" },
826
+ step: { run: "pnpm exec tooling docker:publish" }
827
+ }
828
+ ];
829
+ }
830
+ /** Convention paths to check for Dockerfiles. */
831
+ const CONVENTION_DOCKERFILE_PATHS$1 = ["Dockerfile", "docker/Dockerfile"];
832
+ const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
833
+ /** Get names of packages that have Docker builds (by convention or .tooling.json config). */
834
+ function getDockerPackageNames(ctx) {
835
+ const names = [];
836
+ const configRaw = ctx.read(".tooling.json");
837
+ if (configRaw) {
838
+ const result = DockerMapSchema.safeParse(JSON.parse(configRaw));
839
+ if (result.success && result.data.docker) names.push(...Object.keys(result.data.docker));
840
+ }
841
+ if (ctx.config.structure === "monorepo") {
842
+ const packages = getMonorepoPackages(ctx.targetDir);
843
+ for (const pkg of packages) {
844
+ const dirName = pkg.name.split("/").pop() ?? pkg.name;
845
+ if (names.includes(dirName)) continue;
846
+ for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(`packages/${dirName}/${rel}`)) {
847
+ names.push(dirName);
848
+ break;
849
+ }
850
+ }
851
+ } else for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(rel)) {
852
+ if (!names.includes(ctx.config.name)) names.push(ctx.config.name);
853
+ break;
854
+ }
855
+ return names;
856
+ }
857
+ /** Check whether any Docker packages exist by convention or .tooling.json config. */
858
+ function hasDockerPackages(ctx) {
859
+ return getDockerPackageNames(ctx).length > 0;
860
+ }
861
+ async function generateDeployCi(ctx) {
862
+ const filePath = "deploy-ci";
863
+ if (!hasDockerPackages(ctx) || ctx.config.ci === "none") return {
864
+ filePath,
865
+ action: "skipped",
866
+ description: "Deploy CI workflow not applicable"
867
+ };
868
+ const isGitHub = ctx.config.ci === "github";
869
+ const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
870
+ const nodeVersionYaml = hasEnginesNode$2(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
871
+ const content = deployWorkflow(ctx.config.ci, nodeVersionYaml);
872
+ if (ctx.exists(workflowPath)) {
873
+ const existing = ctx.read(workflowPath);
874
+ if (existing) {
875
+ if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) return {
876
+ filePath: workflowPath,
877
+ action: "skipped",
878
+ description: "Deploy workflow already up to date"
879
+ };
880
+ const merged = mergeWorkflowSteps(existing, "deploy", requiredDeploySteps());
881
+ const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
882
+ if (withComment === content) {
883
+ ctx.write(workflowPath, content);
884
+ return {
885
+ filePath: workflowPath,
886
+ action: "updated",
887
+ description: "Added missing steps to deploy workflow"
888
+ };
889
+ }
890
+ if (await ctx.confirmOverwrite(workflowPath) === "skip") {
891
+ if (merged.changed || withComment !== merged.content) {
892
+ ctx.write(workflowPath, withComment);
893
+ return {
894
+ filePath: workflowPath,
895
+ action: "updated",
896
+ description: "Added missing steps to deploy workflow"
897
+ };
898
+ }
899
+ return {
900
+ filePath: workflowPath,
901
+ action: "skipped",
902
+ description: "Existing deploy workflow preserved"
903
+ };
904
+ }
905
+ ctx.write(workflowPath, content);
906
+ return {
907
+ filePath: workflowPath,
908
+ action: "updated",
909
+ description: "Replaced deploy workflow with updated template"
910
+ };
911
+ }
912
+ return {
913
+ filePath: workflowPath,
914
+ action: "skipped",
915
+ description: "Deploy workflow already up to date"
916
+ };
917
+ }
918
+ ctx.write(workflowPath, content);
919
+ return {
920
+ filePath: workflowPath,
921
+ action: "created",
922
+ description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions deploy workflow`
923
+ };
924
+ }
925
+ //#endregion
572
926
  //#region src/generators/package-json.ts
573
927
  const STANDARD_SCRIPTS_SINGLE = {
574
928
  build: "tsdown",
@@ -598,7 +952,9 @@ const MANAGED_SCRIPTS = {
598
952
  check: "checks:run",
599
953
  "ci:check": "pnpm check",
600
954
  "tooling:check": "repo:sync --check",
601
- "tooling:sync": "repo:sync"
955
+ "tooling:sync": "repo:sync",
956
+ "docker:build": "docker:build",
957
+ "docker:check": "docker:check"
602
958
  };
603
959
  /** Deprecated scripts to remove during migration. */
604
960
  const DEPRECATED_SCRIPTS = ["tooling:init", "tooling:update"];
@@ -644,9 +1000,7 @@ function addReleaseDeps(deps, config) {
644
1000
  deps["release-it"] = "18.1.2";
645
1001
  if (config.structure === "monorepo") deps["@release-it/bumper"] = "7.0.2";
646
1002
  break;
647
- case "simple":
648
- deps["commit-and-tag-version"] = "12.5.0";
649
- break;
1003
+ case "simple": break;
650
1004
  case "changesets":
651
1005
  deps["@changesets/cli"] = "2.29.4";
652
1006
  break;
@@ -657,7 +1011,7 @@ function getAddedDevDepNames(config) {
657
1011
  const deps = { ...ROOT_DEV_DEPS };
658
1012
  if (config.structure !== "monorepo") Object.assign(deps, PER_PACKAGE_DEV_DEPS);
659
1013
  deps["@bensandee/config"] = "0.8.2";
660
- deps["@bensandee/tooling"] = "0.19.0";
1014
+ deps["@bensandee/tooling"] = "0.21.0";
661
1015
  if (config.formatter === "oxfmt") deps["oxfmt"] = "0.35.0";
662
1016
  if (config.formatter === "prettier") deps["prettier"] = "3.8.1";
663
1017
  addReleaseDeps(deps, config);
@@ -675,10 +1029,14 @@ async function generatePackageJson(ctx) {
675
1029
  };
676
1030
  if (ctx.config.releaseStrategy === "changesets") allScripts["changeset"] = "changeset";
677
1031
  if (ctx.config.releaseStrategy !== "none" && ctx.config.releaseStrategy !== "changesets") allScripts["trigger-release"] = "pnpm exec tooling release:trigger";
1032
+ if (hasDockerPackages(ctx)) {
1033
+ allScripts["docker:build"] = "pnpm exec tooling docker:build";
1034
+ allScripts["docker:check"] = "pnpm exec tooling docker:check";
1035
+ }
678
1036
  const devDeps = { ...ROOT_DEV_DEPS };
679
1037
  if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
680
1038
  devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.8.2";
681
- devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.19.0";
1039
+ devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.21.0";
682
1040
  if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.9.2";
683
1041
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = "0.35.0";
684
1042
  if (ctx.config.formatter === "prettier") devDeps["prettier"] = "3.8.1";
@@ -1121,219 +1479,52 @@ const ALL_ENTRIES = [...REQUIRED_ENTRIES, ...OPTIONAL_ENTRIES];
1121
1479
  function normalizeEntry(entry) {
1122
1480
  let s = entry.trim();
1123
1481
  if (s.startsWith("/")) s = s.slice(1);
1124
- if (s.endsWith("/")) s = s.slice(0, -1);
1125
- return s;
1126
- }
1127
- async function generateGitignore(ctx) {
1128
- const filePath = ".gitignore";
1129
- const existing = ctx.read(filePath);
1130
- if (existing) {
1131
- const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
1132
- const missing = ALL_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
1133
- if (missing.length === 0) return {
1134
- filePath,
1135
- action: "skipped",
1136
- description: "Already has all standard entries"
1137
- };
1138
- const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
1139
- const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
1140
- ctx.write(filePath, updated);
1141
- if (missingRequired.length === 0) return {
1142
- filePath,
1143
- action: "skipped",
1144
- description: "Only optional entries missing"
1145
- };
1146
- return {
1147
- filePath,
1148
- action: "updated",
1149
- description: `Appended ${String(missing.length)} missing entries`
1150
- };
1151
- }
1152
- ctx.write(filePath, ALL_ENTRIES.join("\n") + "\n");
1153
- return {
1154
- filePath,
1155
- action: "created",
1156
- description: "Generated .gitignore"
1157
- };
1158
- }
1159
- //#endregion
1160
- //#region src/utils/yaml-merge.ts
1161
- const IGNORE_PATTERN = "@bensandee/tooling:ignore";
1162
- const FORGEJO_SCHEMA_COMMENT = "# yaml-language-server: $schema=../../.vscode/forgejo-workflow.schema.json\n";
1163
- /** Returns a yaml-language-server schema comment for Forgejo workflows, empty string otherwise. */
1164
- function workflowSchemaComment(ci) {
1165
- return ci === "forgejo" ? FORGEJO_SCHEMA_COMMENT : "";
1166
- }
1167
- /** Prepend the Forgejo schema comment if it's not already present. No-op for GitHub. */
1168
- function ensureSchemaComment(content, ci) {
1169
- if (ci !== "forgejo") return content;
1170
- if (content.includes("yaml-language-server")) return content;
1171
- return FORGEJO_SCHEMA_COMMENT + content;
1172
- }
1173
- /** Check if a YAML file has an opt-out comment in the first 10 lines. */
1174
- function isToolingIgnored(content) {
1175
- return content.split("\n", 10).some((line) => line.includes(IGNORE_PATTERN));
1176
- }
1177
- /**
1178
- * Ensure required commands exist under `pre-commit.commands` in a lefthook config.
1179
- * Only adds missing commands — never modifies existing ones.
1180
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1181
- */
1182
- function mergeLefthookCommands(existing, requiredCommands) {
1183
- if (isToolingIgnored(existing)) return {
1184
- content: existing,
1185
- changed: false
1186
- };
1187
- try {
1188
- const doc = parseDocument(existing);
1189
- let changed = false;
1190
- if (!doc.hasIn(["pre-commit", "commands"])) {
1191
- doc.setIn(["pre-commit", "commands"], requiredCommands);
1192
- return {
1193
- content: doc.toString(),
1194
- changed: true
1195
- };
1196
- }
1197
- const commands = doc.getIn(["pre-commit", "commands"]);
1198
- if (!isMap(commands)) return {
1199
- content: existing,
1200
- changed: false
1201
- };
1202
- for (const [name, config] of Object.entries(requiredCommands)) if (!commands.has(name)) {
1203
- commands.set(name, config);
1204
- changed = true;
1205
- }
1206
- return {
1207
- content: changed ? doc.toString() : existing,
1208
- changed
1209
- };
1210
- } catch {
1211
- return {
1212
- content: existing,
1213
- changed: false
1214
- };
1215
- }
1216
- }
1217
- /**
1218
- * Ensure required steps exist in a workflow job's steps array.
1219
- * Only adds missing steps at the end — never modifies existing ones.
1220
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1221
- */
1222
- function mergeWorkflowSteps(existing, jobName, requiredSteps) {
1223
- if (isToolingIgnored(existing)) return {
1224
- content: existing,
1225
- changed: false
1226
- };
1227
- try {
1228
- const doc = parseDocument(existing);
1229
- const steps = doc.getIn([
1230
- "jobs",
1231
- jobName,
1232
- "steps"
1233
- ]);
1234
- if (!isSeq(steps)) return {
1235
- content: existing,
1236
- changed: false
1237
- };
1238
- let changed = false;
1239
- for (const { match, step } of requiredSteps) if (!steps.items.some((item) => {
1240
- if (!isMap(item)) return false;
1241
- if (match.run) {
1242
- const run = item.get("run");
1243
- return typeof run === "string" && run.includes(match.run);
1244
- }
1245
- if (match.uses) {
1246
- const uses = item.get("uses");
1247
- return typeof uses === "string" && uses.startsWith(match.uses);
1248
- }
1249
- return false;
1250
- })) {
1251
- steps.add(doc.createNode(step));
1252
- changed = true;
1253
- }
1254
- return {
1255
- content: changed ? doc.toString() : existing,
1256
- changed
1257
- };
1258
- } catch {
1259
- return {
1260
- content: existing,
1261
- changed: false
1262
- };
1263
- }
1264
- }
1265
- /**
1266
- * Add a job to an existing workflow YAML if it doesn't already exist.
1267
- * Returns unchanged content if the job already exists, the file has an opt-out comment,
1268
- * or the document can't be parsed.
1269
- */
1270
- /**
1271
- * Ensure a `concurrency` block exists at the workflow top level.
1272
- * Adds it if missing — never modifies an existing one.
1273
- * Returns unchanged content if the file has an opt-out comment or can't be parsed.
1274
- */
1275
- function ensureWorkflowConcurrency(existing, concurrency) {
1276
- if (isToolingIgnored(existing)) return {
1277
- content: existing,
1278
- changed: false
1279
- };
1280
- try {
1281
- const doc = parseDocument(existing);
1282
- if (doc.has("concurrency")) return {
1283
- content: existing,
1284
- changed: false
1285
- };
1286
- doc.set("concurrency", doc.createNode(concurrency));
1287
- return {
1288
- content: doc.toString(),
1289
- changed: true
1290
- };
1291
- } catch {
1292
- return {
1293
- content: existing,
1294
- changed: false
1295
- };
1296
- }
1482
+ if (s.endsWith("/")) s = s.slice(0, -1);
1483
+ return s;
1297
1484
  }
1298
- function addWorkflowJob(existing, jobName, jobConfig) {
1299
- if (isToolingIgnored(existing)) return {
1300
- content: existing,
1301
- changed: false
1302
- };
1303
- try {
1304
- const doc = parseDocument(existing);
1305
- const jobs = doc.getIn(["jobs"]);
1306
- if (!isMap(jobs)) return {
1307
- content: existing,
1308
- changed: false
1309
- };
1310
- if (jobs.has(jobName)) return {
1311
- content: existing,
1312
- changed: false
1485
+ async function generateGitignore(ctx) {
1486
+ const filePath = ".gitignore";
1487
+ const existing = ctx.read(filePath);
1488
+ if (existing) {
1489
+ const existingNormalized = new Set(existing.split("\n").map(normalizeEntry).filter((line) => line.length > 0));
1490
+ const missing = ALL_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
1491
+ if (missing.length === 0) return {
1492
+ filePath,
1493
+ action: "skipped",
1494
+ description: "Already has all standard entries"
1313
1495
  };
1314
- jobs.set(jobName, doc.createNode(jobConfig));
1315
- return {
1316
- content: doc.toString(),
1317
- changed: true
1496
+ const missingRequired = REQUIRED_ENTRIES.filter((entry) => !existingNormalized.has(normalizeEntry(entry)));
1497
+ const updated = existing.trimEnd() + "\n\n# Added by @bensandee/tooling\n" + missing.join("\n") + "\n";
1498
+ ctx.write(filePath, updated);
1499
+ if (missingRequired.length === 0) return {
1500
+ filePath,
1501
+ action: "skipped",
1502
+ description: "Only optional entries missing"
1318
1503
  };
1319
- } catch {
1320
1504
  return {
1321
- content: existing,
1322
- changed: false
1505
+ filePath,
1506
+ action: "updated",
1507
+ description: `Appended ${String(missing.length)} missing entries`
1323
1508
  };
1324
1509
  }
1510
+ ctx.write(filePath, ALL_ENTRIES.join("\n") + "\n");
1511
+ return {
1512
+ filePath,
1513
+ action: "created",
1514
+ description: "Generated .gitignore"
1515
+ };
1325
1516
  }
1326
1517
  //#endregion
1327
1518
  //#region src/generators/ci.ts
1328
1519
  /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
1329
- function actionsExpr$2(expr) {
1520
+ function actionsExpr$1(expr) {
1330
1521
  return `\${{ ${expr} }}`;
1331
1522
  }
1332
1523
  const CI_CONCURRENCY = {
1333
- group: `ci-${actionsExpr$2("github.ref")}`,
1334
- "cancel-in-progress": actionsExpr$2("github.ref != 'refs/heads/main'")
1524
+ group: `ci-${actionsExpr$1("github.ref")}`,
1525
+ "cancel-in-progress": true
1335
1526
  };
1336
- function hasEnginesNode$2(ctx) {
1527
+ function hasEnginesNode$1(ctx) {
1337
1528
  const raw = ctx.read("package.json");
1338
1529
  if (!raw) return false;
1339
1530
  return typeof parsePackageJson(raw)?.engines?.["node"] === "string";
@@ -1347,8 +1538,8 @@ ${emailNotifications}on:
1347
1538
  pull_request:
1348
1539
 
1349
1540
  concurrency:
1350
- group: ci-${actionsExpr$2("github.ref")}
1351
- cancel-in-progress: ${actionsExpr$2("github.ref != 'refs/heads/main'")}
1541
+ group: ci-${actionsExpr$1("github.ref")}
1542
+ cancel-in-progress: true
1352
1543
 
1353
1544
  jobs:
1354
1545
  check:
@@ -1405,7 +1596,7 @@ async function generateCi(ctx) {
1405
1596
  description: "CI workflow not requested"
1406
1597
  };
1407
1598
  const isGitHub = ctx.config.ci === "github";
1408
- const nodeVersionYaml = hasEnginesNode$2(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1599
+ const nodeVersionYaml = hasEnginesNode$1(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
1409
1600
  const filePath = isGitHub ? ".github/workflows/check.yml" : ".forgejo/workflows/check.yml";
1410
1601
  const content = ciWorkflow(nodeVersionYaml, !isGitHub);
1411
1602
  if (ctx.exists(filePath)) {
@@ -1883,13 +2074,13 @@ async function generateChangesets(ctx) {
1883
2074
  //#endregion
1884
2075
  //#region src/generators/release-ci.ts
1885
2076
  /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
1886
- function actionsExpr$1(expr) {
2077
+ function actionsExpr(expr) {
1887
2078
  return `\${{ ${expr} }}`;
1888
2079
  }
1889
- function hasEnginesNode$1(ctx) {
2080
+ function hasEnginesNode(ctx) {
1890
2081
  return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
1891
2082
  }
1892
- function commonSteps(nodeVersionYaml) {
2083
+ function commonSteps(nodeVersionYaml, publishesNpm) {
1893
2084
  return ` - uses: actions/checkout@v4
1894
2085
  with:
1895
2086
  fetch-depth: 0
@@ -1897,18 +2088,18 @@ function commonSteps(nodeVersionYaml) {
1897
2088
  - uses: actions/setup-node@v4
1898
2089
  with:
1899
2090
  ${nodeVersionYaml}
1900
- cache: pnpm
1901
- registry-url: "https://registry.npmjs.org"
2091
+ cache: pnpm${publishesNpm ? `\n registry-url: "https://registry.npmjs.org"` : ""}
1902
2092
  - run: pnpm install --frozen-lockfile
1903
2093
  - run: pnpm build`;
1904
2094
  }
1905
- function releaseItWorkflow(ci, nodeVersionYaml) {
2095
+ function releaseItWorkflow(ci, nodeVersionYaml, publishesNpm) {
1906
2096
  const isGitHub = ci === "github";
1907
2097
  const permissions = isGitHub ? `
1908
2098
  permissions:
1909
2099
  contents: write
1910
2100
  ` : "";
1911
2101
  const tokenEnv = isGitHub ? `GITHUB_TOKEN: \${{ github.token }}` : `FORGEJO_TOKEN: \${{ secrets.FORGEJO_TOKEN }}`;
2102
+ const npmEnv = publishesNpm ? `\n NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}` : "";
1912
2103
  return `${workflowSchemaComment(ci)}name: Release
1913
2104
  on:
1914
2105
  workflow_dispatch:
@@ -1917,14 +2108,13 @@ jobs:
1917
2108
  release:
1918
2109
  runs-on: ubuntu-latest
1919
2110
  steps:
1920
- ${commonSteps(nodeVersionYaml)}
2111
+ ${commonSteps(nodeVersionYaml, publishesNpm)}
1921
2112
  - run: pnpm release-it --ci
1922
2113
  env:
1923
- ${tokenEnv}
1924
- NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}
2114
+ ${tokenEnv}${npmEnv}
1925
2115
  `;
1926
2116
  }
1927
- function commitAndTagVersionWorkflow(ci, nodeVersionYaml) {
2117
+ function commitAndTagVersionWorkflow(ci, nodeVersionYaml, publishesNpm) {
1928
2118
  const isGitHub = ci === "github";
1929
2119
  const permissions = isGitHub ? `
1930
2120
  permissions:
@@ -1954,52 +2144,69 @@ jobs:
1954
2144
  release:
1955
2145
  runs-on: ubuntu-latest
1956
2146
  steps:
1957
- ${commonSteps(nodeVersionYaml)}${gitConfigStep}${releaseStep}
2147
+ ${commonSteps(nodeVersionYaml, publishesNpm)}${gitConfigStep}${releaseStep}
1958
2148
  `;
1959
2149
  }
1960
- function changesetsReleaseJobConfig(ci, nodeVersionYaml) {
2150
+ function changesetsReleaseJobConfig(ci, nodeVersionYaml, publishesNpm) {
1961
2151
  const isGitHub = ci === "github";
1962
2152
  const nodeWith = {
1963
2153
  ...nodeVersionYaml.startsWith("node-version-file") ? { "node-version-file": "package.json" } : { "node-version": "24" },
1964
2154
  cache: "pnpm",
1965
- "registry-url": "https://registry.npmjs.org"
2155
+ ...publishesNpm && { "registry-url": "https://registry.npmjs.org" }
1966
2156
  };
1967
- if (isGitHub) return {
1968
- needs: "check",
1969
- if: "github.ref == 'refs/heads/main'",
1970
- "runs-on": "ubuntu-latest",
1971
- permissions: {
1972
- contents: "write",
1973
- "pull-requests": "write"
1974
- },
1975
- steps: [
1976
- {
1977
- uses: "actions/checkout@v4",
1978
- with: { "fetch-depth": 0 }
2157
+ if (isGitHub) {
2158
+ const changesetsEnv = {
2159
+ GITHUB_TOKEN: actionsExpr("github.token"),
2160
+ ...publishesNpm && { NPM_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
2161
+ };
2162
+ return {
2163
+ needs: "check",
2164
+ if: "github.ref == 'refs/heads/main'",
2165
+ concurrency: {
2166
+ group: "release",
2167
+ "cancel-in-progress": false
1979
2168
  },
1980
- { uses: "pnpm/action-setup@v4" },
1981
- {
1982
- uses: "actions/setup-node@v4",
1983
- with: nodeWith
2169
+ "runs-on": "ubuntu-latest",
2170
+ permissions: {
2171
+ contents: "write",
2172
+ "pull-requests": "write"
1984
2173
  },
1985
- { run: "pnpm install --frozen-lockfile" },
1986
- { run: "pnpm build" },
1987
- {
1988
- uses: "changesets/action@v1",
1989
- with: {
1990
- publish: "pnpm changeset publish",
1991
- version: "pnpm changeset version"
2174
+ steps: [
2175
+ {
2176
+ uses: "actions/checkout@v4",
2177
+ with: { "fetch-depth": 0 }
2178
+ },
2179
+ { uses: "pnpm/action-setup@v4" },
2180
+ {
2181
+ uses: "actions/setup-node@v4",
2182
+ with: nodeWith
1992
2183
  },
1993
- env: {
1994
- GITHUB_TOKEN: actionsExpr$1("github.token"),
1995
- NPM_TOKEN: actionsExpr$1("secrets.NPM_TOKEN")
2184
+ { run: "pnpm install --frozen-lockfile" },
2185
+ { run: "pnpm build" },
2186
+ {
2187
+ uses: "changesets/action@v1",
2188
+ with: {
2189
+ publish: "pnpm changeset publish",
2190
+ version: "pnpm changeset version"
2191
+ },
2192
+ env: changesetsEnv
1996
2193
  }
1997
- }
1998
- ]
2194
+ ]
2195
+ };
2196
+ }
2197
+ const releaseEnv = {
2198
+ FORGEJO_SERVER_URL: actionsExpr("github.server_url"),
2199
+ FORGEJO_REPOSITORY: actionsExpr("github.repository"),
2200
+ FORGEJO_TOKEN: actionsExpr("secrets.FORGEJO_TOKEN"),
2201
+ ...publishesNpm && { NODE_AUTH_TOKEN: actionsExpr("secrets.NPM_TOKEN") }
1999
2202
  };
2000
2203
  return {
2001
2204
  needs: "check",
2002
2205
  if: "github.ref == 'refs/heads/main'",
2206
+ concurrency: {
2207
+ group: "release",
2208
+ "cancel-in-progress": false
2209
+ },
2003
2210
  "runs-on": "ubuntu-latest",
2004
2211
  steps: [
2005
2212
  {
@@ -2019,18 +2226,13 @@ function changesetsReleaseJobConfig(ci, nodeVersionYaml) {
2019
2226
  },
2020
2227
  {
2021
2228
  name: "Release",
2022
- env: {
2023
- FORGEJO_SERVER_URL: actionsExpr$1("github.server_url"),
2024
- FORGEJO_REPOSITORY: actionsExpr$1("github.repository"),
2025
- FORGEJO_TOKEN: actionsExpr$1("secrets.FORGEJO_TOKEN"),
2026
- NODE_AUTH_TOKEN: actionsExpr$1("secrets.NPM_TOKEN")
2027
- },
2229
+ env: releaseEnv,
2028
2230
  run: "pnpm exec tooling release:changesets"
2029
2231
  }
2030
2232
  ]
2031
2233
  };
2032
2234
  }
2033
- function requiredReleaseSteps(strategy, nodeVersionYaml) {
2235
+ function requiredReleaseSteps(strategy, nodeVersionYaml, publishesNpm) {
2034
2236
  const isNodeVersionFile = nodeVersionYaml.startsWith("node-version-file");
2035
2237
  const steps = [
2036
2238
  {
@@ -2051,7 +2253,7 @@ function requiredReleaseSteps(strategy, nodeVersionYaml) {
2051
2253
  with: {
2052
2254
  ...isNodeVersionFile ? { "node-version-file": "package.json" } : { "node-version": "24" },
2053
2255
  cache: "pnpm",
2054
- "registry-url": "https://registry.npmjs.org"
2256
+ ...publishesNpm && { "registry-url": "https://registry.npmjs.org" }
2055
2257
  }
2056
2258
  }
2057
2259
  },
@@ -2086,23 +2288,23 @@ function requiredReleaseSteps(strategy, nodeVersionYaml) {
2086
2288
  }
2087
2289
  return steps;
2088
2290
  }
2089
- function buildWorkflow(strategy, ci, nodeVersionYaml) {
2291
+ function buildWorkflow(strategy, ci, nodeVersionYaml, publishesNpm) {
2090
2292
  switch (strategy) {
2091
- case "release-it": return releaseItWorkflow(ci, nodeVersionYaml);
2092
- case "simple": return commitAndTagVersionWorkflow(ci, nodeVersionYaml);
2293
+ case "release-it": return releaseItWorkflow(ci, nodeVersionYaml, publishesNpm);
2294
+ case "simple": return commitAndTagVersionWorkflow(ci, nodeVersionYaml, publishesNpm);
2093
2295
  default: return null;
2094
2296
  }
2095
2297
  }
2096
- function generateChangesetsReleaseCi(ctx) {
2298
+ function generateChangesetsReleaseCi(ctx, publishesNpm) {
2097
2299
  const checkPath = ctx.config.ci === "github" ? ".github/workflows/check.yml" : ".forgejo/workflows/check.yml";
2098
- const nodeVersionYaml = hasEnginesNode$1(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
2300
+ const nodeVersionYaml = hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
2099
2301
  const existing = ctx.read(checkPath);
2100
2302
  if (!existing) return {
2101
2303
  filePath: checkPath,
2102
2304
  action: "skipped",
2103
2305
  description: "CI workflow not found — run check generator first"
2104
2306
  };
2105
- const addResult = addWorkflowJob(existing, "release", changesetsReleaseJobConfig(ctx.config.ci, nodeVersionYaml));
2307
+ const addResult = addWorkflowJob(existing, "release", changesetsReleaseJobConfig(ctx.config.ci, nodeVersionYaml, publishesNpm));
2106
2308
  if (addResult.changed) {
2107
2309
  const withComment = ensureSchemaComment(addResult.content, ctx.config.ci);
2108
2310
  ctx.write(checkPath, withComment);
@@ -2112,7 +2314,7 @@ function generateChangesetsReleaseCi(ctx) {
2112
2314
  description: "Added release job to CI workflow"
2113
2315
  };
2114
2316
  }
2115
- const merged = mergeWorkflowSteps(existing, "release", requiredReleaseSteps("changesets", nodeVersionYaml));
2317
+ const merged = mergeWorkflowSteps(existing, "release", requiredReleaseSteps("changesets", nodeVersionYaml, publishesNpm));
2116
2318
  if (!merged.changed) return {
2117
2319
  filePath: checkPath,
2118
2320
  action: "skipped",
@@ -2133,11 +2335,12 @@ async function generateReleaseCi(ctx) {
2133
2335
  action: "skipped",
2134
2336
  description: "Release CI workflow not applicable"
2135
2337
  };
2136
- if (ctx.config.releaseStrategy === "changesets") return generateChangesetsReleaseCi(ctx);
2338
+ const publishesNpm = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson).length > 0;
2339
+ if (ctx.config.releaseStrategy === "changesets") return generateChangesetsReleaseCi(ctx, publishesNpm);
2137
2340
  const isGitHub = ctx.config.ci === "github";
2138
2341
  const workflowPath = isGitHub ? ".github/workflows/release.yml" : ".forgejo/workflows/release.yml";
2139
- const nodeVersionYaml = hasEnginesNode$1(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
2140
- const content = buildWorkflow(ctx.config.releaseStrategy, ctx.config.ci, nodeVersionYaml);
2342
+ const nodeVersionYaml = hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
2343
+ const content = buildWorkflow(ctx.config.releaseStrategy, ctx.config.ci, nodeVersionYaml, publishesNpm);
2141
2344
  if (!content) return {
2142
2345
  filePath,
2143
2346
  action: "skipped",
@@ -2151,7 +2354,7 @@ async function generateReleaseCi(ctx) {
2151
2354
  action: "skipped",
2152
2355
  description: "Release workflow already up to date"
2153
2356
  };
2154
- const merged = mergeWorkflowSteps(existing, "release", requiredReleaseSteps(ctx.config.releaseStrategy, nodeVersionYaml));
2357
+ const merged = mergeWorkflowSteps(existing, "release", requiredReleaseSteps(ctx.config.releaseStrategy, nodeVersionYaml, publishesNpm));
2155
2358
  const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2156
2359
  if (withComment === content) {
2157
2360
  ctx.write(workflowPath, content);
@@ -2452,148 +2655,6 @@ async function generateVscodeSettings(ctx) {
2452
2655
  return results;
2453
2656
  }
2454
2657
  //#endregion
2455
- //#region src/generators/deploy-ci.ts
2456
- /** Build a GitHub Actions expression like `${{ expr }}` without triggering no-template-curly-in-string. */
2457
- function actionsExpr(expr) {
2458
- return `\${{ ${expr} }}`;
2459
- }
2460
- function hasEnginesNode(ctx) {
2461
- return typeof ctx.packageJson?.["engines"]?.["node"] === "string";
2462
- }
2463
- function deployWorkflow(ci, nodeVersionYaml) {
2464
- return `${workflowSchemaComment(ci)}name: Deploy
2465
- on:
2466
- push:
2467
- tags:
2468
- - "v[0-9]+.[0-9]+.[0-9]+"
2469
-
2470
- jobs:
2471
- deploy:
2472
- runs-on: ubuntu-latest
2473
- steps:
2474
- - uses: actions/checkout@v4
2475
- - uses: pnpm/action-setup@v4
2476
- - uses: actions/setup-node@v4
2477
- with:
2478
- ${nodeVersionYaml}
2479
- - run: pnpm install --frozen-lockfile
2480
- - name: Publish Docker images
2481
- env:
2482
- DOCKER_REGISTRY_HOST: ${actionsExpr("vars.DOCKER_REGISTRY_HOST")}
2483
- DOCKER_REGISTRY_NAMESPACE: ${actionsExpr("vars.DOCKER_REGISTRY_NAMESPACE")}
2484
- DOCKER_USERNAME: ${actionsExpr("secrets.DOCKER_USERNAME")}
2485
- DOCKER_PASSWORD: ${actionsExpr("secrets.DOCKER_PASSWORD")}
2486
- run: pnpm exec tooling docker:publish
2487
- `;
2488
- }
2489
- function requiredDeploySteps() {
2490
- return [
2491
- {
2492
- match: { uses: "actions/checkout" },
2493
- step: { uses: "actions/checkout@v4" }
2494
- },
2495
- {
2496
- match: { uses: "pnpm/action-setup" },
2497
- step: { uses: "pnpm/action-setup@v4" }
2498
- },
2499
- {
2500
- match: { uses: "actions/setup-node" },
2501
- step: { uses: "actions/setup-node@v4" }
2502
- },
2503
- {
2504
- match: { run: "pnpm install" },
2505
- step: { run: "pnpm install --frozen-lockfile" }
2506
- },
2507
- {
2508
- match: { run: "docker:publish" },
2509
- step: { run: "pnpm exec tooling docker:publish" }
2510
- }
2511
- ];
2512
- }
2513
- /** Convention paths to check for Dockerfiles. */
2514
- const CONVENTION_DOCKERFILE_PATHS$1 = ["Dockerfile", "docker/Dockerfile"];
2515
- const DockerMapSchema = z.object({ docker: z.record(z.string(), z.unknown()).optional() });
2516
- /** Check whether any Docker packages exist by convention or .tooling.json config. */
2517
- function hasDockerPackages(ctx) {
2518
- const configRaw = ctx.read(".tooling.json");
2519
- if (configRaw) {
2520
- const result = DockerMapSchema.safeParse(JSON.parse(configRaw));
2521
- if (result.success && result.data.docker && Object.keys(result.data.docker).length > 0) return true;
2522
- }
2523
- if (ctx.config.structure === "monorepo") {
2524
- const packages = getMonorepoPackages(ctx.targetDir);
2525
- for (const pkg of packages) {
2526
- const dirName = pkg.name.split("/").pop() ?? pkg.name;
2527
- for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(`packages/${dirName}/${rel}`)) return true;
2528
- }
2529
- } else for (const rel of CONVENTION_DOCKERFILE_PATHS$1) if (ctx.exists(rel)) return true;
2530
- return false;
2531
- }
2532
- async function generateDeployCi(ctx) {
2533
- const filePath = "deploy-ci";
2534
- if (!hasDockerPackages(ctx) || ctx.config.ci === "none") return {
2535
- filePath,
2536
- action: "skipped",
2537
- description: "Deploy CI workflow not applicable"
2538
- };
2539
- const isGitHub = ctx.config.ci === "github";
2540
- const workflowPath = isGitHub ? ".github/workflows/publish.yml" : ".forgejo/workflows/publish.yml";
2541
- const nodeVersionYaml = hasEnginesNode(ctx) ? "node-version-file: package.json" : "node-version: \"24\"";
2542
- const content = deployWorkflow(ctx.config.ci, nodeVersionYaml);
2543
- if (ctx.exists(workflowPath)) {
2544
- const existing = ctx.read(workflowPath);
2545
- if (existing) {
2546
- if (existing === content || ensureSchemaComment(existing, ctx.config.ci) === content) return {
2547
- filePath: workflowPath,
2548
- action: "skipped",
2549
- description: "Deploy workflow already up to date"
2550
- };
2551
- const merged = mergeWorkflowSteps(existing, "deploy", requiredDeploySteps());
2552
- const withComment = ensureSchemaComment(merged.content, ctx.config.ci);
2553
- if (withComment === content) {
2554
- ctx.write(workflowPath, content);
2555
- return {
2556
- filePath: workflowPath,
2557
- action: "updated",
2558
- description: "Added missing steps to deploy workflow"
2559
- };
2560
- }
2561
- if (await ctx.confirmOverwrite(workflowPath) === "skip") {
2562
- if (merged.changed || withComment !== merged.content) {
2563
- ctx.write(workflowPath, withComment);
2564
- return {
2565
- filePath: workflowPath,
2566
- action: "updated",
2567
- description: "Added missing steps to deploy workflow"
2568
- };
2569
- }
2570
- return {
2571
- filePath: workflowPath,
2572
- action: "skipped",
2573
- description: "Existing deploy workflow preserved"
2574
- };
2575
- }
2576
- ctx.write(workflowPath, content);
2577
- return {
2578
- filePath: workflowPath,
2579
- action: "updated",
2580
- description: "Replaced deploy workflow with updated template"
2581
- };
2582
- }
2583
- return {
2584
- filePath: workflowPath,
2585
- action: "skipped",
2586
- description: "Deploy workflow already up to date"
2587
- };
2588
- }
2589
- ctx.write(workflowPath, content);
2590
- return {
2591
- filePath: workflowPath,
2592
- action: "created",
2593
- description: `Generated ${isGitHub ? "GitHub" : "Forgejo"} Actions deploy workflow`
2594
- };
2595
- }
2596
- //#endregion
2597
2658
  //#region src/generators/pipeline.ts
2598
2659
  /** Run all generators sequentially and return their results. */
2599
2660
  async function runGenerators(ctx) {
@@ -2790,6 +2851,16 @@ function generateMigratePrompt(results, config, detected) {
2790
2851
  }
2791
2852
  //#endregion
2792
2853
  //#region src/commands/repo-init.ts
2854
+ /** Log what was detected so the user understands generator decisions. */
2855
+ function logDetectionSummary(ctx) {
2856
+ const dockerNames = getDockerPackageNames(ctx);
2857
+ if (dockerNames.length > 0) p.log.info(`Detected Docker packages: ${dockerNames.join(", ")}`);
2858
+ if (ctx.config.releaseStrategy !== "none") {
2859
+ const publishable = getPublishablePackages(ctx.targetDir, ctx.config.structure, ctx.packageJson);
2860
+ if (publishable.length > 0) p.log.info(`Will publish npm packages: ${publishable.map((pkg) => pkg.name).join(", ")}`);
2861
+ else p.log.info("No publishable npm packages — npm registry setup will be skipped");
2862
+ }
2863
+ }
2793
2864
  async function runInit(config, options = {}) {
2794
2865
  const detected = detectProject(config.targetDir);
2795
2866
  const s = p.spinner();
@@ -2809,6 +2880,7 @@ async function runInit(config, options = {}) {
2809
2880
  if (p.isCancel(result)) return "skip";
2810
2881
  return result;
2811
2882
  }));
2883
+ logDetectionSummary(ctx);
2812
2884
  s.start("Generating configuration files...");
2813
2885
  const results = await runGenerators(ctx);
2814
2886
  const alreadyArchived = new Set(results.filter((r) => r.action === "archived").map((r) => r.filePath));
@@ -2819,8 +2891,7 @@ async function runInit(config, options = {}) {
2819
2891
  });
2820
2892
  const created = results.filter((r) => r.action === "created");
2821
2893
  const updated = results.filter((r) => r.action === "updated");
2822
- const archived = results.filter((r) => r.action === "archived");
2823
- if (!(created.length > 0 || updated.length > 0 || archived.length > 0) && options.noPrompt) {
2894
+ if (!(created.length > 0 || updated.length > 0 || archivedFiles.length > 0) && options.noPrompt) {
2824
2895
  s.stop("Repository is up to date.");
2825
2896
  return results;
2826
2897
  }
@@ -2834,7 +2905,6 @@ async function runInit(config, options = {}) {
2834
2905
  const summaryLines = [];
2835
2906
  if (created.length > 0) summaryLines.push(`Created: ${created.map((r) => r.filePath).join(", ")}`);
2836
2907
  if (updated.length > 0) summaryLines.push(`Updated: ${updated.map((r) => r.filePath).join(", ")}`);
2837
- if (archived.length > 0) summaryLines.push(`Archived: ${archived.map((r) => r.filePath).join(", ")}`);
2838
2908
  p.note(summaryLines.join("\n"), "Summary");
2839
2909
  if (!options.noPrompt) {
2840
2910
  const prompt = generateMigratePrompt(results, config, detected);
@@ -2929,6 +2999,7 @@ async function runCheck(targetDir) {
2929
2999
  const saved = loadToolingConfig(targetDir);
2930
3000
  const detected = buildDefaultConfig(targetDir, {});
2931
3001
  const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
3002
+ logDetectionSummary(ctx);
2932
3003
  const actionable = (await runGenerators(ctx)).filter((r) => {
2933
3004
  if (r.action !== "created" && r.action !== "updated") return false;
2934
3005
  const newContent = pendingWrites.get(r.filePath);
@@ -3845,7 +3916,7 @@ const CHECKS = [
3845
3916
  },
3846
3917
  { name: "knip" },
3847
3918
  { name: "tooling:check" },
3848
- { name: "image:check" }
3919
+ { name: "docker:check" }
3849
3920
  ];
3850
3921
  function defaultGetScripts(targetDir) {
3851
3922
  try {
@@ -3912,7 +3983,7 @@ function runRunChecks(targetDir, options = {}) {
3912
3983
  const runChecksCommand = defineCommand({
3913
3984
  meta: {
3914
3985
  name: "checks:run",
3915
- description: "Run all standard checks (build, typecheck, lint, test, format, knip, tooling:check, image:check)"
3986
+ description: "Run all standard checks (build, typecheck, lint, test, format, knip, tooling:check, docker:check)"
3916
3987
  },
3917
3988
  args: {
3918
3989
  dir: {
@@ -3922,7 +3993,7 @@ const runChecksCommand = defineCommand({
3922
3993
  },
3923
3994
  skip: {
3924
3995
  type: "string",
3925
- description: "Comma-separated list of checks to skip (build, typecheck, lint, test, format, knip, tooling:check, image:check)",
3996
+ description: "Comma-separated list of checks to skip (build, typecheck, lint, test, format, knip, tooling:check, docker:check)",
3926
3997
  required: false
3927
3998
  },
3928
3999
  add: {
@@ -4313,6 +4384,7 @@ const ComposePortSchema = z.union([z.string(), z.object({
4313
4384
  target: z.union([z.string(), z.number()]).optional()
4314
4385
  }).loose()]);
4315
4386
  const ComposeServiceSchema = z.object({
4387
+ image: z.string().optional(),
4316
4388
  ports: z.array(ComposePortSchema).optional(),
4317
4389
  healthcheck: z.unknown().optional()
4318
4390
  }).loose();
@@ -4387,6 +4459,7 @@ function parseComposeServices(cwd, composeFiles) {
4387
4459
  }
4388
4460
  serviceMap.set(name, {
4389
4461
  name,
4462
+ image: existing?.image ?? service.image,
4390
4463
  hostPort,
4391
4464
  hasHealthcheck: existing?.hasHealthcheck ?? service.healthcheck !== void 0
4392
4465
  });
@@ -4394,6 +4467,15 @@ function parseComposeServices(cwd, composeFiles) {
4394
4467
  }
4395
4468
  return [...serviceMap.values()];
4396
4469
  }
4470
+ /** Extract deduplicated bare image names (without tags) from parsed services. */
4471
+ function extractComposeImageNames(services) {
4472
+ const names = /* @__PURE__ */ new Set();
4473
+ for (const service of services) if (service.image) {
4474
+ const bare = service.image.split(":")[0];
4475
+ if (bare) names.add(bare);
4476
+ }
4477
+ return [...names];
4478
+ }
4397
4479
  /** Generate health checks from parsed services: services with exposed ports get HTTP checks, unless they define a compose-level healthcheck. */
4398
4480
  function deriveHealthChecks(services) {
4399
4481
  return services.filter((s) => s.hostPort !== void 0 && !s.hasHealthcheck).map((s) => ({
@@ -4454,6 +4536,26 @@ function computeCheckDefaults(cwd) {
4454
4536
  healthChecks: healthChecks.length > 0 ? healthChecks : void 0
4455
4537
  };
4456
4538
  }
4539
+ /** Create a DockerFileReader backed by the real filesystem. */
4540
+ function createFileReader() {
4541
+ return {
4542
+ listPackageDirs(cwd) {
4543
+ const packagesDir = path.join(cwd, "packages");
4544
+ try {
4545
+ return readdirSync(packagesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
4546
+ } catch {
4547
+ return [];
4548
+ }
4549
+ },
4550
+ readFile(filePath) {
4551
+ try {
4552
+ return readFileSync(filePath, "utf-8");
4553
+ } catch {
4554
+ return null;
4555
+ }
4556
+ }
4557
+ };
4558
+ }
4457
4559
  //#endregion
4458
4560
  //#region src/commands/docker-check.ts
4459
4561
  /** Convert declarative health checks to functional ones. */
@@ -4509,7 +4611,18 @@ const dockerCheckCommand = defineCommand({
4509
4611
  }
4510
4612
  if (!defaults.services || defaults.services.length === 0) throw new FatalError("No services found in compose files.");
4511
4613
  const composeCwd = defaults.composeCwd ?? cwd;
4512
- const tempOverlayPath = writeTempOverlay(generateCheckOverlay(parseComposeServices(composeCwd, defaults.composeFiles)));
4614
+ const services = parseComposeServices(composeCwd, defaults.composeFiles);
4615
+ const fileReader = createFileReader();
4616
+ const rootPkgRaw = fileReader.readFile(path.join(cwd, "package.json"));
4617
+ if (rootPkgRaw) {
4618
+ const rootPkg = parsePackageJson(rootPkgRaw);
4619
+ if (rootPkg?.name) {
4620
+ const dockerPackages = detectDockerPackages(fileReader, cwd, rootPkg.name);
4621
+ const composeImages = extractComposeImageNames(services);
4622
+ for (const pkg of dockerPackages) if (!composeImages.some((img) => img === pkg.imageName || img.endsWith(`/${pkg.imageName}`))) warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
4623
+ }
4624
+ }
4625
+ const tempOverlayPath = writeTempOverlay(generateCheckOverlay(services));
4513
4626
  const composeFiles = [
4514
4627
  ...defaults.composeFiles,
4515
4628
  tempOverlayPath,
@@ -4543,7 +4656,7 @@ const dockerCheckCommand = defineCommand({
4543
4656
  const main = defineCommand({
4544
4657
  meta: {
4545
4658
  name: "tooling",
4546
- version: "0.19.0",
4659
+ version: "0.21.0",
4547
4660
  description: "Bootstrap and maintain standardized TypeScript project tooling"
4548
4661
  },
4549
4662
  subCommands: {
@@ -4559,7 +4672,7 @@ const main = defineCommand({
4559
4672
  "docker:check": dockerCheckCommand
4560
4673
  }
4561
4674
  });
4562
- console.log(`@bensandee/tooling v0.19.0`);
4675
+ console.log(`@bensandee/tooling v0.21.0`);
4563
4676
  runMain(main);
4564
4677
  //#endregion
4565
4678
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "bin": {
6
6
  "tooling": "./dist/bin.mjs"