create-packkit 2.9.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,22 @@ npx create-packkit ts-lib my-lib --here --merge
61
61
 
62
62
  **Existing files are never overwritten.** Anything that collides is left alone and reported, so you can diff at your leisure. (A directory containing only `.git` counts as empty — a fresh clone scaffolds without needing `--merge` at all.)
63
63
 
64
+ ## Keep a project current
65
+
66
+ Every scaffolded project records what it came from in `packkit.json`. Later, from
67
+ inside the project:
68
+
69
+ ```sh
70
+ npx create-packkit upgrade # dry run: what's changed since you scaffolded
71
+ npx create-packkit upgrade --apply # add new files, bump deps, keep your edits
72
+ ```
73
+
74
+ Upgrade regenerates the project Packkit would produce today and reports the
75
+ difference — new files, added or bumped dependencies, new scripts. `--apply`
76
+ brings in the safe changes (your own files, deps, and scripts are preserved);
77
+ files you've edited are listed for review and never overwritten unless you pass
78
+ `--force`.
79
+
64
80
  ## Or configure it on the web
65
81
 
66
82
  No install needed: **[danmat.github.io/create-packkit](https://danmat.github.io/create-packkit/)** — tick the options, preview the file tree, and **download a zip** (or copy the equivalent `npx create-packkit` command). Everything runs in your browser.
@@ -193,14 +209,32 @@ There's also an [`llms.txt`](llms.txt) (served at [danmat.github.io/create-packk
193
209
  { "mcpServers": { "packkit": { "command": "npx", "args": ["-y", "packkit-mcp"] } } }
194
210
  ```
195
211
 
212
+ ## Embed Packkit in your own app
213
+
214
+ Packkit ships a typed, side-effect-free API so a Node application can use it as a
215
+ project-generation engine — generate in memory, add your own deployment files,
216
+ and write to disk when you're ready. No prompts, installs, git, or network.
217
+
218
+ ```js
219
+ import { createProject, extendProject, writeGeneratedProject } from 'create-packkit/embedded';
220
+
221
+ const project = createProject({ preset: 'react-app', name: 'weather-dashboard' });
222
+ const extended = extendProject(project, { files: { '.github/workflows/deploy.yml': deployYaml } });
223
+ await writeGeneratedProject({ project: extended, destination: '/tmp/weather-dashboard' });
224
+ ```
225
+
226
+ Full guide, including diagnostics, reproducible definitions, digests, and the
227
+ provider-neutral deployment contract: **[Embedding Packkit](docs/EMBEDDING.md)**.
228
+
196
229
  ## How it works
197
230
 
198
231
  Packkit is a pure `config → { files }` **core** that runs in both Node and the browser:
199
232
 
200
233
  - the **CLI** writes the files to disk, runs `git init`, and installs dependencies;
201
- - the **web configurator** zips the same files client-side (no server).
234
+ - the **web configurator** zips the same files client-side (no server);
235
+ - the **embedded API** ([`create-packkit/embedded`](docs/EMBEDDING.md)) hands the file map to a host application.
202
236
 
203
- Both drive from one options schema ([`src/core/options.js`](src/core/options.js)), so the CLI and the web page always stay in sync.
237
+ All three drive from one options schema ([`src/core/options.js`](src/core/options.js)), so they always stay in sync.
204
238
 
205
239
  ## Staying fresh
206
240
 
package/package.json CHANGED
@@ -1,20 +1,34 @@
1
1
  {
2
2
  "name": "create-packkit",
3
- "version": "2.9.0",
3
+ "version": "3.1.0",
4
4
  "description": "Highly configurable scaffolder for modern npm packages and CLIs — pick your stack (TS/JS, bundler, tests, linter, CI, releases) from a CLI or a web configurator.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-packkit": "bin/cli.js"
8
8
  },
9
+ "types": "./types/index.d.ts",
9
10
  "exports": {
10
- ".": "./src/core/index.js",
11
+ ".": {
12
+ "types": "./types/index.d.ts",
13
+ "default": "./src/index.js"
14
+ },
11
15
  "./core": "./src/core/index.js",
16
+ "./embedded": {
17
+ "types": "./types/embedded.d.ts",
18
+ "default": "./src/embedded/index.js"
19
+ },
20
+ "./writer": {
21
+ "types": "./types/writer.d.ts",
22
+ "default": "./src/embedded/writer.js"
23
+ },
12
24
  "./cli": "./src/cli/index.js",
13
- "./scaffold": "./src/cli/write.js"
25
+ "./scaffold": "./src/cli/write.js",
26
+ "./package.json": "./package.json"
14
27
  },
15
28
  "files": [
16
29
  "bin",
17
30
  "src",
31
+ "types",
18
32
  "README.md",
19
33
  "LICENSE",
20
34
  "llms.txt"
@@ -25,6 +39,8 @@
25
39
  "scripts": {
26
40
  "start": "node bin/cli.js",
27
41
  "test": "node --test",
42
+ "lint": "eslint .",
43
+ "check": "npm run lint && npm test",
28
44
  "check:deps": "node scripts/check-template-deps.mjs",
29
45
  "integration": "node scripts/integration.mjs",
30
46
  "build:web": "esbuild src/core/index.js --bundle --format=esm --outfile=docs/packkit-core.js",
@@ -60,6 +76,9 @@
60
76
  "@clack/prompts": "^0.7.0"
61
77
  },
62
78
  "devDependencies": {
63
- "esbuild": "^0.23.0"
79
+ "@eslint/js": "^10.0.1",
80
+ "esbuild": "^0.23.0",
81
+ "eslint": "^10.8.0",
82
+ "globals": "^17.8.0"
64
83
  }
65
84
  }
package/src/cli/args.js CHANGED
@@ -53,7 +53,6 @@ export function parseCliArgs(argv) {
53
53
  minify: { type: 'boolean' },
54
54
  target: { type: 'string', multiple: true },
55
55
  workflows: { type: 'string', multiple: true },
56
- minify: { type: 'boolean' },
57
56
  monorepo: { type: 'boolean' },
58
57
  storybook: { type: 'boolean' },
59
58
  e2e: { type: 'boolean' },
package/src/cli/index.js CHANGED
@@ -2,12 +2,17 @@ import { resolve, basename } from 'node:path';
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import * as p from '@clack/prompts';
5
- import { generate, fromPreset, normalizeConfig, PRESET_NAMES, OPTIONS, OPTION_HELP, PRESET_INFO, PRESET_ALIASES } from '../core/index.js';
5
+ import { PRESET_NAMES, OPTIONS, OPTION_HELP, PRESET_INFO, PRESET_ALIASES } from '../core/index.js';
6
6
  import { engineFloor, meetsNodeFloor } from '../core/node.js';
7
+ // The CLI is an adapter over the embedded API: it resolves + generates through
8
+ // the same pipeline every other surface uses (so it shares diagnostics,
9
+ // collision handling, and path safety), and adds the side effects the embedded
10
+ // API deliberately never performs — git, install, and creating the remote.
11
+ import { resolveProjectConfig, createProjectFromResolvedConfig, PackkitValidationError } from '../embedded/index.js';
12
+ import { writeGeneratedProject } from '../embedded/writer.js';
7
13
  import { parseCliArgs } from './args.js';
8
14
  import { runWizard } from './wizard.js';
9
15
  import {
10
- writeProject,
11
16
  existingEntries,
12
17
  gitInit,
13
18
  installDeps,
@@ -34,6 +39,7 @@ packkit — scaffold a modern npm package, CLI, service, or app
34
39
  Usage:
35
40
  npm create packkit@latest [name] [options]
36
41
  npx packkit [preset] [name] [options]
42
+ npx packkit upgrade [dir] Pull a scaffolded project up to current templates
37
43
 
38
44
  Run with no options for an interactive wizard, or add -y for recommended
39
45
  defaults in one shot. Every option is documented (with why-you'd-use-it) at:
@@ -109,6 +115,13 @@ Examples:
109
115
  `;
110
116
 
111
117
  export async function run(argv = process.argv.slice(2)) {
118
+ // Subcommands take the first positional. `upgrade` is a distinct flow (it
119
+ // reads an existing project rather than scaffolding one), so route it early.
120
+ if (argv[0] === 'upgrade') {
121
+ const { runUpgrade } = await import('./upgrade.js');
122
+ return runUpgrade(argv.slice(1));
123
+ }
124
+
112
125
  const args = parseCliArgs(argv);
113
126
  if (args.help) return void console.log(HELP);
114
127
  if (args.version) return void console.log(pkgVersion());
@@ -125,21 +138,33 @@ export async function run(argv = process.argv.slice(2)) {
125
138
  const profile = loadProfile(args);
126
139
  const seed = { ...profile, ...args.overrides };
127
140
 
128
- let config;
141
+ // Gather raw input for the embedded resolver — don't normalize here, so the
142
+ // resolver captures the coercions it makes (they surface as warnings below).
143
+ let rawInput;
144
+ let presetName;
129
145
  if (interactive) {
130
146
  p.intro('📦 Packkit');
131
- config = normalizeConfig(await runWizard(seed));
147
+ rawInput = await runWizard(seed);
132
148
  } else if (args.preset) {
133
- config = fromPreset(args.preset, seed);
149
+ presetName = args.preset;
150
+ rawInput = seed;
134
151
  } else {
135
- config = normalizeConfig(seed);
152
+ rawInput = seed;
136
153
  }
154
+ // gitInit/install are real options, so they flow through resolution.
155
+ rawInput = { ...rawInput, gitInit: args.git, install: args.install };
137
156
 
138
- config.gitInit = args.git;
139
- config.install = args.install;
140
- // Core is version-agnostic (it also runs in the browser), so the surface
141
- // that knows the version supplies it for packkit.json.
142
- config.generatorVersion = pkgVersion();
157
+ let config;
158
+ let diagnostics;
159
+ try {
160
+ ({ config, diagnostics } = resolveProjectConfig({ preset: presetName, config: rawInput }));
161
+ } catch (err) {
162
+ if (err instanceof PackkitValidationError) {
163
+ console.error(err.diagnostics.map((d) => `✖ ${d.message}`).join('\n'));
164
+ process.exit(1);
165
+ }
166
+ throw err;
167
+ }
143
168
 
144
169
  // Node preflight: the generated project's tools (eslint, vite, vitest) hard-
145
170
  // require this floor. npm only *warns* on engines, so catch it here — clearly,
@@ -186,9 +211,25 @@ export async function run(argv = process.argv.slice(2)) {
186
211
  }
187
212
  if (remote?.url && /^https?:/.test(remote.url)) config.repo = remote.url;
188
213
 
189
- // Generate + write.
190
- const { files, summary } = generate(config);
191
- const { skipped } = await writeProject(targetDir, files, { merge: args.merge });
214
+ // Generate through the embedded pipeline (carrying the resolution diagnostics)
215
+ // and write through the safe writer. --merge maps to the 'skip' policy: write
216
+ // what's absent, keep what's there never overwrite.
217
+ const project = createProjectFromResolvedConfig(config, { diagnostics });
218
+ const { summary } = project;
219
+
220
+ // Surface anything Packkit changed or flagged during resolution — a coercion
221
+ // (Storybook off for a service), an unknown option — before writing.
222
+ const warnings = project.diagnostics.filter((d) => d.severity === 'warning');
223
+ if (warnings.length) {
224
+ const lines = warnings.map((d) => d.message).join('\n');
225
+ if (interactive) p.log.warn(lines); else console.error('\n⚠ ' + warnings.map((d) => d.message).join('\n '));
226
+ }
227
+
228
+ const { skippedFiles: skipped } = await writeGeneratedProject({
229
+ project,
230
+ destination: targetDir,
231
+ collisionPolicy: args.merge ? 'skip' : 'error',
232
+ });
192
233
 
193
234
  // Post steps.
194
235
  if (config.gitInit) gitInit(targetDir);
@@ -0,0 +1,147 @@
1
+ // `packkit upgrade` — regenerate a scaffolded project's current recommended
2
+ // output and report (or apply) what has drifted from Packkit's templates.
3
+ //
4
+ // It reads packkit.json to learn the preset + settings the project came from,
5
+ // regenerates in memory through the embedded API, and diffs against disk. New
6
+ // files and package.json dep/script updates apply cleanly; files that differ
7
+ // are surfaced for review, never overwritten unless explicitly forced.
8
+
9
+ import { parseArgs } from 'node:util';
10
+ import { readFileSync, existsSync } from 'node:fs';
11
+ import { resolve, join } from 'node:path';
12
+ import { createProject, planUpgrade, isUpgradeEmpty, buildUpgradeWrite } from '../embedded/index.js';
13
+ import { writeGeneratedProject } from '../embedded/writer.js';
14
+
15
+ const UPGRADE_HELP = `
16
+ packkit upgrade — pull your project up to Packkit's current templates
17
+
18
+ Usage:
19
+ packkit upgrade [directory] [options]
20
+
21
+ Reads packkit.json in the directory (default: current), regenerates the project
22
+ Packkit would produce today, and shows what changed since you scaffolded.
23
+
24
+ Options:
25
+ --apply Write new files and update package.json / packkit.json
26
+ --force With --apply, also overwrite files that differ (review first!)
27
+ -h, --help Show this help
28
+
29
+ Without --apply this is a dry run — it only reports.
30
+ `;
31
+
32
+ export async function runUpgrade(argv) {
33
+ const { values, positionals } = parseArgs({
34
+ args: argv,
35
+ allowPositionals: true,
36
+ options: {
37
+ apply: { type: 'boolean' },
38
+ force: { type: 'boolean' },
39
+ help: { type: 'boolean', short: 'h' },
40
+ },
41
+ });
42
+ if (values.help) return void console.log(UPGRADE_HELP);
43
+
44
+ const dir = resolve(positionals[0] || '.');
45
+ const provPath = join(dir, 'packkit.json');
46
+ if (!existsSync(provPath)) {
47
+ console.error(`No packkit.json in "${dir}". Upgrade only works on a project Packkit scaffolded.`);
48
+ process.exit(1);
49
+ }
50
+
51
+ let provenance;
52
+ try {
53
+ provenance = JSON.parse(readFileSync(provPath, 'utf8'));
54
+ } catch (err) {
55
+ console.error(`Could not read packkit.json: ${err.message}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ // The project name isn't a "setting", so it lives in package.json, not
60
+ // packkit.json — read it back so regeneration reproduces the same names.
61
+ const name = readName(dir) || provenance.name || 'my-package';
62
+ const fromVersion = provenance.version || 'unknown';
63
+
64
+ let project;
65
+ try {
66
+ project = createProject({ preset: provenance.preset, name, config: provenance.settings || {} });
67
+ } catch (err) {
68
+ console.error(`Could not regenerate from packkit.json: ${err.message}`);
69
+ process.exit(1);
70
+ }
71
+ const toVersion = project.metadata.packkitVersion;
72
+
73
+ const onDisk = {};
74
+ for (const path of Object.keys(project.files)) {
75
+ const full = join(dir, path);
76
+ onDisk[path] = existsSync(full) ? readFileSync(full, 'utf8') : undefined;
77
+ }
78
+
79
+ const plan = planUpgrade({ generated: project.files, onDisk });
80
+
81
+ if (isUpgradeEmpty(plan)) {
82
+ console.log(`Already current with Packkit ${toVersion}. Nothing to upgrade.`);
83
+ return;
84
+ }
85
+
86
+ report(plan, fromVersion, toVersion);
87
+
88
+ if (!values.apply) {
89
+ console.log('\nThis was a dry run. Re-run with --apply to bring in the safe changes.');
90
+ return;
91
+ }
92
+
93
+ const writeMap = buildUpgradeWrite({ generated: project.files, onDisk, plan, includeChanged: !!values.force });
94
+ const paths = Object.keys(writeMap);
95
+ if (paths.length) {
96
+ await writeGeneratedProject({
97
+ project: { config: project.config, files: writeMap },
98
+ destination: dir,
99
+ collisionPolicy: 'overwrite',
100
+ });
101
+ console.log(`\n✓ Applied ${paths.length} file update${paths.length > 1 ? 's' : ''}.`);
102
+ }
103
+ const leftover = values.force ? [] : plan.files.changed;
104
+ if (leftover.length) {
105
+ const plural = leftover.length > 1;
106
+ console.log(
107
+ `\n${leftover.length} file${plural ? 's' : ''} ${plural ? 'differ' : 'differs'} from the current template and ` +
108
+ `${plural ? 'were' : 'was'} left untouched (they may be your edits):\n ` +
109
+ leftover.join('\n ') +
110
+ `\nReview them, then re-run with --force to overwrite the ones you want Packkit's version of.`,
111
+ );
112
+ }
113
+ }
114
+
115
+ function readName(dir) {
116
+ try {
117
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function report(plan, fromVersion, toVersion) {
124
+ console.log(`Packkit ${fromVersion} → ${toVersion}\n`);
125
+ const p = plan.packageJson;
126
+
127
+ if (plan.files.added.length) {
128
+ console.log(`New files (${plan.files.added.length}):\n ` + plan.files.added.join('\n '));
129
+ }
130
+ const addedDeps = Object.entries(p.addedDependencies);
131
+ const bumpedDeps = Object.entries(p.updatedDependencies);
132
+ if (addedDeps.length) {
133
+ console.log(`\nNew dependencies (${addedDeps.length}):\n ` + addedDeps.map(([n, d]) => `${n}@${d.version} (${d.map})`).join('\n '));
134
+ }
135
+ if (bumpedDeps.length) {
136
+ console.log(`\nDependency updates (${bumpedDeps.length}):\n ` + bumpedDeps.map(([n, d]) => `${n}: ${d.from} → ${d.to}`).join('\n '));
137
+ }
138
+ const addedScripts = Object.keys(p.addedScripts);
139
+ const changedScripts = Object.entries(p.changedScripts);
140
+ if (addedScripts.length) console.log(`\nNew scripts (${addedScripts.length}):\n ` + addedScripts.join('\n '));
141
+ if (changedScripts.length) {
142
+ console.log(`\nScript changes (${changedScripts.length}):\n ` + changedScripts.map(([n, d]) => `${n}: ${d.from} → ${d.to}`).join('\n '));
143
+ }
144
+ if (plan.files.changed.length) {
145
+ console.log(`\nFiles that differ — review before overwriting (${plan.files.changed.length}):\n ` + plan.files.changed.join('\n '));
146
+ }
147
+ }
@@ -1,3 +1,4 @@
1
+ import { V } from '../versions.js';
1
2
  // Always-on. Owns the package entry points (exports/main/module/types/files)
2
3
  // and the build tooling for the chosen bundler.
3
4
 
@@ -48,24 +49,24 @@ export default {
48
49
  files[`${tool}.config.${cfg.ext}`] = tsupConfig(cfg, entries, formats, tool);
49
50
  pkg.scripts.build = tool;
50
51
  pkg.scripts.dev = `${tool} --watch`;
51
- pkg.devDependencies = { [tool]: tool === 'tsup' ? '^8.0.0' : '^0.6.0' };
52
+ pkg.devDependencies = { [tool]: tool === 'tsup' ? V.tsup : V.tsdown };
52
53
  // tsup/tsdown resolve `typescript` even for plain-JS builds; TS projects
53
54
  // already get it from the typescript feature.
54
- if (!cfg.isTs) pkg.devDependencies.typescript = '^5.9.3';
55
+ if (!cfg.isTs) pkg.devDependencies.typescript = V.typescript;
55
56
  } else if (cfg.bundler === 'unbuild') {
56
57
  files['build.config.ts'] = unbuildConfig(cfg);
57
58
  pkg.scripts.build = 'unbuild';
58
59
  pkg.scripts.dev = 'unbuild --stub';
59
- pkg.devDependencies = { unbuild: '^3.0.0' };
60
+ pkg.devDependencies = { unbuild: V.unbuild };
60
61
  } else if (cfg.bundler === 'rollup') {
61
62
  // Always a .js config — rollup can't load a .ts config without a loader.
62
63
  files['rollup.config.js'] = rollupConfig(cfg, formats);
63
64
  pkg.scripts.build = 'rollup -c';
64
65
  pkg.scripts.dev = 'rollup -c -w';
65
66
  pkg.devDependencies = {
66
- rollup: '^4.0.0',
67
- ...(cfg.isTs ? { '@rollup/plugin-typescript': '^12.0.0', tslib: '^2.6.0' } : {}),
68
- ...(cfg.minify ? { '@rollup/plugin-terser': '^1.0.0' } : {}),
67
+ rollup: V.rollup,
68
+ ...(cfg.isTs ? { '@rollup/plugin-typescript': V['@rollup/plugin-typescript'], tslib: V.tslib } : {}),
69
+ ...(cfg.minify ? { '@rollup/plugin-terser': V['@rollup/plugin-terser'] } : {}),
69
70
  };
70
71
  } else if (cfg.bundler === 'none' && cfg.isTs) {
71
72
  // tsc-only build for TypeScript.
@@ -73,9 +74,14 @@ export default {
73
74
  pkg.scripts.dev = 'tsc --watch';
74
75
  }
75
76
 
77
+ // A service owns its `dev` script (tsx/node --watch on the server entry); the
78
+ // bundler only builds it. Don't also contribute a `dev` — that was a silent
79
+ // conflict the two features "resolved" purely by array order.
80
+ if (cfg.hasService) delete pkg.scripts.dev;
81
+
76
82
  if (build) pkg.scripts.prepublishOnly = pkg.scripts.build;
77
83
  pkg.scripts.clean = 'rimraf dist';
78
- if (build) pkg.devDependencies = { ...pkg.devDependencies, rimraf: '^6.0.0' };
84
+ if (build) pkg.devDependencies = { ...pkg.devDependencies, rimraf: V.rimraf };
79
85
 
80
86
  return { files, pkg };
81
87
  },
@@ -1,3 +1,4 @@
1
+ import { V } from '../versions.js';
1
2
  // Package-correctness + hygiene checks (opt-in):
2
3
  // - publint validates package.json shape (exports/main/module)
3
4
  // - are-the-types-wrong verifies TS consumers resolve your .d.ts correctly
@@ -14,12 +15,12 @@ export default {
14
15
  // node16 profile flags a "false" failure — use the esm-only profile.
15
16
  const attw = cfg.moduleFormat === 'esm' ? 'attw --pack --profile esm-only' : 'attw --pack';
16
17
  pkg.scripts['check:pkg'] = `publint && ${attw}`;
17
- pkg.devDependencies.publint = '^0.3.0';
18
- pkg.devDependencies['@arethetypeswrong/cli'] = '^0.18.0';
18
+ pkg.devDependencies.publint = V.publint;
19
+ pkg.devDependencies['@arethetypeswrong/cli'] = V['@arethetypeswrong/cli'];
19
20
  }
20
21
  if (cfg.knip) {
21
22
  pkg.scripts.knip = 'knip';
22
- pkg.devDependencies.knip = '^5.0.0';
23
+ pkg.devDependencies.knip = V.knip;
23
24
  }
24
25
 
25
26
  return { files: {}, pkg };
@@ -1,3 +1,4 @@
1
+ import { V } from '../versions.js';
1
2
  // Type-safe environment-variable validation for server-side runtimes (services
2
3
  // and CLIs). Validates process.env once at startup with a Zod schema, so a
3
4
  // misconfigured deploy fails fast with a clear message instead of a surprise
@@ -12,7 +13,7 @@ export default {
12
13
  files[`src/env.${cfg.ext}`] = cfg.isTs ? envTs() : envJs();
13
14
  files['.env.example'] = ['NODE_ENV=development', 'PORT=3000', ''].join('\n');
14
15
 
15
- return { files, pkg: { dependencies: { zod: '^4.0.0' } } };
16
+ return { files, pkg: { dependencies: { zod: V.zod } } };
16
17
  },
17
18
  };
18
19
 
@@ -1,3 +1,4 @@
1
+ import { V, PEER } from '../versions.js';
1
2
  // React / Vue / Svelte source + dependencies, for both component libraries and
2
3
  // Vite apps. The Vite build wiring lives in vite.js; here we emit the source
3
4
  // files, the runtime deps (peer for libs, direct for apps), and — for Svelte
@@ -40,7 +41,7 @@ function react(cfg, files, pkg, forApp) {
40
41
  `}`,
41
42
  ``,
42
43
  ].join('\n');
43
- pkg.dependencies = { react: '^19.0.0', 'react-dom': '^19.0.0' };
44
+ pkg.dependencies = { react: V.react, 'react-dom': V['react-dom'] };
44
45
  } else {
45
46
  files[`src/index.${x}`] = cfg.isTs
46
47
  ? [
@@ -55,13 +56,13 @@ function react(cfg, files, pkg, forApp) {
55
56
  ``,
56
57
  ].join('\n')
57
58
  : [`export function Button({ label, onClick }) {`, `\treturn <button onClick={onClick}>{label}</button>;`, `}`, ``].join('\n');
58
- pkg.peerDependencies = { react: '>=18', 'react-dom': '>=18' };
59
- pkg.devDependencies.react = '^19.0.0';
60
- pkg.devDependencies['react-dom'] = '^19.0.0';
59
+ pkg.peerDependencies = { react: PEER.react, 'react-dom': PEER['react-dom'] };
60
+ pkg.devDependencies.react = V.react;
61
+ pkg.devDependencies['react-dom'] = V['react-dom'];
61
62
  }
62
63
  if (cfg.isTs) {
63
- pkg.devDependencies['@types/react'] = '^19.0.0';
64
- pkg.devDependencies['@types/react-dom'] = '^19.0.0';
64
+ pkg.devDependencies['@types/react'] = V['@types/react'];
65
+ pkg.devDependencies['@types/react-dom'] = V['@types/react-dom'];
65
66
  }
66
67
  }
67
68
 
@@ -78,7 +79,7 @@ function vue(cfg, files, pkg, forApp) {
78
79
  ``,
79
80
  ].join('\n');
80
81
  files['src/App.vue'] = [script, `</script>`, ``, `<template>`, `\t<h1>Hello from ${cfg.name}</h1>`, `</template>`, ``].join('\n');
81
- pkg.dependencies = { vue: '^3.4.0' };
82
+ pkg.dependencies = { vue: V.vue };
82
83
  } else {
83
84
  files[`src/index.${cfg.ext}`] = `export { default as Button } from './Button.vue';\n`;
84
85
  files['src/Button.vue'] = [
@@ -91,8 +92,8 @@ function vue(cfg, files, pkg, forApp) {
91
92
  `</template>`,
92
93
  ``,
93
94
  ].join('\n');
94
- pkg.peerDependencies = { vue: '>=3' };
95
- pkg.devDependencies.vue = '^3.4.0';
95
+ pkg.peerDependencies = { vue: PEER.vue };
96
+ pkg.devDependencies.vue = V.vue;
96
97
  }
97
98
  }
98
99
 
@@ -101,8 +102,8 @@ function svelte(cfg, files, pkg, forApp) {
101
102
  const script = cfg.isTs ? `<script lang="ts">` : `<script>`;
102
103
  // Shared by the Vite app build and the Vitest (test) config; enables TS in SFCs.
103
104
  files['svelte.config.js'] = `import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';\n\nexport default { preprocess: vitePreprocess() };\n`;
104
- pkg.devDependencies['@sveltejs/vite-plugin-svelte'] = '^7.0.0';
105
- if (cfg.isTs) pkg.devDependencies['svelte-check'] = '^4.0.0';
105
+ pkg.devDependencies['@sveltejs/vite-plugin-svelte'] = V['@sveltejs/vite-plugin-svelte'];
106
+ if (cfg.isTs) pkg.devDependencies['svelte-check'] = V['svelte-check'];
106
107
  if (forApp) {
107
108
  files['index.html'] = htmlShell(cfg, `/src/main.${cfg.ext}`);
108
109
  files[`src/main.${cfg.ext}`] = [
@@ -114,7 +115,7 @@ function svelte(cfg, files, pkg, forApp) {
114
115
  ``,
115
116
  ].join('\n');
116
117
  files['src/App.svelte'] = [script, `</script>`, ``, `<h1>Hello from ${cfg.name}</h1>`, ``].join('\n');
117
- pkg.dependencies = { svelte: '^5.0.0' };
118
+ pkg.dependencies = { svelte: V.svelte };
118
119
  } else {
119
120
  // Svelte libraries ship uncompiled source; the consumer's bundler compiles.
120
121
  files[`src/index.${cfg.ext}`] = `export { default as Button } from './Button.svelte';\n`;
@@ -127,8 +128,8 @@ function svelte(cfg, files, pkg, forApp) {
127
128
  `<button>{label}</button>`,
128
129
  ``,
129
130
  ].filter((l) => l !== ``).join('\n') + '\n';
130
- pkg.peerDependencies = { svelte: '>=5' };
131
- pkg.devDependencies.svelte = '^5.0.0';
131
+ pkg.peerDependencies = { svelte: PEER.svelte };
132
+ pkg.devDependencies.svelte = V.svelte;
132
133
  // Ship source; point consumers at it.
133
134
  pkg.svelte = `./src/index.${cfg.ext}`;
134
135
  pkg.exports = { '.': { svelte: `./src/index.${cfg.ext}`, default: `./src/index.${cfg.ext}` } };
@@ -1,3 +1,4 @@
1
+ import { V } from '../versions.js';
1
2
  export default {
2
3
  id: 'githooks',
3
4
  active: (cfg) => cfg.gitHooks !== 'none',
@@ -21,31 +22,31 @@ export default {
21
22
  if (cfg.gitHooks === 'simple-git-hooks') {
22
23
  pkg['simple-git-hooks'] = { 'pre-commit': needsLintStaged ? 'npx lint-staged' : 'npm test' };
23
24
  pkg.scripts.prepare = 'simple-git-hooks';
24
- pkg.devDependencies['simple-git-hooks'] = '^2.11.0';
25
+ pkg.devDependencies['simple-git-hooks'] = V['simple-git-hooks'];
25
26
  } else if (cfg.gitHooks === 'husky') {
26
27
  files['.husky/pre-commit'] = (needsLintStaged ? 'npx lint-staged\n' : 'npm test\n');
27
28
  pkg.scripts.prepare = 'husky';
28
- pkg.devDependencies.husky = '^9.1.0';
29
+ pkg.devDependencies.husky = V.husky;
29
30
  } else if (cfg.gitHooks === 'lefthook') {
30
- files['lefthook.yml'] = lefthookYml(cfg, staged);
31
+ files['lefthook.yml'] = lefthookYml(cfg);
31
32
  // `|| true` so `npm install` doesn't fail before `git init` — lefthook
32
33
  // (unlike husky/simple-git-hooks) errors hard without a .git directory.
33
34
  pkg.scripts.prepare = 'lefthook install || true';
34
- pkg.devDependencies.lefthook = '^2.0.0';
35
+ pkg.devDependencies.lefthook = V.lefthook;
35
36
  }
36
37
 
37
38
  if (needsLintStaged) {
38
39
  pkg['lint-staged'] = staged;
39
40
  // Held at 16: v17 requires Node >=22.22.1, above our Node 22 engines
40
41
  // floor (22.13) — it would break the Maintenance-LTS line. See ENGINE_MIN.
41
- pkg.devDependencies['lint-staged'] = '^16.2.0';
42
+ pkg.devDependencies['lint-staged'] = V['lint-staged'];
42
43
  }
43
44
 
44
45
  return { files, pkg };
45
46
  },
46
47
  };
47
48
 
48
- function lefthookYml(cfg, staged) {
49
+ function lefthookYml(cfg) {
49
50
  const cmd =
50
51
  cfg.lint === 'biome'
51
52
  ? 'biome check --write --no-errors-on-unmatched {staged_files}'
@@ -1,5 +1,12 @@
1
1
  // Feature registry. Order matters only for how package.json fragments merge
2
2
  // (later features deep-merge over earlier ones); files are keyed by path.
3
+ //
4
+ // Ordering contract: no two features may own the same file path or the same
5
+ // package.json field for a given config. That invariant is enforced by
6
+ // test/feature-registry.test.js across the whole config matrix, so ordering is
7
+ // never load-bearing for correctness — if a new feature collides with an
8
+ // existing one, the test fails rather than the winner being decided by position
9
+ // here. Each feature declares a unique `id`, surfaced in collision diagnostics.
3
10
 
4
11
  import meta from './meta.js';
5
12
  import bundler from './bundler.js';
@@ -1,4 +1,5 @@
1
1
  import { toJson } from '../render.js';
2
+ import { V } from '../versions.js';
2
3
 
3
4
  export default {
4
5
  id: 'lint',
@@ -19,19 +20,19 @@ export default {
19
20
  files['.prettierignore'] = 'dist\ncoverage\n';
20
21
  pkg.scripts.format = 'prettier --write .';
21
22
  pkg.scripts['format:check'] = 'prettier --check .';
22
- pkg.devDependencies.prettier = '^3.3.0';
23
+ pkg.devDependencies.prettier = V.prettier;
23
24
  }
24
25
 
25
26
  if (cfg.lint === 'eslint-prettier') {
26
27
  files['eslint.config.js'] = eslintFlatConfig(cfg);
27
28
  pkg.scripts.lint = 'eslint .';
28
29
  pkg.scripts['lint:fix'] = 'eslint . --fix';
29
- pkg.devDependencies.eslint = '^10.0.0';
30
- pkg.devDependencies['@eslint/js'] = '^10.0.0';
31
- if (cfg.isTs) pkg.devDependencies['typescript-eslint'] = '^8.0.0';
30
+ pkg.devDependencies.eslint = V.eslint;
31
+ pkg.devDependencies['@eslint/js'] = V['@eslint/js'];
32
+ if (cfg.isTs) pkg.devDependencies['typescript-eslint'] = V['typescript-eslint'];
32
33
  } else if (cfg.lint === 'oxlint') {
33
34
  pkg.scripts.lint = 'oxlint';
34
- pkg.devDependencies.oxlint = '^1.0.0';
35
+ pkg.devDependencies.oxlint = V.oxlint;
35
36
  } else if (cfg.lint === 'biome') {
36
37
  files['biome.json'] = toJson({
37
38
  $schema: 'https://biomejs.dev/schemas/2.1.2/schema.json',
@@ -42,7 +43,7 @@ export default {
42
43
  pkg.scripts.lint = 'biome lint .';
43
44
  pkg.scripts['lint:fix'] = 'biome lint --write .';
44
45
  pkg.scripts.format = 'biome format --write .';
45
- pkg.devDependencies['@biomejs/biome'] = '^2.0.0';
46
+ pkg.devDependencies['@biomejs/biome'] = V['@biomejs/biome'];
46
47
  }
47
48
 
48
49
  return { files, pkg };