create-packkit 2.8.0 → 3.0.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.
@@ -36,6 +36,7 @@ export const OPTIONS = {
36
36
  },
37
37
  serviceFramework: {
38
38
  group: 'core', type: 'select', label: 'Service framework (HTTP service)', default: 'hono',
39
+ when: (cfg) => cfg.target?.includes('service'),
39
40
  choices: [
40
41
  { value: 'hono', label: 'Hono (fast, web-standard)' },
41
42
  { value: 'fastify', label: 'Fastify' },
@@ -54,6 +55,14 @@ export const OPTIONS = {
54
55
  monorepo: {
55
56
  group: 'core', type: 'boolean', label: 'Monorepo (pnpm/Turborepo workspace)', default: false,
56
57
  },
58
+ monorepoLayout: {
59
+ group: 'core', type: 'select', label: 'Monorepo layout', default: 'libraries',
60
+ when: (cfg) => cfg.monorepo,
61
+ choices: [
62
+ { value: 'libraries', label: 'Libraries — linked packages you publish' },
63
+ { value: 'fullstack', label: 'Full-stack app — web + server + shared' },
64
+ ],
65
+ },
57
66
  framework: {
58
67
  group: 'core', type: 'select', label: 'Framework', default: 'none',
59
68
  choices: [
@@ -209,6 +218,7 @@ export const OPTION_HELP = {
209
218
  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
219
  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
220
  serviceFramework: 'For the service target: Hono (fast, web-standard, tiny — default), Fastify (batteries-included, plugins, schema validation), or Express (ubiquitous, huge ecosystem).',
221
+ 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
222
  monorepo: 'Generate a pnpm + Turborepo workspace with two linked example packages and Changesets. Only worth it when ≥2 packages share code.',
213
223
  framework: 'UI framework for component libraries and apps: React, Vue, or Svelte (or none for a plain package).',
214
224
  packageManager: 'Which package manager the scripts, lockfile, and CI target: npm, pnpm, yarn, or bun.',
@@ -250,21 +260,42 @@ export function defaultConfig() {
250
260
  return cfg;
251
261
  }
252
262
 
253
- /** Merge partial input over the defaults and coerce a few fields. */
254
- export function normalizeConfig(input = {}) {
263
+ /**
264
+ * Merge partial input over the defaults and coerce a few fields.
265
+ *
266
+ * Pass a `diagnostics` array to have every coercion that overrides an
267
+ * explicitly-supplied value recorded there (the embedded API surfaces these so
268
+ * a host application learns when Packkit changed its requested config, instead
269
+ * of the change happening silently). Existing callers omit it and see identical
270
+ * behavior — the coercions run exactly the same either way.
271
+ */
272
+ export function normalizeConfig(input = {}, diagnostics = null) {
255
273
  const cfg = { ...defaultConfig(), ...input };
274
+ const requested = new Set(Object.keys(input));
256
275
 
257
- // A CLI target needs an executable build; JS `strict` isn't a thing, etc.
258
- if (!Array.isArray(cfg.target) || cfg.target.length === 0) cfg.target = ['library'];
259
- if (!Array.isArray(cfg.workflows)) cfg.workflows = [];
276
+ // Record a coercion only when it actually changes an explicitly-requested
277
+ // value a field left at its default that "changes" to the same default is
278
+ // not news. Derived helper flags (isTs, hasApp…) go through plain assignment
279
+ // below; they are computed, not overrides, so they never emit a diagnostic.
280
+ const coerce = (field, value, code, message, severity = 'warning') => {
281
+ const same = JSON.stringify(cfg[field]) === JSON.stringify(value);
282
+ if (same) return;
283
+ const previousValue = cfg[field];
284
+ cfg[field] = value;
285
+ if (diagnostics && requested.has(field)) {
286
+ diagnostics.push({ severity, code, field, previousValue, resolvedValue: value, message, source: 'normalize' });
287
+ }
288
+ };
289
+
290
+ // A target is required; default it if missing.
291
+ if (!Array.isArray(cfg.target) || cfg.target.length === 0) {
292
+ coerce('target', ['library'], 'TARGET_DEFAULTED', 'No target was given, so "library" was used.', 'info');
293
+ }
294
+ if (!Array.isArray(cfg.workflows)) coerce('workflows', [], 'WORKFLOWS_DEFAULTED', 'workflows was not a list, so it was reset to none.', 'info');
260
295
 
261
- // Minify needs a bundler.
262
- if (cfg.bundler === 'none') cfg.minify = false;
263
- // Coverage only makes sense with a test runner that supports it.
264
- if (cfg.test === 'none' || cfg.test === 'node') cfg.coverage = false;
265
- // Codecov workflow implies coverage.
266
- if (cfg.workflows.includes('codecov')) cfg.coverage = true;
267
- // npm-publish + changesets are complementary; nothing to coerce, just noted.
296
+ if (cfg.bundler === 'none') coerce('minify', false, 'MINIFY_REQUIRES_BUNDLER', 'Minify was disabled because no bundler produces output to minify.');
297
+ if (cfg.test === 'none' || cfg.test === 'node') coerce('coverage', false, 'COVERAGE_UNSUPPORTED_RUNNER', `Coverage was disabled because the "${cfg.test}" test runner does not report it.`);
298
+ if (cfg.workflows.includes('codecov')) coerce('coverage', true, 'COVERAGE_FORCED_BY_CODECOV', 'Coverage was enabled because the Codecov workflow requires it.', 'info');
268
299
 
269
300
  cfg.isReact = cfg.framework === 'react';
270
301
  cfg.isVue = cfg.framework === 'vue';
@@ -274,7 +305,7 @@ export function normalizeConfig(input = {}) {
274
305
  cfg.hasApp = cfg.target.includes('app');
275
306
  // A component-framework package that isn't an app is a library.
276
307
  if (cfg.hasFramework && !cfg.hasApp && !cfg.target.includes('library')) {
277
- cfg.target = ['library', ...cfg.target];
308
+ coerce('target', ['library', ...cfg.target], 'TARGET_LIBRARY_ADDED', 'A "library" target was added because a component framework needs something to build.', 'info');
278
309
  }
279
310
 
280
311
  cfg.isTs = cfg.language === 'ts';
@@ -296,31 +327,31 @@ export function normalizeConfig(input = {}) {
296
327
  cfg.hasBuild = cfg.viteBuild || (!cfg.svelteLib && (cfg.bundler !== 'none' || cfg.isTs));
297
328
 
298
329
  // Apps aren't published packages.
299
- if (cfg.hasApp) cfg.moduleFormat = 'esm';
330
+ if (cfg.hasApp) coerce('moduleFormat', 'esm', 'MODULE_FORMAT_FORCED_FOR_APP', 'An app is bundled, not published, so its module format is ESM.', 'info');
300
331
  cfg.hasEsm = cfg.moduleFormat === 'esm' || cfg.moduleFormat === 'dual';
301
332
  cfg.hasCjs = cfg.moduleFormat === 'cjs' || cfg.moduleFormat === 'dual';
302
333
 
303
334
  // Storybook only applies to component libraries.
304
- if (!cfg.hasFramework || cfg.hasApp || !cfg.hasLibrary) cfg.storybook = false;
335
+ if (!cfg.hasFramework || cfg.hasApp || !cfg.hasLibrary) coerce('storybook', false, 'STORYBOOK_REQUIRES_COMPONENT_LIBRARY', 'Storybook was disabled because it only applies to a component library.');
305
336
 
306
337
  // Playwright E2E only applies to app targets.
307
- if (!cfg.hasApp) cfg.e2e = false;
338
+ if (!cfg.hasApp) coerce('e2e', false, 'E2E_REQUIRES_APP', 'End-to-end tests were disabled because they only apply to an app target.');
308
339
 
309
340
  // A monorepo is its own generation path (see buildMonorepo); it has a build.
310
341
  if (cfg.monorepo) cfg.hasBuild = true;
311
342
 
312
343
  cfg.publishable = (cfg.hasLibrary || cfg.hasCli) && !cfg.hasApp && !cfg.hasService;
313
344
  // Package-correctness checks only make sense for a publishable package.
314
- if (!cfg.publishable) cfg.pkgChecks = false;
345
+ if (!cfg.publishable) coerce('pkgChecks', false, 'PKG_CHECKS_REQUIRES_PUBLISHABLE', 'Package-correctness checks were disabled because this project is not published to npm.');
315
346
  // Sourcemaps + shipped source only matter for a published package.
316
- if (!cfg.publishable) cfg.sourcemaps = false;
347
+ if (!cfg.publishable) coerce('sourcemaps', false, 'SOURCEMAPS_REQUIRES_PUBLISHABLE', 'Sourcemaps were disabled because this project is not published to npm.');
317
348
  // A bundle-size budget needs a published package with a built entry.
318
- if (!(cfg.publishable && cfg.hasBuild)) cfg.sizeLimit = false;
349
+ if (!(cfg.publishable && cfg.hasBuild)) coerce('sizeLimit', false, 'SIZE_LIMIT_REQUIRES_BUILT_LIBRARY', 'The bundle-size budget was disabled because it needs a published package with a build.');
319
350
  // Env validation is for server-side runtimes (services / CLIs), not libs/apps.
320
- if (!(cfg.hasService || cfg.hasCli)) cfg.env = false;
351
+ if (!(cfg.hasService || cfg.hasCli)) coerce('env', false, 'ENV_REQUIRES_SERVICE_OR_CLI', 'Env validation was disabled because it only applies to a service or CLI.');
321
352
  // Canary snapshots ride on the Changesets flow.
322
- if (cfg.release !== 'changesets') cfg.canary = false;
353
+ if (cfg.release !== 'changesets') coerce('canary', false, 'CANARY_REQUIRES_CHANGESETS', 'Canary releases were disabled because they require the Changesets release flow.');
323
354
  // JSR is TypeScript-first, ESM, and for plain (non-framework) libraries.
324
- if (!(cfg.isTs && cfg.hasLibrary && !cfg.hasFramework && !cfg.hasApp)) cfg.jsr = false;
355
+ if (!(cfg.isTs && cfg.hasLibrary && !cfg.hasFramework && !cfg.hasApp)) coerce('jsr', false, 'JSR_REQUIRES_PLAIN_TS_LIBRARY', 'JSR publishing was disabled because it applies only to a plain TypeScript library.');
325
356
  return cfg;
326
357
  }
@@ -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
+ }
@@ -9,6 +9,11 @@ export function render(template, vars = {}) {
9
9
  });
10
10
  }
11
11
 
12
+ // Keys that would let a merged-in object reach an object's prototype. The
13
+ // embedded API deep-merges host-supplied data (extension package.json), so
14
+ // these are skipped defensively even though the merge is immutable.
15
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
16
+
12
17
  /** Deep-merge plain objects (arrays are concatenated + de-duped). Used to fold
13
18
  * each feature's package.json fragment into the accumulator. */
14
19
  export function deepMerge(target, source) {
@@ -18,6 +23,7 @@ export function deepMerge(target, source) {
18
23
  if (isPlainObject(target) && isPlainObject(source)) {
19
24
  const out = { ...target };
20
25
  for (const [k, v] of Object.entries(source)) {
26
+ if (UNSAFE_KEYS.has(k)) continue;
21
27
  out[k] = k in target ? deepMerge(target[k], v) : v;
22
28
  }
23
29
  return out;
@@ -0,0 +1,64 @@
1
+ // Provider-neutral deployment contract.
2
+ //
3
+ // A host application deploying a generated project needs to know how to build
4
+ // and run it — but Packkit must not know or care whether that host is Vercel,
5
+ // a Kubernetes cluster, or a Raspberry Pi. So this describes build/runtime
6
+ // requirements in provider-agnostic terms, derived purely from the resolved
7
+ // config. No AWS/Netlify/Vercel/Cloudflare/GitHub fields, by design.
8
+
9
+ /**
10
+ * Derive the deployment contract from a resolved config.
11
+ * @returns {import('./index.js').DeploymentContract}
12
+ */
13
+ export function deriveDeploymentContract(cfg) {
14
+ const run = (script) => (cfg.packageManager === 'npm' ? `npm run ${script}` : `${cfg.packageManager} ${script}`);
15
+ const start = cfg.packageManager === 'npm' ? 'npm start' : `${cfg.packageManager} start`;
16
+
17
+ if (cfg.hasService) {
18
+ // The generated server reads PORT but defaults to 3000, so PORT is optional,
19
+ // not required — `port` communicates the default and the `PORT` env var
20
+ // overrides it. `healthCheckPath` is only asserted because every service
21
+ // framework Packkit generates (Hono/Fastify/Express) defines /health.
22
+ return prune({
23
+ type: 'node-service',
24
+ buildCommand: cfg.hasBuild ? run('build') : undefined,
25
+ startCommand: start,
26
+ port: 3000,
27
+ healthCheckPath: '/health',
28
+ requiredEnvironmentVariables: [],
29
+ });
30
+ }
31
+
32
+ if (cfg.hasApp) {
33
+ return prune({
34
+ type: 'static',
35
+ buildCommand: run('build'),
36
+ // Vite's default build output.
37
+ outputDirectory: 'dist',
38
+ });
39
+ }
40
+
41
+ if (cfg.hasCli) {
42
+ return prune({
43
+ type: 'cli',
44
+ buildCommand: cfg.hasBuild ? run('build') : undefined,
45
+ });
46
+ }
47
+
48
+ return prune({
49
+ type: 'library',
50
+ buildCommand: cfg.hasBuild ? run('build') : undefined,
51
+ });
52
+ }
53
+
54
+ // Drop undefined fields and empty required-env arrays so the contract stays
55
+ // minimal and deterministic — the same config always yields the same object.
56
+ function prune(contract) {
57
+ const out = {};
58
+ for (const [k, v] of Object.entries(contract)) {
59
+ if (v === undefined) continue;
60
+ if (k === 'requiredEnvironmentVariables' && Array.isArray(v) && v.length === 0) continue;
61
+ out[k] = v;
62
+ }
63
+ return out;
64
+ }