From 0f2701682579a182fdf92d7d4617de99826b0776 Mon Sep 17 00:00:00 2001 From: DanMat Date: Tue, 21 Jul 2026 19:40:24 -0400 Subject: [PATCH 1/2] feat: full-stack monorepo preset, packkit.json, agent tool steering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three items from the field review of a real project built with Packkit. Full-stack monorepo (`fullstack`, alias `fs`): apps/web (React+Vite), apps/server (Hono), packages/shared. The pieces already existed as standalone presets — what was missing was the composition. Both apps import the shared package, Vite proxies /api to the server so requests are same-origin in dev exactly as in production, and the server serves the web build in production so one process covers the whole app. The existing `monorepo` preset is unchanged and remains the default layout. Verified by generating it, installing, and running the real toolchain rather than eyeballing the output. That caught two things templates get subtly wrong: missing @types/react (turbo typecheck failed on every JSX element) and a test script with no test files (vitest exits 1). Both fixed; build, typecheck and tests are green across all three packages, and the production server was confirmed serving both /api/health and the built web app on one port. packkit.json records the generator version, preset, and only the settings that differ from the defaults, so a project can finally answer "what did I start from?" — the prerequisite for any future upgrade command. No timestamps or machine details: generation stays a pure function of the config, so the same config yields the same bytes. MCP tool descriptions now point agents at packkit_schema first and describe packkit_preview as the structure-preview step. The reviewer asked for a "preview_project_structure" tool that already existed under a name they never found; the misremembering was the finding. Also fixes the MCP scaffold path to write a lockfile before pushing, matching the CLI — without it a created repo fails its first CI run. Co-Authored-By: Claude Opus 4.8 --- ROADMAP.md | 6 +- docs/packkit-core.js | 384 ++++++++++++++++++++++++++++++++++++++++- mcp/package-lock.json | 4 +- mcp/package.json | 2 +- mcp/server.js | 21 ++- server.json | 4 +- src/cli/args.js | 1 + src/cli/index.js | 7 +- src/core/index.js | 7 +- src/core/monorepo.js | 364 ++++++++++++++++++++++++++++++++++++++ src/core/options.js | 9 + src/core/presets.js | 7 + src/core/provenance.js | 35 ++++ test/core.test.js | 62 +++++++ 14 files changed, 897 insertions(+), 16 deletions(-) create mode 100644 src/core/provenance.js diff --git a/ROADMAP.md b/ROADMAP.md index c8cfe4b..818f3b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,10 +63,10 @@ _From mining pain points across create-typescript-app, tsup, Changesets, create- ## From field use (2026) _First unsolicited review from an agent that used Packkit to build a real internal full-stack tool. Verdict: "a scaffold, not a framework" — genuinely useful for day-zero plumbing, ~neutral once the domain work starts. That framing is fair and worth designing to, rather than against._ -- [ ] **Full-stack monorepo preset** — `web` + `server` + `shared` in one shot, workspace deps pre-wired, prod "server serves web dist" pattern. The `monorepo` preset hardcodes two *library* packages (`packages/core` + `packages/utils`, `packages/*` only) with no `apps/`, so a web+API repo means scaffolding pieces separately and merging them by hand — the opposite of one command. The individual parts already exist (`node-service`, `react-app`); it's the **composition** that's missing. Highest-value item here. -- [ ] **`packkit.json` provenance** — record preset, version, and flags used. Today Packkit leaves **zero footprint**, so a project can't answer "what did I start from?", can't diff against template updates, and has no upgrade path. This is the whole "no ongoing relationship" complaint, and it's the prerequisite for any future `packkit upgrade`. +- [x] ~~**Full-stack monorepo preset**~~ — **Shipped (2.9).** `fullstack` preset (alias `fs`) / `--monorepo-layout fullstack`. Verified by installing the generated workspace and running the real toolchain: build, typecheck and tests all green, and the production server serves the web build on one port. Original note: `web` + `server` + `shared` in one shot, workspace deps pre-wired, prod "server serves web dist" pattern. The `monorepo` preset hardcodes two *library* packages (`packages/core` + `packages/utils`, `packages/*` only) with no `apps/`, so a web+API repo means scaffolding pieces separately and merging them by hand — the opposite of one command. The individual parts already exist (`node-service`, `react-app`); it's the **composition** that's missing. Highest-value item here. +- [x] ~~**`packkit.json` provenance**~~ — **Shipped (2.9).** Records generator version, preset, and only the settings that differ from the defaults. No timestamps, so generation stays a pure function of the config. Original note: record preset, version, and flags used. Today Packkit leaves **zero footprint**, so a project can't answer "what did I start from?", can't diff against template updates, and has no upgrade path. This is the whole "no ongoing relationship" complaint, and it's the prerequisite for any future `packkit upgrade`. - [ ] **One-line MCP install** — mostly closed already: `packkit-mcp` now resolves from the official registry, which is exactly the gap ("wasn't configured in Cursor, so we fell back to `npx`"). What's left is a Cursor/VS Code deep-link install button in the READMEs so the agent-native path is the obvious one. -- [ ] **Preset discovery for agents** — the review asked for `preview_project_structure` as a default pre-scaffold step. That tool exists, as `packkit_preview` — misremembering the name *is* the finding. Steer agents in the tool descriptions ("call `packkit_schema` first") so wrong-preset detours stop happening. +- [x] ~~**Preset discovery for agents**~~ — **Shipped (2.9).** MCP tool descriptions now steer agents to `packkit_schema` first and describe `packkit_preview` as the structure-preview step, which is what the reviewer was looking for under a name that never existed. Original note: the review asked for `preview_project_structure` as a default pre-scaffold step. That tool exists, as `packkit_preview` — misremembering the name *is* the finding. Steer agents in the tool descriptions ("call `packkit_schema` first") so wrong-preset detours stop happening. - [ ] **Post-scaffold checklist** — "you chose X, here's what to customize next" for auth, env, deployment. Partially exists (the CLI already prints next steps and framework-specific advice); extend rather than build. **Two we should not act on as written.** The *"Node 22 vs 24 friction"* is the `--doctor`/engine preflight working correctly — it refuses to scaffold a project whose eslint/vite/vitest can't run, with the exact `nvm install` fix. Removing that trades one loud error for a broken install. Worth making the message more actionable, not softer. And *"overwrite risk"* in `--here` is inverted: it never overwrites, it hard-aborts — the fix is the merge mode above, not more guardrails. diff --git a/docs/packkit-core.js b/docs/packkit-core.js index dcbe91c..d587ede 100644 --- a/docs/packkit-core.js +++ b/docs/packkit-core.js @@ -71,6 +71,17 @@ var OPTIONS = { label: "Monorepo (pnpm/Turborepo workspace)", default: false }, + monorepoLayout: { + group: "core", + type: "select", + label: "Monorepo layout", + default: "libraries", + when: (cfg) => cfg.monorepo, + choices: [ + { value: "libraries", label: "Libraries \u2014 linked packages you publish" }, + { value: "fullstack", label: "Full-stack app \u2014 web + server + shared" } + ] + }, framework: { group: "core", type: "select", @@ -250,6 +261,7 @@ var OPTION_HELP = { moduleFormat: "How the package is consumed. ESM-only (default) is the modern, leanest choice \u2014 Node 20.19+/22.12+ can `require()` ESM. Pick dual only if you must support older CJS-only consumers; cjs-only is rarely needed.", target: "What you are building \u2014 mix and match: a library (importable package), a CLI (ships a bin), an HTTP service, or an app (Vite SPA).", serviceFramework: "For the service target: Hono (fast, web-standard, tiny \u2014 default), Fastify (batteries-included, plugins, schema validation), or Express (ubiquitous, huge ecosystem).", + 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.', monorepo: "Generate a pnpm + Turborepo workspace with two linked example packages and Changesets. Only worth it when \u22652 packages share code.", framework: "UI framework for component libraries and apps: React, Vue, or Svelte (or none for a plain package).", packageManager: "Which package manager the scripts, lockfile, and CI target: npm, pnpm, yarn, or bun.", @@ -2335,8 +2347,27 @@ var features_default = [ gitfiles_default ]; +// src/core/provenance.js +var TRANSIENT = /* @__PURE__ */ new Set(["gitInit", "install", "generatorVersion", "preset", "name"]); +function provenance(cfg) { + const defaults = defaultConfig(); + const settings = {}; + for (const [key, value] of Object.entries(cfg)) { + if (!(key in defaults) || TRANSIENT.has(key)) continue; + if (JSON.stringify(value) !== JSON.stringify(defaults[key])) settings[key] = value; + } + return toJson({ + $schema: "https://danmat.github.io/create-packkit/packkit.schema.json", + generator: "create-packkit", + ...cfg.generatorVersion ? { version: cfg.generatorVersion } : {}, + ...cfg.preset ? { preset: cfg.preset } : {}, + settings + }); +} + // src/core/monorepo.js function buildMonorepo(cfg) { + if (cfg.monorepoLayout === "fullstack") return buildFullstack(cfg); const files = {}; const pm = cfg.packageManager; const scope = cfg.name.replace(/^@/, "").split("/")[0]; @@ -2423,6 +2454,7 @@ function buildMonorepo(cfg) { ].join("\n"); files[".prettierrc.json"] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: "all" }); files["README.md"] = rootReadme(cfg, pm, core, utils); + files["packkit.json"] = provenance(cfg); files[".github/workflows/ci.yml"] = ciWorkflow2(cfg, pm); addPackage(files, { name: core, @@ -2465,6 +2497,340 @@ function buildMonorepo(cfg) { } }; } +function buildFullstack(cfg) { + const files = {}; + const pm = cfg.packageManager; + const scope = cfg.name.replace(/^@/, "").split("/")[0]; + const shared = `@${scope}/shared`; + const wsProto = pm === "pnpm" ? "workspace:*" : "*"; + const run2 = (s) => pm === "npm" ? `npm run ${s}` : `${pm} ${s}`; + for (const feat of [community_default, agents_default, gitfiles_default]) { + if (feat.active(cfg)) Object.assign(files, feat.apply(cfg).files); + } + files["package.json"] = toJson({ + name: cfg.name, + version: "0.0.0", + private: true, + type: "module", + ...cfg.license !== "none" ? { license: cfg.license } : {}, + ...pm === "pnpm" ? { packageManager: "pnpm@9.10.0" } : { workspaces: ["apps/*", "packages/*"] }, + scripts: { + dev: "turbo dev", + build: "turbo build", + // Production runs the built server, which also serves the web build. + start: `${pm === "npm" ? "npm --prefix apps/server run" : `${pm} --filter ./apps/server`} start`, + test: "turbo test", + lint: "turbo lint", + typecheck: "turbo typecheck" + }, + devDependencies: { + turbo: "^2.0.0", + typescript: "^5.9.3", + vitest: "^4.0.0", + eslint: "^10.0.0", + "@eslint/js": "^10.0.0", + "typescript-eslint": "^8.0.0", + prettier: "^3.3.0", + "@types/node": `^${cfg.nodeVersion}.0.0` + } + }); + if (pm === "pnpm") { + files["pnpm-workspace.yaml"] = 'packages:\n - "apps/*"\n - "packages/*"\n'; + } + files["turbo.json"] = toJson({ + $schema: "https://turbo.build/schema.json", + tasks: { + build: { dependsOn: ["^build"], outputs: ["dist/**"] }, + // Shared is built before the apps start, so both sides always import a + // current copy without a separate watch process. + dev: { dependsOn: ["^build"], cache: false, persistent: true }, + test: { dependsOn: ["^build"] }, + typecheck: { dependsOn: ["^build"] }, + lint: {} + } + }); + files["tsconfig.base.json"] = toJson({ + $schema: "https://json.schemastore.org/tsconfig", + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + lib: ["ES2022", "DOM"], + strict: true, + esModuleInterop: true, + skipLibCheck: true, + declaration: true, + noEmit: true + } + }); + files["eslint.config.js"] = [ + `import js from '@eslint/js';`, + `import tseslint from 'typescript-eslint';`, + ``, + `export default tseslint.config(`, + ` js.configs.recommended,`, + ` ...tseslint.configs.recommended,`, + ` { ignores: ['**/dist'] },`, + `);`, + `` + ].join("\n"); + files[".prettierrc.json"] = toJson({ useTabs: true, singleQuote: true, semi: true, printWidth: 100, trailingComma: "all" }); + files[".github/workflows/ci.yml"] = ciWorkflow2(cfg, pm); + files["README.md"] = fullstackReadme(cfg, pm, shared); + files["packkit.json"] = provenance(cfg); + files["packages/shared/package.json"] = toJson({ + name: shared, + version: "0.0.0", + private: true, + type: "module", + main: "./dist/index.js", + types: "./dist/index.d.ts", + exports: { ".": { types: "./dist/index.d.ts", default: "./dist/index.js" } }, + scripts: { + build: "tsup src/index.ts --format esm --dts --clean", + test: "vitest run", + typecheck: "tsc --noEmit", + lint: "eslint ." + }, + devDependencies: { tsup: "^8.0.0" } + }); + files["packages/shared/tsconfig.json"] = toJson({ extends: "../../tsconfig.base.json", include: ["src"] }); + files["packages/shared/src/index.ts"] = [ + `/** The shape the server returns and the web app renders. Change it once. */`, + `export interface Health {`, + ` ok: boolean;`, + ` service: string;`, + ` uptime: number;`, + `}`, + ``, + `export function describeHealth(h: Health): string {`, + ` return h.ok ? \`\${h.service} is up (\${Math.round(h.uptime)}s)\` : \`\${h.service} is down\`;`, + `}`, + `` + ].join("\n"); + files["packages/shared/src/index.test.ts"] = exampleTest2( + `import { describeHealth } from './index.js';`, + `expect(describeHealth({ ok: true, service: 'api', uptime: 12 })).toBe('api is up (12s)')` + ); + files["apps/server/package.json"] = toJson({ + name: `@${scope}/server`, + version: "0.0.0", + private: true, + type: "module", + scripts: { + dev: "tsx watch src/index.ts", + build: "tsup src/index.ts --format esm --clean", + start: "node dist/index.js", + test: "vitest run", + typecheck: "tsc --noEmit", + lint: "eslint ." + }, + dependencies: { hono: "^4.5.0", "@hono/node-server": "^2.0.0", [shared]: wsProto }, + devDependencies: { tsx: "^4.0.0", tsup: "^8.0.0" } + }); + files["apps/server/tsconfig.json"] = toJson({ extends: "../../tsconfig.base.json", include: ["src"] }); + files["apps/server/src/app.ts"] = [ + `import { Hono } from 'hono';`, + `import { serveStatic } from '@hono/node-server/serve-static';`, + `import type { Health } from '${shared}';`, + ``, + `export const app = new Hono();`, + ``, + `app.get('/api/health', (c) => {`, + ` const body: Health = { ok: true, service: '${cfg.name}', uptime: process.uptime() };`, + ` return c.json(body);`, + `});`, + ``, + `// In production the API also serves the built web app, so one process and`, + `// one port covers the whole thing. In dev, Vite serves the app and proxies`, + `// /api here instead (see apps/web/vite.config.ts).`, + `if (process.env.NODE_ENV === 'production') {`, + ` app.use('/*', serveStatic({ root: '../web/dist' }));`, + `}`, + `` + ].join("\n"); + files["apps/server/src/index.ts"] = [ + `import { serve } from '@hono/node-server';`, + `import { app } from './app.js';`, + ``, + `const port = Number(process.env.PORT ?? 3000);`, + `serve({ fetch: app.fetch, port });`, + `console.log(\`Listening on http://localhost:\${port}\`);`, + `` + ].join("\n"); + files["apps/server/src/app.test.ts"] = [ + `import { describe, it, expect } from 'vitest';`, + `import { app } from './app.js';`, + ``, + `describe('api', () => {`, + ` it('reports health', async () => {`, + ` const res = await app.request('/api/health');`, + ` expect(res.status).toBe(200);`, + ` expect(await res.json()).toMatchObject({ ok: true });`, + ` });`, + `});`, + `` + ].join("\n"); + files["apps/web/package.json"] = toJson({ + name: `@${scope}/web`, + version: "0.0.0", + private: true, + type: "module", + scripts: { + dev: "vite", + build: "vite build", + preview: "vite preview", + test: "vitest run", + typecheck: "tsc --noEmit", + lint: "eslint ." + }, + dependencies: { react: "^19.0.0", "react-dom": "^19.0.0", [shared]: wsProto }, + devDependencies: { + vite: "^8.0.0", + "@vitejs/plugin-react": "^6.0.0", + // Without the React types, `turbo typecheck` fails on the very first run + // with TS7016/TS7026 on every JSX element. + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@testing-library/react": "^16.0.0", + "@testing-library/dom": "^10.0.0", + jsdom: "^29.0.0" + } + }); + files["apps/web/tsconfig.json"] = toJson({ + extends: "../../tsconfig.base.json", + compilerOptions: { jsx: "react-jsx" }, + include: ["src"] + }); + files["apps/web/vite.config.ts"] = [ + `/// `, + `import { defineConfig } from 'vite';`, + `import react from '@vitejs/plugin-react';`, + ``, + `export default defineConfig({`, + ` plugins: [react()],`, + ` // Same-origin /api in dev as in production, so no CORS and no base URL`, + ` // juggling between environments.`, + ` server: { proxy: { '/api': 'http://localhost:3000' } },`, + ` test: { environment: 'jsdom' },`, + `});`, + `` + ].join("\n"); + files["apps/web/src/App.test.tsx"] = [ + `import { describe, it, expect } from 'vitest';`, + `import { render, screen } from '@testing-library/react';`, + `import { App } from './App.js';`, + ``, + `describe('App', () => {`, + ` it('renders the service name', () => {`, + ` render();`, + ` expect(screen.getByRole('heading', { name: '${cfg.name}' })).toBeDefined();`, + ` });`, + `});`, + `` + ].join("\n"); + files["apps/web/index.html"] = [ + ``, + ``, + ` `, + ` `, + ` `, + ` ${cfg.name}`, + ` `, + ` `, + `
`, + ` `, + `\t`, + ``, + ``, + ].join('\n'); + files['apps/web/src/main.tsx'] = [ + `import { StrictMode } from 'react';`, + `import { createRoot } from 'react-dom/client';`, + `import { App } from './App.js';`, + ``, + `createRoot(document.getElementById('root')!).render(`, + `\t`, + `\t\t`, + `\t,`, + `);`, + ``, + ].join('\n'); + files['apps/web/src/App.tsx'] = [ + `import { useEffect, useState } from 'react';`, + `import { describeHealth, type Health } from '${shared}';`, + ``, + `export function App() {`, + `\tconst [health, setHealth] = useState(null);`, + ``, + `\tuseEffect(() => {`, + `\t\tfetch('/api/health')`, + `\t\t\t.then((r) => r.json() as Promise)`, + `\t\t\t.then(setHealth)`, + `\t\t\t.catch(() => setHealth({ ok: false, service: '${cfg.name}', uptime: 0 }));`, + `\t}, []);`, + ``, + `\treturn (`, + `\t\t
`, + `\t\t\t

${cfg.name}

`, + `\t\t\t

{health ? describeHealth(health) : 'Checking…'}

`, + `\t\t
`, + `\t);`, + `}`, + ``, + ].join('\n'); + + return { + config: cfg, + files, + postCommands: cfg.gitInit ? ['git init', 'git add -A', 'git commit -m "Initial commit from Packkit"'] : [], + summary: { + name: cfg.name, + fileCount: Object.keys(files).length, + stack: ['monorepo', 'full-stack', `${pm}+turbo`, 'React+Vite', 'Hono', 'TypeScript', 'vitest'], + workflows: ['ci'], + }, + }; +} + +function fullstackReadme(cfg, pm, shared) { + const install = pm === 'npm' ? 'npm install' : `${pm} install`; + const run = (s) => (pm === 'npm' ? `npm run ${s}` : `${pm} ${s}`); + return [ + `# ${cfg.name}`, + '', + cfg.description || '_A full-stack monorepo scaffolded with [Packkit](https://danmat.github.io/create-packkit/)._', + '', + '## Layout', + '', + '```', + 'apps/web React + Vite front end', + 'apps/server Hono API (also serves the web build in production)', + 'packages/shared types and helpers both sides import', + '```', + '', + '## Develop', + '', + '```sh', + install, + run('dev') + ' # web on :5173, api on :3000', + '```', + '', + `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.`, + '', + '## Production', + '', + '```sh', + run('build'), + run('start') + ' # one process serving the API and the built web app', + '```', + '', + `\`${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.`, + '', + cfg.license !== 'none' ? `## License\n\n${cfg.license}${cfg.author ? ' © ' + cfg.author : ''}\n` : '', + ].join('\n'); +} + function addPackage(files, { name, dir, src, test, deps }) { const pkg = { name, diff --git a/src/core/options.js b/src/core/options.js index abb6640..eaacdf2 100644 --- a/src/core/options.js +++ b/src/core/options.js @@ -54,6 +54,14 @@ export const OPTIONS = { monorepo: { group: 'core', type: 'boolean', label: 'Monorepo (pnpm/Turborepo workspace)', default: false, }, + monorepoLayout: { + group: 'core', type: 'select', label: 'Monorepo layout', default: 'libraries', + when: (cfg) => cfg.monorepo, + choices: [ + { value: 'libraries', label: 'Libraries — linked packages you publish' }, + { value: 'fullstack', label: 'Full-stack app — web + server + shared' }, + ], + }, framework: { group: 'core', type: 'select', label: 'Framework', default: 'none', choices: [ @@ -209,6 +217,7 @@ export const OPTION_HELP = { 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.', 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).', serviceFramework: 'For the service target: Hono (fast, web-standard, tiny — default), Fastify (batteries-included, plugins, schema validation), or Express (ubiquitous, huge ecosystem).', + 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.', monorepo: 'Generate a pnpm + Turborepo workspace with two linked example packages and Changesets. Only worth it when ≥2 packages share code.', framework: 'UI framework for component libraries and apps: React, Vue, or Svelte (or none for a plain package).', packageManager: 'Which package manager the scripts, lockfile, and CI target: npm, pnpm, yarn, or bun.', diff --git a/src/core/presets.js b/src/core/presets.js index d8d02c0..5a7eea8 100644 --- a/src/core/presets.js +++ b/src/core/presets.js @@ -19,6 +19,10 @@ export const PRESETS = { release: 'none', workflows: ['ci'], deps: 'renovate', agents: true, vscode: true, }, monorepo: { monorepo: true, language: 'ts', packageManager: 'pnpm' }, + fullstack: { + monorepo: true, monorepoLayout: 'fullstack', language: 'ts', packageManager: 'pnpm', + framework: 'react', test: 'vitest', release: 'none', workflows: ['ci'], + }, oss: { language: 'ts', target: ['library'], moduleFormat: 'esm', bundler: 'tsup', test: 'vitest', coverage: true, lint: 'eslint-prettier', gitHooks: 'simple-git-hooks', @@ -53,6 +57,8 @@ export const PRESET_ALIASES = { slib: 'svelte-lib', sapp: 'svelte-app', svc: 'node-service', + fs: 'fullstack', + app: 'fullstack', service: 'node-service', }; @@ -78,6 +84,7 @@ export const PRESET_INFO = { 'svelte-app': 'Svelte SPA — Vite dev server, build, Testing Library.', 'node-service': 'Node HTTP service (Hono) — tsx dev, tsup build, Dockerfile.', monorepo: 'pnpm + Turborepo workspace — two example packages, Changesets, CI.', + fullstack: 'Full-stack monorepo — React+Vite web, Hono API, shared package; server serves the web build in production.', oss: 'Full open-source library — coverage, CodeQL, Codecov, Renovate, Changesets.', minimal: 'Bare TS library — tsup only, no tests/lint/CI.', full: 'Everything on — library + CLI, all workflows and extras.', diff --git a/src/core/provenance.js b/src/core/provenance.js new file mode 100644 index 0000000..9f8899d --- /dev/null +++ b/src/core/provenance.js @@ -0,0 +1,35 @@ +// packkit.json — what this project was generated from. +// +// Packkit used to leave no trace, so a project couldn't answer "what did I +// start from?", couldn't be diffed against a newer template, and had no upgrade +// path. This records the answer next to the code. +// +// Only settings that differ from the defaults are stored, so the file reads as +// the decisions someone actually made rather than a dump of every option. It is +// deliberately free of timestamps and machine details: generation stays a pure +// function of the config, so the same config always produces the same bytes. + +import { toJson } from './render.js'; +import { defaultConfig } from './options.js'; + +// How this particular run was invoked, rather than what the project is. +// Replaying a config shouldn't re-run someone else's `git init` or install. +const TRANSIENT = new Set(['gitInit', 'install', 'generatorVersion', 'preset', 'name']); + +export function provenance(cfg) { + const defaults = defaultConfig(); + const settings = {}; + for (const [key, value] of Object.entries(cfg)) { + // Skip derived helpers (isTs, hasApp, ext…) — they aren't inputs. + if (!(key in defaults) || TRANSIENT.has(key)) continue; + if (JSON.stringify(value) !== JSON.stringify(defaults[key])) settings[key] = value; + } + + return toJson({ + $schema: 'https://danmat.github.io/create-packkit/packkit.schema.json', + generator: 'create-packkit', + ...(cfg.generatorVersion ? { version: cfg.generatorVersion } : {}), + ...(cfg.preset ? { preset: cfg.preset } : {}), + settings, + }); +} diff --git a/test/core.test.js b/test/core.test.js index 840f97d..5b4c65b 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -309,3 +309,65 @@ test('every generated package.json parses', () => { assert.doesNotThrow(() => JSON.parse(out.files['package.json']), preset); } }); + +test('fullstack: web + server + shared, wired as a workspace', () => { + const out = generate(fromPreset('fullstack', { name: 'acme' })); + const root = JSON.parse(out.files['package.json']); + const web = JSON.parse(out.files['apps/web/package.json']); + const server = JSON.parse(out.files['apps/server/package.json']); + const shared = JSON.parse(out.files['packages/shared/package.json']); + + assert.equal(out.files['pnpm-workspace.yaml'], 'packages:\n - "apps/*"\n - "packages/*"\n'); + assert.equal(root.private, true, 'an app monorepo publishes nothing'); + + // Both sides depend on shared, which is what makes this a composition rather + // than three projects in one folder. + assert.ok(web.dependencies['@acme/shared']); + assert.ok(server.dependencies['@acme/shared']); + assert.equal(shared.private, true); + + // The React types are what `turbo typecheck` fails without. + assert.ok(web.devDependencies['@types/react'], '@types/react'); + assert.ok(web.devDependencies['@types/react-dom'], '@types/react-dom'); + // A test script with no test files makes vitest exit 1. + assert.ok(out.files['apps/web/src/App.test.tsx'], 'web has a test'); + assert.match(out.files['apps/web/vite.config.ts'], /environment: 'jsdom'/); + + // Same-origin /api in dev and prod. + assert.match(out.files['apps/web/vite.config.ts'], /'\/api': 'http:\/\/localhost:3000'/); + assert.match(out.files['apps/server/src/app.ts'], /serveStatic\(\{ root: '\.\.\/web\/dist' \}\)/); +}); + +test('fullstack: shared is built before the apps start, so dev has current types', () => { + const out = generate(fromPreset('fullstack', { name: 'acme' })); + const turbo = JSON.parse(out.files['turbo.json']); + assert.deepEqual(turbo.tasks.dev.dependsOn, ['^build']); + assert.equal(turbo.tasks.dev.persistent, true); +}); + +test('monorepo default layout is still the library one', () => { + const out = generate(fromPreset('monorepo', { name: 'libs' })); + assert.ok(out.files['packages/core/package.json']); + assert.equal(out.files['apps/web/package.json'], undefined); +}); + +test('packkit.json records only what differs from the defaults', () => { + const out = generate(fromPreset('node-service', { name: 'svc', generatorVersion: '9.9.9' })); + const prov = JSON.parse(out.files['packkit.json']); + assert.equal(prov.generator, 'create-packkit'); + assert.equal(prov.version, '9.9.9'); + assert.equal(prov.preset, 'node-service'); + // Derived helpers and per-run choices aren't inputs, so they'd be misleading. + for (const k of ['isTs', 'hasApp', 'ext', 'gitInit', 'install', 'name']) { + assert.equal(prov.settings[k], undefined, `${k} should not be recorded`); + } + assert.ok(Object.keys(prov.settings).length > 0, 'a preset differs from defaults'); +}); + +test('packkit.json is deterministic — same config, same bytes', () => { + const cfg = { name: 'x', generatorVersion: '1.0.0' }; + assert.equal( + generate(fromPreset('ts-lib', cfg)).files['packkit.json'], + generate(fromPreset('ts-lib', cfg)).files['packkit.json'], + ); +}); From a45cc040d4d350de097a7fb2e0307261b6b3439f Mon Sep 17 00:00:00 2001 From: DanMat Date: Tue, 21 Jul 2026 19:44:09 -0400 Subject: [PATCH 2/2] test: cover fullstack in the integration matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every preset but the new one was covered, and it is the most complex — three packages, a workspace, and a dev story with two servers. Verified locally: install, typecheck, lint, build, test, and `turbo dev` bringing up Vite on 5173 and Hono on 3000 together. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integration.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 660e84a..926d834 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -37,6 +37,7 @@ jobs: - svelte-app - node-service - monorepo + - fullstack - oss - full - minimal