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.
- package/README.md +20 -2
- package/package.json +17 -3
- package/src/cli/args.js +1 -0
- package/src/cli/index.js +11 -1
- package/src/core/features/meta.js +25 -0
- package/src/core/index.js +47 -8
- package/src/core/monorepo.js +364 -0
- package/src/core/options.js +53 -22
- package/src/core/presets.js +7 -0
- package/src/core/provenance.js +35 -0
- package/src/core/render.js +6 -0
- package/src/embedded/contract.js +64 -0
- package/src/embedded/index.js +451 -0
- package/src/embedded/paths.js +88 -0
- package/src/embedded/pkg-merge.js +89 -0
- package/src/embedded/writer.js +151 -0
- package/src/index.js +12 -0
- package/types/core.d.ts +96 -0
- package/types/embedded.d.ts +92 -0
- package/types/index.d.ts +6 -0
- package/types/writer.d.ts +25 -0
package/src/core/options.js
CHANGED
|
@@ -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
|
-
/**
|
|
254
|
-
|
|
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
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
if (cfg.
|
|
263
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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))
|
|
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))
|
|
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')
|
|
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))
|
|
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
|
}
|
package/src/core/presets.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/render.js
CHANGED
|
@@ -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
|
+
}
|