${cfg.name}
`, + `\t\t\t{health ? describeHealth(health) : 'Checking…'}
`, + `\t\tdiff --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"] = [
+ `///
{health ? describeHealth(health) : 'Checking…'}
`, + `\t\t