@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 +643 -600
- package/dist/index.d.mts +4 -3
- package/package.json +1 -1
- package/tooling.schema.json +0 -4
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: !!
|
|
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
|
|
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
|
|
425
|
-
const detected = detectProject(targetDir);
|
|
426
|
-
const defaults = computeDefaults(targetDir);
|
|
429
|
+
const { defaults, name } = detectProjectInfo(targetDir);
|
|
427
430
|
return {
|
|
428
|
-
name
|
|
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/
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
706
|
-
|
|
691
|
+
name: result.data.name,
|
|
692
|
+
version: result.data.version
|
|
707
693
|
};
|
|
708
|
-
} catch {
|
|
694
|
+
} catch (_error) {
|
|
709
695
|
return {
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
*
|
|
717
|
-
*
|
|
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
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
*
|
|
765
|
-
*
|
|
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
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
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
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
return
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
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
|
|
832
|
-
DOCKER_REGISTRY_NAMESPACE: ${actionsExpr
|
|
833
|
-
DOCKER_USERNAME: ${actionsExpr
|
|
834
|
-
DOCKER_PASSWORD: ${actionsExpr
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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: "
|
|
1244
|
+
description: "Publish workflow already up to date"
|
|
921
1245
|
};
|
|
922
1246
|
}
|
|
923
|
-
const merged = mergeWorkflowSteps(existing, "
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
1572
|
-
"cancel-in-progress": actionsExpr
|
|
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
|
|
1584
|
-
cancel-in-progress: ${actionsExpr
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
const
|
|
2856
|
-
const
|
|
2857
|
-
if (
|
|
2858
|
-
|
|
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
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2923
|
+
merged: {
|
|
2924
|
+
...settings,
|
|
2925
|
+
"yaml.schemas": yamlSchemas
|
|
2926
|
+
},
|
|
2927
|
+
changed: true
|
|
2864
2928
|
};
|
|
2865
2929
|
}
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
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
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2959
|
+
filePath: SETTINGS_PATH,
|
|
2960
|
+
action: "created",
|
|
2961
|
+
description: "Generated .vscode/settings.json with Forgejo workflow schema"
|
|
2875
2962
|
};
|
|
2876
2963
|
}
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
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
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
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
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
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
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
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
|
-
|
|
2993
|
-
return
|
|
2994
|
-
|
|
2995
|
-
|
|
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.
|
|
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.
|
|
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);
|