diff --git a/ROADMAP.md b/ROADMAP.md
index 87ddd47..9f1ce57 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -109,7 +109,7 @@ _Second external review, this one of the codebase rather than the experience. Ra
- [ ] **A resolved domain model** — the config carries ~16 derived booleans (`isReact`, `hasApp`, `publishable`, `usesVite`…) that can in principle contradict each other. Features should consume `resolved.targets` / `resolved.build` / `resolved.package` instead of re-interpreting raw selections.
- [x] ~~**Make feature ordering explicit**~~ — **Shipped (3.1).** Rather than build speculative phase/requires/conflictsWith machinery, the real risk — order silently deciding a collision — is now an enforced invariant: `test/feature-registry.test.js` asserts every feature has a unique id and that **no two features collide** on a file or package.json field across the full config matrix (1683 configs). That immediately surfaced a real one — `bundler` and `service` both set `scripts.dev` on `node-service`, resolved only by array position — now fixed by making the service the explicit owner of its `dev` script. Ordering is no longer load-bearing for correctness; a colliding new feature fails the test instead of shipping.
- [ ] **Pairwise combination testing** — the integration matrix covers presets, which are the *common* paths. The risk is cross-feature interaction (dual + Rollup + Jest + Biome). Generate a matrix where every option pair appears at least once.
-- [ ] **Dependency version catalog** — template versions are embedded across feature modules. Centralize them with compatibility notes and a last-reviewed date; the freshness workflow already treats them as product data, this makes that explicit.
+- [x] ~~**Dependency version catalog**~~ — **Shipped (3.1).** Every template version now lives in `src/core/versions.js` (`V` for specs, `PEER` for peer ranges, `HELD` for deliberately-held deps + reasons, `LAST_REVIEWED`). All 16 feature/monorepo modules reference it instead of hard-coding specs — verified **byte-identical across 3740 configs**, and a conformance test asserts every generated version comes from the catalog (it caught a wrong `ts-jest` spec and two missing deps while being built). The freshness workflow imports `HELD` instead of duplicating it. A bump is now one line: proven by fixing #19 (`release-it` ^20 → ^21) in the catalog alone.
- [ ] **Unify monorepo and single-package generation** — `generate()` returns early for monorepos, and 2.9 added a second early return inside that. Modelling a project as workspaces (a plain package being a project with one) would stop features having to be implemented twice.
### The strategic one
diff --git a/docs/packkit-core.js b/docs/packkit-core.js
index 4641a96..0a4b6da 100644
--- a/docs/packkit-core.js
+++ b/docs/packkit-core.js
@@ -584,6 +584,95 @@ function readme(cfg) {
return lines.join("\n");
}
+// src/core/versions.js
+var V = {
+ // toolchain
+ typescript: "^5.9.3",
+ "typescript-eslint": "^8.0.0",
+ eslint: "^10.0.0",
+ "@eslint/js": "^10.0.0",
+ prettier: "^3.3.0",
+ "@biomejs/biome": "^2.0.0",
+ oxlint: "^1.0.0",
+ turbo: "^2.0.0",
+ rimraf: "^6.0.0",
+ "@types/node": "^24.0.0",
+ // build
+ tsup: "^8.0.0",
+ tsdown: "^0.6.0",
+ unbuild: "^3.0.0",
+ rollup: "^4.0.0",
+ "@rollup/plugin-typescript": "^12.0.0",
+ "@rollup/plugin-terser": "^1.0.0",
+ tslib: "^2.6.0",
+ tsx: "^4.0.0",
+ vite: "^8.0.0",
+ "vite-plugin-dts": "^5.0.0",
+ // test
+ vitest: "^4.0.0",
+ "@vitest/coverage-v8": "^4.0.0",
+ jsdom: "^29.0.0",
+ jest: "^30.0.0",
+ "@types/jest": "^30.0.0",
+ "ts-jest": "^29.0.0",
+ supertest: "^7.0.0",
+ "@types/supertest": "^6.0.0",
+ "@playwright/test": "^1.50.0",
+ // release / package checks
+ "@changesets/cli": "^2.27.0",
+ "release-it": "^21.0.0",
+ np: "^12.0.0",
+ publint: "^0.3.0",
+ "@arethetypeswrong/cli": "^0.18.0",
+ knip: "^5.0.0",
+ "size-limit": "^11.0.0",
+ "@size-limit/preset-small-lib": "^11.0.0",
+ // git hooks
+ "simple-git-hooks": "^2.11.0",
+ husky: "^9.1.0",
+ lefthook: "^2.0.0",
+ "lint-staged": "^16.2.0",
+ // react
+ react: "^19.0.0",
+ "react-dom": "^19.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^6.0.0",
+ "@testing-library/react": "^16.0.0",
+ "@testing-library/dom": "^10.0.0",
+ // vue
+ vue: "^3.4.0",
+ "vue-tsc": "^3.0.0",
+ "@vitejs/plugin-vue": "^6.0.0",
+ "@testing-library/vue": "^8.1.0",
+ // svelte
+ svelte: "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
+ "@testing-library/svelte": "^5.2.0",
+ // storybook
+ storybook: "^10.0.0",
+ "@storybook/react": "^10.0.0",
+ "@storybook/react-vite": "^10.0.0",
+ "@storybook/vue3": "^10.0.0",
+ "@storybook/vue3-vite": "^10.0.0",
+ "@storybook/svelte": "^10.0.0",
+ "@storybook/svelte-vite": "^10.0.0",
+ // services
+ hono: "^4.5.0",
+ "@hono/node-server": "^2.0.0",
+ fastify: "^5.0.0",
+ express: "^5.0.0",
+ "@types/express": "^5.0.0",
+ zod: "^4.0.0"
+};
+var PEER = {
+ react: ">=18",
+ "react-dom": ">=18",
+ vue: ">=3",
+ svelte: ">=5"
+};
+
// src/core/features/bundler.js
var bundler_default = {
id: "bundler",
@@ -621,21 +710,21 @@ var bundler_default = {
files[`${tool}.config.${cfg.ext}`] = tsupConfig(cfg, entries, formats, tool);
pkg.scripts.build = tool;
pkg.scripts.dev = `${tool} --watch`;
- pkg.devDependencies = { [tool]: tool === "tsup" ? "^8.0.0" : "^0.6.0" };
- if (!cfg.isTs) pkg.devDependencies.typescript = "^5.9.3";
+ pkg.devDependencies = { [tool]: tool === "tsup" ? V.tsup : V.tsdown };
+ if (!cfg.isTs) pkg.devDependencies.typescript = V.typescript;
} else if (cfg.bundler === "unbuild") {
files["build.config.ts"] = unbuildConfig(cfg);
pkg.scripts.build = "unbuild";
pkg.scripts.dev = "unbuild --stub";
- pkg.devDependencies = { unbuild: "^3.0.0" };
+ pkg.devDependencies = { unbuild: V.unbuild };
} else if (cfg.bundler === "rollup") {
files["rollup.config.js"] = rollupConfig(cfg, formats);
pkg.scripts.build = "rollup -c";
pkg.scripts.dev = "rollup -c -w";
pkg.devDependencies = {
- rollup: "^4.0.0",
- ...cfg.isTs ? { "@rollup/plugin-typescript": "^12.0.0", tslib: "^2.6.0" } : {},
- ...cfg.minify ? { "@rollup/plugin-terser": "^1.0.0" } : {}
+ rollup: V.rollup,
+ ...cfg.isTs ? { "@rollup/plugin-typescript": V["@rollup/plugin-typescript"], tslib: V.tslib } : {},
+ ...cfg.minify ? { "@rollup/plugin-terser": V["@rollup/plugin-terser"] } : {}
};
} else if (cfg.bundler === "none" && cfg.isTs) {
pkg.scripts.build = "tsc";
@@ -644,7 +733,7 @@ var bundler_default = {
if (cfg.hasService) delete pkg.scripts.dev;
if (build) pkg.scripts.prepublishOnly = pkg.scripts.build;
pkg.scripts.clean = "rimraf dist";
- if (build) pkg.devDependencies = { ...pkg.devDependencies, rimraf: "^6.0.0" };
+ if (build) pkg.devDependencies = { ...pkg.devDependencies, rimraf: V.rimraf };
return { files, pkg };
}
};
@@ -742,7 +831,7 @@ var typescript_default = {
},
pkg: {
devDependencies: {
- typescript: "^5.9.3",
+ typescript: V.typescript,
"@types/node": `^${cfg.nodeVersion}.0.0`
}
}
@@ -784,7 +873,7 @@ function react(cfg, files, pkg, forApp) {
`}`,
``
].join("\n");
- pkg.dependencies = { react: "^19.0.0", "react-dom": "^19.0.0" };
+ pkg.dependencies = { react: V.react, "react-dom": V["react-dom"] };
} else {
files[`src/index.${x}`] = cfg.isTs ? [
`export interface ButtonProps {`,
@@ -797,13 +886,13 @@ function react(cfg, files, pkg, forApp) {
`}`,
``
].join("\n") : [`export function Button({ label, onClick }) {`, ` return ;`, `}`, ``].join("\n");
- pkg.peerDependencies = { react: ">=18", "react-dom": ">=18" };
- pkg.devDependencies.react = "^19.0.0";
- pkg.devDependencies["react-dom"] = "^19.0.0";
+ pkg.peerDependencies = { react: PEER.react, "react-dom": PEER["react-dom"] };
+ pkg.devDependencies.react = V.react;
+ pkg.devDependencies["react-dom"] = V["react-dom"];
}
if (cfg.isTs) {
- pkg.devDependencies["@types/react"] = "^19.0.0";
- pkg.devDependencies["@types/react-dom"] = "^19.0.0";
+ pkg.devDependencies["@types/react"] = V["@types/react"];
+ pkg.devDependencies["@types/react-dom"] = V["@types/react-dom"];
}
}
function vue(cfg, files, pkg, forApp) {
@@ -818,7 +907,7 @@ function vue(cfg, files, pkg, forApp) {
``
].join("\n");
files["src/App.vue"] = [script2, `<\/script>`, ``, ``, ` Hello from ${cfg.name}
`, ``, ``].join("\n");
- pkg.dependencies = { vue: "^3.4.0" };
+ pkg.dependencies = { vue: V.vue };
} else {
files[`src/index.${cfg.ext}`] = `export { default as Button } from './Button.vue';
`;
@@ -832,8 +921,8 @@ function vue(cfg, files, pkg, forApp) {
``,
``
].join("\n");
- pkg.peerDependencies = { vue: ">=3" };
- pkg.devDependencies.vue = "^3.4.0";
+ pkg.peerDependencies = { vue: PEER.vue };
+ pkg.devDependencies.vue = V.vue;
}
}
function svelte(cfg, files, pkg, forApp) {
@@ -842,8 +931,8 @@ function svelte(cfg, files, pkg, forApp) {
export default { preprocess: vitePreprocess() };
`;
- pkg.devDependencies["@sveltejs/vite-plugin-svelte"] = "^7.0.0";
- if (cfg.isTs) pkg.devDependencies["svelte-check"] = "^4.0.0";
+ pkg.devDependencies["@sveltejs/vite-plugin-svelte"] = V["@sveltejs/vite-plugin-svelte"];
+ if (cfg.isTs) pkg.devDependencies["svelte-check"] = V["svelte-check"];
if (forApp) {
files["index.html"] = htmlShell(cfg, `/src/main.${cfg.ext}`);
files[`src/main.${cfg.ext}`] = [
@@ -855,7 +944,7 @@ export default { preprocess: vitePreprocess() };
``
].join("\n");
files["src/App.svelte"] = [script2, `<\/script>`, ``, `
Hello from ${cfg.name}
`, ``].join("\n");
- pkg.dependencies = { svelte: "^5.0.0" };
+ pkg.dependencies = { svelte: V.svelte };
} else {
files[`src/index.${cfg.ext}`] = `export { default as Button } from './Button.svelte';
`;
@@ -868,8 +957,8 @@ export default { preprocess: vitePreprocess() };
``,
``
].filter((l) => l !== ``).join("\n") + "\n";
- pkg.peerDependencies = { svelte: ">=5" };
- pkg.devDependencies.svelte = "^5.0.0";
+ pkg.peerDependencies = { svelte: PEER.svelte };
+ pkg.devDependencies.svelte = V.svelte;
pkg.svelte = `./src/index.${cfg.ext}`;
pkg.exports = { ".": { svelte: `./src/index.${cfg.ext}`, default: `./src/index.${cfg.ext}` } };
pkg.files = ["src"];
@@ -896,19 +985,19 @@ function htmlShell(cfg, entry) {
// src/core/features/vite.js
var PLUGIN = {
- react: { import: `import react from '@vitejs/plugin-react';`, call: "react()", dep: { "@vitejs/plugin-react": "^6.0.0" } },
- vue: { import: `import vue from '@vitejs/plugin-vue';`, call: "vue()", dep: { "@vitejs/plugin-vue": "^6.0.0" } },
- svelte: { import: `import { svelte } from '@sveltejs/vite-plugin-svelte';`, call: "svelte()", dep: { "@sveltejs/vite-plugin-svelte": "^7.0.0" } }
+ react: { import: `import react from '@vitejs/plugin-react';`, call: "react()", dep: { "@vitejs/plugin-react": V["@vitejs/plugin-react"] } },
+ vue: { import: `import vue from '@vitejs/plugin-vue';`, call: "vue()", dep: { "@vitejs/plugin-vue": V["@vitejs/plugin-vue"] } },
+ svelte: { import: `import { svelte } from '@sveltejs/vite-plugin-svelte';`, call: "svelte()", dep: { "@sveltejs/vite-plugin-svelte": V["@sveltejs/vite-plugin-svelte"] } }
};
var vite_default = {
id: "vite",
active: (cfg) => cfg.viteBuild,
apply(cfg) {
const files = {};
- const pkg = { scripts: {}, devDependencies: { vite: "^8.0.0" } };
+ const pkg = { scripts: {}, devDependencies: { vite: V.vite } };
const p = PLUGIN[cfg.framework];
Object.assign(pkg.devDependencies, p.dep);
- if (cfg.isVue && cfg.isTs) pkg.devDependencies["vue-tsc"] = "^3.0.0";
+ if (cfg.isVue && cfg.isTs) pkg.devDependencies["vue-tsc"] = V["vue-tsc"];
if (cfg.hasApp) {
files[`vite.config.${cfg.ext}`] = [p.import, ``, `import { defineConfig } from 'vite';`, ``, `export default defineConfig({`, ` plugins: [${p.call}],`, `});`, ``].join("\n");
pkg.private = true;
@@ -934,8 +1023,8 @@ var vite_default = {
].join("\n");
pkg.scripts.build = "vite build";
pkg.scripts.dev = "vite build --watch";
- pkg.devDependencies["vite-plugin-dts"] = "^5.0.0";
- if (cfg.isVue) pkg.devDependencies["vue-tsc"] = "^3.0.0";
+ pkg.devDependencies["vite-plugin-dts"] = V["vite-plugin-dts"];
+ if (cfg.isVue) pkg.devDependencies["vue-tsc"] = V["vue-tsc"];
pkg.files = ["dist"];
pkg.type = "module";
pkg.main = "./dist/index.cjs";
@@ -944,7 +1033,7 @@ var vite_default = {
pkg.exports = { ".": { types: "./dist/index.d.ts", import: "./dist/index.js", require: "./dist/index.cjs" } };
}
pkg.scripts.clean = "rimraf dist";
- pkg.devDependencies.rimraf = "^6.0.0";
+ pkg.devDependencies.rimraf = V.rimraf;
return { files, pkg };
}
};
@@ -972,7 +1061,7 @@ var service_default = {
},
dependencies: deps(fw),
devDependencies: {
- ...cfg.isTs ? { tsx: "^4.0.0" } : {},
+ ...cfg.isTs ? { tsx: V.tsx } : {},
...cfg.isTs ? typeDeps(fw) : {}
}
}
@@ -980,12 +1069,12 @@ var service_default = {
}
};
function deps(fw) {
- if (fw === "fastify") return { fastify: "^5.0.0" };
- if (fw === "express") return { express: "^5.0.0" };
- return { hono: "^4.5.0", "@hono/node-server": "^2.0.0" };
+ if (fw === "fastify") return { fastify: V.fastify };
+ if (fw === "express") return { express: V.express };
+ return { hono: V.hono, "@hono/node-server": V["@hono/node-server"] };
}
function typeDeps(fw) {
- if (fw === "express") return { "@types/express": "^5.0.0" };
+ if (fw === "express") return { "@types/express": V["@types/express"] };
return {};
}
function appFile(cfg, fw) {
@@ -1107,7 +1196,7 @@ var env_default = {
const files = {};
files[`src/env.${cfg.ext}`] = cfg.isTs ? envTs() : envJs();
files[".env.example"] = ["NODE_ENV=development", "PORT=3000", ""].join("\n");
- return { files, pkg: { dependencies: { zod: "^4.0.0" } } };
+ return { files, pkg: { dependencies: { zod: V.zod } } };
}
};
function envTs() {
@@ -1184,38 +1273,38 @@ import { svelteTesting } from '@testing-library/svelte/vite';`,
].filter((l) => l !== null).join("\n");
pkg.scripts.test = "vitest run";
pkg.scripts["test:watch"] = "vitest";
- pkg.devDependencies.vitest = "^4.0.0";
+ pkg.devDependencies.vitest = V.vitest;
if (cfg.hasFramework) {
- pkg.devDependencies.jsdom = "^29.0.0";
- pkg.devDependencies["@testing-library/dom"] = "^10.0.0";
- if (cfg.isReact) pkg.devDependencies["@testing-library/react"] = "^16.0.0";
- if (cfg.isVue) pkg.devDependencies["@testing-library/vue"] = "^8.1.0";
- if (cfg.isSvelte) pkg.devDependencies["@testing-library/svelte"] = "^5.2.0";
+ pkg.devDependencies.jsdom = V.jsdom;
+ pkg.devDependencies["@testing-library/dom"] = V["@testing-library/dom"];
+ if (cfg.isReact) pkg.devDependencies["@testing-library/react"] = V["@testing-library/react"];
+ if (cfg.isVue) pkg.devDependencies["@testing-library/vue"] = V["@testing-library/vue"];
+ if (cfg.isSvelte) pkg.devDependencies["@testing-library/svelte"] = V["@testing-library/svelte"];
}
if (cfg.coverage) {
pkg.scripts.coverage = "vitest run --coverage";
- pkg.devDependencies["@vitest/coverage-v8"] = "^4.0.0";
+ pkg.devDependencies["@vitest/coverage-v8"] = V["@vitest/coverage-v8"];
}
files[`src/index.test.${testExt}`] = exampleTest("vitest", cfg);
} else if (cfg.test === "jest") {
files["jest.config.js"] = jestConfig(cfg);
pkg.scripts.test = "jest";
pkg.scripts["test:watch"] = "jest --watch";
- pkg.devDependencies.jest = "^30.0.0";
+ pkg.devDependencies.jest = V.jest;
if (cfg.isTs) {
- pkg.devDependencies["ts-jest"] = "^29.0.0";
- pkg.devDependencies["@types/jest"] = "^30.0.0";
+ pkg.devDependencies["ts-jest"] = V["ts-jest"];
+ pkg.devDependencies["@types/jest"] = V["@types/jest"];
}
if (cfg.coverage) pkg.scripts.coverage = "jest --coverage";
files[`src/index.test.${testExt}`] = exampleTest("jest", cfg);
} else if (cfg.test === "node") {
pkg.scripts.test = cfg.isTs ? 'node --import tsx --test "src/**/*.test.ts"' : "node --test";
- if (cfg.isTs) pkg.devDependencies.tsx = "^4.0.0";
+ if (cfg.isTs) pkg.devDependencies.tsx = V.tsx;
files[`src/index.test.${testExt}`] = exampleTest("node", cfg);
}
if (cfg.hasService && cfg.serviceFramework === "express") {
- pkg.devDependencies.supertest = "^7.0.0";
- if (cfg.isTs) pkg.devDependencies["@types/supertest"] = "^6.0.0";
+ pkg.devDependencies.supertest = V.supertest;
+ if (cfg.isTs) pkg.devDependencies["@types/supertest"] = V["@types/supertest"];
}
return { files, pkg };
}
@@ -1399,18 +1488,18 @@ var lint_default = {
files[".prettierignore"] = "dist\ncoverage\n";
pkg.scripts.format = "prettier --write .";
pkg.scripts["format:check"] = "prettier --check .";
- pkg.devDependencies.prettier = "^3.3.0";
+ pkg.devDependencies.prettier = V.prettier;
}
if (cfg.lint === "eslint-prettier") {
files["eslint.config.js"] = eslintFlatConfig(cfg);
pkg.scripts.lint = "eslint .";
pkg.scripts["lint:fix"] = "eslint . --fix";
- pkg.devDependencies.eslint = "^10.0.0";
- pkg.devDependencies["@eslint/js"] = "^10.0.0";
- if (cfg.isTs) pkg.devDependencies["typescript-eslint"] = "^8.0.0";
+ pkg.devDependencies.eslint = V.eslint;
+ pkg.devDependencies["@eslint/js"] = V["@eslint/js"];
+ if (cfg.isTs) pkg.devDependencies["typescript-eslint"] = V["typescript-eslint"];
} else if (cfg.lint === "oxlint") {
pkg.scripts.lint = "oxlint";
- pkg.devDependencies.oxlint = "^1.0.0";
+ pkg.devDependencies.oxlint = V.oxlint;
} else if (cfg.lint === "biome") {
files["biome.json"] = toJson({
$schema: "https://biomejs.dev/schemas/2.1.2/schema.json",
@@ -1421,7 +1510,7 @@ var lint_default = {
pkg.scripts.lint = "biome lint .";
pkg.scripts["lint:fix"] = "biome lint --write .";
pkg.scripts.format = "biome format --write .";
- pkg.devDependencies["@biomejs/biome"] = "^2.0.0";
+ pkg.devDependencies["@biomejs/biome"] = V["@biomejs/biome"];
}
return { files, pkg };
}
@@ -1465,19 +1554,19 @@ var githooks_default = {
if (cfg.gitHooks === "simple-git-hooks") {
pkg["simple-git-hooks"] = { "pre-commit": needsLintStaged ? "npx lint-staged" : "npm test" };
pkg.scripts.prepare = "simple-git-hooks";
- pkg.devDependencies["simple-git-hooks"] = "^2.11.0";
+ pkg.devDependencies["simple-git-hooks"] = V["simple-git-hooks"];
} else if (cfg.gitHooks === "husky") {
files[".husky/pre-commit"] = needsLintStaged ? "npx lint-staged\n" : "npm test\n";
pkg.scripts.prepare = "husky";
- pkg.devDependencies.husky = "^9.1.0";
+ pkg.devDependencies.husky = V.husky;
} else if (cfg.gitHooks === "lefthook") {
files["lefthook.yml"] = lefthookYml(cfg);
pkg.scripts.prepare = "lefthook install || true";
- pkg.devDependencies.lefthook = "^2.0.0";
+ pkg.devDependencies.lefthook = V.lefthook;
}
if (needsLintStaged) {
pkg["lint-staged"] = staged;
- pkg.devDependencies["lint-staged"] = "^16.2.0";
+ pkg.devDependencies["lint-staged"] = V["lint-staged"];
}
return { files, pkg };
}
@@ -1514,7 +1603,7 @@ var release_default = {
pkg.scripts.changeset = "changeset";
pkg.scripts.version = "changeset version";
pkg.scripts.release = `${buildThen(cfg)}changeset publish`;
- pkg.devDependencies["@changesets/cli"] = "^2.27.0";
+ pkg.devDependencies["@changesets/cli"] = V["@changesets/cli"];
} else if (cfg.release === "release-it") {
files[".release-it.json"] = toJson({
git: { commitMessage: "chore: release v${version}" },
@@ -1522,10 +1611,10 @@ var release_default = {
npm: { publish: true }
});
pkg.scripts.release = "release-it";
- pkg.devDependencies["release-it"] = "^20.0.0";
+ pkg.devDependencies["release-it"] = V["release-it"];
} else if (cfg.release === "np") {
pkg.scripts.release = "np";
- pkg.devDependencies.np = "^12.0.0";
+ pkg.devDependencies.np = V.np;
}
return { files, pkg };
}
@@ -1575,12 +1664,12 @@ var checks_default = {
if (cfg.pkgChecks) {
const attw = cfg.moduleFormat === "esm" ? "attw --pack --profile esm-only" : "attw --pack";
pkg.scripts["check:pkg"] = `publint && ${attw}`;
- pkg.devDependencies.publint = "^0.3.0";
- pkg.devDependencies["@arethetypeswrong/cli"] = "^0.18.0";
+ pkg.devDependencies.publint = V.publint;
+ pkg.devDependencies["@arethetypeswrong/cli"] = V["@arethetypeswrong/cli"];
}
if (cfg.knip) {
pkg.scripts.knip = "knip";
- pkg.devDependencies.knip = "^5.0.0";
+ pkg.devDependencies.knip = V.knip;
}
return { files: {}, pkg };
}
@@ -1598,8 +1687,8 @@ var sizelimit_default = {
pkg: {
scripts: { size: "size-limit" },
devDependencies: {
- "size-limit": "^11.0.0",
- "@size-limit/preset-small-lib": "^11.0.0"
+ "size-limit": V["size-limit"],
+ "@size-limit/preset-small-lib": V["@size-limit/preset-small-lib"]
}
}
};
@@ -1935,10 +2024,10 @@ var storybook_default = {
"build-storybook": "storybook build"
},
devDependencies: {
- storybook: "^10.0.0",
- [fw.builder]: "^10.0.0",
- [fw.renderer]: "^10.0.0",
- vite: "^8.0.0"
+ storybook: V.storybook,
+ [fw.builder]: V[fw.builder],
+ [fw.renderer]: V[fw.renderer],
+ vite: V.vite
}
}
};
@@ -2409,15 +2498,15 @@ function buildMonorepo(cfg) {
release: "turbo build && changeset publish"
},
devDependencies: {
- turbo: "^2.0.0",
- typescript: "^5.9.3",
- tsup: "^8.0.0",
- vitest: "^4.0.0",
- eslint: "^10.0.0",
- "@eslint/js": "^10.0.0",
- "typescript-eslint": "^8.0.0",
- prettier: "^3.3.0",
- "@changesets/cli": "^2.27.0",
+ turbo: V.turbo,
+ typescript: V.typescript,
+ tsup: V.tsup,
+ vitest: V.vitest,
+ eslint: V.eslint,
+ "@eslint/js": V["@eslint/js"],
+ "typescript-eslint": V["typescript-eslint"],
+ prettier: V.prettier,
+ "@changesets/cli": V["@changesets/cli"],
"@types/node": `^${cfg.nodeVersion}.0.0`
}
};
@@ -2536,13 +2625,13 @@ function buildFullstack(cfg) {
typecheck: "turbo typecheck"
},
devDependencies: {
- turbo: "^2.0.0",
- typescript: "^5.9.3",
- vitest: "^4.0.0",
- eslint: "^10.0.0",
- "@eslint/js": "^10.0.0",
- "typescript-eslint": "^8.0.0",
- prettier: "^3.3.0",
+ turbo: V.turbo,
+ typescript: V.typescript,
+ vitest: V.vitest,
+ eslint: V.eslint,
+ "@eslint/js": V["@eslint/js"],
+ "typescript-eslint": V["typescript-eslint"],
+ prettier: V.prettier,
"@types/node": `^${cfg.nodeVersion}.0.0`
}
});
@@ -2604,7 +2693,7 @@ function buildFullstack(cfg) {
typecheck: "tsc --noEmit",
lint: "eslint ."
},
- devDependencies: { tsup: "^8.0.0" }
+ devDependencies: { tsup: V.tsup }
});
files["packages/shared/tsconfig.json"] = toJson({ extends: "../../tsconfig.base.json", include: ["src"] });
files["packages/shared/src/index.ts"] = [
@@ -2637,8 +2726,8 @@ function buildFullstack(cfg) {
typecheck: "tsc --noEmit",
lint: "eslint ."
},
- dependencies: { hono: "^4.5.0", "@hono/node-server": "^2.0.0", [shared]: wsProto },
- devDependencies: { tsx: "^4.0.0", tsup: "^8.0.0" }
+ dependencies: { hono: V.hono, "@hono/node-server": V["@hono/node-server"], [shared]: wsProto },
+ devDependencies: { tsx: V.tsx, tsup: V.tsup }
});
files["apps/server/tsconfig.json"] = toJson({ extends: "../../tsconfig.base.json", include: ["src"] });
files["apps/server/src/app.ts"] = [
@@ -2696,17 +2785,17 @@ function buildFullstack(cfg) {
typecheck: "tsc --noEmit",
lint: "eslint ."
},
- dependencies: { react: "^19.0.0", "react-dom": "^19.0.0", [shared]: wsProto },
+ dependencies: { react: V.react, "react-dom": V["react-dom"], [shared]: wsProto },
devDependencies: {
- vite: "^8.0.0",
- "@vitejs/plugin-react": "^6.0.0",
+ vite: V.vite,
+ "@vitejs/plugin-react": V["@vitejs/plugin-react"],
// Without the React types, `turbo typecheck` fails on the very first run
// with TS7016/TS7026 on every JSX element.
- "@types/react": "^19.0.0",
- "@types/react-dom": "^19.0.0",
- "@testing-library/react": "^16.0.0",
- "@testing-library/dom": "^10.0.0",
- jsdom: "^29.0.0"
+ "@types/react": V["@types/react"],
+ "@types/react-dom": V["@types/react-dom"],
+ "@testing-library/react": V["@testing-library/react"],
+ "@testing-library/dom": V["@testing-library/dom"],
+ jsdom: V.jsdom
}
});
files["apps/web/tsconfig.json"] = toJson({
diff --git a/scripts/check-template-deps.mjs b/scripts/check-template-deps.mjs
index 21e59fc..f504627 100644
--- a/scripts/check-template-deps.mjs
+++ b/scripts/check-template-deps.mjs
@@ -5,16 +5,14 @@
// dependency that is a full major version behind. Exits 1 if anything is stale.
import { generate, normalizeConfig, fromPreset, PRESET_NAMES } from '../src/core/index.js';
+import { HELD } from '../src/core/versions.js';
// Ignore version-pinned-to-something-else or non-semver specifiers.
const IGNORE = new Set(['@types/node']);
-// Intentionally held back for compatibility — reported separately, not as failures.
-const HELD = {
- typescript: 'typescript-eslint peers typescript <6.1.0 (no TS 7 support yet)',
- knip: 'v6 crashes on the oxc-parser native binding',
- 'lint-staged': 'v17 requires Node >=22.22.1, above our Node 22 engines floor; hold at 16 to keep the Maintenance-LTS line working',
-};
+// HELD (deps deliberately kept below their latest major, with the reason) is the
+// single source of truth in src/core/versions.js — imported here so the reasons
+// aren't duplicated and drift.
const isSemverish = (v) => /^[\^~>=]*\d+\.\d+/.test(v) || /^[\^~>=]*\d+$/.test(v);
const floorMajor = (v) => parseInt(String(v).replace(/^[\^~>=v\s]+/, ''), 10);
diff --git a/src/core/features/bundler.js b/src/core/features/bundler.js
index 9b99976..0f5edf9 100644
--- a/src/core/features/bundler.js
+++ b/src/core/features/bundler.js
@@ -1,3 +1,4 @@
+import { V } from '../versions.js';
// Always-on. Owns the package entry points (exports/main/module/types/files)
// and the build tooling for the chosen bundler.
@@ -48,24 +49,24 @@ export default {
files[`${tool}.config.${cfg.ext}`] = tsupConfig(cfg, entries, formats, tool);
pkg.scripts.build = tool;
pkg.scripts.dev = `${tool} --watch`;
- pkg.devDependencies = { [tool]: tool === 'tsup' ? '^8.0.0' : '^0.6.0' };
+ pkg.devDependencies = { [tool]: tool === 'tsup' ? V.tsup : V.tsdown };
// tsup/tsdown resolve `typescript` even for plain-JS builds; TS projects
// already get it from the typescript feature.
- if (!cfg.isTs) pkg.devDependencies.typescript = '^5.9.3';
+ if (!cfg.isTs) pkg.devDependencies.typescript = V.typescript;
} else if (cfg.bundler === 'unbuild') {
files['build.config.ts'] = unbuildConfig(cfg);
pkg.scripts.build = 'unbuild';
pkg.scripts.dev = 'unbuild --stub';
- pkg.devDependencies = { unbuild: '^3.0.0' };
+ pkg.devDependencies = { unbuild: V.unbuild };
} else if (cfg.bundler === 'rollup') {
// Always a .js config — rollup can't load a .ts config without a loader.
files['rollup.config.js'] = rollupConfig(cfg, formats);
pkg.scripts.build = 'rollup -c';
pkg.scripts.dev = 'rollup -c -w';
pkg.devDependencies = {
- rollup: '^4.0.0',
- ...(cfg.isTs ? { '@rollup/plugin-typescript': '^12.0.0', tslib: '^2.6.0' } : {}),
- ...(cfg.minify ? { '@rollup/plugin-terser': '^1.0.0' } : {}),
+ rollup: V.rollup,
+ ...(cfg.isTs ? { '@rollup/plugin-typescript': V['@rollup/plugin-typescript'], tslib: V.tslib } : {}),
+ ...(cfg.minify ? { '@rollup/plugin-terser': V['@rollup/plugin-terser'] } : {}),
};
} else if (cfg.bundler === 'none' && cfg.isTs) {
// tsc-only build for TypeScript.
@@ -80,7 +81,7 @@ export default {
if (build) pkg.scripts.prepublishOnly = pkg.scripts.build;
pkg.scripts.clean = 'rimraf dist';
- if (build) pkg.devDependencies = { ...pkg.devDependencies, rimraf: '^6.0.0' };
+ if (build) pkg.devDependencies = { ...pkg.devDependencies, rimraf: V.rimraf };
return { files, pkg };
},
diff --git a/src/core/features/checks.js b/src/core/features/checks.js
index b9a8e7c..17af4e8 100644
--- a/src/core/features/checks.js
+++ b/src/core/features/checks.js
@@ -1,3 +1,4 @@
+import { V } from '../versions.js';
// Package-correctness + hygiene checks (opt-in):
// - publint validates package.json shape (exports/main/module)
// - are-the-types-wrong verifies TS consumers resolve your .d.ts correctly
@@ -14,12 +15,12 @@ export default {
// node16 profile flags a "false" failure — use the esm-only profile.
const attw = cfg.moduleFormat === 'esm' ? 'attw --pack --profile esm-only' : 'attw --pack';
pkg.scripts['check:pkg'] = `publint && ${attw}`;
- pkg.devDependencies.publint = '^0.3.0';
- pkg.devDependencies['@arethetypeswrong/cli'] = '^0.18.0';
+ pkg.devDependencies.publint = V.publint;
+ pkg.devDependencies['@arethetypeswrong/cli'] = V['@arethetypeswrong/cli'];
}
if (cfg.knip) {
pkg.scripts.knip = 'knip';
- pkg.devDependencies.knip = '^5.0.0';
+ pkg.devDependencies.knip = V.knip;
}
return { files: {}, pkg };
diff --git a/src/core/features/env.js b/src/core/features/env.js
index d459b25..3255aac 100644
--- a/src/core/features/env.js
+++ b/src/core/features/env.js
@@ -1,3 +1,4 @@
+import { V } from '../versions.js';
// Type-safe environment-variable validation for server-side runtimes (services
// and CLIs). Validates process.env once at startup with a Zod schema, so a
// misconfigured deploy fails fast with a clear message instead of a surprise
@@ -12,7 +13,7 @@ export default {
files[`src/env.${cfg.ext}`] = cfg.isTs ? envTs() : envJs();
files['.env.example'] = ['NODE_ENV=development', 'PORT=3000', ''].join('\n');
- return { files, pkg: { dependencies: { zod: '^4.0.0' } } };
+ return { files, pkg: { dependencies: { zod: V.zod } } };
},
};
diff --git a/src/core/features/frameworks.js b/src/core/features/frameworks.js
index 64acf30..45ea01d 100644
--- a/src/core/features/frameworks.js
+++ b/src/core/features/frameworks.js
@@ -1,3 +1,4 @@
+import { V, PEER } from '../versions.js';
// React / Vue / Svelte source + dependencies, for both component libraries and
// Vite apps. The Vite build wiring lives in vite.js; here we emit the source
// files, the runtime deps (peer for libs, direct for apps), and — for Svelte
@@ -40,7 +41,7 @@ function react(cfg, files, pkg, forApp) {
`}`,
``,
].join('\n');
- pkg.dependencies = { react: '^19.0.0', 'react-dom': '^19.0.0' };
+ pkg.dependencies = { react: V.react, 'react-dom': V['react-dom'] };
} else {
files[`src/index.${x}`] = cfg.isTs
? [
@@ -55,13 +56,13 @@ function react(cfg, files, pkg, forApp) {
``,
].join('\n')
: [`export function Button({ label, onClick }) {`, `\treturn ;`, `}`, ``].join('\n');
- pkg.peerDependencies = { react: '>=18', 'react-dom': '>=18' };
- pkg.devDependencies.react = '^19.0.0';
- pkg.devDependencies['react-dom'] = '^19.0.0';
+ pkg.peerDependencies = { react: PEER.react, 'react-dom': PEER['react-dom'] };
+ pkg.devDependencies.react = V.react;
+ pkg.devDependencies['react-dom'] = V['react-dom'];
}
if (cfg.isTs) {
- pkg.devDependencies['@types/react'] = '^19.0.0';
- pkg.devDependencies['@types/react-dom'] = '^19.0.0';
+ pkg.devDependencies['@types/react'] = V['@types/react'];
+ pkg.devDependencies['@types/react-dom'] = V['@types/react-dom'];
}
}
@@ -78,7 +79,7 @@ function vue(cfg, files, pkg, forApp) {
``,
].join('\n');
files['src/App.vue'] = [script, ``, ``, ``, `\tHello from ${cfg.name}
`, ``, ``].join('\n');
- pkg.dependencies = { vue: '^3.4.0' };
+ pkg.dependencies = { vue: V.vue };
} else {
files[`src/index.${cfg.ext}`] = `export { default as Button } from './Button.vue';\n`;
files['src/Button.vue'] = [
@@ -91,8 +92,8 @@ function vue(cfg, files, pkg, forApp) {
``,
``,
].join('\n');
- pkg.peerDependencies = { vue: '>=3' };
- pkg.devDependencies.vue = '^3.4.0';
+ pkg.peerDependencies = { vue: PEER.vue };
+ pkg.devDependencies.vue = V.vue;
}
}
@@ -101,8 +102,8 @@ function svelte(cfg, files, pkg, forApp) {
const script = cfg.isTs ? ``, ``, `Hello from ${cfg.name}
`, ``].join('\n');
- pkg.dependencies = { svelte: '^5.0.0' };
+ pkg.dependencies = { svelte: V.svelte };
} else {
// Svelte libraries ship uncompiled source; the consumer's bundler compiles.
files[`src/index.${cfg.ext}`] = `export { default as Button } from './Button.svelte';\n`;
@@ -127,8 +128,8 @@ function svelte(cfg, files, pkg, forApp) {
``,
``,
].filter((l) => l !== ``).join('\n') + '\n';
- pkg.peerDependencies = { svelte: '>=5' };
- pkg.devDependencies.svelte = '^5.0.0';
+ pkg.peerDependencies = { svelte: PEER.svelte };
+ pkg.devDependencies.svelte = V.svelte;
// Ship source; point consumers at it.
pkg.svelte = `./src/index.${cfg.ext}`;
pkg.exports = { '.': { svelte: `./src/index.${cfg.ext}`, default: `./src/index.${cfg.ext}` } };
diff --git a/src/core/features/githooks.js b/src/core/features/githooks.js
index 2a406b9..8c79f50 100644
--- a/src/core/features/githooks.js
+++ b/src/core/features/githooks.js
@@ -1,3 +1,4 @@
+import { V } from '../versions.js';
export default {
id: 'githooks',
active: (cfg) => cfg.gitHooks !== 'none',
@@ -21,24 +22,24 @@ export default {
if (cfg.gitHooks === 'simple-git-hooks') {
pkg['simple-git-hooks'] = { 'pre-commit': needsLintStaged ? 'npx lint-staged' : 'npm test' };
pkg.scripts.prepare = 'simple-git-hooks';
- pkg.devDependencies['simple-git-hooks'] = '^2.11.0';
+ pkg.devDependencies['simple-git-hooks'] = V['simple-git-hooks'];
} else if (cfg.gitHooks === 'husky') {
files['.husky/pre-commit'] = (needsLintStaged ? 'npx lint-staged\n' : 'npm test\n');
pkg.scripts.prepare = 'husky';
- pkg.devDependencies.husky = '^9.1.0';
+ pkg.devDependencies.husky = V.husky;
} else if (cfg.gitHooks === 'lefthook') {
files['lefthook.yml'] = lefthookYml(cfg);
// `|| true` so `npm install` doesn't fail before `git init` — lefthook
// (unlike husky/simple-git-hooks) errors hard without a .git directory.
pkg.scripts.prepare = 'lefthook install || true';
- pkg.devDependencies.lefthook = '^2.0.0';
+ pkg.devDependencies.lefthook = V.lefthook;
}
if (needsLintStaged) {
pkg['lint-staged'] = staged;
// Held at 16: v17 requires Node >=22.22.1, above our Node 22 engines
// floor (22.13) — it would break the Maintenance-LTS line. See ENGINE_MIN.
- pkg.devDependencies['lint-staged'] = '^16.2.0';
+ pkg.devDependencies['lint-staged'] = V['lint-staged'];
}
return { files, pkg };
diff --git a/src/core/features/lint.js b/src/core/features/lint.js
index de1188f..4cc17c0 100644
--- a/src/core/features/lint.js
+++ b/src/core/features/lint.js
@@ -1,4 +1,5 @@
import { toJson } from '../render.js';
+import { V } from '../versions.js';
export default {
id: 'lint',
@@ -19,19 +20,19 @@ export default {
files['.prettierignore'] = 'dist\ncoverage\n';
pkg.scripts.format = 'prettier --write .';
pkg.scripts['format:check'] = 'prettier --check .';
- pkg.devDependencies.prettier = '^3.3.0';
+ pkg.devDependencies.prettier = V.prettier;
}
if (cfg.lint === 'eslint-prettier') {
files['eslint.config.js'] = eslintFlatConfig(cfg);
pkg.scripts.lint = 'eslint .';
pkg.scripts['lint:fix'] = 'eslint . --fix';
- pkg.devDependencies.eslint = '^10.0.0';
- pkg.devDependencies['@eslint/js'] = '^10.0.0';
- if (cfg.isTs) pkg.devDependencies['typescript-eslint'] = '^8.0.0';
+ pkg.devDependencies.eslint = V.eslint;
+ pkg.devDependencies['@eslint/js'] = V['@eslint/js'];
+ if (cfg.isTs) pkg.devDependencies['typescript-eslint'] = V['typescript-eslint'];
} else if (cfg.lint === 'oxlint') {
pkg.scripts.lint = 'oxlint';
- pkg.devDependencies.oxlint = '^1.0.0';
+ pkg.devDependencies.oxlint = V.oxlint;
} else if (cfg.lint === 'biome') {
files['biome.json'] = toJson({
$schema: 'https://biomejs.dev/schemas/2.1.2/schema.json',
@@ -42,7 +43,7 @@ export default {
pkg.scripts.lint = 'biome lint .';
pkg.scripts['lint:fix'] = 'biome lint --write .';
pkg.scripts.format = 'biome format --write .';
- pkg.devDependencies['@biomejs/biome'] = '^2.0.0';
+ pkg.devDependencies['@biomejs/biome'] = V['@biomejs/biome'];
}
return { files, pkg };
diff --git a/src/core/features/release.js b/src/core/features/release.js
index 4c26b30..0ccd407 100644
--- a/src/core/features/release.js
+++ b/src/core/features/release.js
@@ -1,4 +1,5 @@
import { toJson } from '../render.js';
+import { V } from '../versions.js';
export default {
id: 'release',
@@ -21,7 +22,7 @@ export default {
pkg.scripts.changeset = 'changeset';
pkg.scripts.version = 'changeset version';
pkg.scripts.release = `${buildThen(cfg)}changeset publish`;
- pkg.devDependencies['@changesets/cli'] = '^2.27.0';
+ pkg.devDependencies['@changesets/cli'] = V['@changesets/cli'];
} else if (cfg.release === 'release-it') {
files['.release-it.json'] = toJson({
git: { commitMessage: 'chore: release v${version}' },
@@ -29,10 +30,10 @@ export default {
npm: { publish: true },
});
pkg.scripts.release = 'release-it';
- pkg.devDependencies['release-it'] = '^20.0.0';
+ pkg.devDependencies['release-it'] = V['release-it'];
} else if (cfg.release === 'np') {
pkg.scripts.release = 'np';
- pkg.devDependencies.np = '^12.0.0';
+ pkg.devDependencies.np = V.np;
}
return { files, pkg };
diff --git a/src/core/features/service.js b/src/core/features/service.js
index 43690a7..7281fed 100644
--- a/src/core/features/service.js
+++ b/src/core/features/service.js
@@ -1,3 +1,4 @@
+import { V } from '../versions.js';
// HTTP service target. Supports Hono (default), Fastify and Express. Splits the
// app (testable) from the server entry (which listens), adds start/dev scripts,
// and a production Dockerfile.
@@ -24,7 +25,7 @@ export default {
},
dependencies: deps(fw),
devDependencies: {
- ...(cfg.isTs ? { tsx: '^4.0.0' } : {}),
+ ...(cfg.isTs ? { tsx: V.tsx } : {}),
...(cfg.isTs ? typeDeps(fw) : {}),
},
},
@@ -33,13 +34,13 @@ export default {
};
function deps(fw) {
- if (fw === 'fastify') return { fastify: '^5.0.0' };
- if (fw === 'express') return { express: '^5.0.0' };
- return { hono: '^4.5.0', '@hono/node-server': '^2.0.0' };
+ if (fw === 'fastify') return { fastify: V.fastify };
+ if (fw === 'express') return { express: V.express };
+ return { hono: V.hono, '@hono/node-server': V['@hono/node-server'] };
}
function typeDeps(fw) {
- if (fw === 'express') return { '@types/express': '^5.0.0' };
+ if (fw === 'express') return { '@types/express': V['@types/express'] };
return {};
}
diff --git a/src/core/features/sizelimit.js b/src/core/features/sizelimit.js
index 5848862..21efffb 100644
--- a/src/core/features/sizelimit.js
+++ b/src/core/features/sizelimit.js
@@ -2,6 +2,7 @@
// brotli-compressed size of the built entry and fails when it exceeds the
// limit — cheap insurance against a dependency silently bloating the package.
import { toJson } from '../render.js';
+import { V } from '../versions.js';
export default {
id: 'sizelimit',
@@ -14,8 +15,8 @@ export default {
pkg: {
scripts: { size: 'size-limit' },
devDependencies: {
- 'size-limit': '^11.0.0',
- '@size-limit/preset-small-lib': '^11.0.0',
+ 'size-limit': V['size-limit'],
+ '@size-limit/preset-small-lib': V['@size-limit/preset-small-lib'],
},
},
};
diff --git a/src/core/features/storybook.js b/src/core/features/storybook.js
index fab4e94..b945588 100644
--- a/src/core/features/storybook.js
+++ b/src/core/features/storybook.js
@@ -1,3 +1,4 @@
+import { V } from '../versions.js';
// Storybook for component libraries (React / Vue / Svelte), on the Vite builder.
const FW = {
@@ -37,10 +38,10 @@ export default {
'build-storybook': 'storybook build',
},
devDependencies: {
- storybook: '^10.0.0',
- [fw.builder]: '^10.0.0',
- [fw.renderer]: '^10.0.0',
- vite: '^8.0.0',
+ storybook: V.storybook,
+ [fw.builder]: V[fw.builder],
+ [fw.renderer]: V[fw.renderer],
+ vite: V.vite,
},
},
};
diff --git a/src/core/features/test.js b/src/core/features/test.js
index 8e8a6c0..0879189 100644
--- a/src/core/features/test.js
+++ b/src/core/features/test.js
@@ -1,3 +1,4 @@
+import { V } from '../versions.js';
export default {
id: 'test',
@@ -37,40 +38,40 @@ export default {
].filter((l) => l !== null).join('\n');
pkg.scripts.test = 'vitest run';
pkg.scripts['test:watch'] = 'vitest';
- pkg.devDependencies.vitest = '^4.0.0';
+ pkg.devDependencies.vitest = V.vitest;
if (cfg.hasFramework) {
- pkg.devDependencies.jsdom = '^29.0.0';
- pkg.devDependencies['@testing-library/dom'] = '^10.0.0';
- if (cfg.isReact) pkg.devDependencies['@testing-library/react'] = '^16.0.0';
- if (cfg.isVue) pkg.devDependencies['@testing-library/vue'] = '^8.1.0';
- if (cfg.isSvelte) pkg.devDependencies['@testing-library/svelte'] = '^5.2.0';
+ pkg.devDependencies.jsdom = V.jsdom;
+ pkg.devDependencies['@testing-library/dom'] = V['@testing-library/dom'];
+ if (cfg.isReact) pkg.devDependencies['@testing-library/react'] = V['@testing-library/react'];
+ if (cfg.isVue) pkg.devDependencies['@testing-library/vue'] = V['@testing-library/vue'];
+ if (cfg.isSvelte) pkg.devDependencies['@testing-library/svelte'] = V['@testing-library/svelte'];
}
if (cfg.coverage) {
pkg.scripts.coverage = 'vitest run --coverage';
- pkg.devDependencies['@vitest/coverage-v8'] = '^4.0.0';
+ pkg.devDependencies['@vitest/coverage-v8'] = V['@vitest/coverage-v8'];
}
files[`src/index.test.${testExt}`] = exampleTest('vitest', cfg);
} else if (cfg.test === 'jest') {
files['jest.config.js'] = jestConfig(cfg);
pkg.scripts.test = 'jest';
pkg.scripts['test:watch'] = 'jest --watch';
- pkg.devDependencies.jest = '^30.0.0';
+ pkg.devDependencies.jest = V.jest;
if (cfg.isTs) {
- pkg.devDependencies['ts-jest'] = '^29.0.0';
- pkg.devDependencies['@types/jest'] = '^30.0.0';
+ pkg.devDependencies['ts-jest'] = V['ts-jest'];
+ pkg.devDependencies['@types/jest'] = V['@types/jest'];
}
if (cfg.coverage) pkg.scripts.coverage = 'jest --coverage';
files[`src/index.test.${testExt}`] = exampleTest('jest', cfg);
} else if (cfg.test === 'node') {
pkg.scripts.test = cfg.isTs ? 'node --import tsx --test "src/**/*.test.ts"' : 'node --test';
- if (cfg.isTs) pkg.devDependencies.tsx = '^4.0.0';
+ if (cfg.isTs) pkg.devDependencies.tsx = V.tsx;
files[`src/index.test.${testExt}`] = exampleTest('node', cfg);
}
// Express services test through supertest (no built-in inject).
if (cfg.hasService && cfg.serviceFramework === 'express') {
- pkg.devDependencies.supertest = '^7.0.0';
- if (cfg.isTs) pkg.devDependencies['@types/supertest'] = '^6.0.0';
+ pkg.devDependencies.supertest = V.supertest;
+ if (cfg.isTs) pkg.devDependencies['@types/supertest'] = V['@types/supertest'];
}
return { files, pkg };
diff --git a/src/core/features/typescript.js b/src/core/features/typescript.js
index e34b71c..a585576 100644
--- a/src/core/features/typescript.js
+++ b/src/core/features/typescript.js
@@ -1,4 +1,5 @@
import { toJson } from '../render.js';
+import { V } from '../versions.js';
export default {
id: 'typescript',
@@ -45,7 +46,7 @@ export default {
},
pkg: {
devDependencies: {
- typescript: '^5.9.3',
+ typescript: V.typescript,
'@types/node': `^${cfg.nodeVersion}.0.0`,
},
},
diff --git a/src/core/features/vite.js b/src/core/features/vite.js
index 5b67fdb..53ff219 100644
--- a/src/core/features/vite.js
+++ b/src/core/features/vite.js
@@ -1,12 +1,13 @@
+import { V } from '../versions.js';
// Vite build path: powers Vite SPA apps (React/Vue/Svelte) and Vue component
// libraries (SFCs need a real compiler). Owns the vite config, build scripts,
// and — for libraries — the package entry points. index.html + source come
// from frameworks.js. Svelte libraries ship source and don't come through here.
const PLUGIN = {
- react: { import: `import react from '@vitejs/plugin-react';`, call: 'react()', dep: { '@vitejs/plugin-react': '^6.0.0' } },
- vue: { import: `import vue from '@vitejs/plugin-vue';`, call: 'vue()', dep: { '@vitejs/plugin-vue': '^6.0.0' } },
- svelte: { import: `import { svelte } from '@sveltejs/vite-plugin-svelte';`, call: 'svelte()', dep: { '@sveltejs/vite-plugin-svelte': '^7.0.0' } },
+ react: { import: `import react from '@vitejs/plugin-react';`, call: 'react()', dep: { '@vitejs/plugin-react': V['@vitejs/plugin-react'] } },
+ vue: { import: `import vue from '@vitejs/plugin-vue';`, call: 'vue()', dep: { '@vitejs/plugin-vue': V['@vitejs/plugin-vue'] } },
+ svelte: { import: `import { svelte } from '@sveltejs/vite-plugin-svelte';`, call: 'svelte()', dep: { '@sveltejs/vite-plugin-svelte': V['@sveltejs/vite-plugin-svelte'] } },
};
export default {
@@ -14,10 +15,10 @@ export default {
active: (cfg) => cfg.viteBuild,
apply(cfg) {
const files = {};
- const pkg = { scripts: {}, devDependencies: { vite: '^8.0.0' } };
+ const pkg = { scripts: {}, devDependencies: { vite: V.vite } };
const p = PLUGIN[cfg.framework];
Object.assign(pkg.devDependencies, p.dep);
- if (cfg.isVue && cfg.isTs) pkg.devDependencies['vue-tsc'] = '^3.0.0';
+ if (cfg.isVue && cfg.isTs) pkg.devDependencies['vue-tsc'] = V['vue-tsc'];
if (cfg.hasApp) {
// Front-end SPA — not a published package.
@@ -48,8 +49,8 @@ export default {
].join('\n');
pkg.scripts.build = 'vite build';
pkg.scripts.dev = 'vite build --watch';
- pkg.devDependencies['vite-plugin-dts'] = '^5.0.0';
- if (cfg.isVue) pkg.devDependencies['vue-tsc'] = '^3.0.0';
+ pkg.devDependencies['vite-plugin-dts'] = V['vite-plugin-dts'];
+ if (cfg.isVue) pkg.devDependencies['vue-tsc'] = V['vue-tsc'];
// entry points
pkg.files = ['dist'];
pkg.type = 'module';
@@ -60,7 +61,7 @@ export default {
}
pkg.scripts.clean = 'rimraf dist';
- pkg.devDependencies.rimraf = '^6.0.0';
+ pkg.devDependencies.rimraf = V.rimraf;
return { files, pkg };
},
};
diff --git a/src/core/monorepo.js b/src/core/monorepo.js
index e2c9a5a..bd7f5f4 100644
--- a/src/core/monorepo.js
+++ b/src/core/monorepo.js
@@ -7,6 +7,7 @@ import community from './features/community.js';
import agents from './features/agents.js';
import gitfiles from './features/gitfiles.js';
import { provenance } from './provenance.js';
+import { V } from './versions.js';
export function buildMonorepo(cfg) {
// Two genuinely different shapes: a set of publishable libraries, or a
@@ -46,15 +47,15 @@ export function buildMonorepo(cfg) {
release: 'turbo build && changeset publish',
},
devDependencies: {
- turbo: '^2.0.0',
- typescript: '^5.9.3',
- tsup: '^8.0.0',
- vitest: '^4.0.0',
- eslint: '^10.0.0',
- '@eslint/js': '^10.0.0',
- 'typescript-eslint': '^8.0.0',
- prettier: '^3.3.0',
- '@changesets/cli': '^2.27.0',
+ turbo: V.turbo,
+ typescript: V.typescript,
+ tsup: V.tsup,
+ vitest: V.vitest,
+ eslint: V.eslint,
+ '@eslint/js': V['@eslint/js'],
+ 'typescript-eslint': V['typescript-eslint'],
+ prettier: V.prettier,
+ '@changesets/cli': V['@changesets/cli'],
'@types/node': `^${cfg.nodeVersion}.0.0`,
},
};
@@ -196,13 +197,13 @@ function buildFullstack(cfg) {
typecheck: 'turbo typecheck',
},
devDependencies: {
- turbo: '^2.0.0',
- typescript: '^5.9.3',
- vitest: '^4.0.0',
- eslint: '^10.0.0',
- '@eslint/js': '^10.0.0',
- 'typescript-eslint': '^8.0.0',
- prettier: '^3.3.0',
+ turbo: V.turbo,
+ typescript: V.typescript,
+ vitest: V.vitest,
+ eslint: V.eslint,
+ '@eslint/js': V['@eslint/js'],
+ 'typescript-eslint': V['typescript-eslint'],
+ prettier: V.prettier,
'@types/node': `^${cfg.nodeVersion}.0.0`,
},
});
@@ -270,7 +271,7 @@ function buildFullstack(cfg) {
typecheck: 'tsc --noEmit',
lint: 'eslint .',
},
- devDependencies: { tsup: '^8.0.0' },
+ devDependencies: { tsup: V.tsup },
});
files['packages/shared/tsconfig.json'] = toJson({ extends: '../../tsconfig.base.json', include: ['src'] });
files['packages/shared/src/index.ts'] = [
@@ -305,8 +306,8 @@ function buildFullstack(cfg) {
typecheck: 'tsc --noEmit',
lint: 'eslint .',
},
- dependencies: { hono: '^4.5.0', '@hono/node-server': '^2.0.0', [shared]: wsProto },
- devDependencies: { tsx: '^4.0.0', tsup: '^8.0.0' },
+ dependencies: { hono: V.hono, '@hono/node-server': V['@hono/node-server'], [shared]: wsProto },
+ devDependencies: { tsx: V.tsx, tsup: V.tsup },
});
files['apps/server/tsconfig.json'] = toJson({ extends: '../../tsconfig.base.json', include: ['src'] });
files['apps/server/src/app.ts'] = [
@@ -366,17 +367,17 @@ function buildFullstack(cfg) {
typecheck: 'tsc --noEmit',
lint: 'eslint .',
},
- dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0', [shared]: wsProto },
+ dependencies: { react: V.react, 'react-dom': V['react-dom'], [shared]: wsProto },
devDependencies: {
- vite: '^8.0.0',
- '@vitejs/plugin-react': '^6.0.0',
+ vite: V.vite,
+ '@vitejs/plugin-react': V['@vitejs/plugin-react'],
// Without the React types, `turbo typecheck` fails on the very first run
// with TS7016/TS7026 on every JSX element.
- '@types/react': '^19.0.0',
- '@types/react-dom': '^19.0.0',
- '@testing-library/react': '^16.0.0',
- '@testing-library/dom': '^10.0.0',
- jsdom: '^29.0.0',
+ '@types/react': V['@types/react'],
+ '@types/react-dom': V['@types/react-dom'],
+ '@testing-library/react': V['@testing-library/react'],
+ '@testing-library/dom': V['@testing-library/dom'],
+ jsdom: V.jsdom,
},
});
files['apps/web/tsconfig.json'] = toJson({
diff --git a/src/core/versions.js b/src/core/versions.js
new file mode 100644
index 0000000..a0d7972
--- /dev/null
+++ b/src/core/versions.js
@@ -0,0 +1,121 @@
+// The single source of truth for every dependency version Packkit writes into
+// generated projects. Feature modules reference these by name instead of
+// hard-coding a spec, so a bump happens in one place and the freshness workflow
+// (scripts/check-template-deps.mjs) has one authoritative list to check.
+//
+// A conformance test (test/versions.test.js) asserts that every version in the
+// generated output comes from this catalog, so nothing can drift back into a
+// feature module unnoticed.
+//
+// When you bump anything here, update LAST_REVIEWED.
+
+export const LAST_REVIEWED = '2026-07-26';
+
+// name → npm spec. Deps that ship at one version everywhere.
+export const V = {
+ // toolchain
+ typescript: '^5.9.3',
+ 'typescript-eslint': '^8.0.0',
+ eslint: '^10.0.0',
+ '@eslint/js': '^10.0.0',
+ prettier: '^3.3.0',
+ '@biomejs/biome': '^2.0.0',
+ oxlint: '^1.0.0',
+ turbo: '^2.0.0',
+ rimraf: '^6.0.0',
+ '@types/node': '^24.0.0',
+
+ // build
+ tsup: '^8.0.0',
+ tsdown: '^0.6.0',
+ unbuild: '^3.0.0',
+ rollup: '^4.0.0',
+ '@rollup/plugin-typescript': '^12.0.0',
+ '@rollup/plugin-terser': '^1.0.0',
+ tslib: '^2.6.0',
+ tsx: '^4.0.0',
+ vite: '^8.0.0',
+ 'vite-plugin-dts': '^5.0.0',
+
+ // test
+ vitest: '^4.0.0',
+ '@vitest/coverage-v8': '^4.0.0',
+ jsdom: '^29.0.0',
+ jest: '^30.0.0',
+ '@types/jest': '^30.0.0',
+ 'ts-jest': '^29.0.0',
+ supertest: '^7.0.0',
+ '@types/supertest': '^6.0.0',
+ '@playwright/test': '^1.50.0',
+
+ // release / package checks
+ '@changesets/cli': '^2.27.0',
+ 'release-it': '^21.0.0',
+ np: '^12.0.0',
+ publint: '^0.3.0',
+ '@arethetypeswrong/cli': '^0.18.0',
+ knip: '^5.0.0',
+ 'size-limit': '^11.0.0',
+ '@size-limit/preset-small-lib': '^11.0.0',
+
+ // git hooks
+ 'simple-git-hooks': '^2.11.0',
+ husky: '^9.1.0',
+ lefthook: '^2.0.0',
+ 'lint-staged': '^16.2.0',
+
+ // react
+ react: '^19.0.0',
+ 'react-dom': '^19.0.0',
+ '@types/react': '^19.0.0',
+ '@types/react-dom': '^19.0.0',
+ '@vitejs/plugin-react': '^6.0.0',
+ '@testing-library/react': '^16.0.0',
+ '@testing-library/dom': '^10.0.0',
+
+ // vue
+ vue: '^3.4.0',
+ 'vue-tsc': '^3.0.0',
+ '@vitejs/plugin-vue': '^6.0.0',
+ '@testing-library/vue': '^8.1.0',
+
+ // svelte
+ svelte: '^5.0.0',
+ 'svelte-check': '^4.0.0',
+ '@sveltejs/vite-plugin-svelte': '^7.0.0',
+ '@testing-library/svelte': '^5.2.0',
+
+ // storybook
+ storybook: '^10.0.0',
+ '@storybook/react': '^10.0.0',
+ '@storybook/react-vite': '^10.0.0',
+ '@storybook/vue3': '^10.0.0',
+ '@storybook/vue3-vite': '^10.0.0',
+ '@storybook/svelte': '^10.0.0',
+ '@storybook/svelte-vite': '^10.0.0',
+
+ // services
+ hono: '^4.5.0',
+ '@hono/node-server': '^2.0.0',
+ fastify: '^5.0.0',
+ express: '^5.0.0',
+ '@types/express': '^5.0.0',
+ zod: '^4.0.0',
+};
+
+// Peer-dependency ranges — deliberately looser than the direct-dependency spec,
+// so a component library declares broad compatibility rather than a hard pin.
+export const PEER = {
+ react: '>=18',
+ 'react-dom': '>=18',
+ vue: '>=3',
+ svelte: '>=5',
+};
+
+// Deps intentionally held below their latest major, with the reason. The
+// freshness check reads this so a held-back dep is reported, not failed.
+export const HELD = {
+ typescript: 'typescript-eslint peers typescript <6.1.0 (no TS 7 support yet)',
+ knip: 'v6 crashes on the oxc-parser native binding',
+ 'lint-staged': 'v17 requires Node >=22.22.1, above our Node 22 engines floor; hold at 16 to keep the Maintenance-LTS line working',
+};
diff --git a/test/versions.test.js b/test/versions.test.js
new file mode 100644
index 0000000..52a5489
--- /dev/null
+++ b/test/versions.test.js
@@ -0,0 +1,59 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { V, PEER } from '../src/core/versions.js';
+import { createProject } from '../src/embedded/index.js';
+import { PRESET_NAMES } from '../src/core/index.js';
+
+// Every dependency version Packkit writes must come from the catalog
+// (src/core/versions.js). This binds the catalog to reality: a literal that
+// drifts out of a feature module, or a dep at a spec the catalog doesn't list,
+// fails here — so the catalog stays the single source of truth even for any
+// version still written inline.
+
+const MATRIX = (() => {
+ const toggles = [{}, { storybook: true }, { knip: true }, { jsr: true }, { pkgChecks: true }, { sizeLimit: true }, { e2e: true }, { env: true }, { canary: true }, { doctor: true }, { minify: true }];
+ const bundlers = ['tsup', 'tsdown', 'unbuild', 'rollup', 'none'];
+ const tests = ['vitest', 'jest', 'node', 'none'];
+ const linters = ['eslint-prettier', 'biome', 'oxlint', 'none'];
+ const hooks = ['simple-git-hooks', 'husky', 'lefthook', 'none'];
+ const servers = ['hono', 'fastify', 'express'];
+ const configs = [];
+ for (const preset of PRESET_NAMES) {
+ for (const t of toggles) for (const bundler of bundlers) for (const test of tests) {
+ configs.push({ preset, overrides: { ...t, bundler, test } });
+ }
+ for (const lint of linters) for (const gitHooks of hooks) configs.push({ preset, overrides: { lint, gitHooks } });
+ }
+ for (const serviceFramework of servers) for (const language of ['ts', 'js']) {
+ configs.push({ preset: 'node-service', overrides: { serviceFramework, language, env: true } });
+ }
+ return configs;
+})();
+
+test('every generated dependency version comes from the catalog', () => {
+ const offenders = new Map(); // "name@spec" -> example
+ for (const { preset, overrides } of MATRIX) {
+ let project;
+ try {
+ project = createProject({ preset, name: 'x', overrides });
+ } catch {
+ continue;
+ }
+ for (const [path, content] of Object.entries(project.files)) {
+ if (!path.endsWith('package.json')) continue;
+ const pkg = JSON.parse(content);
+ for (const map of ['dependencies', 'devDependencies', 'peerDependencies']) {
+ for (const [name, spec] of Object.entries(pkg[map] || {})) {
+ if (spec.startsWith('workspace:')) continue; // monorepo internal refs
+ const known = spec === V[name] || spec === PEER[name];
+ if (!known) offenders.set(`${name}@${spec}`, `${preset} ${map}`);
+ }
+ }
+ }
+ }
+ assert.deepEqual(
+ [...offenders.keys()],
+ [],
+ `these versions are not in src/core/versions.js (add them or fix the feature):\n${[...offenders].map(([k, v]) => ` ${k} (${v})`).join('\n')}`,
+ );
+});