@bensandee/tooling 0.57.4 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/bin.mjs +259 -113
- package/dist/docker-check/index.d.mts +121 -1
- package/dist/docker-check/index.mjs +2 -2
- package/dist/index.d.mts +1 -117
- package/dist/{check-B5DFYJ3x.mjs → project-name-CAVRoqNy.mjs} +172 -2
- package/dist/types-DtwkblrK.d.mts +119 -0
- package/package.json +2 -2
- package/tooling.schema.json +5 -0
package/README.md
CHANGED
|
@@ -78,6 +78,28 @@ Custom blocks are anchored to the nearest preceding non-blank line. When `repo:s
|
|
|
78
78
|
|
|
79
79
|
To skip a workflow file entirely, add `# @bensandee/tooling:ignore` as the first line — `repo:sync` will leave the file untouched.
|
|
80
80
|
|
|
81
|
+
### pnpm-workspace.yaml
|
|
82
|
+
|
|
83
|
+
`repo:sync` manages `pnpm-workspace.yaml` with a narrow contract — mandated keys, a few seeded-on-creation keys, and everything else passed through verbatim.
|
|
84
|
+
|
|
85
|
+
**Mandated** (drift fails `repo:sync --check`):
|
|
86
|
+
|
|
87
|
+
- `blockExoticSubdeps: true`
|
|
88
|
+
- `minimumReleaseAge` — any non-negative number (`0` is valid)
|
|
89
|
+
|
|
90
|
+
**Seeded on creation only** (free to change or remove afterward):
|
|
91
|
+
|
|
92
|
+
- `minimumReleaseAgeExclude: ["@bensandee/*"]`
|
|
93
|
+
- `packages: ["packages/*"]` (monorepos only)
|
|
94
|
+
|
|
95
|
+
All other keys — `allowBuilds`, custom entries — are preserved verbatim through `repo:sync`. The generator emits the file for single-package repos too, not just monorepos.
|
|
96
|
+
|
|
97
|
+
Opt out entirely with `"pnpmWorkspace": false` in `.tooling.json`; the generator then never inspects or writes the file.
|
|
98
|
+
|
|
99
|
+
### packageManager force-bump
|
|
100
|
+
|
|
101
|
+
`repo:sync` rewrites `package.json#packageManager` when an existing value is `pnpm@<N>.x` with `N < 11`, setting it to the tooling's pinned pnpm version (currently `11.1.1`). This is delivered through `repo:sync` rather than Renovate because the mandated `pnpm-workspace.yaml` keys (`blockExoticSubdeps`) require pnpm 11+. Within-major bumps (`pnpm@11.x → 11.y`) remain Renovate's job; non-pnpm package managers (yarn, npm) are out of scope.
|
|
102
|
+
|
|
81
103
|
### Repository setup
|
|
82
104
|
|
|
83
105
|
| Command | Description |
|
|
@@ -350,6 +372,7 @@ Available override fields:
|
|
|
350
372
|
| `releaseStrategy` | string | Detected from existing config, else monorepo: `"changesets"`, single: `"simple"` |
|
|
351
373
|
| `projectType` | string | Auto-detected from `package.json` deps |
|
|
352
374
|
| `detectPackageTypes` | boolean | `true` |
|
|
375
|
+
| `pnpmWorkspace` | `false` | Set to `false` to opt out of `pnpm-workspace.yaml` management |
|
|
353
376
|
|
|
354
377
|
## Debug logging
|
|
355
378
|
|
package/dist/bin.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { d as
|
|
2
|
+
import { _ as log, d as isExecSyncError, f as lintComposeFiles, g as redact, h as isEnvVerbose, m as debugExec, n as runDockerCheck, p as debug, t as deriveProjectName, u as createRealExecutor$1, v as note } from "./project-name-CAVRoqNy.mjs";
|
|
3
3
|
import { defineCommand, runMain } from "citty";
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
5
|
import { isCancel, select } from "@clack/prompts";
|
|
@@ -10,7 +10,7 @@ import JSON5 from "json5";
|
|
|
10
10
|
import { parse } from "jsonc-parser";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { FatalError, TransientError, UnexpectedError } from "@bensandee/common";
|
|
13
|
-
import { isMap, isScalar, parse as parse$1, parseDocument, stringify, visit } from "yaml";
|
|
13
|
+
import { isMap, isScalar, isSeq, parse as parse$1, parseDocument, stringify, visit } from "yaml";
|
|
14
14
|
import picomatch from "picomatch";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { createHmac } from "node:crypto";
|
|
@@ -39,7 +39,8 @@ const PackageJsonSchema = z.looseObject({
|
|
|
39
39
|
types: z.string().optional(),
|
|
40
40
|
typings: z.string().optional(),
|
|
41
41
|
engines: StringRecord.optional(),
|
|
42
|
-
repository: z.union([z.string(), z.looseObject({ url: z.string() })]).optional()
|
|
42
|
+
repository: z.union([z.string(), z.looseObject({ url: z.string() })]).optional(),
|
|
43
|
+
packageManager: z.string().optional()
|
|
43
44
|
});
|
|
44
45
|
const TsconfigSchema = z.looseObject({
|
|
45
46
|
extends: z.union([z.string(), z.array(z.string())]).optional(),
|
|
@@ -644,7 +645,8 @@ const ToolingConfigSchema = z.strictObject({
|
|
|
644
645
|
releaseAssets: z.array(ReleaseAssetSchema).optional().meta({ description: "Custom assets to build and upload to the release" }),
|
|
645
646
|
postReleaseHook: z.string().optional().meta({ description: "Override the post-release hook command (default: POST to POST_RELEASE_HOOK_URL with HMAC-SHA256 auth)" }),
|
|
646
647
|
postReleaseHookSecrets: z.array(z.string()).optional().meta({ description: "Additional secret names to inject as env vars in the post-release hook step (e.g. ['WEBHOOK_SECRET'])" }),
|
|
647
|
-
coverage: z.union([z.literal(false), CoverageConfigSchema]).optional().meta({ description: "Coverage tracking configuration. Set to false to disable; omit for default-on (when a vitest config is present)." })
|
|
648
|
+
coverage: z.union([z.literal(false), CoverageConfigSchema]).optional().meta({ description: "Coverage tracking configuration. Set to false to disable; omit for default-on (when a vitest config is present)." }),
|
|
649
|
+
pnpmWorkspace: z.literal(false).optional().meta({ description: "Set to false to opt out of pnpm-workspace.yaml management (skips both generation and the repo:sync --check validation)." })
|
|
648
650
|
});
|
|
649
651
|
/** Format a Zod issue as a human-readable bullet: location + plain-English explanation. */
|
|
650
652
|
function formatIssue(issue) {
|
|
@@ -1591,7 +1593,7 @@ const PER_PACKAGE_DEV_DEPS = {
|
|
|
1591
1593
|
"tsdown": "0.22.0",
|
|
1592
1594
|
"typescript": "6.0.3",
|
|
1593
1595
|
"vitest": "4.1.5",
|
|
1594
|
-
"pnpm": "11.
|
|
1596
|
+
"pnpm": "11.1.1"
|
|
1595
1597
|
}["@types/node"],
|
|
1596
1598
|
tsdown: {
|
|
1597
1599
|
"@changesets/cli": "2.31.0",
|
|
@@ -1607,7 +1609,7 @@ const PER_PACKAGE_DEV_DEPS = {
|
|
|
1607
1609
|
"tsdown": "0.22.0",
|
|
1608
1610
|
"typescript": "6.0.3",
|
|
1609
1611
|
"vitest": "4.1.5",
|
|
1610
|
-
"pnpm": "11.
|
|
1612
|
+
"pnpm": "11.1.1"
|
|
1611
1613
|
}["tsdown"],
|
|
1612
1614
|
typescript: {
|
|
1613
1615
|
"@changesets/cli": "2.31.0",
|
|
@@ -1623,7 +1625,7 @@ const PER_PACKAGE_DEV_DEPS = {
|
|
|
1623
1625
|
"tsdown": "0.22.0",
|
|
1624
1626
|
"typescript": "6.0.3",
|
|
1625
1627
|
"vitest": "4.1.5",
|
|
1626
|
-
"pnpm": "11.
|
|
1628
|
+
"pnpm": "11.1.1"
|
|
1627
1629
|
}["typescript"],
|
|
1628
1630
|
vitest: {
|
|
1629
1631
|
"@changesets/cli": "2.31.0",
|
|
@@ -1639,7 +1641,7 @@ const PER_PACKAGE_DEV_DEPS = {
|
|
|
1639
1641
|
"tsdown": "0.22.0",
|
|
1640
1642
|
"typescript": "6.0.3",
|
|
1641
1643
|
"vitest": "4.1.5",
|
|
1642
|
-
"pnpm": "11.
|
|
1644
|
+
"pnpm": "11.1.1"
|
|
1643
1645
|
}["vitest"]
|
|
1644
1646
|
};
|
|
1645
1647
|
/** DevDeps that belong at the root regardless of structure. */
|
|
@@ -1658,7 +1660,7 @@ const ROOT_DEV_DEPS = {
|
|
|
1658
1660
|
"tsdown": "0.22.0",
|
|
1659
1661
|
"typescript": "6.0.3",
|
|
1660
1662
|
"vitest": "4.1.5",
|
|
1661
|
-
"pnpm": "11.
|
|
1663
|
+
"pnpm": "11.1.1"
|
|
1662
1664
|
}["knip"],
|
|
1663
1665
|
lefthook: {
|
|
1664
1666
|
"@changesets/cli": "2.31.0",
|
|
@@ -1674,7 +1676,7 @@ const ROOT_DEV_DEPS = {
|
|
|
1674
1676
|
"tsdown": "0.22.0",
|
|
1675
1677
|
"typescript": "6.0.3",
|
|
1676
1678
|
"vitest": "4.1.5",
|
|
1677
|
-
"pnpm": "11.
|
|
1679
|
+
"pnpm": "11.1.1"
|
|
1678
1680
|
}["lefthook"],
|
|
1679
1681
|
oxlint: {
|
|
1680
1682
|
"@changesets/cli": "2.31.0",
|
|
@@ -1690,7 +1692,7 @@ const ROOT_DEV_DEPS = {
|
|
|
1690
1692
|
"tsdown": "0.22.0",
|
|
1691
1693
|
"typescript": "6.0.3",
|
|
1692
1694
|
"vitest": "4.1.5",
|
|
1693
|
-
"pnpm": "11.
|
|
1695
|
+
"pnpm": "11.1.1"
|
|
1694
1696
|
}["oxlint"]
|
|
1695
1697
|
};
|
|
1696
1698
|
/**
|
|
@@ -1731,7 +1733,7 @@ function addReleaseDeps(deps, config) {
|
|
|
1731
1733
|
"tsdown": "0.22.0",
|
|
1732
1734
|
"typescript": "6.0.3",
|
|
1733
1735
|
"vitest": "4.1.5",
|
|
1734
|
-
"pnpm": "11.
|
|
1736
|
+
"pnpm": "11.1.1"
|
|
1735
1737
|
}["release-it"];
|
|
1736
1738
|
if (config.structure === "monorepo") deps["@release-it/bumper"] = {
|
|
1737
1739
|
"@changesets/cli": "2.31.0",
|
|
@@ -1747,7 +1749,7 @@ function addReleaseDeps(deps, config) {
|
|
|
1747
1749
|
"tsdown": "0.22.0",
|
|
1748
1750
|
"typescript": "6.0.3",
|
|
1749
1751
|
"vitest": "4.1.5",
|
|
1750
|
-
"pnpm": "11.
|
|
1752
|
+
"pnpm": "11.1.1"
|
|
1751
1753
|
}["@release-it/bumper"];
|
|
1752
1754
|
break;
|
|
1753
1755
|
case "simple":
|
|
@@ -1765,7 +1767,7 @@ function addReleaseDeps(deps, config) {
|
|
|
1765
1767
|
"tsdown": "0.22.0",
|
|
1766
1768
|
"typescript": "6.0.3",
|
|
1767
1769
|
"vitest": "4.1.5",
|
|
1768
|
-
"pnpm": "11.
|
|
1770
|
+
"pnpm": "11.1.1"
|
|
1769
1771
|
}["commit-and-tag-version"];
|
|
1770
1772
|
break;
|
|
1771
1773
|
case "changesets":
|
|
@@ -1783,7 +1785,7 @@ function addReleaseDeps(deps, config) {
|
|
|
1783
1785
|
"tsdown": "0.22.0",
|
|
1784
1786
|
"typescript": "6.0.3",
|
|
1785
1787
|
"vitest": "4.1.5",
|
|
1786
|
-
"pnpm": "11.
|
|
1788
|
+
"pnpm": "11.1.1"
|
|
1787
1789
|
}["@changesets/cli"];
|
|
1788
1790
|
break;
|
|
1789
1791
|
}
|
|
@@ -1806,8 +1808,8 @@ async function generatePackageJson(ctx) {
|
|
|
1806
1808
|
}
|
|
1807
1809
|
const devDeps = { ...ROOT_DEV_DEPS };
|
|
1808
1810
|
if (!isMonorepo) Object.assign(devDeps, PER_PACKAGE_DEV_DEPS);
|
|
1809
|
-
devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.11.
|
|
1810
|
-
devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.
|
|
1811
|
+
devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.11.2";
|
|
1812
|
+
devDeps["@bensandee/tooling"] = isWorkspacePackage(ctx, "@bensandee/tooling") ? "workspace:*" : "0.59.0";
|
|
1811
1813
|
if (ctx.config.useEslintPlugin) devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.13.0";
|
|
1812
1814
|
if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
|
|
1813
1815
|
"@changesets/cli": "2.31.0",
|
|
@@ -1823,7 +1825,7 @@ async function generatePackageJson(ctx) {
|
|
|
1823
1825
|
"tsdown": "0.22.0",
|
|
1824
1826
|
"typescript": "6.0.3",
|
|
1825
1827
|
"vitest": "4.1.5",
|
|
1826
|
-
"pnpm": "11.
|
|
1828
|
+
"pnpm": "11.1.1"
|
|
1827
1829
|
}["oxfmt"];
|
|
1828
1830
|
if (ctx.config.formatter === "prettier") devDeps["prettier"] = {
|
|
1829
1831
|
"@changesets/cli": "2.31.0",
|
|
@@ -1839,7 +1841,7 @@ async function generatePackageJson(ctx) {
|
|
|
1839
1841
|
"tsdown": "0.22.0",
|
|
1840
1842
|
"typescript": "6.0.3",
|
|
1841
1843
|
"vitest": "4.1.5",
|
|
1842
|
-
"pnpm": "11.
|
|
1844
|
+
"pnpm": "11.1.1"
|
|
1843
1845
|
}["prettier"];
|
|
1844
1846
|
addReleaseDeps(devDeps, ctx.config);
|
|
1845
1847
|
if (existing) {
|
|
@@ -1897,6 +1899,36 @@ async function generatePackageJson(ctx) {
|
|
|
1897
1899
|
pkg["engines"] = { node: ">=24.13.0" };
|
|
1898
1900
|
changes.push("set engines.node >= 24.13.0");
|
|
1899
1901
|
}
|
|
1902
|
+
const pinnedPnpm = {
|
|
1903
|
+
"@changesets/cli": "2.31.0",
|
|
1904
|
+
"@release-it/bumper": "7.0.5",
|
|
1905
|
+
"@types/node": "24.12.3",
|
|
1906
|
+
"commit-and-tag-version": "12.7.1",
|
|
1907
|
+
"knip": "6.12.1",
|
|
1908
|
+
"lefthook": "2.1.6",
|
|
1909
|
+
"oxfmt": "0.48.0",
|
|
1910
|
+
"oxlint": "1.63.0",
|
|
1911
|
+
"prettier": "3.8.3",
|
|
1912
|
+
"release-it": "20.0.1",
|
|
1913
|
+
"tsdown": "0.22.0",
|
|
1914
|
+
"typescript": "6.0.3",
|
|
1915
|
+
"vitest": "4.1.5",
|
|
1916
|
+
"pnpm": "11.1.1"
|
|
1917
|
+
}["pnpm"];
|
|
1918
|
+
const desiredPm = pinnedPnpm ? `pnpm@${pinnedPnpm}` : void 0;
|
|
1919
|
+
if (desiredPm) {
|
|
1920
|
+
const currentPm = pkg.packageManager;
|
|
1921
|
+
if (!currentPm) {
|
|
1922
|
+
pkg.packageManager = desiredPm;
|
|
1923
|
+
changes.push(`set packageManager: ${desiredPm}`);
|
|
1924
|
+
} else {
|
|
1925
|
+
const match = /^pnpm@(\d+)\./.exec(currentPm);
|
|
1926
|
+
if (match && Number(match[1]) < 11) {
|
|
1927
|
+
pkg.packageManager = desiredPm;
|
|
1928
|
+
changes.push(`bumped packageManager: ${currentPm} → ${desiredPm}`);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1900
1932
|
if (changes.length === 0) return {
|
|
1901
1933
|
filePath,
|
|
1902
1934
|
action: "skipped",
|
|
@@ -1933,7 +1965,7 @@ async function generatePackageJson(ctx) {
|
|
|
1933
1965
|
"tsdown": "0.22.0",
|
|
1934
1966
|
"typescript": "6.0.3",
|
|
1935
1967
|
"vitest": "4.1.5",
|
|
1936
|
-
"pnpm": "11.
|
|
1968
|
+
"pnpm": "11.1.1"
|
|
1937
1969
|
}["pnpm"]}`
|
|
1938
1970
|
};
|
|
1939
1971
|
ctx.write(filePath, JSON.stringify(pkg, null, 2) + "\n");
|
|
@@ -3842,26 +3874,103 @@ async function generateRenovate(ctx) {
|
|
|
3842
3874
|
}
|
|
3843
3875
|
//#endregion
|
|
3844
3876
|
//#region src/generators/pnpm-workspace.ts
|
|
3845
|
-
const
|
|
3877
|
+
const FILE_PATH = "pnpm-workspace.yaml";
|
|
3878
|
+
const DEFAULT_MIN_RELEASE_AGE = 1440;
|
|
3879
|
+
const BENSANDEE_EXCLUDE_PATTERN = "@bensandee/*";
|
|
3880
|
+
const FRESH_MONOREPO = `packages:
|
|
3846
3881
|
- "packages/*"
|
|
3882
|
+
|
|
3883
|
+
blockExoticSubdeps: true
|
|
3884
|
+
|
|
3885
|
+
minimumReleaseAge: ${DEFAULT_MIN_RELEASE_AGE}
|
|
3886
|
+
minimumReleaseAgeExclude:
|
|
3887
|
+
- "@bensandee/*"
|
|
3888
|
+
`;
|
|
3889
|
+
const FRESH_SINGLE = `blockExoticSubdeps: true
|
|
3890
|
+
|
|
3891
|
+
minimumReleaseAge: ${DEFAULT_MIN_RELEASE_AGE}
|
|
3892
|
+
minimumReleaseAgeExclude:
|
|
3893
|
+
- "@bensandee/*"
|
|
3847
3894
|
`;
|
|
3895
|
+
/** Minimal projection of `.tooling.json` needed by this generator. */
|
|
3896
|
+
const PnpmWorkspaceOptOutSchema = z.object({ pnpmWorkspace: z.literal(false).optional() });
|
|
3897
|
+
function isPnpmWorkspaceDisabled(ctx) {
|
|
3898
|
+
const raw = ctx.read(".tooling.json");
|
|
3899
|
+
if (!raw) return false;
|
|
3900
|
+
try {
|
|
3901
|
+
const result = PnpmWorkspaceOptOutSchema.safeParse(JSON.parse(raw));
|
|
3902
|
+
return result.success && result.data.pnpmWorkspace === false;
|
|
3903
|
+
} catch {
|
|
3904
|
+
return false;
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
/**
|
|
3908
|
+
* Apply managed-key contract to an existing doc. Returns the list of changes made.
|
|
3909
|
+
*
|
|
3910
|
+
* Mandated:
|
|
3911
|
+
* - `blockExoticSubdeps: true`
|
|
3912
|
+
* - `minimumReleaseAge`: any non-negative number; seeded to 1440 if missing/invalid.
|
|
3913
|
+
* - `minimumReleaseAgeExclude`: must contain `"@bensandee/*"` so this monorepo's
|
|
3914
|
+
* own packages can be installed immediately after publish. Other entries are
|
|
3915
|
+
* preserved.
|
|
3916
|
+
*
|
|
3917
|
+
* Not enforced (left alone after creation):
|
|
3918
|
+
* - everything else (`packages`, `allowBuilds`, etc.)
|
|
3919
|
+
*/
|
|
3920
|
+
function applyManagedKeys(doc) {
|
|
3921
|
+
const changes = [];
|
|
3922
|
+
if (doc.get("blockExoticSubdeps") !== true) {
|
|
3923
|
+
doc.set("blockExoticSubdeps", true);
|
|
3924
|
+
changes.push("set blockExoticSubdeps: true");
|
|
3925
|
+
}
|
|
3926
|
+
const minAge = doc.get("minimumReleaseAge");
|
|
3927
|
+
if (typeof minAge !== "number" || !Number.isFinite(minAge) || minAge < 0) {
|
|
3928
|
+
doc.set("minimumReleaseAge", DEFAULT_MIN_RELEASE_AGE);
|
|
3929
|
+
changes.push(`set minimumReleaseAge: ${DEFAULT_MIN_RELEASE_AGE}`);
|
|
3930
|
+
}
|
|
3931
|
+
const excludeNode = doc.get("minimumReleaseAgeExclude");
|
|
3932
|
+
if (!isSeq(excludeNode)) {
|
|
3933
|
+
doc.set("minimumReleaseAgeExclude", [BENSANDEE_EXCLUDE_PATTERN]);
|
|
3934
|
+
changes.push(`set minimumReleaseAgeExclude: ["${BENSANDEE_EXCLUDE_PATTERN}"]`);
|
|
3935
|
+
} else if (!excludeNode.items.some((item) => isScalar(item) && item.value === BENSANDEE_EXCLUDE_PATTERN)) {
|
|
3936
|
+
excludeNode.add(BENSANDEE_EXCLUDE_PATTERN);
|
|
3937
|
+
changes.push(`added "${BENSANDEE_EXCLUDE_PATTERN}" to minimumReleaseAgeExclude`);
|
|
3938
|
+
}
|
|
3939
|
+
return changes;
|
|
3940
|
+
}
|
|
3848
3941
|
async function generatePnpmWorkspace(ctx) {
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
filePath,
|
|
3942
|
+
if (isPnpmWorkspaceDisabled(ctx)) return {
|
|
3943
|
+
filePath: FILE_PATH,
|
|
3852
3944
|
action: "skipped",
|
|
3853
|
-
description: "
|
|
3945
|
+
description: "pnpmWorkspace: false in .tooling.json"
|
|
3854
3946
|
};
|
|
3855
|
-
|
|
3856
|
-
|
|
3947
|
+
const isMonorepo = ctx.config.structure === "monorepo";
|
|
3948
|
+
const existing = ctx.read(FILE_PATH);
|
|
3949
|
+
if (existing === void 0) {
|
|
3950
|
+
ctx.write(FILE_PATH, isMonorepo ? FRESH_MONOREPO : FRESH_SINGLE);
|
|
3951
|
+
return {
|
|
3952
|
+
filePath: FILE_PATH,
|
|
3953
|
+
action: "created",
|
|
3954
|
+
description: "Generated pnpm-workspace.yaml"
|
|
3955
|
+
};
|
|
3956
|
+
}
|
|
3957
|
+
const doc = parseDocument(existing);
|
|
3958
|
+
if (doc.errors.length > 0) return {
|
|
3959
|
+
filePath: FILE_PATH,
|
|
3857
3960
|
action: "skipped",
|
|
3858
|
-
description:
|
|
3961
|
+
description: `Could not parse existing pnpm-workspace.yaml: ${doc.errors[0]?.message ?? "unknown error"}`
|
|
3859
3962
|
};
|
|
3860
|
-
|
|
3963
|
+
const changes = applyManagedKeys(doc);
|
|
3964
|
+
if (changes.length === 0) return {
|
|
3965
|
+
filePath: FILE_PATH,
|
|
3966
|
+
action: "skipped",
|
|
3967
|
+
description: "Already up to spec"
|
|
3968
|
+
};
|
|
3969
|
+
ctx.write(FILE_PATH, doc.toString());
|
|
3861
3970
|
return {
|
|
3862
|
-
filePath,
|
|
3863
|
-
action: "
|
|
3864
|
-
description: "
|
|
3971
|
+
filePath: FILE_PATH,
|
|
3972
|
+
action: "updated",
|
|
3973
|
+
description: changes.join(", ")
|
|
3865
3974
|
};
|
|
3866
3975
|
}
|
|
3867
3976
|
//#endregion
|
|
@@ -3922,6 +4031,22 @@ function parseClaudeSettings(raw) {
|
|
|
3922
4031
|
return;
|
|
3923
4032
|
}
|
|
3924
4033
|
}
|
|
4034
|
+
/**
|
|
4035
|
+
* Instructions previously emitted to `.claude/settings.json#instructions`.
|
|
4036
|
+
* Migrated to a managed block in CLAUDE.md (see `claude-md.ts`); on sync we
|
|
4037
|
+
* strip any existing entry that matches (by either-direction prefix, mirroring
|
|
4038
|
+
* the upgrade-in-place semantics the merge logic used to apply on add).
|
|
4039
|
+
*/
|
|
4040
|
+
const MIGRATED_INSTRUCTIONS = [
|
|
4041
|
+
"Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
|
|
4042
|
+
"No typecasts (as/any). Use zod schemas, type guards, or narrowing instead.",
|
|
4043
|
+
"Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option. For no-empty-function: add a `{ /* no-op */ }` comment body instead of a disable comment.",
|
|
4044
|
+
"Prefer extensionless imports; if an extension is required, use .ts over .js.",
|
|
4045
|
+
"The bst-hook manages Bash/file permissions. See .claude/hooks/bst-hook-guide.md for auto-approved, denied, and passthrough commands."
|
|
4046
|
+
];
|
|
4047
|
+
function isMigratedInstruction(s) {
|
|
4048
|
+
return MIGRATED_INSTRUCTIONS.some((c) => s.startsWith(c) || c.startsWith(s));
|
|
4049
|
+
}
|
|
3925
4050
|
function buildSettings(ctx) {
|
|
3926
4051
|
const allow = [];
|
|
3927
4052
|
const enabledPlugins = { "code-simplifier@claude-plugins-official": true };
|
|
@@ -3941,13 +4066,7 @@ function buildSettings(ctx) {
|
|
|
3941
4066
|
command: `node "$(git rev-parse --show-toplevel)/.claude/hooks/bst-hook.mjs"`
|
|
3942
4067
|
}]
|
|
3943
4068
|
}] },
|
|
3944
|
-
instructions: [
|
|
3945
|
-
"Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
|
|
3946
|
-
"No typecasts (as/any). Use zod schemas, type guards, or narrowing instead.",
|
|
3947
|
-
"Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option. For no-empty-function: add a `{ /* no-op */ }` comment body instead of a disable comment.",
|
|
3948
|
-
"Prefer extensionless imports; if an extension is required, use .ts over .js.",
|
|
3949
|
-
"The bst-hook manages Bash/file permissions. See .claude/hooks/bst-hook-guide.md for auto-approved, denied, and passthrough commands."
|
|
3950
|
-
],
|
|
4069
|
+
instructions: [],
|
|
3951
4070
|
enabledPlugins,
|
|
3952
4071
|
extraKnownMarketplaces
|
|
3953
4072
|
};
|
|
@@ -3962,11 +4081,12 @@ function removeObsoleteBstHookEntries(matchers, consolidatedMatcher) {
|
|
|
3962
4081
|
return false;
|
|
3963
4082
|
});
|
|
3964
4083
|
}
|
|
3965
|
-
/** Remove
|
|
4084
|
+
/** Remove empty optional fields to keep the file clean. */
|
|
3966
4085
|
function serializeSettings$1(settings) {
|
|
3967
|
-
const { enabledPlugins, extraKnownMarketplaces, hooks, ...rest } = settings;
|
|
4086
|
+
const { enabledPlugins, extraKnownMarketplaces, hooks, instructions, ...rest } = settings;
|
|
3968
4087
|
const out = { ...rest };
|
|
3969
4088
|
if (Object.keys(hooks).length > 0) out["hooks"] = hooks;
|
|
4089
|
+
if (instructions.length > 0) out["instructions"] = instructions;
|
|
3970
4090
|
if (Object.keys(enabledPlugins).length > 0) out["enabledPlugins"] = enabledPlugins;
|
|
3971
4091
|
if (Object.keys(extraKnownMarketplaces).length > 0) out["extraKnownMarketplaces"] = extraKnownMarketplaces;
|
|
3972
4092
|
return JSON.stringify(out, null, 2) + "\n";
|
|
@@ -3983,15 +4103,9 @@ function writeOrMergeSettings(ctx, filePath, generated) {
|
|
|
3983
4103
|
const { settings, rawJson } = parsed;
|
|
3984
4104
|
const missingAllow = generated.permissions.allow.filter((rule) => !settings.permissions.allow.includes(rule));
|
|
3985
4105
|
const missingDeny = generated.permissions.deny.filter((rule) => !settings.permissions.deny.includes(rule));
|
|
3986
|
-
const
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
if (mergedInstructions.includes(inst)) continue;
|
|
3990
|
-
const prefixIdx = mergedInstructions.findIndex((e) => inst.startsWith(e) || e.startsWith(inst));
|
|
3991
|
-
if (prefixIdx !== -1) mergedInstructions[prefixIdx] = inst;
|
|
3992
|
-
else mergedInstructions.push(inst);
|
|
3993
|
-
instructionChanges++;
|
|
3994
|
-
}
|
|
4106
|
+
const filteredInstructions = settings.instructions.filter((s) => !isMigratedInstruction(s));
|
|
4107
|
+
const instructionChanges = settings.instructions.length - filteredInstructions.length;
|
|
4108
|
+
const mergedInstructions = filteredInstructions;
|
|
3995
4109
|
let hookChanges = 0;
|
|
3996
4110
|
const mergedHooks = { ...settings.hooks };
|
|
3997
4111
|
for (const [event, generatedMatchers] of Object.entries(generated.hooks)) {
|
|
@@ -4032,7 +4146,8 @@ function writeOrMergeSettings(ctx, filePath, generated) {
|
|
|
4032
4146
|
deny: [...settings.permissions.deny, ...missingDeny]
|
|
4033
4147
|
};
|
|
4034
4148
|
if (Object.keys(mergedHooks).length > 0) rawJson["hooks"] = mergedHooks;
|
|
4035
|
-
rawJson["instructions"] = mergedInstructions;
|
|
4149
|
+
if (mergedInstructions.length > 0) rawJson["instructions"] = mergedInstructions;
|
|
4150
|
+
else delete rawJson["instructions"];
|
|
4036
4151
|
const updatedPlugins = { ...settings.enabledPlugins };
|
|
4037
4152
|
for (const [key, value] of missingPlugins) updatedPlugins[key] = value;
|
|
4038
4153
|
const updatedMarketplaces = { ...settings.extraKnownMarketplaces };
|
|
@@ -4512,52 +4627,72 @@ async function generateCoverageCi(ctx) {
|
|
|
4512
4627
|
//#endregion
|
|
4513
4628
|
//#region src/generators/claude-md.ts
|
|
4514
4629
|
const FILE = "CLAUDE.md";
|
|
4515
|
-
|
|
4516
|
-
|
|
4630
|
+
/**
|
|
4631
|
+
* General agent rules previously emitted as the `instructions` array in
|
|
4632
|
+
* `.claude/settings.json`. Migrated here so CLAUDE.md is the single source
|
|
4633
|
+
* for human-readable agent guidance.
|
|
4634
|
+
*
|
|
4635
|
+
* Also includes the plan-lifecycle convention so `docs/plan-*.md` files get
|
|
4636
|
+
* retired consistently when they ship.
|
|
4637
|
+
*/
|
|
4638
|
+
const AGENT_RULES_BODY = [
|
|
4639
|
+
"### Agent rules",
|
|
4640
|
+
"",
|
|
4641
|
+
"- Use pnpm, not npm/yarn/npx. Run binaries with `pnpm exec`.",
|
|
4642
|
+
"- No typecasts (`as`/`any`). Use zod schemas, type guards, or narrowing instead.",
|
|
4643
|
+
"- Fix lint violations instead of suppressing them. Only add disable comments when suppression is genuinely the best option. For `no-empty-function`: add a `{ /* no-op */ }` comment body instead of a disable comment.",
|
|
4644
|
+
"- Prefer extensionless imports; if an extension is required, use `.ts` over `.js`.",
|
|
4645
|
+
"- The bst-hook manages Bash/file permissions. See `.claude/hooks/bst-hook-guide.md` for auto-approved, denied, and passthrough commands.",
|
|
4646
|
+
"",
|
|
4647
|
+
"#### Plan lifecycle",
|
|
4648
|
+
"",
|
|
4649
|
+
"When a plan under `docs/` (e.g. `docs/plan-foo.md`) is fully implemented, retire it:",
|
|
4650
|
+
"",
|
|
4651
|
+
"1. Move the file into `docs/completed/`.",
|
|
4652
|
+
"2. Rename so the `plan-` prefix becomes `completed-` — `docs/plan-foo.md` → `docs/completed/completed-foo.md`. Don't keep both prefixes (no `completed-plan-foo.md`).",
|
|
4653
|
+
"3. Update the plan body: change the `Status:` line to `Status: **completed YYYY-MM-DD**` using today's date, and add a short \"Shipped:\" paragraph naming what landed and where the post-implementation truth lives (CLAUDE.md, source files, etc.).",
|
|
4654
|
+
"4. Fix any relative links inside the moved file that broke from the move (e.g. `[todo.md](todo.md)` → `[todo.md](../todo.md)`).",
|
|
4655
|
+
"5. Confirm no stale references to the old path remain elsewhere in the repo."
|
|
4656
|
+
].join("\n");
|
|
4517
4657
|
/**
|
|
4518
4658
|
* The coverage-baseline rule that the agent must consult before declaring a
|
|
4519
4659
|
* task complete. Kept terse — the linked plan and `--help` carry the long form.
|
|
4520
4660
|
*/
|
|
4521
|
-
const
|
|
4661
|
+
const COVERAGE_RULE_BODY = [
|
|
4522
4662
|
"### Coverage baseline",
|
|
4523
4663
|
"",
|
|
4524
4664
|
"Before declaring a task complete that touched files under `packages/*/src/` (or `src/` for single-package repos), run `pnpm test:coverage` then `pnpm exec bst coverage:check`. If any of the four overall totals (lines/statements/branches/functions) dropped by more than the configured tolerance, add tests until parity is restored — or, if a touched file is genuinely not worth covering, exclude it via `coverage.exclude` in `vitest.config.ts` and explain why in the commit message. If all four are flat-or-up, run `pnpm exec bst coverage:check --update-baseline` and stage `coverage-baseline.json` with the change. Skip this gate for docs-only, config-only, dependency-bump, or test-only changes."
|
|
4525
4665
|
].join("\n");
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
return `${START_MARKER}\n\n${RULE_BODY}\n\n${END_MARKER}\n`;
|
|
4666
|
+
function buildBlockText(block) {
|
|
4667
|
+
return `${block.startMarker}\n\n${block.body}\n\n${block.endMarker}\n`;
|
|
4529
4668
|
}
|
|
4530
|
-
function findBlock(existing) {
|
|
4669
|
+
function findBlock(existing, block) {
|
|
4531
4670
|
const lines = existing.split("\n");
|
|
4532
4671
|
let startLine = -1;
|
|
4533
|
-
for (let i = 0; i < lines.length; i++) if (lines[i]?.trim() ===
|
|
4672
|
+
for (let i = 0; i < lines.length; i++) if (lines[i]?.trim() === block.startMarker) {
|
|
4534
4673
|
startLine = i;
|
|
4535
4674
|
break;
|
|
4536
4675
|
}
|
|
4537
4676
|
if (startLine === -1) return void 0;
|
|
4538
|
-
for (let i = startLine + 1; i < lines.length; i++) if (lines[i]?.trim() ===
|
|
4677
|
+
for (let i = startLine + 1; i < lines.length; i++) if (lines[i]?.trim() === block.endMarker) return {
|
|
4539
4678
|
startLine,
|
|
4540
4679
|
endLine: i
|
|
4541
4680
|
};
|
|
4542
4681
|
}
|
|
4543
|
-
/**
|
|
4544
|
-
* Replace any existing `<!-- @tooling:coverage-rule -->...<!-- @tooling:endcoverage-rule -->`
|
|
4545
|
-
* block in `existing` with `block`, or append it at the end if no block is found.
|
|
4546
|
-
*/
|
|
4547
4682
|
function upsertBlock(existing, block) {
|
|
4548
|
-
const
|
|
4549
|
-
|
|
4683
|
+
const blockText = buildBlockText(block);
|
|
4684
|
+
const found = findBlock(existing, block);
|
|
4685
|
+
if (!found) return `${existing.replace(/\n+$/, "")}\n\n${blockText}`;
|
|
4550
4686
|
const lines = existing.split("\n");
|
|
4551
4687
|
const beforeLines = lines.slice(0, found.startLine);
|
|
4552
4688
|
const afterLines = lines.slice(found.endLine + 1);
|
|
4553
4689
|
const before = beforeLines.join("\n").replace(/\n+$/, "");
|
|
4554
4690
|
const after = afterLines.join("\n").replace(/^\n+/, "");
|
|
4555
4691
|
const tail = after.length > 0 ? `\n\n${after}` : "\n";
|
|
4556
|
-
return `${before}\n\n${
|
|
4692
|
+
return `${before}\n\n${blockText.replace(/\n+$/, "")}${tail}`;
|
|
4557
4693
|
}
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
const found = findBlock(existing);
|
|
4694
|
+
function removeBlock(existing, block) {
|
|
4695
|
+
const found = findBlock(existing, block);
|
|
4561
4696
|
if (!found) return existing;
|
|
4562
4697
|
const lines = existing.split("\n");
|
|
4563
4698
|
const beforeLines = lines.slice(0, found.startLine);
|
|
@@ -4567,50 +4702,46 @@ function removeBlock(existing) {
|
|
|
4567
4702
|
if (after.length === 0) return `${before}\n`;
|
|
4568
4703
|
return `${before}\n\n${after}`;
|
|
4569
4704
|
}
|
|
4705
|
+
function applyBlock(existing, block) {
|
|
4706
|
+
return block.enabled ? upsertBlock(existing, block) : removeBlock(existing, block);
|
|
4707
|
+
}
|
|
4570
4708
|
const STARTER_CLAUDE_MD = "# CLAUDE.md\n\nProject guidance for Claude Code.\n";
|
|
4571
4709
|
async function generateClaudeMd(ctx) {
|
|
4572
|
-
const
|
|
4710
|
+
const coverageConfig = resolveCoverageConfigFromContext(ctx, ctx.config.setupVitest);
|
|
4711
|
+
const blocks = [{
|
|
4712
|
+
name: "agent-rules",
|
|
4713
|
+
startMarker: "<!-- @tooling:agent-rules -->",
|
|
4714
|
+
endMarker: "<!-- @tooling:endagent-rules -->",
|
|
4715
|
+
body: AGENT_RULES_BODY,
|
|
4716
|
+
enabled: true
|
|
4717
|
+
}, {
|
|
4718
|
+
name: "coverage-rule",
|
|
4719
|
+
startMarker: "<!-- @tooling:coverage-rule -->",
|
|
4720
|
+
endMarker: "<!-- @tooling:endcoverage-rule -->",
|
|
4721
|
+
body: COVERAGE_RULE_BODY,
|
|
4722
|
+
enabled: coverageConfig.enabled
|
|
4723
|
+
}];
|
|
4573
4724
|
const existing = ctx.read(FILE);
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
filePath: FILE,
|
|
4577
|
-
action: "skipped",
|
|
4578
|
-
description: "Coverage disabled; no CLAUDE.md to manage"
|
|
4579
|
-
};
|
|
4580
|
-
if (!existing.includes(START_MARKER)) return {
|
|
4581
|
-
filePath: FILE,
|
|
4582
|
-
action: "skipped",
|
|
4583
|
-
description: "Coverage disabled; no managed block to remove"
|
|
4584
|
-
};
|
|
4585
|
-
const updated = removeBlock(existing);
|
|
4586
|
-
ctx.write(FILE, updated);
|
|
4587
|
-
return {
|
|
4588
|
-
filePath: FILE,
|
|
4589
|
-
action: "updated",
|
|
4590
|
-
description: "Removed coverage-rule managed block"
|
|
4591
|
-
};
|
|
4592
|
-
}
|
|
4593
|
-
const block = buildBlock();
|
|
4725
|
+
let updated = existing ?? STARTER_CLAUDE_MD;
|
|
4726
|
+
for (const block of blocks) updated = applyBlock(updated, block);
|
|
4594
4727
|
if (!existing) {
|
|
4595
|
-
|
|
4596
|
-
ctx.write(FILE, content);
|
|
4728
|
+
ctx.write(FILE, updated);
|
|
4597
4729
|
return {
|
|
4598
4730
|
filePath: FILE,
|
|
4599
4731
|
action: "created",
|
|
4600
|
-
description: "Generated CLAUDE.md with
|
|
4732
|
+
description: "Generated CLAUDE.md with managed rule blocks"
|
|
4601
4733
|
};
|
|
4602
4734
|
}
|
|
4603
|
-
const updated = upsertBlock(existing, block);
|
|
4604
4735
|
if (updated === existing) return {
|
|
4605
4736
|
filePath: FILE,
|
|
4606
4737
|
action: "skipped",
|
|
4607
|
-
description: "
|
|
4738
|
+
description: "Managed rule blocks already up to date"
|
|
4608
4739
|
};
|
|
4609
4740
|
ctx.write(FILE, updated);
|
|
4610
4741
|
return {
|
|
4611
4742
|
filePath: FILE,
|
|
4612
4743
|
action: "updated",
|
|
4613
|
-
description: "Updated
|
|
4744
|
+
description: "Updated managed rule blocks"
|
|
4614
4745
|
};
|
|
4615
4746
|
}
|
|
4616
4747
|
//#endregion
|
|
@@ -4718,7 +4849,7 @@ async function runGenerators(ctx) {
|
|
|
4718
4849
|
function generateMigratePrompt(results, config, detected) {
|
|
4719
4850
|
const sections = [];
|
|
4720
4851
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
4721
|
-
sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.
|
|
4852
|
+
sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.59.0 repo:sync\` on ${timestamp}_\n`);
|
|
4722
4853
|
const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
|
|
4723
4854
|
if (changed.length > 0) {
|
|
4724
4855
|
const items = changed.map((r) => {
|
|
@@ -4985,6 +5116,10 @@ const syncCommand = defineCommand({
|
|
|
4985
5116
|
return;
|
|
4986
5117
|
}
|
|
4987
5118
|
const saved = loadToolingConfig(targetDir);
|
|
5119
|
+
if (runComposeLintGate(targetDir, saved) > 0) {
|
|
5120
|
+
process.exitCode = 1;
|
|
5121
|
+
return;
|
|
5122
|
+
}
|
|
4988
5123
|
const isFirstRun = !saved;
|
|
4989
5124
|
let config;
|
|
4990
5125
|
if (args.yes || !isFirstRun) {
|
|
@@ -5003,6 +5138,7 @@ const syncCommand = defineCommand({
|
|
|
5003
5138
|
/** Run sync in check mode: dry-run drift detection. */
|
|
5004
5139
|
async function runCheck(targetDir) {
|
|
5005
5140
|
const saved = loadToolingConfig(targetDir);
|
|
5141
|
+
if (runComposeLintGate(targetDir, saved) > 0) return 1;
|
|
5006
5142
|
const detected = buildDefaultConfig(targetDir, {});
|
|
5007
5143
|
const { ctx, pendingWrites } = createDryRunContext(saved ? mergeWithSavedConfig(detected, saved) : detected);
|
|
5008
5144
|
logDetectionSummary(ctx);
|
|
@@ -5039,6 +5175,16 @@ async function runCheck(targetDir) {
|
|
|
5039
5175
|
}
|
|
5040
5176
|
return 1;
|
|
5041
5177
|
}
|
|
5178
|
+
/**
|
|
5179
|
+
* Lint compose files before any generator or apply step runs. Returns the number
|
|
5180
|
+
* of issues found (0 = clean). Each issue is emitted via `log.error` so the
|
|
5181
|
+
* user sees the full message and the suggested fix.
|
|
5182
|
+
*/
|
|
5183
|
+
function runComposeLintGate(targetDir, config) {
|
|
5184
|
+
const issues = lintComposeFiles(targetDir, config);
|
|
5185
|
+
for (const issue of issues) log.error(issue.message);
|
|
5186
|
+
return issues.length;
|
|
5187
|
+
}
|
|
5042
5188
|
/** Files that are minified/bundled and produce unreadable diffs. */
|
|
5043
5189
|
function isBundledFile(filePath) {
|
|
5044
5190
|
return filePath.endsWith("bst-hook.mjs");
|
|
@@ -6828,20 +6974,19 @@ const dockerCheckCommand = defineCommand({
|
|
|
6828
6974
|
const services = parseComposeServices(composeCwd, defaults.composeFiles);
|
|
6829
6975
|
const fileReader = createFileReader();
|
|
6830
6976
|
const rootPkgRaw = fileReader.readFile(path.join(cwd, "package.json"));
|
|
6977
|
+
const rootPkg = rootPkgRaw ? parsePackageJson(rootPkgRaw) : void 0;
|
|
6978
|
+
const projectName = deriveProjectName(rootPkg?.name ?? path.basename(cwd));
|
|
6831
6979
|
const locallyBuiltImageNames = [];
|
|
6832
|
-
if (
|
|
6833
|
-
const
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
const matchesBuild = composeBuildDockerfiles.has(pkgDockerfileAbs);
|
|
6843
|
-
if (!matchesImage && !matchesBuild) log.warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
|
|
6844
|
-
}
|
|
6980
|
+
if (rootPkg?.name) {
|
|
6981
|
+
const dockerPackages = detectDockerPackages(fileReader, cwd, rootPkg.name);
|
|
6982
|
+
const composeImages = extractComposeImageNames(services);
|
|
6983
|
+
const composeBuildDockerfiles = new Set(extractComposeBuildDockerfiles(services));
|
|
6984
|
+
for (const pkg of dockerPackages) {
|
|
6985
|
+
locallyBuiltImageNames.push(pkg.imageName);
|
|
6986
|
+
const matchesImage = composeImages.some((img) => img === pkg.imageName || img.endsWith(`/${pkg.imageName}`));
|
|
6987
|
+
const pkgDockerfileAbs = path.resolve(cwd, pkg.docker.dockerfile);
|
|
6988
|
+
const matchesBuild = composeBuildDockerfiles.has(pkgDockerfileAbs);
|
|
6989
|
+
if (!matchesImage && !matchesBuild) log.warn(`Docker package "${pkg.dir}" (image: ${pkg.imageName}) is not referenced in any compose service.`);
|
|
6845
6990
|
}
|
|
6846
6991
|
}
|
|
6847
6992
|
const tempOverlayPath = writeTempOverlay(generateCheckOverlay(services, locallyBuiltImageNames));
|
|
@@ -6855,6 +7000,7 @@ const dockerCheckCommand = defineCommand({
|
|
|
6855
7000
|
compose: {
|
|
6856
7001
|
cwd: composeCwd,
|
|
6857
7002
|
composeFiles,
|
|
7003
|
+
projectName,
|
|
6858
7004
|
envFile: defaults.envFile,
|
|
6859
7005
|
services: defaults.services
|
|
6860
7006
|
},
|
|
@@ -8173,7 +8319,7 @@ const out = (msg) => console.log(msg);
|
|
|
8173
8319
|
const main = defineCommand({
|
|
8174
8320
|
meta: {
|
|
8175
8321
|
name: "bst",
|
|
8176
|
-
version: "0.
|
|
8322
|
+
version: "0.59.0",
|
|
8177
8323
|
description: "Bootstrap and maintain standardized TypeScript project tooling"
|
|
8178
8324
|
},
|
|
8179
8325
|
subCommands: {
|
|
@@ -8245,7 +8391,7 @@ const main = defineCommand({
|
|
|
8245
8391
|
})
|
|
8246
8392
|
}
|
|
8247
8393
|
});
|
|
8248
|
-
console.log(`@bensandee/tooling v0.
|
|
8394
|
+
console.log(`@bensandee/tooling v0.59.0`);
|
|
8249
8395
|
async function run() {
|
|
8250
8396
|
await runMain(main);
|
|
8251
8397
|
process.exit(process.exitCode ?? 0);
|
|
@@ -53,6 +53,12 @@ interface ComposeConfig {
|
|
|
53
53
|
cwd: string;
|
|
54
54
|
/** Compose files to use (e.g. ["docker-compose.yaml", "docker-compose.check.yaml"]). */
|
|
55
55
|
composeFiles: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Project namespace passed to `docker compose --project-name`. Required so a
|
|
58
|
+
* check run cannot share a project (and thus its named volumes) with another
|
|
59
|
+
* stack on the same docker daemon. See `deriveProjectName()`.
|
|
60
|
+
*/
|
|
61
|
+
projectName: string;
|
|
56
62
|
/** Optional env file for compose. */
|
|
57
63
|
envFile?: string;
|
|
58
64
|
/** Service names to monitor for container-level health. */
|
|
@@ -125,4 +131,118 @@ declare function getContainerHealth(containers: ContainerInfo[], serviceName: st
|
|
|
125
131
|
/** Run a single HTTP health check, returning true if the validator passes. */
|
|
126
132
|
declare function checkHttpHealth(executor: DockerCheckExecutor, check: HttpHealthCheck): Promise<boolean>;
|
|
127
133
|
//#endregion
|
|
128
|
-
|
|
134
|
+
//#region src/docker-check/project-name.d.ts
|
|
135
|
+
/**
|
|
136
|
+
* Derive the `docker compose --project-name` value for a check run.
|
|
137
|
+
*
|
|
138
|
+
* Always returns a unique, non-colliding namespace so a stray `compose down -v`
|
|
139
|
+
* during the check cannot target volumes belonging to a long-running stack
|
|
140
|
+
* sharing the same docker daemon (the failure mode that wiped prod volumes
|
|
141
|
+
* when a CI runner bind-mounted /var/run/docker.sock and the check overlay
|
|
142
|
+
* inherited the base file's `name:`).
|
|
143
|
+
*
|
|
144
|
+
* Format: `<sanitized-repo>-check-<suffix>` where suffix prefers a CI run id
|
|
145
|
+
* for traceability and falls back to `process.pid` locally so two concurrent
|
|
146
|
+
* `bst docker:check` invocations on the same machine don't collide.
|
|
147
|
+
*/
|
|
148
|
+
declare function deriveProjectName(repoName: string, env?: NodeJS.ProcessEnv): string;
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/utils/tooling-config.d.ts
|
|
151
|
+
declare const ToolingConfigSchema: z.ZodObject<{
|
|
152
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
153
|
+
structure: z.ZodOptional<z.ZodEnum<{
|
|
154
|
+
single: "single";
|
|
155
|
+
monorepo: "monorepo";
|
|
156
|
+
}>>;
|
|
157
|
+
useEslintPlugin: z.ZodOptional<z.ZodBoolean>;
|
|
158
|
+
formatter: z.ZodOptional<z.ZodEnum<{
|
|
159
|
+
prettier: "prettier";
|
|
160
|
+
oxfmt: "oxfmt";
|
|
161
|
+
}>>;
|
|
162
|
+
setupVitest: z.ZodOptional<z.ZodBoolean>;
|
|
163
|
+
ci: z.ZodOptional<z.ZodEnum<{
|
|
164
|
+
github: "github";
|
|
165
|
+
forgejo: "forgejo";
|
|
166
|
+
none: "none";
|
|
167
|
+
}>>;
|
|
168
|
+
setupRenovate: z.ZodOptional<z.ZodBoolean>;
|
|
169
|
+
releaseStrategy: z.ZodOptional<z.ZodEnum<{
|
|
170
|
+
none: "none";
|
|
171
|
+
"release-it": "release-it";
|
|
172
|
+
simple: "simple";
|
|
173
|
+
changesets: "changesets";
|
|
174
|
+
}>>;
|
|
175
|
+
publishNpm: z.ZodOptional<z.ZodBoolean>;
|
|
176
|
+
publishDocker: z.ZodOptional<z.ZodBoolean>;
|
|
177
|
+
projectType: z.ZodOptional<z.ZodEnum<{
|
|
178
|
+
default: "default";
|
|
179
|
+
node: "node";
|
|
180
|
+
react: "react";
|
|
181
|
+
library: "library";
|
|
182
|
+
}>>;
|
|
183
|
+
detectPackageTypes: z.ZodOptional<z.ZodBoolean>;
|
|
184
|
+
docker: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
185
|
+
dockerfile: z.ZodOptional<z.ZodString>;
|
|
186
|
+
context: z.ZodOptional<z.ZodString>;
|
|
187
|
+
buildArgs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
188
|
+
}, z.core.$strip>>>;
|
|
189
|
+
dockerCheck: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
|
|
190
|
+
composeFiles: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
191
|
+
envFile: z.ZodOptional<z.ZodString>;
|
|
192
|
+
services: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
193
|
+
healthChecks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
194
|
+
name: z.ZodString;
|
|
195
|
+
url: z.ZodString;
|
|
196
|
+
status: z.ZodOptional<z.ZodNumber>;
|
|
197
|
+
}, z.core.$strip>>>;
|
|
198
|
+
buildCommand: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
199
|
+
buildCwd: z.ZodOptional<z.ZodString>;
|
|
200
|
+
smoketest: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
201
|
+
smoketestCwd: z.ZodOptional<z.ZodString>;
|
|
202
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
203
|
+
pollIntervalMs: z.ZodOptional<z.ZodNumber>;
|
|
204
|
+
}, z.core.$strip>]>>;
|
|
205
|
+
registryHost: z.ZodOptional<z.ZodString>;
|
|
206
|
+
registryNamespace: z.ZodOptional<z.ZodString>;
|
|
207
|
+
releaseAssets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
208
|
+
command: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
209
|
+
file: z.ZodString;
|
|
210
|
+
name: z.ZodOptional<z.ZodString>;
|
|
211
|
+
}, z.core.$strict>>>;
|
|
212
|
+
postReleaseHook: z.ZodOptional<z.ZodString>;
|
|
213
|
+
postReleaseHookSecrets: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
214
|
+
coverage: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
|
|
215
|
+
history: z.ZodOptional<z.ZodEnum<{
|
|
216
|
+
none: "none";
|
|
217
|
+
"orphan-branch": "orphan-branch";
|
|
218
|
+
}>>;
|
|
219
|
+
historyBranch: z.ZodOptional<z.ZodString>;
|
|
220
|
+
tolerancePp: z.ZodOptional<z.ZodNumber>;
|
|
221
|
+
gateOnPullRequests: z.ZodOptional<z.ZodBoolean>;
|
|
222
|
+
}, z.core.$strict>]>>;
|
|
223
|
+
pnpmWorkspace: z.ZodOptional<z.ZodLiteral<false>>;
|
|
224
|
+
}, z.core.$strict>;
|
|
225
|
+
type ToolingConfig = z.infer<typeof ToolingConfigSchema>;
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/docker-check/compose-lint.d.ts
|
|
228
|
+
interface ComposeLintIssue {
|
|
229
|
+
kind: "missing-name" | "duplicate-name";
|
|
230
|
+
/** Repo-relative paths involved in this issue (1 for missing-name, 2+ for duplicate-name). */
|
|
231
|
+
files: string[];
|
|
232
|
+
/** Pre-rendered human-readable message ready for `log.error`. */
|
|
233
|
+
message: string;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Walk compose files in `targetDir` and report lint violations:
|
|
237
|
+
*
|
|
238
|
+
* - Rule A (`missing-name`): every override compose file must declare a
|
|
239
|
+
* top-level `name:` directive. Base files (docker-compose.yml etc.) are
|
|
240
|
+
* exempt — their default of dir-basename is fine — but overrides without
|
|
241
|
+
* `name:` silently inherit the base's project namespace, which is how a
|
|
242
|
+
* `compose down -v` against the check overlay wiped the prod-volume project.
|
|
243
|
+
* - Rule B (`duplicate-name`): no two compose files in a single dir may
|
|
244
|
+
* declare the same `name:`. Base files participate in collision detection.
|
|
245
|
+
*/
|
|
246
|
+
declare function lintComposeFiles(targetDir: string, config: ToolingConfig | undefined): ComposeLintIssue[];
|
|
247
|
+
//#endregion
|
|
248
|
+
export { type CheckConfig, type CheckResult, type ComposeConfig, type ComposeLintIssue, type ContainerInfo, type DockerCheckExecutor, type HttpHealthCheck, checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, deriveProjectName, getContainerHealth, lintComposeFiles, runDockerCheck };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export { checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, getContainerHealth, runDockerCheck };
|
|
1
|
+
import { a as composeArgs, c as composePs, f as lintComposeFiles, i as getContainerHealth, l as composeUp, n as runDockerCheck, o as composeDown, r as checkHttpHealth, s as composeLogs, t as deriveProjectName, u as createRealExecutor } from "../project-name-CAVRoqNy.mjs";
|
|
2
|
+
export { checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, deriveProjectName, getContainerHealth, lintComposeFiles, runDockerCheck };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,118 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
//#region src/utils/json.d.ts
|
|
4
|
-
declare const PackageJsonSchema: z.ZodObject<{
|
|
5
|
-
name: z.ZodOptional<z.ZodString>;
|
|
6
|
-
version: z.ZodOptional<z.ZodString>;
|
|
7
|
-
private: z.ZodOptional<z.ZodBoolean>;
|
|
8
|
-
type: z.ZodOptional<z.ZodString>;
|
|
9
|
-
scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
10
|
-
dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
11
|
-
devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
12
|
-
bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
|
|
13
|
-
exports: z.ZodOptional<z.ZodUnknown>;
|
|
14
|
-
main: z.ZodOptional<z.ZodString>;
|
|
15
|
-
types: z.ZodOptional<z.ZodString>;
|
|
16
|
-
typings: z.ZodOptional<z.ZodString>;
|
|
17
|
-
engines: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
18
|
-
repository: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
19
|
-
url: z.ZodString;
|
|
20
|
-
}, z.core.$loose>]>>;
|
|
21
|
-
}, z.core.$loose>;
|
|
22
|
-
type PackageJson = z.infer<typeof PackageJsonSchema>;
|
|
23
|
-
//#endregion
|
|
24
|
-
//#region src/types.d.ts
|
|
25
|
-
type CiPlatform = "github" | "forgejo" | "none";
|
|
26
|
-
type ReleaseStrategy = "release-it" | "simple" | "changesets" | "none";
|
|
27
|
-
type Formatter = "oxfmt" | "prettier";
|
|
28
|
-
/** User's answers from the interactive prompt or CLI flags. */
|
|
29
|
-
interface ProjectConfig {
|
|
30
|
-
/** Project name (from package.json name or user input) */
|
|
31
|
-
name: string;
|
|
32
|
-
/** Project structure */
|
|
33
|
-
structure: "single" | "monorepo";
|
|
34
|
-
/** Include @bensandee/eslint-plugin oxlint plugin */
|
|
35
|
-
useEslintPlugin: boolean;
|
|
36
|
-
/** Formatter choice */
|
|
37
|
-
formatter: Formatter;
|
|
38
|
-
/** Set up vitest with a starter test */
|
|
39
|
-
setupVitest: boolean;
|
|
40
|
-
/** CI platform choice */
|
|
41
|
-
ci: CiPlatform;
|
|
42
|
-
/** Set up Renovate for automated dependency updates */
|
|
43
|
-
setupRenovate: boolean;
|
|
44
|
-
/** Release management strategy */
|
|
45
|
-
releaseStrategy: ReleaseStrategy;
|
|
46
|
-
/** Opt-in: publish packages to npm registry */
|
|
47
|
-
publishNpm: boolean;
|
|
48
|
-
/** Opt-in: publish Docker images to a registry */
|
|
49
|
-
publishDocker: boolean;
|
|
50
|
-
/** Project type determines tsconfig base configuration */
|
|
51
|
-
projectType: "default" | "node" | "react" | "library";
|
|
52
|
-
/** Auto-detect and configure tsconfig bases for monorepo packages */
|
|
53
|
-
detectPackageTypes: boolean;
|
|
54
|
-
/** Target directory (default: cwd) */
|
|
55
|
-
targetDir: string;
|
|
56
|
-
}
|
|
57
|
-
/** Result from a single generator: what file was written and how. */
|
|
58
|
-
interface GeneratorResult {
|
|
59
|
-
filePath: string;
|
|
60
|
-
action: "created" | "updated" | "skipped" | "archived" | "removed";
|
|
61
|
-
/** Human-readable description of what changed */
|
|
62
|
-
description: string;
|
|
63
|
-
}
|
|
64
|
-
/** Context passed to each generator function. */
|
|
65
|
-
interface GeneratorContext {
|
|
66
|
-
config: ProjectConfig;
|
|
67
|
-
/** Absolute path to target directory */
|
|
68
|
-
targetDir: string;
|
|
69
|
-
/** Pre-parsed package.json from the target directory, or undefined if missing/invalid */
|
|
70
|
-
packageJson: PackageJson | undefined;
|
|
71
|
-
/** Check whether a file exists in the target directory */
|
|
72
|
-
exists: (relativePath: string) => boolean;
|
|
73
|
-
/** Read an existing file from the target directory, returns undefined if not found */
|
|
74
|
-
read: (relativePath: string) => string | undefined;
|
|
75
|
-
/** Write a file to the target directory (creating directories as needed) */
|
|
76
|
-
write: (relativePath: string, content: string) => void;
|
|
77
|
-
/** Remove a file from the target directory (no-op if not found) */
|
|
78
|
-
remove: (relativePath: string) => void;
|
|
79
|
-
/** Prompt user for conflict resolution on non-mergeable files */
|
|
80
|
-
confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
|
|
81
|
-
}
|
|
82
|
-
/** Generator function signature. */
|
|
83
|
-
type Generator = (ctx: GeneratorContext) => Promise<GeneratorResult>;
|
|
84
|
-
/** State detected from an existing project directory. */
|
|
85
|
-
interface DetectedProjectState {
|
|
86
|
-
hasPackageJson: boolean;
|
|
87
|
-
hasTsconfig: boolean;
|
|
88
|
-
hasOxlintConfig: boolean;
|
|
89
|
-
/** Existing oxlint.config.ts already imports @bensandee/config/oxlint/recommended */
|
|
90
|
-
oxlintExtendsRecommended: boolean;
|
|
91
|
-
/** Legacy .oxlintrc.json found (should be migrated to oxlint.config.ts) */
|
|
92
|
-
hasLegacyOxlintJson: boolean;
|
|
93
|
-
hasGitignore: boolean;
|
|
94
|
-
hasVitestConfig: boolean;
|
|
95
|
-
hasTsdownConfig: boolean;
|
|
96
|
-
hasPnpmWorkspace: boolean;
|
|
97
|
-
hasKnipConfig: boolean;
|
|
98
|
-
hasRenovateConfig: boolean;
|
|
99
|
-
hasReleaseItConfig: boolean;
|
|
100
|
-
hasSimpleReleaseConfig: boolean;
|
|
101
|
-
hasChangesetsConfig: boolean;
|
|
102
|
-
/** Whether package.json has a repository field (needed for release workflows) */
|
|
103
|
-
hasRepositoryField: boolean;
|
|
104
|
-
/** Whether commit-and-tag-version is in root devDependencies (required for simple release strategy) */
|
|
105
|
-
hasCommitAndTagVersion: boolean;
|
|
106
|
-
/** Git remote origin URL (for inferring repository field) */
|
|
107
|
-
gitRemoteUrl: string | null;
|
|
108
|
-
/** Legacy tooling configs found */
|
|
109
|
-
legacyConfigs: LegacyConfig[];
|
|
110
|
-
}
|
|
111
|
-
declare const LEGACY_TOOLS: readonly ["eslint", "prettier", "jest", "webpack", "rollup"];
|
|
112
|
-
type LegacyTool = (typeof LEGACY_TOOLS)[number];
|
|
113
|
-
interface LegacyConfig {
|
|
114
|
-
tool: LegacyTool;
|
|
115
|
-
files: string[];
|
|
116
|
-
}
|
|
117
|
-
//#endregion
|
|
1
|
+
import { a as LegacyConfig, i as GeneratorResult, n as Generator, o as ProjectConfig, r as GeneratorContext, t as DetectedProjectState } from "./types-DtwkblrK.mjs";
|
|
118
2
|
export { type DetectedProjectState, type Generator, type GeneratorContext, type GeneratorResult, type LegacyConfig, type ProjectConfig };
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as clack from "@clack/prompts";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
4
|
import { execFileSync } from "node:child_process";
|
|
3
5
|
import { z } from "zod";
|
|
6
|
+
import { parse } from "yaml";
|
|
4
7
|
//#region src/utils/log.ts
|
|
5
8
|
const out = (msg) => console.log(msg);
|
|
6
9
|
const isCI = Boolean(process.env["CI"]);
|
|
@@ -42,6 +45,148 @@ function debugExec(config, label, result) {
|
|
|
42
45
|
log.info(lines.join("\n"));
|
|
43
46
|
}
|
|
44
47
|
//#endregion
|
|
48
|
+
//#region src/docker-check/compose-lint.ts
|
|
49
|
+
const ComposeFileShape = z.looseObject({ name: z.unknown().optional() });
|
|
50
|
+
/**
|
|
51
|
+
* Compose files live by convention in `./` or `docker/`. The lint walks those
|
|
52
|
+
* by default; if `.tooling.json#dockerCheck.composeFiles` overrides the
|
|
53
|
+
* location, each listed file (and its parent dir) is also linted.
|
|
54
|
+
*/
|
|
55
|
+
const COMPOSE_DIR_CANDIDATES = [".", "docker"];
|
|
56
|
+
/** Base compose file names whose project namespace defaults to dir basename. */
|
|
57
|
+
const BASE_COMPOSE_FILES = new Set([
|
|
58
|
+
"docker-compose.yml",
|
|
59
|
+
"docker-compose.yaml",
|
|
60
|
+
"compose.yml",
|
|
61
|
+
"compose.yaml"
|
|
62
|
+
]);
|
|
63
|
+
const COMPOSE_FILENAME_PATTERN = /^(?:docker-compose|compose).*\.(?:yml|yaml)$/;
|
|
64
|
+
/**
|
|
65
|
+
* Walk compose files in `targetDir` and report lint violations:
|
|
66
|
+
*
|
|
67
|
+
* - Rule A (`missing-name`): every override compose file must declare a
|
|
68
|
+
* top-level `name:` directive. Base files (docker-compose.yml etc.) are
|
|
69
|
+
* exempt — their default of dir-basename is fine — but overrides without
|
|
70
|
+
* `name:` silently inherit the base's project namespace, which is how a
|
|
71
|
+
* `compose down -v` against the check overlay wiped the prod-volume project.
|
|
72
|
+
* - Rule B (`duplicate-name`): no two compose files in a single dir may
|
|
73
|
+
* declare the same `name:`. Base files participate in collision detection.
|
|
74
|
+
*/
|
|
75
|
+
function lintComposeFiles(targetDir, config) {
|
|
76
|
+
const issues = [];
|
|
77
|
+
const visitedDirs = /* @__PURE__ */ new Set();
|
|
78
|
+
const standaloneFiles = [];
|
|
79
|
+
const dockerCheck = config?.dockerCheck;
|
|
80
|
+
const isObjectConfig = dockerCheck !== void 0 && dockerCheck !== false;
|
|
81
|
+
for (const candidate of COMPOSE_DIR_CANDIDATES) {
|
|
82
|
+
const absDir = path.resolve(targetDir, candidate);
|
|
83
|
+
if (existsSync(absDir)) visitDir(targetDir, absDir, visitedDirs, issues);
|
|
84
|
+
}
|
|
85
|
+
if (isObjectConfig && dockerCheck.composeFiles) for (const rel of dockerCheck.composeFiles) {
|
|
86
|
+
const absFile = path.resolve(targetDir, rel);
|
|
87
|
+
if (!existsSync(absFile)) continue;
|
|
88
|
+
const dir = path.dirname(absFile);
|
|
89
|
+
if (visitedDirs.has(dir)) continue;
|
|
90
|
+
const basename = path.basename(absFile);
|
|
91
|
+
standaloneFiles.push({
|
|
92
|
+
absPath: absFile,
|
|
93
|
+
relPath: path.relative(targetDir, absFile),
|
|
94
|
+
basename
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const standaloneByDir = /* @__PURE__ */ new Map();
|
|
98
|
+
for (const file of standaloneFiles) {
|
|
99
|
+
const dir = path.dirname(file.absPath);
|
|
100
|
+
const list = standaloneByDir.get(dir) ?? [];
|
|
101
|
+
list.push(file);
|
|
102
|
+
standaloneByDir.set(dir, list);
|
|
103
|
+
}
|
|
104
|
+
for (const files of standaloneByDir.values()) {
|
|
105
|
+
files.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
106
|
+
lintDirFiles(files, issues);
|
|
107
|
+
}
|
|
108
|
+
return issues;
|
|
109
|
+
}
|
|
110
|
+
function visitDir(targetDir, absDir, visitedDirs, issues) {
|
|
111
|
+
if (visitedDirs.has(absDir)) return;
|
|
112
|
+
visitedDirs.add(absDir);
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = readdirSync(absDir);
|
|
116
|
+
} catch (_error) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const files = entries.filter((name) => COMPOSE_FILENAME_PATTERN.test(name)).toSorted().map((name) => {
|
|
120
|
+
const absPath = path.join(absDir, name);
|
|
121
|
+
return {
|
|
122
|
+
absPath,
|
|
123
|
+
relPath: path.relative(targetDir, absPath),
|
|
124
|
+
basename: name
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
if (files.length === 0) return;
|
|
128
|
+
lintDirFiles(files, issues);
|
|
129
|
+
}
|
|
130
|
+
function lintDirFiles(files, issues) {
|
|
131
|
+
const namesByFile = /* @__PURE__ */ new Map();
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
const declaredName = readTopLevelName(file.absPath);
|
|
134
|
+
namesByFile.set(file.relPath, declaredName);
|
|
135
|
+
if (!BASE_COMPOSE_FILES.has(file.basename) && (declaredName === void 0 || declaredName === "")) {
|
|
136
|
+
const suggested = suggestProjectName(file);
|
|
137
|
+
issues.push({
|
|
138
|
+
kind: "missing-name",
|
|
139
|
+
files: [file.relPath],
|
|
140
|
+
message: `${file.relPath}: override compose file is missing required top-level 'name:' directive.\n Add: name: ${suggested}\n Why: without an explicit name, this override inherits the base file's project namespace.\n A 'docker compose down -v' against this override would target the base project's volumes\n — which has caused production volume loss in the past.`
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const filesByName = /* @__PURE__ */ new Map();
|
|
145
|
+
for (const [relPath, declaredName] of namesByFile) {
|
|
146
|
+
if (declaredName === void 0 || declaredName === "") continue;
|
|
147
|
+
const list = filesByName.get(declaredName) ?? [];
|
|
148
|
+
list.push(relPath);
|
|
149
|
+
filesByName.set(declaredName, list);
|
|
150
|
+
}
|
|
151
|
+
for (const [declaredName, dupFiles] of filesByName) {
|
|
152
|
+
if (dupFiles.length < 2) continue;
|
|
153
|
+
dupFiles.sort();
|
|
154
|
+
const [first, second, ...rest] = dupFiles;
|
|
155
|
+
const ext = rest.length > 0 ? `, ${rest.join(", ")}` : "";
|
|
156
|
+
issues.push({
|
|
157
|
+
kind: "duplicate-name",
|
|
158
|
+
files: dupFiles,
|
|
159
|
+
message: `${String(first)} and ${String(second)}${ext} both declare 'name: ${declaredName}'.\n Each compose file in the same directory must declare a unique 'name:'.\n Suggested fix: rename one (e.g. 'name: ${declaredName}-test' in one of them).`
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Suggest a default name for an override file by stripping the variant from the filename. */
|
|
164
|
+
function suggestProjectName(file) {
|
|
165
|
+
const parts = file.basename.replace(/\.(yml|yaml)$/, "").split(".");
|
|
166
|
+
const variant = parts[parts.length - 1];
|
|
167
|
+
if (parts.length > 1 && variant) return `<project>-${variant}`;
|
|
168
|
+
return "<project>-check";
|
|
169
|
+
}
|
|
170
|
+
function readTopLevelName(absPath) {
|
|
171
|
+
let raw;
|
|
172
|
+
try {
|
|
173
|
+
raw = readFileSync(absPath, "utf-8");
|
|
174
|
+
} catch (_error) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let yamlValue;
|
|
178
|
+
try {
|
|
179
|
+
yamlValue = parse(raw);
|
|
180
|
+
} catch (_error) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const parsed = ComposeFileShape.safeParse(yamlValue);
|
|
184
|
+
if (!parsed.success) return void 0;
|
|
185
|
+
const value = parsed.data.name;
|
|
186
|
+
if (typeof value !== "string") return void 0;
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
//#endregion
|
|
45
190
|
//#region src/utils/exec.ts
|
|
46
191
|
/** Type guard for `execSync` errors that carry stdout/stderr/status. */
|
|
47
192
|
function isExecSyncError(err) {
|
|
@@ -116,7 +261,11 @@ const ContainerInfoSchema = z.object({
|
|
|
116
261
|
});
|
|
117
262
|
/** Build the `docker compose` base args from config. */
|
|
118
263
|
function composeArgs(config) {
|
|
119
|
-
const args = [
|
|
264
|
+
const args = [
|
|
265
|
+
"compose",
|
|
266
|
+
"--project-name",
|
|
267
|
+
config.projectName
|
|
268
|
+
];
|
|
120
269
|
for (const f of config.composeFiles) args.push("-f", f);
|
|
121
270
|
if (config.envFile) args.push("--env-file", config.envFile);
|
|
122
271
|
return args;
|
|
@@ -340,4 +489,25 @@ async function runDockerCheck(executor, config) {
|
|
|
340
489
|
}
|
|
341
490
|
}
|
|
342
491
|
//#endregion
|
|
343
|
-
|
|
492
|
+
//#region src/docker-check/project-name.ts
|
|
493
|
+
/**
|
|
494
|
+
* Derive the `docker compose --project-name` value for a check run.
|
|
495
|
+
*
|
|
496
|
+
* Always returns a unique, non-colliding namespace so a stray `compose down -v`
|
|
497
|
+
* during the check cannot target volumes belonging to a long-running stack
|
|
498
|
+
* sharing the same docker daemon (the failure mode that wiped prod volumes
|
|
499
|
+
* when a CI runner bind-mounted /var/run/docker.sock and the check overlay
|
|
500
|
+
* inherited the base file's `name:`).
|
|
501
|
+
*
|
|
502
|
+
* Format: `<sanitized-repo>-check-<suffix>` where suffix prefers a CI run id
|
|
503
|
+
* for traceability and falls back to `process.pid` locally so two concurrent
|
|
504
|
+
* `bst docker:check` invocations on the same machine don't collide.
|
|
505
|
+
*/
|
|
506
|
+
function deriveProjectName(repoName, env = process.env) {
|
|
507
|
+
return `${sanitize(repoName)}-check-${sanitize(env["GITHUB_RUN_ID"] ?? env["CI_RUN_ID"] ?? String(process.pid))}`;
|
|
508
|
+
}
|
|
509
|
+
function sanitize(name) {
|
|
510
|
+
return name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
|
|
511
|
+
}
|
|
512
|
+
//#endregion
|
|
513
|
+
export { log as _, composeArgs as a, composePs as c, isExecSyncError as d, lintComposeFiles as f, redact as g, isEnvVerbose as h, getContainerHealth as i, composeUp as l, debugExec as m, runDockerCheck as n, composeDown as o, debug as p, checkHttpHealth as r, composeLogs as s, deriveProjectName as t, createRealExecutor as u, note as v };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/json.d.ts
|
|
4
|
+
declare const PackageJsonSchema: z.ZodObject<{
|
|
5
|
+
name: z.ZodOptional<z.ZodString>;
|
|
6
|
+
version: z.ZodOptional<z.ZodString>;
|
|
7
|
+
private: z.ZodOptional<z.ZodBoolean>;
|
|
8
|
+
type: z.ZodOptional<z.ZodString>;
|
|
9
|
+
scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
10
|
+
dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
11
|
+
devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
12
|
+
bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
|
|
13
|
+
exports: z.ZodOptional<z.ZodUnknown>;
|
|
14
|
+
main: z.ZodOptional<z.ZodString>;
|
|
15
|
+
types: z.ZodOptional<z.ZodString>;
|
|
16
|
+
typings: z.ZodOptional<z.ZodString>;
|
|
17
|
+
engines: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
18
|
+
repository: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
19
|
+
url: z.ZodString;
|
|
20
|
+
}, z.core.$loose>]>>;
|
|
21
|
+
packageManager: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, z.core.$loose>;
|
|
23
|
+
type PackageJson = z.infer<typeof PackageJsonSchema>;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/types.d.ts
|
|
26
|
+
type CiPlatform = "github" | "forgejo" | "none";
|
|
27
|
+
type ReleaseStrategy = "release-it" | "simple" | "changesets" | "none";
|
|
28
|
+
type Formatter = "oxfmt" | "prettier";
|
|
29
|
+
/** User's answers from the interactive prompt or CLI flags. */
|
|
30
|
+
interface ProjectConfig {
|
|
31
|
+
/** Project name (from package.json name or user input) */
|
|
32
|
+
name: string;
|
|
33
|
+
/** Project structure */
|
|
34
|
+
structure: "single" | "monorepo";
|
|
35
|
+
/** Include @bensandee/eslint-plugin oxlint plugin */
|
|
36
|
+
useEslintPlugin: boolean;
|
|
37
|
+
/** Formatter choice */
|
|
38
|
+
formatter: Formatter;
|
|
39
|
+
/** Set up vitest with a starter test */
|
|
40
|
+
setupVitest: boolean;
|
|
41
|
+
/** CI platform choice */
|
|
42
|
+
ci: CiPlatform;
|
|
43
|
+
/** Set up Renovate for automated dependency updates */
|
|
44
|
+
setupRenovate: boolean;
|
|
45
|
+
/** Release management strategy */
|
|
46
|
+
releaseStrategy: ReleaseStrategy;
|
|
47
|
+
/** Opt-in: publish packages to npm registry */
|
|
48
|
+
publishNpm: boolean;
|
|
49
|
+
/** Opt-in: publish Docker images to a registry */
|
|
50
|
+
publishDocker: boolean;
|
|
51
|
+
/** Project type determines tsconfig base configuration */
|
|
52
|
+
projectType: "default" | "node" | "react" | "library";
|
|
53
|
+
/** Auto-detect and configure tsconfig bases for monorepo packages */
|
|
54
|
+
detectPackageTypes: boolean;
|
|
55
|
+
/** Target directory (default: cwd) */
|
|
56
|
+
targetDir: string;
|
|
57
|
+
}
|
|
58
|
+
/** Result from a single generator: what file was written and how. */
|
|
59
|
+
interface GeneratorResult {
|
|
60
|
+
filePath: string;
|
|
61
|
+
action: "created" | "updated" | "skipped" | "archived" | "removed";
|
|
62
|
+
/** Human-readable description of what changed */
|
|
63
|
+
description: string;
|
|
64
|
+
}
|
|
65
|
+
/** Context passed to each generator function. */
|
|
66
|
+
interface GeneratorContext {
|
|
67
|
+
config: ProjectConfig;
|
|
68
|
+
/** Absolute path to target directory */
|
|
69
|
+
targetDir: string;
|
|
70
|
+
/** Pre-parsed package.json from the target directory, or undefined if missing/invalid */
|
|
71
|
+
packageJson: PackageJson | undefined;
|
|
72
|
+
/** Check whether a file exists in the target directory */
|
|
73
|
+
exists: (relativePath: string) => boolean;
|
|
74
|
+
/** Read an existing file from the target directory, returns undefined if not found */
|
|
75
|
+
read: (relativePath: string) => string | undefined;
|
|
76
|
+
/** Write a file to the target directory (creating directories as needed) */
|
|
77
|
+
write: (relativePath: string, content: string) => void;
|
|
78
|
+
/** Remove a file from the target directory (no-op if not found) */
|
|
79
|
+
remove: (relativePath: string) => void;
|
|
80
|
+
/** Prompt user for conflict resolution on non-mergeable files */
|
|
81
|
+
confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
|
|
82
|
+
}
|
|
83
|
+
/** Generator function signature. */
|
|
84
|
+
type Generator = (ctx: GeneratorContext) => Promise<GeneratorResult>;
|
|
85
|
+
/** State detected from an existing project directory. */
|
|
86
|
+
interface DetectedProjectState {
|
|
87
|
+
hasPackageJson: boolean;
|
|
88
|
+
hasTsconfig: boolean;
|
|
89
|
+
hasOxlintConfig: boolean;
|
|
90
|
+
/** Existing oxlint.config.ts already imports @bensandee/config/oxlint/recommended */
|
|
91
|
+
oxlintExtendsRecommended: boolean;
|
|
92
|
+
/** Legacy .oxlintrc.json found (should be migrated to oxlint.config.ts) */
|
|
93
|
+
hasLegacyOxlintJson: boolean;
|
|
94
|
+
hasGitignore: boolean;
|
|
95
|
+
hasVitestConfig: boolean;
|
|
96
|
+
hasTsdownConfig: boolean;
|
|
97
|
+
hasPnpmWorkspace: boolean;
|
|
98
|
+
hasKnipConfig: boolean;
|
|
99
|
+
hasRenovateConfig: boolean;
|
|
100
|
+
hasReleaseItConfig: boolean;
|
|
101
|
+
hasSimpleReleaseConfig: boolean;
|
|
102
|
+
hasChangesetsConfig: boolean;
|
|
103
|
+
/** Whether package.json has a repository field (needed for release workflows) */
|
|
104
|
+
hasRepositoryField: boolean;
|
|
105
|
+
/** Whether commit-and-tag-version is in root devDependencies (required for simple release strategy) */
|
|
106
|
+
hasCommitAndTagVersion: boolean;
|
|
107
|
+
/** Git remote origin URL (for inferring repository field) */
|
|
108
|
+
gitRemoteUrl: string | null;
|
|
109
|
+
/** Legacy tooling configs found */
|
|
110
|
+
legacyConfigs: LegacyConfig[];
|
|
111
|
+
}
|
|
112
|
+
declare const LEGACY_TOOLS: readonly ["eslint", "prettier", "jest", "webpack", "rollup"];
|
|
113
|
+
type LegacyTool = (typeof LEGACY_TOOLS)[number];
|
|
114
|
+
interface LegacyConfig {
|
|
115
|
+
tool: LegacyTool;
|
|
116
|
+
files: string[];
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
export { LegacyConfig as a, GeneratorResult as i, Generator as n, ProjectConfig as o, GeneratorContext as r, DetectedProjectState as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bensandee/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"release-it": "20.0.1",
|
|
56
56
|
"tsdown": "0.22.0",
|
|
57
57
|
"@bensandee/claude-hooks": "0.7.4",
|
|
58
|
-
"@bensandee/config": "0.11.
|
|
58
|
+
"@bensandee/config": "0.11.2"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "tsdown",
|
package/tooling.schema.json
CHANGED
|
@@ -258,6 +258,11 @@
|
|
|
258
258
|
"additionalProperties": false
|
|
259
259
|
}
|
|
260
260
|
]
|
|
261
|
+
},
|
|
262
|
+
"pnpmWorkspace": {
|
|
263
|
+
"description": "Set to false to opt out of pnpm-workspace.yaml management (skips both generation and the repo:sync --check validation).",
|
|
264
|
+
"type": "boolean",
|
|
265
|
+
"const": false
|
|
261
266
|
}
|
|
262
267
|
},
|
|
263
268
|
"additionalProperties": false
|