create-packkit 2.7.2 → 2.9.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
@@ -31,6 +31,36 @@ npx create-packkit --preset full my-pkg --pm pnpm
31
31
 
32
32
  Then `cd`, and you already have a working project — `build`, `test`, and `lint` all pass out of the box.
33
33
 
34
+ ## Create the repo, not just the folder
35
+
36
+ Packkit can create the remote and push the first commit, so you don't have to make an empty repo in a browser first:
37
+
38
+ ```sh
39
+ # create it on GitHub (private) and push
40
+ npx create-packkit ts-lib my-lib --github
41
+
42
+ # public instead
43
+ npx create-packkit ts-lib my-lib --github --public
44
+
45
+ # any other host — GitLab, Bitbucket, Gitea, self-hosted
46
+ npx create-packkit ts-lib my-lib --git-remote git@bitbucket.org:me/my-lib.git
47
+ ```
48
+
49
+ `--github` shells out to the [GitHub CLI](https://cli.github.com), so **Packkit never asks for, reads, or stores a token** — `gh` already holds your credentials. Created repos are **private unless you pass `--public`**.
50
+
51
+ This also fixes your links: the repository URL is baked into `package.json` and the README's CI badges when the files are generated, so letting Packkit resolve it up front means the badges point somewhere real from the first commit.
52
+
53
+ ### Scaffolding into a repo you already have
54
+
55
+ Already cloned an empty repo, or started some work? `--merge` scaffolds around what's there:
56
+
57
+ ```sh
58
+ git clone git@github.com:me/my-lib.git && cd my-lib
59
+ npx create-packkit ts-lib my-lib --here --merge
60
+ ```
61
+
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
+
34
64
  ## Or configure it on the web
35
65
 
36
66
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-packkit",
3
- "version": "2.7.2",
3
+ "version": "2.9.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": {
@@ -9,7 +9,8 @@
9
9
  "exports": {
10
10
  ".": "./src/core/index.js",
11
11
  "./core": "./src/core/index.js",
12
- "./cli": "./src/cli/index.js"
12
+ "./cli": "./src/cli/index.js",
13
+ "./scaffold": "./src/cli/write.js"
13
14
  },
14
15
  "files": [
15
16
  "bin",
@@ -28,7 +29,8 @@
28
29
  "integration": "node scripts/integration.mjs",
29
30
  "build:web": "esbuild src/core/index.js --bundle --format=esm --outfile=docs/packkit-core.js",
30
31
  "update:node": "node scripts/update-node-versions.mjs",
31
- "gen:reference": "node scripts/gen-reference.mjs"
32
+ "gen:reference": "node scripts/gen-reference.mjs",
33
+ "sync:mcp": "node scripts/sync-mcp-version.mjs"
32
34
  },
33
35
  "repository": {
34
36
  "type": "git",
package/src/cli/args.js CHANGED
@@ -20,6 +20,7 @@ const OVERRIDE_FLAGS = {
20
20
  description: 'description',
21
21
  keywords: 'keywords',
22
22
  repo: 'repo',
23
+ 'monorepo-layout': 'monorepoLayout',
23
24
  };
24
25
 
25
26
  // Boolean options that default ON — a --no-<flag> turns them off.
@@ -43,6 +44,10 @@ export function parseCliArgs(argv) {
43
44
  yes: { type: 'boolean', short: 'y' },
44
45
  recommended: { type: 'boolean' },
45
46
  here: { type: 'boolean' },
47
+ merge: { type: 'boolean' },
48
+ github: { type: 'boolean' },
49
+ 'git-remote': { type: 'string' },
50
+ public: { type: 'boolean' },
46
51
  'no-install': { type: 'boolean' },
47
52
  'no-git': { type: 'boolean' },
48
53
  minify: { type: 'boolean' },
@@ -104,6 +109,12 @@ export function parseCliArgs(argv) {
104
109
  from: values.from,
105
110
  name,
106
111
  here: !!values.here,
112
+ merge: !!values.merge,
113
+ github: !!values.github,
114
+ gitRemote: values['git-remote'] || null,
115
+ // Publishing code outward is opt-in, and private is the safe default — a
116
+ // wrong guess here leaks source, so --public must be asked for explicitly.
117
+ private: !values.public,
107
118
  yes: !!values.yes || !!values.recommended,
108
119
  hasConfigFlags,
109
120
  install: !values['no-install'],
package/src/cli/index.js CHANGED
@@ -6,7 +6,18 @@ import { generate, fromPreset, normalizeConfig, PRESET_NAMES, OPTIONS, OPTION_HE
6
6
  import { engineFloor, meetsNodeFloor } from '../core/node.js';
7
7
  import { parseCliArgs } from './args.js';
8
8
  import { runWizard } from './wizard.js';
9
- import { writeProject, dirIsEmptyOrMissing, gitInit, installDeps } from './write.js';
9
+ import {
10
+ writeProject,
11
+ existingEntries,
12
+ gitInit,
13
+ installDeps,
14
+ hasCommand,
15
+ githubLogin,
16
+ createGithubRepo,
17
+ pushToRemote,
18
+ writeLockfile,
19
+ commitAll,
20
+ } from './write.js';
10
21
 
11
22
  const pkgVersion = () => {
12
23
  try {
@@ -30,7 +41,7 @@ defaults in one shot. Every option is documented (with why-you'd-use-it) at:
30
41
 
31
42
  Presets:
32
43
  ${PRESET_NAMES.join(' ')}
33
- shortcuts: lib jslib rlib rapp vlib vapp slib sapp svc
44
+ shortcuts: lib jslib rlib rapp vlib vapp slib sapp svc fs
34
45
 
35
46
  Getting started:
36
47
  --preset <name> Start from a preset (skips the wizard)
@@ -38,9 +49,15 @@ Getting started:
38
49
  -y, --yes Accept defaults / preset, no prompts (one-shot)
39
50
  --recommended Alias for -y
40
51
  --here Scaffold into the current directory
52
+ --merge Scaffold into a non-empty directory (never overwrites)
41
53
  --no-install Skip dependency install
42
54
  --no-git Skip git init
43
55
 
56
+ Create the repo:
57
+ --github Create it on GitHub and push (uses the gh CLI)
58
+ --git-remote <url> Push to an existing remote (GitLab, Bitbucket, self-hosted)
59
+ --public Make the created repo public (default: private)
60
+
44
61
  Stack:
45
62
  --language <ts|js>
46
63
  --module <esm|cjs|dual> ESM-only is the default
@@ -49,6 +66,7 @@ Stack:
49
66
  --server <hono|fastify|express> HTTP service framework
50
67
  --pm <npm|pnpm|yarn|bun>
51
68
  --monorepo pnpm + Turborepo workspace
69
+ --monorepo-layout <libraries|fullstack> Linked packages, or web+server+shared
52
70
 
53
71
  Build & test:
54
72
  --bundler <tsup|tsdown|unbuild|rollup|none>
@@ -86,6 +104,7 @@ Examples:
86
104
  npx packkit ts-lib my-lib -y
87
105
  npx packkit react-app my-app --e2e
88
106
  npx packkit node-service api --server fastify --env
107
+ npx packkit fullstack acme --github # web + API + shared, repo created
89
108
  npm create packkit@latest my-pkg -- --preset oss --pm pnpm
90
109
  `;
91
110
 
@@ -118,6 +137,9 @@ export async function run(argv = process.argv.slice(2)) {
118
137
 
119
138
  config.gitInit = args.git;
120
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();
121
143
 
122
144
  // Node preflight: the generated project's tools (eslint, vite, vitest) hard-
123
145
  // require this floor. npm only *warns* on engines, so catch it here — clearly,
@@ -144,14 +166,29 @@ export async function run(argv = process.argv.slice(2)) {
144
166
  console.error('A package name is required (pass one, or run interactively).');
145
167
  process.exit(1);
146
168
  }
147
- if (!dirIsEmptyOrMissing(targetDir)) {
148
- console.error(`Target directory "${basename(targetDir)}" is not empty. Aborting.`);
169
+ const occupied = existingEntries(targetDir);
170
+ if (occupied.length && !args.merge) {
171
+ const sample = occupied.slice(0, 4).join(', ') + (occupied.length > 4 ? ', …' : '');
172
+ console.error(
173
+ `Target directory "${basename(targetDir)}" is not empty (${sample}).\n` +
174
+ `Re-run with --merge to scaffold around what's there — existing files are never overwritten.`,
175
+ );
149
176
  process.exit(1);
150
177
  }
151
178
 
179
+ // Resolve the remote *before* generating: the repository URL is baked into
180
+ // package.json links and README badges at generate time, so creating the repo
181
+ // afterwards would ship a first commit pointing at nothing.
182
+ const remote = resolveRemote(args, config);
183
+ if (remote?.error) {
184
+ console.error(remote.error);
185
+ process.exit(1);
186
+ }
187
+ if (remote?.url && /^https?:/.test(remote.url)) config.repo = remote.url;
188
+
152
189
  // Generate + write.
153
190
  const { files, summary } = generate(config);
154
- await writeProject(targetDir, files);
191
+ const { skipped } = await writeProject(targetDir, files, { merge: args.merge });
155
192
 
156
193
  // Post steps.
157
194
  if (config.gitInit) gitInit(targetDir);
@@ -162,6 +199,40 @@ export async function run(argv = process.argv.slice(2)) {
162
199
  s.stop(ok ? 'Dependencies installed' : 'Install skipped (run it manually)');
163
200
  }
164
201
 
202
+ // Create the remote last, so a failure here still leaves a complete local
203
+ // project behind — nothing to clean up, just a command to re-run.
204
+ let pushedTo = null;
205
+ if (remote) {
206
+ const s = p.spinner();
207
+ // A repo pushed without a lockfile fails CI immediately — actions/setup-node
208
+ // errors when its cache can't find one. Produce it without a full install.
209
+ if (!config.install) {
210
+ s.start('Writing a lockfile so CI passes on the first run');
211
+ const ok = writeLockfile(config.packageManager, targetDir);
212
+ s.stop(ok ? 'Lockfile written' : `No lockfile — run \`${config.packageManager} install\` and commit it, or CI will fail`);
213
+ }
214
+ commitAll(targetDir, 'Add lockfile');
215
+ s.start(remote.kind === 'github' ? `Creating ${remote.slug} on GitHub` : 'Pushing to origin');
216
+ const res =
217
+ remote.kind === 'github'
218
+ ? createGithubRepo({
219
+ slug: remote.slug,
220
+ description: config.description,
221
+ private: args.private,
222
+ cwd: targetDir,
223
+ })
224
+ : pushToRemote(remote.url, targetDir);
225
+ s.stop(res.ok ? `Pushed to ${remote.url}` : 'Could not create the remote');
226
+ if (res.ok) pushedTo = remote.url;
227
+ else {
228
+ const retry =
229
+ remote.kind === 'github'
230
+ ? `gh repo create ${remote.slug} --${args.private ? 'private' : 'public'} --source . --remote origin --push`
231
+ : `git remote add origin ${remote.url} && git push -u origin HEAD`;
232
+ console.error(`\n${res.error || 'The command failed.'}\n\nThe project is scaffolded. To retry:\n cd ${basename(targetDir)} && ${retry}`);
233
+ }
234
+ }
235
+
165
236
  const rel = args.here ? '.' : config.name;
166
237
  const next = [
167
238
  args.here ? null : `cd ${rel}`,
@@ -178,9 +249,19 @@ export async function run(argv = process.argv.slice(2)) {
178
249
  ? `This is a ${config.framework} component library — \`${runWord(config)} dev\` rebuilds on change (there's no dev server). For a runnable app, scaffold the "${config.framework}-app" preset instead.`
179
250
  : null,
180
251
  `Requires Node >= ${floor}${nodeOk ? '' : ` — you're on ${process.version}, upgrade first`}.`,
252
+ skipped.length
253
+ ? `Kept ${skipped.length} existing file${skipped.length > 1 ? 's' : ''} — Packkit's version was not written: ${skipped.join(', ')}`
254
+ : null,
255
+ // Said here too, not just in the README: this is the moment a red X appears
256
+ // on a repo that is ten seconds old, and the cause is not obvious.
257
+ pushedTo && config.workflows?.includes('npm-publish') && config.release === 'changesets'
258
+ ? `The Release workflow will fail until npm credentials exist — set up npm Trusted Publishing, or add an NPM_TOKEN secret. See "Releasing" in the README.`
259
+ : null,
181
260
  ].filter(Boolean);
182
261
 
183
- const done = `Created ${summary.name} — ${summary.fileCount} files · ${summary.stack.join(' · ')}`;
262
+ const done =
263
+ `Created ${summary.name} — ${summary.fileCount - skipped.length} files · ${summary.stack.join(' · ')}` +
264
+ (pushedTo ? `\n${pushedTo}` : '');
184
265
  if (interactive) {
185
266
  p.note(next.join('\n') || 'You are all set.', 'Next steps');
186
267
  if (hints.length) p.log.info(hints.join('\n'));
@@ -199,6 +280,43 @@ function runWord(config) {
199
280
  return config.packageManager === 'npm' ? 'npm run' : config.packageManager;
200
281
  }
201
282
 
283
+ // owner/name for the repo to create: an explicit --repo URL wins, otherwise the
284
+ // authenticated gh account plus the project name.
285
+ function githubSlug(repoUrl, login, name) {
286
+ const m = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/*$/.exec(repoUrl || '');
287
+ if (m) return `${m[1]}/${m[2]}`;
288
+ return login ? `${login}/${name}` : null;
289
+ }
290
+
291
+ /**
292
+ * Work out what remote to create, if any. Returns null (nothing to do),
293
+ * { error } to abort before writing anything, or the resolved target.
294
+ */
295
+ function resolveRemote(args, config) {
296
+ if (!args.github && !args.gitRemote) return null;
297
+ if (!config.gitInit) {
298
+ return { error: 'Creating a repo needs a local git repo — drop --no-git.' };
299
+ }
300
+ if (args.gitRemote) return { kind: 'remote', url: args.gitRemote };
301
+
302
+ // Delegate to `gh` rather than calling the API: it already holds the user's
303
+ // credentials, so Packkit never reads, prompts for, or stores a token.
304
+ if (!hasCommand('gh')) {
305
+ return {
306
+ error:
307
+ '--github needs the GitHub CLI. Install it (https://cli.github.com), or use\n' +
308
+ '--git-remote <url> to push to a repo you have already created.',
309
+ };
310
+ }
311
+ const login = githubLogin();
312
+ if (!login) {
313
+ return { error: '--github needs an authenticated GitHub CLI. Run: gh auth login' };
314
+ }
315
+ const slug = githubSlug(config.repo, login, config.name);
316
+ if (!slug) return { error: 'Could not work out the repository name. Pass --repo <url>.' };
317
+ return { kind: 'github', slug, url: `https://github.com/${slug}` };
318
+ }
319
+
202
320
  // Load a partial config from --from <file>, or a packkit.config.json in cwd.
203
321
  function loadProfile(args) {
204
322
  const path = args.from || (existsSync('packkit.config.json') ? 'packkit.config.json' : null);
package/src/cli/write.js CHANGED
@@ -3,26 +3,48 @@ import { existsSync, readdirSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
 
6
- /** Write a { path: contents } map under `targetDir`. Returns the count. */
7
- export async function writeProject(targetDir, files) {
6
+ /**
7
+ * Write a { path: contents } map under `targetDir`.
8
+ * In `merge` mode an existing file is never touched — it's reported as skipped,
9
+ * so scaffolding into a repo that already has files can't destroy work.
10
+ * Returns { written, skipped } as arrays of relative paths.
11
+ */
12
+ export async function writeProject(targetDir, files, { merge = false } = {}) {
13
+ const written = [];
14
+ const skipped = [];
8
15
  for (const [rel, contents] of Object.entries(files)) {
9
16
  const full = join(targetDir, rel);
17
+ if (merge && existsSync(full)) {
18
+ skipped.push(rel);
19
+ continue;
20
+ }
10
21
  await mkdir(dirname(full), { recursive: true });
11
22
  await writeFile(full, contents);
23
+ written.push(rel);
12
24
  }
13
- return Object.keys(files).length;
25
+ return { written, skipped };
14
26
  }
15
27
 
16
- /** True if the directory doesn't exist or contains no entries. */
17
- export function dirIsEmptyOrMissing(dir) {
18
- if (!existsSync(dir)) return true;
28
+ // Entries that don't make a directory "occupied": VCS metadata and OS noise.
29
+ // `git clone` of a fresh empty repo leaves .git/ behind, and treating that as
30
+ // non-empty broke the most common flow — create the repo, clone it, scaffold in.
31
+ const IGNORED_ENTRIES = new Set(['.git', '.DS_Store', 'Thumbs.db', '.hg', '.svn']);
32
+
33
+ /** Real (non-ignorable) entries in `dir`. Empty when the dir is missing. */
34
+ export function existingEntries(dir) {
35
+ if (!existsSync(dir)) return [];
19
36
  try {
20
- return readdirSync(dir).length === 0;
37
+ return readdirSync(dir).filter((e) => !IGNORED_ENTRIES.has(e));
21
38
  } catch {
22
- return false;
39
+ return [];
23
40
  }
24
41
  }
25
42
 
43
+ /** True if the directory doesn't exist or holds nothing that would be clobbered. */
44
+ export function dirIsEmptyOrMissing(dir) {
45
+ return existingEntries(dir).length === 0;
46
+ }
47
+
26
48
  /** Run a command in `cwd` (arg array — no shell parsing). Never throws. */
27
49
  export function run(cmd, args, cwd, { quiet = true } = {}) {
28
50
  const res = spawnSync(cmd, args, {
@@ -44,3 +66,76 @@ export function gitInit(cwd) {
44
66
  export function installDeps(pm, cwd) {
45
67
  return run(pm, ['install'], cwd, { quiet: false });
46
68
  }
69
+
70
+ // Resolving the dependency graph without downloading it. Yarn has no equivalent
71
+ // that works across its v1/v2+ split, so it's left out and reported instead.
72
+ const LOCKFILE_ONLY = {
73
+ npm: ['install', '--package-lock-only', '--ignore-scripts'],
74
+ pnpm: ['install', '--lockfile-only', '--ignore-scripts'],
75
+ bun: ['install', '--lockfile-only'],
76
+ };
77
+
78
+ /**
79
+ * Write a lockfile without installing.
80
+ *
81
+ * A pushed repo with no lockfile fails CI on its very first run — `cache: npm`
82
+ * in actions/setup-node errors outright when it can't find one. So when we're
83
+ * about to create a remote but the install was skipped, produce the lockfile
84
+ * anyway. Returns false when the package manager has no such mode.
85
+ */
86
+ export function writeLockfile(pm, cwd) {
87
+ const args = LOCKFILE_ONLY[pm];
88
+ return args ? run(pm, args, cwd) : false;
89
+ }
90
+
91
+ /** Run a command and capture its output. Never throws. */
92
+ function capture(cmd, args, cwd) {
93
+ const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: process.platform === 'win32' });
94
+ return {
95
+ ok: res.status === 0,
96
+ stdout: (res.stdout || '').trim(),
97
+ stderr: (res.stderr || '').trim(),
98
+ };
99
+ }
100
+
101
+ /** True if `cmd` is on PATH. */
102
+ export function hasCommand(cmd) {
103
+ return capture(process.platform === 'win32' ? 'where' : 'which', [cmd], process.cwd()).ok;
104
+ }
105
+
106
+ /** The authenticated GitHub login, or null if gh isn't installed or logged in. */
107
+ export function githubLogin() {
108
+ const res = capture('gh', ['api', 'user', '-q', '.login'], process.cwd());
109
+ return res.ok && res.stdout ? res.stdout : null;
110
+ }
111
+
112
+ /**
113
+ * Create the GitHub repo and push to it, via the `gh` CLI.
114
+ *
115
+ * Deliberately shells out rather than calling the REST API: `gh` already owns
116
+ * the user's credentials, so Packkit never reads, prompts for, or stores a
117
+ * token. Returns { ok, error }.
118
+ */
119
+ export function createGithubRepo({ slug, description, private: isPrivate = true, cwd }) {
120
+ const args = ['repo', 'create', slug, isPrivate ? '--private' : '--public',
121
+ '--source', '.', '--remote', 'origin', '--push'];
122
+ if (description) args.push('--description', description);
123
+ const res = capture('gh', args, cwd);
124
+ return { ok: res.ok, error: res.stderr || res.stdout };
125
+ }
126
+
127
+ /** Point `origin` at an existing remote URL and push the current branch. */
128
+ export function pushToRemote(url, cwd) {
129
+ const added = capture('git', ['remote', 'add', 'origin', url], cwd);
130
+ if (!added.ok) return { ok: false, error: added.stderr };
131
+ const branch = capture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
132
+ const res = capture('git', ['push', '-u', 'origin', branch.ok ? branch.stdout : 'main'], cwd);
133
+ return { ok: res.ok, error: res.stderr };
134
+ }
135
+
136
+ /** Stage and commit everything if the tree is dirty. No-op on a clean tree. */
137
+ export function commitAll(cwd, message) {
138
+ if (!capture('git', ['status', '--porcelain'], cwd).stdout) return false;
139
+ run('git', ['add', '-A'], cwd);
140
+ return run('git', ['commit', '-m', message, '--quiet'], cwd);
141
+ }
@@ -144,6 +144,31 @@ function readme(cfg) {
144
144
  lines.push('## CLI', '', '```sh', `npx ${cfg.name} --help`, '```', '');
145
145
  }
146
146
 
147
+ // Publishing needs a credential the repo doesn't have yet. The changesets
148
+ // workflow runs on every push, so without this note the first thing a new
149
+ // repo does is fail a job for a reason that's only explained in a YAML
150
+ // comment. Say it where someone will actually read it.
151
+ if (cfg.workflows?.includes('npm-publish')) {
152
+ const changesets = cfg.release === 'changesets';
153
+ lines.push(
154
+ '## Releasing',
155
+ '',
156
+ changesets
157
+ ? 'Releases are handled by the `Release` workflow: push a changeset (`npx changeset`) and it opens a version PR, then publishes when that PR merges.'
158
+ : 'Pushing a `v*` tag triggers the `Publish` workflow, which publishes to npm with provenance.',
159
+ '',
160
+ '**Publishing needs npm credentials, which a new repository does not have.** Either:',
161
+ '',
162
+ '- Set up [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) — no secret to store or rotate, and the recommended option; or',
163
+ '- Add an [npm automation token](https://docs.npmjs.com/creating-and-viewing-access-tokens) as an `NPM_TOKEN` repository secret.',
164
+ '',
165
+ changesets
166
+ ? 'Until one of those is in place the `Release` workflow will fail with `ENEEDAUTH` on every push. That is expected on a brand-new repo.'
167
+ : 'Until one of those is in place, tag pushes will fail with `ENEEDAUTH`.',
168
+ '',
169
+ );
170
+ }
171
+
147
172
  lines.push('## License', '', cfg.license === 'none' ? 'Unlicensed.' : `${cfg.license}${cfg.author ? ' © ' + cfg.author : ''}`, '');
148
173
  return lines.join('\n');
149
174
  }
package/src/core/index.js CHANGED
@@ -7,6 +7,7 @@ import { deepMerge, toJson } from './render.js';
7
7
  import { finalizePackageJson } from './pkg.js';
8
8
  import features from './features/index.js';
9
9
  import { buildMonorepo } from './monorepo.js';
10
+ import { provenance } from './provenance.js';
10
11
  import { PRESETS, PRESET_NAMES, PRESET_INFO, PRESET_ALIASES, resolvePreset } from './presets.js';
11
12
 
12
13
  export { OPTIONS, GROUPS, OPTION_HELP, defaultConfig, normalizeConfig, PRESETS, PRESET_NAMES, PRESET_INFO, PRESET_ALIASES, resolvePreset };
@@ -15,7 +16,10 @@ export { OPTIONS, GROUPS, OPTION_HELP, defaultConfig, normalizeConfig, PRESETS,
15
16
  export function fromPreset(name, overrides = {}) {
16
17
  const canonical = resolvePreset(name);
17
18
  if (!canonical) throw new Error(`Unknown preset "${name}". Known: ${PRESET_NAMES.join(', ')}`);
18
- return normalizeConfig({ ...PRESETS[canonical], ...overrides });
19
+ const cfg = normalizeConfig({ ...PRESETS[canonical], ...overrides });
20
+ // Recorded in packkit.json so a project knows which preset it came from.
21
+ cfg.preset = canonical;
22
+ return cfg;
19
23
  }
20
24
 
21
25
  /** Turn a config into a complete set of files. */
@@ -36,6 +40,7 @@ export function generate(input) {
36
40
  }
37
41
 
38
42
  files['package.json'] = toJson(finalizePackageJson(pkg));
43
+ files['packkit.json'] = provenance(cfg);
39
44
 
40
45
  return {
41
46
  config: cfg,
@@ -6,8 +6,15 @@ import { toJson } from './render.js';
6
6
  import community from './features/community.js';
7
7
  import agents from './features/agents.js';
8
8
  import gitfiles from './features/gitfiles.js';
9
+ import { provenance } from './provenance.js';
9
10
 
10
11
  export function buildMonorepo(cfg) {
12
+ // Two genuinely different shapes: a set of publishable libraries, or a
13
+ // deployable app (web + server + shared code). They differ in workspace
14
+ // globs, whether anything publishes, and what `dev` means, so they don't
15
+ // usefully share a code path.
16
+ if (cfg.monorepoLayout === 'fullstack') return buildFullstack(cfg);
17
+
11
18
  const files = {};
12
19
  const pm = cfg.packageManager;
13
20
  const scope = cfg.name.replace(/^@/, '').split('/')[0];
@@ -104,6 +111,7 @@ export function buildMonorepo(cfg) {
104
111
  files['.prettierrc.json'] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: 'all' });
105
112
 
106
113
  files['README.md'] = rootReadme(cfg, pm, core, utils);
114
+ files['packkit.json'] = provenance(cfg);
107
115
  files['.github/workflows/ci.yml'] = ciWorkflow(cfg, pm);
108
116
 
109
117
  // ---- packages ----
@@ -151,6 +159,362 @@ export function buildMonorepo(cfg) {
151
159
  };
152
160
  }
153
161
 
162
+ // ---------------------------------------------------------------------------
163
+ // Full-stack layout: apps/web + apps/server + packages/shared.
164
+ //
165
+ // The pieces already existed as standalone presets (react-app, node-service);
166
+ // what was missing was the composition — workspace wiring, one shared package
167
+ // both sides import, and a production story where the server serves the built
168
+ // web assets instead of needing a second host.
169
+ // ---------------------------------------------------------------------------
170
+ function buildFullstack(cfg) {
171
+ const files = {};
172
+ const pm = cfg.packageManager;
173
+ const scope = cfg.name.replace(/^@/, '').split('/')[0];
174
+ const shared = `@${scope}/shared`;
175
+ const wsProto = pm === 'pnpm' ? 'workspace:*' : '*';
176
+ const run = (s) => (pm === 'npm' ? `npm run ${s}` : `${pm} ${s}`);
177
+
178
+ for (const feat of [community, agents, gitfiles]) {
179
+ if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files);
180
+ }
181
+
182
+ files['package.json'] = toJson({
183
+ name: cfg.name,
184
+ version: '0.0.0',
185
+ private: true,
186
+ type: 'module',
187
+ ...(cfg.license !== 'none' ? { license: cfg.license } : {}),
188
+ ...(pm === 'pnpm'
189
+ ? { packageManager: 'pnpm@9.10.0' }
190
+ : { workspaces: ['apps/*', 'packages/*'] }),
191
+ scripts: {
192
+ dev: 'turbo dev',
193
+ build: 'turbo build',
194
+ // Production runs the built server, which also serves the web build.
195
+ start: `${pm === 'npm' ? 'npm --prefix apps/server run' : `${pm} --filter ./apps/server`} start`,
196
+ test: 'turbo test',
197
+ lint: 'turbo lint',
198
+ typecheck: 'turbo typecheck',
199
+ },
200
+ devDependencies: {
201
+ turbo: '^2.0.0',
202
+ typescript: '^5.9.3',
203
+ vitest: '^4.0.0',
204
+ eslint: '^10.0.0',
205
+ '@eslint/js': '^10.0.0',
206
+ 'typescript-eslint': '^8.0.0',
207
+ prettier: '^3.3.0',
208
+ '@types/node': `^${cfg.nodeVersion}.0.0`,
209
+ },
210
+ });
211
+
212
+ if (pm === 'pnpm') {
213
+ files['pnpm-workspace.yaml'] = 'packages:\n - "apps/*"\n - "packages/*"\n';
214
+ }
215
+
216
+ files['turbo.json'] = toJson({
217
+ $schema: 'https://turbo.build/schema.json',
218
+ tasks: {
219
+ build: { dependsOn: ['^build'], outputs: ['dist/**'] },
220
+ // Shared is built before the apps start, so both sides always import a
221
+ // current copy without a separate watch process.
222
+ dev: { dependsOn: ['^build'], cache: false, persistent: true },
223
+ test: { dependsOn: ['^build'] },
224
+ typecheck: { dependsOn: ['^build'] },
225
+ lint: {},
226
+ },
227
+ });
228
+
229
+ files['tsconfig.base.json'] = toJson({
230
+ $schema: 'https://json.schemastore.org/tsconfig',
231
+ compilerOptions: {
232
+ target: 'ES2022',
233
+ module: 'ESNext',
234
+ moduleResolution: 'Bundler',
235
+ lib: ['ES2022', 'DOM'],
236
+ strict: true,
237
+ esModuleInterop: true,
238
+ skipLibCheck: true,
239
+ declaration: true,
240
+ noEmit: true,
241
+ },
242
+ });
243
+
244
+ files['eslint.config.js'] = [
245
+ `import js from '@eslint/js';`,
246
+ `import tseslint from 'typescript-eslint';`,
247
+ ``,
248
+ `export default tseslint.config(`,
249
+ `\tjs.configs.recommended,`,
250
+ `\t...tseslint.configs.recommended,`,
251
+ `\t{ ignores: ['**/dist'] },`,
252
+ `);`,
253
+ ``,
254
+ ].join('\n');
255
+ files['.prettierrc.json'] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: 'all' });
256
+ files['.github/workflows/ci.yml'] = ciWorkflow(cfg, pm);
257
+ files['README.md'] = fullstackReadme(cfg, pm, shared);
258
+ files['packkit.json'] = provenance(cfg);
259
+
260
+ // ---- packages/shared: the contract both sides compile against ----
261
+ files['packages/shared/package.json'] = toJson({
262
+ name: shared,
263
+ version: '0.0.0',
264
+ private: true,
265
+ type: 'module',
266
+ main: './dist/index.js',
267
+ types: './dist/index.d.ts',
268
+ exports: { '.': { types: './dist/index.d.ts', default: './dist/index.js' } },
269
+ scripts: {
270
+ build: 'tsup src/index.ts --format esm --dts --clean',
271
+ test: 'vitest run',
272
+ typecheck: 'tsc --noEmit',
273
+ lint: 'eslint .',
274
+ },
275
+ devDependencies: { tsup: '^8.0.0' },
276
+ });
277
+ files['packages/shared/tsconfig.json'] = toJson({ extends: '../../tsconfig.base.json', include: ['src'] });
278
+ files['packages/shared/src/index.ts'] = [
279
+ `/** The shape the server returns and the web app renders. Change it once. */`,
280
+ `export interface Health {`,
281
+ `\tok: boolean;`,
282
+ `\tservice: string;`,
283
+ `\tuptime: number;`,
284
+ `}`,
285
+ ``,
286
+ `export function describeHealth(h: Health): string {`,
287
+ `\treturn h.ok ? \`\${h.service} is up (\${Math.round(h.uptime)}s)\` : \`\${h.service} is down\`;`,
288
+ `}`,
289
+ ``,
290
+ ].join('\n');
291
+ files['packages/shared/src/index.test.ts'] = exampleTest(
292
+ `import { describeHealth } from './index.js';`,
293
+ `expect(describeHealth({ ok: true, service: 'api', uptime: 12 })).toBe('api is up (12s)')`,
294
+ );
295
+
296
+ // ---- apps/server ----
297
+ files['apps/server/package.json'] = toJson({
298
+ name: `@${scope}/server`,
299
+ version: '0.0.0',
300
+ private: true,
301
+ type: 'module',
302
+ scripts: {
303
+ dev: 'tsx watch src/index.ts',
304
+ build: 'tsup src/index.ts --format esm --clean',
305
+ start: 'node dist/index.js',
306
+ test: 'vitest run',
307
+ typecheck: 'tsc --noEmit',
308
+ lint: 'eslint .',
309
+ },
310
+ dependencies: { hono: '^4.5.0', '@hono/node-server': '^2.0.0', [shared]: wsProto },
311
+ devDependencies: { tsx: '^4.0.0', tsup: '^8.0.0' },
312
+ });
313
+ files['apps/server/tsconfig.json'] = toJson({ extends: '../../tsconfig.base.json', include: ['src'] });
314
+ files['apps/server/src/app.ts'] = [
315
+ `import { Hono } from 'hono';`,
316
+ `import { serveStatic } from '@hono/node-server/serve-static';`,
317
+ `import type { Health } from '${shared}';`,
318
+ ``,
319
+ `export const app = new Hono();`,
320
+ ``,
321
+ `app.get('/api/health', (c) => {`,
322
+ `\tconst body: Health = { ok: true, service: '${cfg.name}', uptime: process.uptime() };`,
323
+ `\treturn c.json(body);`,
324
+ `});`,
325
+ ``,
326
+ `// In production the API also serves the built web app, so one process and`,
327
+ `// one port covers the whole thing. In dev, Vite serves the app and proxies`,
328
+ `// /api here instead (see apps/web/vite.config.ts).`,
329
+ `if (process.env.NODE_ENV === 'production') {`,
330
+ `\tapp.use('/*', serveStatic({ root: '../web/dist' }));`,
331
+ `}`,
332
+ ``,
333
+ ].join('\n');
334
+ files['apps/server/src/index.ts'] = [
335
+ `import { serve } from '@hono/node-server';`,
336
+ `import { app } from './app.js';`,
337
+ ``,
338
+ `const port = Number(process.env.PORT ?? 3000);`,
339
+ `serve({ fetch: app.fetch, port });`,
340
+ `console.log(\`Listening on http://localhost:\${port}\`);`,
341
+ ``,
342
+ ].join('\n');
343
+ files['apps/server/src/app.test.ts'] = [
344
+ `import { describe, it, expect } from 'vitest';`,
345
+ `import { app } from './app.js';`,
346
+ ``,
347
+ `describe('api', () => {`,
348
+ `\tit('reports health', async () => {`,
349
+ `\t\tconst res = await app.request('/api/health');`,
350
+ `\t\texpect(res.status).toBe(200);`,
351
+ `\t\texpect(await res.json()).toMatchObject({ ok: true });`,
352
+ `\t});`,
353
+ `});`,
354
+ ``,
355
+ ].join('\n');
356
+
357
+ // ---- apps/web ----
358
+ files['apps/web/package.json'] = toJson({
359
+ name: `@${scope}/web`,
360
+ version: '0.0.0',
361
+ private: true,
362
+ type: 'module',
363
+ scripts: {
364
+ dev: 'vite',
365
+ build: 'vite build',
366
+ preview: 'vite preview',
367
+ test: 'vitest run',
368
+ typecheck: 'tsc --noEmit',
369
+ lint: 'eslint .',
370
+ },
371
+ dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0', [shared]: wsProto },
372
+ devDependencies: {
373
+ vite: '^8.0.0',
374
+ '@vitejs/plugin-react': '^6.0.0',
375
+ // Without the React types, `turbo typecheck` fails on the very first run
376
+ // with TS7016/TS7026 on every JSX element.
377
+ '@types/react': '^19.0.0',
378
+ '@types/react-dom': '^19.0.0',
379
+ '@testing-library/react': '^16.0.0',
380
+ '@testing-library/dom': '^10.0.0',
381
+ jsdom: '^29.0.0',
382
+ },
383
+ });
384
+ files['apps/web/tsconfig.json'] = toJson({
385
+ extends: '../../tsconfig.base.json',
386
+ compilerOptions: { jsx: 'react-jsx' },
387
+ include: ['src'],
388
+ });
389
+ files['apps/web/vite.config.ts'] = [
390
+ `/// <reference types="vitest" />`,
391
+ `import { defineConfig } from 'vite';`,
392
+ `import react from '@vitejs/plugin-react';`,
393
+ ``,
394
+ `export default defineConfig({`,
395
+ `\tplugins: [react()],`,
396
+ `\t// Same-origin /api in dev as in production, so no CORS and no base URL`,
397
+ `\t// juggling between environments.`,
398
+ `\tserver: { proxy: { '/api': 'http://localhost:3000' } },`,
399
+ `\ttest: { environment: 'jsdom' },`,
400
+ `});`,
401
+ ``,
402
+ ].join('\n');
403
+ files['apps/web/src/App.test.tsx'] = [
404
+ `import { describe, it, expect } from 'vitest';`,
405
+ `import { render, screen } from '@testing-library/react';`,
406
+ `import { App } from './App.js';`,
407
+ ``,
408
+ `describe('App', () => {`,
409
+ `\tit('renders the service name', () => {`,
410
+ `\t\trender(<App />);`,
411
+ `\t\texpect(screen.getByRole('heading', { name: '${cfg.name}' })).toBeDefined();`,
412
+ `\t});`,
413
+ `});`,
414
+ ``,
415
+ ].join('\n');
416
+ files['apps/web/index.html'] = [
417
+ `<!doctype html>`,
418
+ `<html lang="en">`,
419
+ `\t<head>`,
420
+ `\t\t<meta charset="UTF-8" />`,
421
+ `\t\t<meta name="viewport" content="width=device-width, initial-scale=1.0" />`,
422
+ `\t\t<title>${cfg.name}</title>`,
423
+ `\t</head>`,
424
+ `\t<body>`,
425
+ `\t\t<div id="root"></div>`,
426
+ `\t\t<script type="module" src="/src/main.tsx"></script>`,
427
+ `\t</body>`,
428
+ `</html>`,
429
+ ``,
430
+ ].join('\n');
431
+ files['apps/web/src/main.tsx'] = [
432
+ `import { StrictMode } from 'react';`,
433
+ `import { createRoot } from 'react-dom/client';`,
434
+ `import { App } from './App.js';`,
435
+ ``,
436
+ `createRoot(document.getElementById('root')!).render(`,
437
+ `\t<StrictMode>`,
438
+ `\t\t<App />`,
439
+ `\t</StrictMode>,`,
440
+ `);`,
441
+ ``,
442
+ ].join('\n');
443
+ files['apps/web/src/App.tsx'] = [
444
+ `import { useEffect, useState } from 'react';`,
445
+ `import { describeHealth, type Health } from '${shared}';`,
446
+ ``,
447
+ `export function App() {`,
448
+ `\tconst [health, setHealth] = useState<Health | null>(null);`,
449
+ ``,
450
+ `\tuseEffect(() => {`,
451
+ `\t\tfetch('/api/health')`,
452
+ `\t\t\t.then((r) => r.json() as Promise<Health>)`,
453
+ `\t\t\t.then(setHealth)`,
454
+ `\t\t\t.catch(() => setHealth({ ok: false, service: '${cfg.name}', uptime: 0 }));`,
455
+ `\t}, []);`,
456
+ ``,
457
+ `\treturn (`,
458
+ `\t\t<main>`,
459
+ `\t\t\t<h1>${cfg.name}</h1>`,
460
+ `\t\t\t<p>{health ? describeHealth(health) : 'Checking…'}</p>`,
461
+ `\t\t</main>`,
462
+ `\t);`,
463
+ `}`,
464
+ ``,
465
+ ].join('\n');
466
+
467
+ return {
468
+ config: cfg,
469
+ files,
470
+ postCommands: cfg.gitInit ? ['git init', 'git add -A', 'git commit -m "Initial commit from Packkit"'] : [],
471
+ summary: {
472
+ name: cfg.name,
473
+ fileCount: Object.keys(files).length,
474
+ stack: ['monorepo', 'full-stack', `${pm}+turbo`, 'React+Vite', 'Hono', 'TypeScript', 'vitest'],
475
+ workflows: ['ci'],
476
+ },
477
+ };
478
+ }
479
+
480
+ function fullstackReadme(cfg, pm, shared) {
481
+ const install = pm === 'npm' ? 'npm install' : `${pm} install`;
482
+ const run = (s) => (pm === 'npm' ? `npm run ${s}` : `${pm} ${s}`);
483
+ return [
484
+ `# ${cfg.name}`,
485
+ '',
486
+ cfg.description || '_A full-stack monorepo scaffolded with [Packkit](https://danmat.github.io/create-packkit/)._',
487
+ '',
488
+ '## Layout',
489
+ '',
490
+ '```',
491
+ 'apps/web React + Vite front end',
492
+ 'apps/server Hono API (also serves the web build in production)',
493
+ 'packages/shared types and helpers both sides import',
494
+ '```',
495
+ '',
496
+ '## Develop',
497
+ '',
498
+ '```sh',
499
+ install,
500
+ run('dev') + ' # web on :5173, api on :3000',
501
+ '```',
502
+ '',
503
+ `Vite proxies \`/api\` to the server, so requests are same-origin in development exactly as they are in production — no CORS, no environment-specific base URL.`,
504
+ '',
505
+ '## Production',
506
+ '',
507
+ '```sh',
508
+ run('build'),
509
+ run('start') + ' # one process serving the API and the built web app',
510
+ '```',
511
+ '',
512
+ `\`${shared}\` is built before either app starts, so a change to a shared type surfaces as a type error on both sides rather than at runtime.`,
513
+ '',
514
+ cfg.license !== 'none' ? `## License\n\n${cfg.license}${cfg.author ? ' © ' + cfg.author : ''}\n` : '',
515
+ ].join('\n');
516
+ }
517
+
154
518
  function addPackage(files, { name, dir, src, test, deps }) {
155
519
  const pkg = {
156
520
  name,
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by scripts/update-node-versions.mjs — do not edit by hand.
2
- // Source: nodejs.org/dist + nodejs/Release schedule, refreshed 2026-07-14.
2
+ // Source: nodejs.org/dist + nodejs/Release schedule, refreshed 2026-07-20.
3
3
  // Offered lines track the Node release schedule; each version is that line's
4
4
  // newest published patch. Run `node scripts/update-node-versions.mjs` to refresh.
5
5
 
@@ -54,6 +54,14 @@ export const OPTIONS = {
54
54
  monorepo: {
55
55
  group: 'core', type: 'boolean', label: 'Monorepo (pnpm/Turborepo workspace)', default: false,
56
56
  },
57
+ monorepoLayout: {
58
+ group: 'core', type: 'select', label: 'Monorepo layout', default: 'libraries',
59
+ when: (cfg) => cfg.monorepo,
60
+ choices: [
61
+ { value: 'libraries', label: 'Libraries — linked packages you publish' },
62
+ { value: 'fullstack', label: 'Full-stack app — web + server + shared' },
63
+ ],
64
+ },
57
65
  framework: {
58
66
  group: 'core', type: 'select', label: 'Framework', default: 'none',
59
67
  choices: [
@@ -209,6 +217,7 @@ export const OPTION_HELP = {
209
217
  moduleFormat: 'How the package is consumed. ESM-only (default) is the modern, leanest choice — Node 20.19+/22.12+ can `require()` ESM. Pick dual only if you must support older CJS-only consumers; cjs-only is rarely needed.',
210
218
  target: 'What you are building — mix and match: a library (importable package), a CLI (ships a bin), an HTTP service, or an app (Vite SPA).',
211
219
  serviceFramework: 'For the service target: Hono (fast, web-standard, tiny — default), Fastify (batteries-included, plugins, schema validation), or Express (ubiquitous, huge ecosystem).',
220
+ monorepoLayout: 'What the workspace contains. "libraries" gives linked packages you publish (Changesets). "fullstack" gives apps/web (React+Vite) + apps/server (Hono) + packages/shared, wired together, with the server serving the web build in production.',
212
221
  monorepo: 'Generate a pnpm + Turborepo workspace with two linked example packages and Changesets. Only worth it when ≥2 packages share code.',
213
222
  framework: 'UI framework for component libraries and apps: React, Vue, or Svelte (or none for a plain package).',
214
223
  packageManager: 'Which package manager the scripts, lockfile, and CI target: npm, pnpm, yarn, or bun.',
@@ -19,6 +19,10 @@ export const PRESETS = {
19
19
  release: 'none', workflows: ['ci'], deps: 'renovate', agents: true, vscode: true,
20
20
  },
21
21
  monorepo: { monorepo: true, language: 'ts', packageManager: 'pnpm' },
22
+ fullstack: {
23
+ monorepo: true, monorepoLayout: 'fullstack', language: 'ts', packageManager: 'pnpm',
24
+ framework: 'react', test: 'vitest', release: 'none', workflows: ['ci'],
25
+ },
22
26
  oss: {
23
27
  language: 'ts', target: ['library'], moduleFormat: 'esm', bundler: 'tsup',
24
28
  test: 'vitest', coverage: true, lint: 'eslint-prettier', gitHooks: 'simple-git-hooks',
@@ -53,6 +57,8 @@ export const PRESET_ALIASES = {
53
57
  slib: 'svelte-lib',
54
58
  sapp: 'svelte-app',
55
59
  svc: 'node-service',
60
+ fs: 'fullstack',
61
+ app: 'fullstack',
56
62
  service: 'node-service',
57
63
  };
58
64
 
@@ -78,6 +84,7 @@ export const PRESET_INFO = {
78
84
  'svelte-app': 'Svelte SPA — Vite dev server, build, Testing Library.',
79
85
  'node-service': 'Node HTTP service (Hono) — tsx dev, tsup build, Dockerfile.',
80
86
  monorepo: 'pnpm + Turborepo workspace — two example packages, Changesets, CI.',
87
+ fullstack: 'Full-stack monorepo — React+Vite web, Hono API, shared package; server serves the web build in production.',
81
88
  oss: 'Full open-source library — coverage, CodeQL, Codecov, Renovate, Changesets.',
82
89
  minimal: 'Bare TS library — tsup only, no tests/lint/CI.',
83
90
  full: 'Everything on — library + CLI, all workflows and extras.',
@@ -0,0 +1,35 @@
1
+ // packkit.json — what this project was generated from.
2
+ //
3
+ // Packkit used to leave no trace, so a project couldn't answer "what did I
4
+ // start from?", couldn't be diffed against a newer template, and had no upgrade
5
+ // path. This records the answer next to the code.
6
+ //
7
+ // Only settings that differ from the defaults are stored, so the file reads as
8
+ // the decisions someone actually made rather than a dump of every option. It is
9
+ // deliberately free of timestamps and machine details: generation stays a pure
10
+ // function of the config, so the same config always produces the same bytes.
11
+
12
+ import { toJson } from './render.js';
13
+ import { defaultConfig } from './options.js';
14
+
15
+ // How this particular run was invoked, rather than what the project is.
16
+ // Replaying a config shouldn't re-run someone else's `git init` or install.
17
+ const TRANSIENT = new Set(['gitInit', 'install', 'generatorVersion', 'preset', 'name']);
18
+
19
+ export function provenance(cfg) {
20
+ const defaults = defaultConfig();
21
+ const settings = {};
22
+ for (const [key, value] of Object.entries(cfg)) {
23
+ // Skip derived helpers (isTs, hasApp, ext…) — they aren't inputs.
24
+ if (!(key in defaults) || TRANSIENT.has(key)) continue;
25
+ if (JSON.stringify(value) !== JSON.stringify(defaults[key])) settings[key] = value;
26
+ }
27
+
28
+ return toJson({
29
+ $schema: 'https://danmat.github.io/create-packkit/packkit.schema.json',
30
+ generator: 'create-packkit',
31
+ ...(cfg.generatorVersion ? { version: cfg.generatorVersion } : {}),
32
+ ...(cfg.preset ? { preset: cfg.preset } : {}),
33
+ settings,
34
+ });
35
+ }