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 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'], + ); +});