create-packkit 2.1.0 → 2.3.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.1.0",
3
+ "version": "2.3.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
@@ -23,6 +23,7 @@ const OVERRIDE_FLAGS = {
23
23
  // Boolean options that default ON — a --no-<flag> turns them off.
24
24
  const NEGATABLE = {
25
25
  'no-coverage': 'coverage',
26
+ 'no-sourcemaps': 'sourcemaps',
26
27
  'no-community': 'community',
27
28
  'no-agents': 'agents',
28
29
  'no-vscode': 'vscode',
@@ -49,8 +50,11 @@ export function parseCliArgs(argv) {
49
50
  monorepo: { type: 'boolean' },
50
51
  storybook: { type: 'boolean' },
51
52
  e2e: { type: 'boolean' },
53
+ env: { type: 'boolean' },
54
+ canary: { type: 'boolean' },
52
55
  'pkg-checks': { type: 'boolean' },
53
56
  knip: { type: 'boolean' },
57
+ 'size-limit': { type: 'boolean' },
54
58
  jsr: { type: 'boolean' },
55
59
  help: { type: 'boolean', short: 'h' },
56
60
  version: { type: 'boolean', short: 'v' },
@@ -76,8 +80,11 @@ export function parseCliArgs(argv) {
76
80
  if (values.monorepo) overrides.monorepo = true;
77
81
  if (values.storybook) overrides.storybook = true;
78
82
  if (values.e2e) overrides.e2e = true;
83
+ if (values.env) overrides.env = true;
84
+ if (values.canary) overrides.canary = true;
79
85
  if (values['pkg-checks']) overrides.pkgChecks = true;
80
86
  if (values.knip) overrides.knip = true;
87
+ if (values['size-limit']) overrides.sizeLimit = true;
81
88
  if (values.jsr) overrides.jsr = true;
82
89
  for (const [flag, key] of Object.entries(NEGATABLE)) {
83
90
  if (values[flag]) overrides[key] = false;
package/src/cli/index.js CHANGED
@@ -41,11 +41,13 @@ Options:
41
41
  --target <library|cli|service|app> (repeatable) --storybook
42
42
  --bundler <tsup|tsdown|unbuild|rollup|none> --minify
43
43
  --test <vitest|jest|node|none> --e2e (Playwright, for apps)
44
+ --env (type-safe env validation, services/CLIs) --no-sourcemaps
44
45
  --lint <eslint-prettier|biome|oxlint|none>
45
46
  --hooks <simple-git-hooks|husky|lefthook|none> --release <changesets|release-it|np|none>
46
47
  --workflows <ci|npm-publish|pages|codeql|codecov|stale> (repeatable)
47
48
  --deps <renovate|dependabot|none> --license <MIT|Apache-2.0|ISC|none>
48
- --pkg-checks (publint + are-the-types-wrong) --knip --jsr
49
+ --pkg-checks (publint + are-the-types-wrong) --knip --jsr --canary
50
+ --size-limit (bundle-size budget, libraries)
49
51
  --no-coverage --no-community --no-agents --no-vscode --no-editorconfig
50
52
  --schema Print the full option/preset schema as JSON (for tools/agents)
51
53
  -h, --help Show this help
package/src/cli/wizard.js CHANGED
@@ -41,7 +41,13 @@ export async function runWizard(seed = {}) {
41
41
  }
42
42
  cfg.lint = bail(await p.select({ message: 'Lint / format', options: asOptions('lint'), initialValue: OPTIONS.lint.default }));
43
43
  cfg.gitHooks = bail(await p.select({ message: 'Git hooks', options: asOptions('gitHooks'), initialValue: OPTIONS.gitHooks.default }));
44
+ if (cfg.target.includes('service') || cfg.target.includes('cli')) {
45
+ cfg.env = bail(await p.confirm({ message: 'Type-safe env validation (Zod)?', initialValue: false }));
46
+ }
44
47
  cfg.release = bail(await p.select({ message: 'Release / versioning', options: asOptions('release'), initialValue: OPTIONS.release.default }));
48
+ if (cfg.release === 'changesets') {
49
+ cfg.canary = bail(await p.confirm({ message: 'Add a canary/snapshot release workflow?', initialValue: false }));
50
+ }
45
51
  cfg.workflows = bail(await p.multiselect({ message: 'GitHub Actions (space to toggle)', options: asOptions('workflows'), initialValues: OPTIONS.workflows.default, required: false }));
46
52
  cfg.deps = bail(await p.select({ message: 'Dependency updates', options: asOptions('deps'), initialValue: OPTIONS.deps.default }));
47
53
  cfg.license = bail(await p.select({ message: 'License', options: asOptions('license'), initialValue: OPTIONS.license.default }));
@@ -18,6 +18,9 @@ export default {
18
18
  if (cfg.hasEsm) pkg.module = './src/index.js';
19
19
  } else {
20
20
  pkg.files = ['dist'];
21
+ // Ship source alongside dist so the JS sourcemaps resolve — consumers can
22
+ // step into and go-to-definition on your original code.
23
+ if (cfg.sourcemaps) pkg.files.push('src');
21
24
  const esm = './dist/index.js';
22
25
  const cjs = './dist/index.cjs';
23
26
  const dtsEsm = './dist/index.d.ts';
@@ -0,0 +1,58 @@
1
+ // Type-safe environment-variable validation for server-side runtimes (services
2
+ // and CLIs). Validates process.env once at startup with a Zod schema, so a
3
+ // misconfigured deploy fails fast with a clear message instead of a surprise
4
+ // runtime crash. Ships a matching .env.example.
5
+
6
+ export default {
7
+ id: 'env',
8
+ active: (cfg) => cfg.env && (cfg.hasService || cfg.hasCli),
9
+ apply(cfg) {
10
+ const files = {};
11
+
12
+ files[`src/env.${cfg.ext}`] = cfg.isTs ? envTs() : envJs();
13
+ files['.env.example'] = ['NODE_ENV=development', 'PORT=3000', ''].join('\n');
14
+
15
+ return { files, pkg: { dependencies: { zod: '^4.0.0' } } };
16
+ },
17
+ };
18
+
19
+ function envTs() {
20
+ return [
21
+ `import { z } from 'zod';`,
22
+ ``,
23
+ `const schema = z.object({`,
24
+ `\tNODE_ENV: z.enum(['development', 'production', 'test']).default('development'),`,
25
+ `\tPORT: z.coerce.number().default(3000),`,
26
+ `});`,
27
+ ``,
28
+ `const parsed = schema.safeParse(process.env);`,
29
+ `if (!parsed.success) {`,
30
+ `\tconsole.error('❌ Invalid environment variables:', z.treeifyError(parsed.error));`,
31
+ `\tprocess.exit(1);`,
32
+ `}`,
33
+ ``,
34
+ `export const env = parsed.data;`,
35
+ ``,
36
+ ].join('\n');
37
+ }
38
+
39
+ function envJs() {
40
+ return [
41
+ `import { z } from 'zod';`,
42
+ ``,
43
+ `const schema = z.object({`,
44
+ `\tNODE_ENV: z.enum(['development', 'production', 'test']).default('development'),`,
45
+ `\tPORT: z.coerce.number().default(3000),`,
46
+ `});`,
47
+ ``,
48
+ `const parsed = schema.safeParse(process.env);`,
49
+ `if (!parsed.success) {`,
50
+ `\tconsole.error('❌ Invalid environment variables:', z.treeifyError(parsed.error));`,
51
+ `\tprocess.exit(1);`,
52
+ `}`,
53
+ ``,
54
+ `/** @type {z.infer<typeof schema>} */`,
55
+ `export const env = parsed.data;`,
56
+ ``,
57
+ ].join('\n');
58
+ }
@@ -7,6 +7,7 @@ import typescript from './typescript.js';
7
7
  import frameworks from './frameworks.js';
8
8
  import vite from './vite.js';
9
9
  import service from './service.js';
10
+ import env from './env.js';
10
11
  import test from './test.js';
11
12
  import e2e from './e2e.js';
12
13
  import lint from './lint.js';
@@ -14,6 +15,7 @@ import githooks from './githooks.js';
14
15
  import release from './release.js';
15
16
  import cli from './cli.js';
16
17
  import checks from './checks.js';
18
+ import sizelimit from './sizelimit.js';
17
19
  import jsr from './jsr.js';
18
20
  import workflows from './workflows.js';
19
21
  import storybook from './storybook.js';
@@ -29,6 +31,7 @@ export default [
29
31
  frameworks,
30
32
  vite,
31
33
  service,
34
+ env,
32
35
  test,
33
36
  e2e,
34
37
  lint,
@@ -36,6 +39,7 @@ export default [
36
39
  release,
37
40
  cli,
38
41
  checks,
42
+ sizelimit,
39
43
  jsr,
40
44
  workflows,
41
45
  storybook,
@@ -10,7 +10,7 @@ export default {
10
10
  [`src/app.${ext}`]: appFile(cfg),
11
11
  [`src/index.${ext}`]: serverFile(cfg),
12
12
  Dockerfile: dockerfile(cfg),
13
- '.dockerignore': 'node_modules\ndist\n.git\n.env\n',
13
+ '.dockerignore': ['node_modules', 'dist', 'coverage', '.git', '.github', '.env', '.env.*', '!.env.example', '*.log', 'Dockerfile', '.dockerignore', ''].join('\n'),
14
14
  };
15
15
  return {
16
16
  files,
@@ -43,22 +43,25 @@ function appFile(cfg) {
43
43
  function serverFile(cfg) {
44
44
  return [
45
45
  `import { serve } from '@hono/node-server';`,
46
- `import { app } from './app${cfg.isTs ? '.js' : '.js'}';`,
46
+ `import { app } from './app.js';`,
47
+ cfg.env ? `import { env } from './env.js';` : null,
47
48
  ``,
48
- `const port = Number(process.env.PORT) || 3000;`,
49
+ `const port = ${cfg.env ? 'env.PORT' : 'Number(process.env.PORT) || 3000'};`,
49
50
  `serve({ fetch: app.fetch, port }, (info) => {`,
50
51
  `\tconsole.log(\`Listening on http://localhost:\${info.port}\`);`,
51
52
  `});`,
52
53
  ``,
53
- ].join('\n');
54
+ ].filter((l) => l !== null).join('\n');
54
55
  }
55
56
 
56
57
  function dockerfile(cfg) {
57
58
  const node = cfg.nodeVersion;
58
59
  const pm = cfg.packageManager;
59
60
  const install = pm === 'npm' ? 'npm ci' : `${pm} install --frozen-lockfile`;
61
+ const prune = pm === 'npm' ? 'npm ci --omit=dev' : `${pm} install --prod --frozen-lockfile`;
60
62
  const build = pm === 'npm' ? 'npm run build' : `${pm} run build`;
61
63
  return [
64
+ `# --- build stage: install everything and compile ---`,
62
65
  `FROM node:${node}-slim AS build`,
63
66
  `WORKDIR /app`,
64
67
  `COPY package*.json ./`,
@@ -66,13 +69,22 @@ function dockerfile(cfg) {
66
69
  `COPY . .`,
67
70
  `RUN ${build}`,
68
71
  ``,
72
+ `# --- deps stage: production-only node_modules for a smaller image ---`,
73
+ `FROM node:${node}-slim AS deps`,
74
+ `WORKDIR /app`,
75
+ `COPY package*.json ./`,
76
+ `RUN ${prune}`,
77
+ ``,
78
+ `# --- runtime stage: slim, non-root, healthchecked ---`,
69
79
  `FROM node:${node}-slim`,
70
80
  `WORKDIR /app`,
71
81
  `ENV NODE_ENV=production`,
72
- `COPY --from=build /app/node_modules ./node_modules`,
82
+ `COPY --from=deps /app/node_modules ./node_modules`,
73
83
  `COPY --from=build /app/dist ./dist`,
74
84
  `COPY package.json ./`,
85
+ `USER node`,
75
86
  `EXPOSE 3000`,
87
+ `HEALTHCHECK --interval=30s --timeout=3s CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"`,
76
88
  `CMD ["node", "dist/index.js"]`,
77
89
  ``,
78
90
  ].join('\n');
@@ -0,0 +1,23 @@
1
+ // size-limit: a bundle-size budget for published libraries. Measures the
2
+ // brotli-compressed size of the built entry and fails when it exceeds the
3
+ // limit — cheap insurance against a dependency silently bloating the package.
4
+ import { toJson } from '../render.js';
5
+
6
+ export default {
7
+ id: 'sizelimit',
8
+ active: (cfg) => cfg.sizeLimit,
9
+ apply(cfg) {
10
+ return {
11
+ files: {
12
+ '.size-limit.json': toJson([{ name: cfg.name, path: 'dist/index.js', limit: '10 kB' }]),
13
+ },
14
+ pkg: {
15
+ scripts: { size: 'size-limit' },
16
+ devDependencies: {
17
+ 'size-limit': '^11.0.0',
18
+ '@size-limit/preset-small-lib': '^11.0.0',
19
+ },
20
+ },
21
+ };
22
+ },
23
+ };
@@ -19,6 +19,9 @@ export default {
19
19
  forceConsistentCasingInFileNames: true,
20
20
  verbatimModuleSyntax: cfg.bundler !== 'none' && !cfg.hasFramework,
21
21
  declaration: true,
22
+ // Declaration maps let editors jump from the published .d.ts into the
23
+ // shipped .ts source (the bundler emits JS sourcemaps to match).
24
+ ...(cfg.sourcemaps ? { declarationMap: true } : {}),
22
25
  };
23
26
  if (noBuild) {
24
27
  // tsc is the build: emit to dist.
@@ -26,6 +29,7 @@ export default {
26
29
  compilerOptions.module = 'NodeNext';
27
30
  compilerOptions.outDir = 'dist';
28
31
  compilerOptions.rootDir = 'src';
32
+ if (cfg.sourcemaps) compilerOptions.sourceMap = true;
29
33
  } else {
30
34
  compilerOptions.noEmit = true;
31
35
  }
@@ -12,6 +12,10 @@ function pmInstall(cfg) {
12
12
  function pmRun(cfg, script) {
13
13
  return cfg.packageManager === 'npm' ? `npm run ${script}` : `${cfg.packageManager} ${script}`;
14
14
  }
15
+ // Run a package binary (not an npm script) with the right runner per pm.
16
+ function pmExec(cfg, cmd) {
17
+ return { npm: `npx ${cmd}`, pnpm: `pnpm exec ${cmd}`, yarn: `yarn ${cmd}`, bun: `bunx ${cmd}` }[cfg.packageManager];
18
+ }
15
19
  function setupSteps(cfg) {
16
20
  const steps = [' - uses: actions/checkout@v4'];
17
21
  if (cfg.packageManager === 'pnpm') steps.push(' - uses: pnpm/action-setup@v4');
@@ -42,6 +46,7 @@ export default {
42
46
  if (wf.includes('codeql')) files['.github/workflows/codeql.yml'] = codeqlWorkflow();
43
47
  if (wf.includes('stale')) files['.github/workflows/stale.yml'] = staleWorkflow();
44
48
  if (cfg.e2e && cfg.hasApp && wf.includes('ci')) files['.github/workflows/e2e.yml'] = e2eWorkflow(cfg);
49
+ if (cfg.canary && cfg.release === 'changesets') files['.github/workflows/canary.yml'] = canaryWorkflow(cfg);
45
50
 
46
51
  if (cfg.deps === 'renovate') {
47
52
  files['.github/renovate.json'] = toJson({
@@ -75,6 +80,7 @@ function ciWorkflow(cfg, codecov) {
75
80
  if (cfg.test !== 'none') jobs.push(` - run: ${pmRun(cfg, codecov ? 'coverage' : 'test')}`);
76
81
  if (cfg.knip) jobs.push(` - run: ${pmRun(cfg, 'knip')}`);
77
82
  if (cfg.hasBuild) jobs.push(` - run: ${pmRun(cfg, 'build')}`);
83
+ if (cfg.sizeLimit) jobs.push(` - run: ${pmRun(cfg, 'size')}`); // after build (measures dist)
78
84
  if (cfg.pkgChecks) jobs.push(` - run: ${pmRun(cfg, 'check:pkg')}`); // after build (attw packs dist)
79
85
  const cov = codecov
80
86
  ? '\n - uses: codecov/codecov-action@v4\n with:\n token: ${{ secrets.CODECOV_TOKEN }}'
@@ -224,6 +230,30 @@ function e2eWorkflow(cfg) {
224
230
  ].join('\n');
225
231
  }
226
232
 
233
+ function canaryWorkflow(cfg) {
234
+ return [
235
+ 'name: Canary',
236
+ '# Manually publish a snapshot (x.y.z-canary-<hash>) to the `canary` dist-tag',
237
+ '# so consumers can test unreleased changes: npm i ' + cfg.name + '@canary',
238
+ 'on:',
239
+ ' workflow_dispatch:',
240
+ 'concurrency: canary-${{ github.ref }}',
241
+ 'permissions:',
242
+ ' contents: read',
243
+ 'jobs:',
244
+ ' canary:',
245
+ ' runs-on: ubuntu-latest',
246
+ ' steps:',
247
+ setupSteps(cfg),
248
+ ' - name: Authenticate with npm',
249
+ ' run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc',
250
+ ` - run: ${pmExec(cfg, 'changeset version --snapshot canary')}`,
251
+ cfg.hasBuild ? ` - run: ${pmRun(cfg, 'build')}` : null,
252
+ ` - run: ${pmExec(cfg, 'changeset publish --no-git-tag --tag canary')}`,
253
+ '',
254
+ ].filter((l) => l !== null).join('\n');
255
+ }
256
+
227
257
  function staleWorkflow() {
228
258
  return [
229
259
  'name: Stale',
@@ -97,8 +97,12 @@ export const OPTIONS = {
97
97
  coverage: { group: 'quality', type: 'boolean', label: 'Coverage reporting', default: true },
98
98
  storybook: { group: 'quality', type: 'boolean', label: 'Storybook (component libraries)', default: false },
99
99
  e2e: { group: 'quality', type: 'boolean', label: 'Playwright end-to-end tests (apps)', default: false },
100
+ sourcemaps: { group: 'build', type: 'boolean', label: 'Sourcemaps + ship source (debug into original code)', default: true },
101
+ env: { group: 'quality', type: 'boolean', label: 'Type-safe env validation (Zod) — services & CLIs', default: false },
102
+ canary: { group: 'release', type: 'boolean', label: 'Snapshot / canary release workflow (Changesets)', default: false },
100
103
  pkgChecks: { group: 'quality', type: 'boolean', label: 'Package checks (publint + are-the-types-wrong)', default: false },
101
104
  knip: { group: 'quality', type: 'boolean', label: 'Knip (unused files / deps / exports)', default: false },
105
+ sizeLimit: { group: 'quality', type: 'boolean', label: 'size-limit (bundle-size budget, libraries)', default: false },
102
106
 
103
107
  // ---- lint / format ----
104
108
  lint: {
@@ -255,6 +259,14 @@ export function normalizeConfig(input = {}) {
255
259
  cfg.publishable = (cfg.hasLibrary || cfg.hasCli) && !cfg.hasApp && !cfg.hasService;
256
260
  // Package-correctness checks only make sense for a publishable package.
257
261
  if (!cfg.publishable) cfg.pkgChecks = false;
262
+ // Sourcemaps + shipped source only matter for a published package.
263
+ if (!cfg.publishable) cfg.sourcemaps = false;
264
+ // A bundle-size budget needs a published package with a built entry.
265
+ if (!(cfg.publishable && cfg.hasBuild)) cfg.sizeLimit = false;
266
+ // Env validation is for server-side runtimes (services / CLIs), not libs/apps.
267
+ if (!(cfg.hasService || cfg.hasCli)) cfg.env = false;
268
+ // Canary snapshots ride on the Changesets flow.
269
+ if (cfg.release !== 'changesets') cfg.canary = false;
258
270
  // JSR is TypeScript-first, ESM, and for plain (non-framework) libraries.
259
271
  if (!(cfg.isTs && cfg.hasLibrary && !cfg.hasFramework && !cfg.hasApp)) cfg.jsr = false;
260
272
  return cfg;