create-packkit 2.9.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.
package/README.md CHANGED
@@ -193,14 +193,32 @@ There's also an [`llms.txt`](llms.txt) (served at [danmat.github.io/create-packk
193
193
  { "mcpServers": { "packkit": { "command": "npx", "args": ["-y", "packkit-mcp"] } } }
194
194
  ```
195
195
 
196
+ ## Embed Packkit in your own app
197
+
198
+ Packkit ships a typed, side-effect-free API so a Node application can use it as a
199
+ project-generation engine — generate in memory, add your own deployment files,
200
+ and write to disk when you're ready. No prompts, installs, git, or network.
201
+
202
+ ```js
203
+ import { createProject, extendProject, writeGeneratedProject } from 'create-packkit/embedded';
204
+
205
+ const project = createProject({ preset: 'react-app', name: 'weather-dashboard' });
206
+ const extended = extendProject(project, { files: { '.github/workflows/deploy.yml': deployYaml } });
207
+ await writeGeneratedProject({ project: extended, destination: '/tmp/weather-dashboard' });
208
+ ```
209
+
210
+ Full guide, including diagnostics, reproducible definitions, digests, and the
211
+ provider-neutral deployment contract: **[Embedding Packkit](docs/EMBEDDING.md)**.
212
+
196
213
  ## How it works
197
214
 
198
215
  Packkit is a pure `config → { files }` **core** that runs in both Node and the browser:
199
216
 
200
217
  - 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).
218
+ - the **web configurator** zips the same files client-side (no server);
219
+ - the **embedded API** ([`create-packkit/embedded`](docs/EMBEDDING.md)) hands the file map to a host application.
202
220
 
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.
221
+ All three drive from one options schema ([`src/core/options.js`](src/core/options.js)), so they always stay in sync.
204
222
 
205
223
  ## Staying fresh
206
224
 
package/package.json CHANGED
@@ -1,20 +1,34 @@
1
1
  {
2
2
  "name": "create-packkit",
3
- "version": "2.9.0",
3
+ "version": "3.0.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"
package/src/core/index.js CHANGED
@@ -22,23 +22,49 @@ export function fromPreset(name, overrides = {}) {
22
22
  return cfg;
23
23
  }
24
24
 
25
- /** Turn a config into a complete set of files. */
26
- export function generate(input) {
27
- const cfg = normalizeConfig(input);
28
- if (cfg.monorepo) return buildMonorepo(cfg);
29
-
25
+ /**
26
+ * Run every active feature, collecting its files and package.json fragment.
27
+ * Returns the raw material — file map, the fragments in contribution order,
28
+ * and which feature produced each file — so callers that need provenance (the
29
+ * embedded API's collision detection) get it without a second pass, while
30
+ * `generate` folds it into the same output as before.
31
+ */
32
+ export function assemble(cfg) {
30
33
  const files = {};
34
+ const fileSources = {};
35
+ const fragments = [];
31
36
  let pkg = {};
32
37
 
33
38
  for (const feat of features) {
34
39
  if (!feat.active(cfg)) continue;
35
40
  const out = feat.apply(cfg) || {};
36
41
  if (out.files) {
37
- for (const [path, contents] of Object.entries(out.files)) files[path] = contents;
42
+ for (const [path, contents] of Object.entries(out.files)) {
43
+ (fileSources[path] ||= []).push(feat.id);
44
+ files[path] = contents;
45
+ }
46
+ }
47
+ if (out.pkg) {
48
+ fragments.push({ source: feat.id, pkg: out.pkg });
49
+ pkg = deepMerge(pkg, out.pkg);
38
50
  }
39
- if (out.pkg) pkg = deepMerge(pkg, out.pkg);
40
51
  }
52
+ return { files, fileSources, fragments, pkg };
53
+ }
41
54
 
55
+ /**
56
+ * Generate, keeping the provenance assemble() produced. One assembly pass feeds
57
+ * both the public files and the embedded API's conflict diagnostics, so the
58
+ * bytes callers get and the provenance they inspect come from the same run.
59
+ */
60
+ export function generateTracked(input, diagnostics = null) {
61
+ const cfg = normalizeConfig(input, diagnostics);
62
+ if (cfg.monorepo) {
63
+ // The monorepo generator is a separate path with no per-feature fragments.
64
+ return { ...buildMonorepo(cfg), fileSources: {}, fragments: [] };
65
+ }
66
+
67
+ const { files, fileSources, fragments, pkg } = assemble(cfg);
42
68
  files['package.json'] = toJson(finalizePackageJson(pkg));
43
69
  files['packkit.json'] = provenance(cfg);
44
70
 
@@ -47,9 +73,17 @@ export function generate(input) {
47
73
  files,
48
74
  postCommands: postCommands(cfg),
49
75
  summary: summarize(cfg, files),
76
+ fileSources,
77
+ fragments,
50
78
  };
51
79
  }
52
80
 
81
+ /** Turn a config into a complete set of files. */
82
+ export function generate(input) {
83
+ const { config, files, postCommands, summary } = generateTracked(input);
84
+ return { config, files, postCommands, summary };
85
+ }
86
+
53
87
  function postCommands(cfg) {
54
88
  const install = {
55
89
  npm: 'npm install',
@@ -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' },
@@ -259,21 +260,42 @@ export function defaultConfig() {
259
260
  return cfg;
260
261
  }
261
262
 
262
- /** Merge partial input over the defaults and coerce a few fields. */
263
- 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) {
264
273
  const cfg = { ...defaultConfig(), ...input };
274
+ const requested = new Set(Object.keys(input));
265
275
 
266
- // A CLI target needs an executable build; JS `strict` isn't a thing, etc.
267
- if (!Array.isArray(cfg.target) || cfg.target.length === 0) cfg.target = ['library'];
268
- 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
+ };
269
289
 
270
- // Minify needs a bundler.
271
- if (cfg.bundler === 'none') cfg.minify = false;
272
- // Coverage only makes sense with a test runner that supports it.
273
- if (cfg.test === 'none' || cfg.test === 'node') cfg.coverage = false;
274
- // Codecov workflow implies coverage.
275
- if (cfg.workflows.includes('codecov')) cfg.coverage = true;
276
- // npm-publish + changesets are complementary; nothing to coerce, just noted.
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');
295
+
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');
277
299
 
278
300
  cfg.isReact = cfg.framework === 'react';
279
301
  cfg.isVue = cfg.framework === 'vue';
@@ -283,7 +305,7 @@ export function normalizeConfig(input = {}) {
283
305
  cfg.hasApp = cfg.target.includes('app');
284
306
  // A component-framework package that isn't an app is a library.
285
307
  if (cfg.hasFramework && !cfg.hasApp && !cfg.target.includes('library')) {
286
- 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');
287
309
  }
288
310
 
289
311
  cfg.isTs = cfg.language === 'ts';
@@ -305,31 +327,31 @@ export function normalizeConfig(input = {}) {
305
327
  cfg.hasBuild = cfg.viteBuild || (!cfg.svelteLib && (cfg.bundler !== 'none' || cfg.isTs));
306
328
 
307
329
  // Apps aren't published packages.
308
- 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');
309
331
  cfg.hasEsm = cfg.moduleFormat === 'esm' || cfg.moduleFormat === 'dual';
310
332
  cfg.hasCjs = cfg.moduleFormat === 'cjs' || cfg.moduleFormat === 'dual';
311
333
 
312
334
  // Storybook only applies to component libraries.
313
- 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.');
314
336
 
315
337
  // Playwright E2E only applies to app targets.
316
- 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.');
317
339
 
318
340
  // A monorepo is its own generation path (see buildMonorepo); it has a build.
319
341
  if (cfg.monorepo) cfg.hasBuild = true;
320
342
 
321
343
  cfg.publishable = (cfg.hasLibrary || cfg.hasCli) && !cfg.hasApp && !cfg.hasService;
322
344
  // Package-correctness checks only make sense for a publishable package.
323
- 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.');
324
346
  // Sourcemaps + shipped source only matter for a published package.
325
- 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.');
326
348
  // A bundle-size budget needs a published package with a built entry.
327
- 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.');
328
350
  // Env validation is for server-side runtimes (services / CLIs), not libs/apps.
329
- 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.');
330
352
  // Canary snapshots ride on the Changesets flow.
331
- 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.');
332
354
  // JSR is TypeScript-first, ESM, and for plain (non-framework) libraries.
333
- 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.');
334
356
  return cfg;
335
357
  }
@@ -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
+ }