create-packkit 2.8.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-packkit",
3
- "version": "2.8.0",
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": {
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.
package/src/cli/index.js CHANGED
@@ -41,7 +41,7 @@ defaults in one shot. Every option is documented (with why-you'd-use-it) at:
41
41
 
42
42
  Presets:
43
43
  ${PRESET_NAMES.join(' ')}
44
- shortcuts: lib jslib rlib rapp vlib vapp slib sapp svc
44
+ shortcuts: lib jslib rlib rapp vlib vapp slib sapp svc fs
45
45
 
46
46
  Getting started:
47
47
  --preset <name> Start from a preset (skips the wizard)
@@ -66,6 +66,7 @@ Stack:
66
66
  --server <hono|fastify|express> HTTP service framework
67
67
  --pm <npm|pnpm|yarn|bun>
68
68
  --monorepo pnpm + Turborepo workspace
69
+ --monorepo-layout <libraries|fullstack> Linked packages, or web+server+shared
69
70
 
70
71
  Build & test:
71
72
  --bundler <tsup|tsdown|unbuild|rollup|none>
@@ -103,6 +104,7 @@ Examples:
103
104
  npx packkit ts-lib my-lib -y
104
105
  npx packkit react-app my-app --e2e
105
106
  npx packkit node-service api --server fastify --env
107
+ npx packkit fullstack acme --github # web + API + shared, repo created
106
108
  npm create packkit@latest my-pkg -- --preset oss --pm pnpm
107
109
  `;
108
110
 
@@ -135,6 +137,9 @@ export async function run(argv = process.argv.slice(2)) {
135
137
 
136
138
  config.gitInit = args.git;
137
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();
138
143
 
139
144
  // Node preflight: the generated project's tools (eslint, vite, vitest) hard-
140
145
  // require this floor. npm only *warns* on engines, so catch it here — clearly,
@@ -247,6 +252,11 @@ export async function run(argv = process.argv.slice(2)) {
247
252
  skipped.length
248
253
  ? `Kept ${skipped.length} existing file${skipped.length > 1 ? 's' : ''} — Packkit's version was not written: ${skipped.join(', ')}`
249
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,
250
260
  ].filter(Boolean);
251
261
 
252
262
  const done =
@@ -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,
@@ -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
+ }