From fe00133a9acf0aa8daeb7b31916e670894bf6c94 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 26 Jul 2026 18:24:48 -0400 Subject: [PATCH] refactor: centralize template dependency versions (closes #19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template versions were hard-coded across 16 feature/monorepo modules, so a bump meant hunting through source and the freshness check duplicated the held-back list. They now live in one catalog, src/core/versions.js: V — dependency specs PEER — the looser peer-dependency ranges (react/vue/svelte) HELD — deps deliberately kept below latest major, with the reason LAST_REVIEWED Every feature module and monorepo.js references the catalog instead of a literal. Guarded two ways: - byte-identical output across 3740 configs (snapshot vs pre-refactor main); - a conformance test (test/versions.test.js) asserts every version in the generated output comes from the catalog — it caught a wrong ts-jest spec and two missing deps (supertest, @types/supertest) while being written. scripts/check-template-deps.mjs now imports HELD rather than keeping its own copy. Closes #19: release-it was a major behind (^20 → ^21). With the catalog that's a one-line change in versions.js — no feature edits — which is the whole point. Freshness check is green again. 97 tests pass, lint clean, web configurator verified. Co-Authored-By: Claude Opus 4.8 --- ROADMAP.md | 2 +- docs/packkit-core.js | 285 +++++++++++++++++++++----------- scripts/check-template-deps.mjs | 10 +- src/core/features/bundler.js | 15 +- src/core/features/checks.js | 7 +- src/core/features/env.js | 3 +- src/core/features/frameworks.js | 29 ++-- src/core/features/githooks.js | 9 +- src/core/features/lint.js | 13 +- src/core/features/release.js | 7 +- src/core/features/service.js | 11 +- src/core/features/sizelimit.js | 5 +- src/core/features/storybook.js | 9 +- src/core/features/test.js | 27 +-- src/core/features/typescript.js | 3 +- src/core/features/vite.js | 17 +- src/core/monorepo.js | 55 +++--- src/core/versions.js | 121 ++++++++++++++ test/versions.test.js | 59 +++++++ 19 files changed, 484 insertions(+), 203 deletions(-) create mode 100644 src/core/versions.js create mode 100644 test/versions.test.js 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>`, ``, ``, ``].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, ``, ``, ``, ``].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')}`, + ); +});