create-patties 0.0.11 → 0.0.12

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +4 -2
  3. package/src/index.ts +345 -167
  4. package/src/prompts.ts +62 -17
  5. package/src/readme.ts +12 -5
  6. package/src/ui.ts +93 -0
  7. package/templates/_backend/app/routes/api/todos.ts +30 -0
  8. package/templates/_claude/.claude/commands/patties-init.md +33 -0
  9. package/templates/_claude/.claude/skills/patties/SKILL.md +104 -0
  10. package/templates/_codex/.codex/rules/patties-patterns.md +105 -0
  11. package/templates/_codex/AGENTS.md +1 -0
  12. package/templates/_container/.dockerignore +7 -0
  13. package/templates/_container/Dockerfile +27 -0
  14. package/templates/_monorepo/packages/README.md +18 -0
  15. package/templates/_shared/patties-patterns.md +105 -0
  16. package/templates/default/README-template.md +85 -71
  17. package/templates/default/app/routes/api/health.ts +8 -0
  18. package/templates/ui-starter/_internal/cn.ts +6 -0
  19. package/templates/ui-starter/_internal/slot.ts +50 -0
  20. package/templates/ui-starter/_internal/variants.ts +1 -0
  21. package/templates/ui-starter/button.tsx +60 -0
  22. package/templates/ui-starter/card.tsx +92 -0
  23. package/templates/ui-starter/demo/TodoApp.tsx +86 -0
  24. package/templates/ui-starter/demo/index.tsx +41 -0
  25. package/templates/ui-starter/input.tsx +20 -0
  26. package/templates/ui-starter/label.tsx +18 -0
  27. package/templates/ui-starter/themes/neutral/tokens.css +46 -0
  28. package/templates/ui-starter/themes/slate/tokens.css +46 -0
  29. package/templates/ui-starter/themes/stone/tokens.css +46 -0
  30. package/templates/ui-starter/themes/zinc/tokens.css +46 -0
  31. package/templates/ui-starter/tokens.css +46 -0
package/src/index.ts CHANGED
@@ -3,14 +3,21 @@ import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { hasGit, probeTools } from "./probes.ts";
4
4
  import {
5
5
  type AgentTemplate,
6
+ type Deploy,
6
7
  isInteractive,
8
+ type ProjectType,
7
9
  promptAgent,
8
10
  promptDeploy,
11
+ promptMonorepo,
9
12
  promptName,
10
- promptScaffold,
11
13
  promptTarget,
14
+ promptType,
15
+ promptUi,
16
+ type Target,
17
+ type Theme,
12
18
  } from "./prompts.ts";
13
19
  import { renderTemplatesInTree } from "./readme.ts";
20
+ import { applyUiStarter, UI_DEPS, UI_DEV_DEPS } from "./ui.ts";
14
21
 
15
22
  // Step logger — visible progress so the user can see what scaffolding does.
16
23
  // Honors NO_COLOR; degrades gracefully on non-TTY streams.
@@ -42,20 +49,27 @@ interface Args {
42
49
  name?: string;
43
50
  template: AgentTemplate;
44
51
  templateExplicit: boolean;
45
- target: "bun" | "edge";
52
+ type: ProjectType;
53
+ typeExplicit: boolean;
54
+ ui: boolean;
55
+ uiExplicit: boolean;
56
+ monorepo: boolean;
57
+ monorepoExplicit: boolean;
58
+ target: Target;
46
59
  targetExplicit: boolean;
47
- deploy: "cloudflare" | "vercel" | "deno" | "netlify" | "bun" | "none";
60
+ theme: Theme;
61
+ deploy: Deploy;
48
62
  deployExplicit: boolean;
49
63
  install: boolean;
50
64
  git: boolean;
51
65
  yes: boolean;
52
- scaffold: "demo" | "blank";
53
- scaffoldExplicit: boolean;
54
66
  }
55
67
 
56
68
  const TEMPLATES_ROOT = resolve(dirname(import.meta.dir), "templates");
57
69
  const BASE_TEMPLATE = "default";
58
70
  const VALID_TEMPLATES: AgentTemplate[] = ["claude", "codex", "none"];
71
+ const VALID_TYPES: ProjectType[] = ["frontend", "backend", "fullstack"];
72
+ const VALID_THEMES: Theme[] = ["neutral", "slate", "stone", "zinc"];
59
73
 
60
74
  export async function run(argv: string[]): Promise<number> {
61
75
  const args = parseArgs(argv);
@@ -81,8 +95,17 @@ export async function run(argv: string[]): Promise<number> {
81
95
 
82
96
  if (interactive) {
83
97
  if (!args.templateExplicit) args.template = promptAgent();
84
- if (!args.scaffoldExplicit) args.scaffold = promptScaffold();
85
- if (!args.targetExplicit) args.target = promptTarget();
98
+ if (!args.typeExplicit) args.type = promptType();
99
+ if (
100
+ (args.type === "frontend" || args.type === "fullstack") &&
101
+ !args.uiExplicit
102
+ ) {
103
+ args.ui = promptUi();
104
+ }
105
+ if (args.type === "fullstack" && !args.monorepoExplicit) {
106
+ args.monorepo = promptMonorepo();
107
+ }
108
+ if (!args.targetExplicit) args.target = promptTarget(args.type);
86
109
  if (args.target === "edge" && !args.deployExplicit) {
87
110
  args.deploy = promptDeploy();
88
111
  }
@@ -90,14 +113,33 @@ export async function run(argv: string[]): Promise<number> {
90
113
 
91
114
  if (!VALID_TEMPLATES.includes(args.template)) {
92
115
  stderr(
93
- `✗ unknown --template "${args.template}" (expected: ${VALID_TEMPLATES.join(", ")})`,
116
+ `✗ unknown --agent "${args.template}" (expected: ${VALID_TEMPLATES.join(", ")})`,
117
+ );
118
+ return 2;
119
+ }
120
+ if (!VALID_TYPES.includes(args.type)) {
121
+ stderr(
122
+ `✗ unknown --type "${args.type}" (expected: ${VALID_TYPES.join(", ")})`,
94
123
  );
95
124
  return 2;
96
125
  }
97
126
 
127
+ // Gating (spec 18 §Behavior): backend has no UI surface; only full-stack
128
+ // projects can be a monorepo or use the container target.
129
+ if (args.type === "backend") args.ui = false;
130
+ if (args.type !== "fullstack") {
131
+ args.monorepo = false;
132
+ if (args.target === "container") {
133
+ stderr("✗ --target container is only available for --type fullstack");
134
+ return 2;
135
+ }
136
+ }
137
+
138
+ const appName = args.name;
98
139
  const targetDir = isAbsolute(args.name)
99
140
  ? args.name
100
141
  : resolve(process.cwd(), args.name);
142
+ const appRoot = args.monorepo ? `${targetDir}/apps/${appName}` : targetDir;
101
143
 
102
144
  if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
103
145
  stderr(`✗ directory not empty: ${targetDir}`);
@@ -114,17 +156,29 @@ export async function run(argv: string[]): Promise<number> {
114
156
 
115
157
  header(`create-patties — scaffolding ${c.bold(args.name)}`);
116
158
 
117
- await Bun.$`mkdir -p ${targetDir}`.quiet();
118
- step(`created directory ${c.dim(targetDir)}`);
119
-
120
- await Bun.$`cp -R ${baseDir}/. ${targetDir}`.quiet();
121
- step(`copied base template ${c.dim("(default)")}`);
159
+ await Bun.$`mkdir -p ${appRoot}`.quiet();
160
+ await Bun.$`cp -R ${baseDir}/. ${appRoot}`.quiet();
161
+ step(`copied base template ${c.dim(`(${args.type})`)}`);
122
162
 
163
+ // README + .gitignore live at the project root; in a monorepo that's above
164
+ // the app dir, so lift them out before renaming.
165
+ if (args.monorepo) {
166
+ for (const f of ["README-template.md", "gitignore"]) {
167
+ if (existsSync(`${appRoot}/${f}`)) {
168
+ await Bun.$`mv ${appRoot}/${f} ${targetDir}/${f}`.quiet();
169
+ }
170
+ }
171
+ }
123
172
  await renameTemplateFiles(targetDir);
124
- await writePackageJson(targetDir, args.name);
125
- step(`wrote package.json ${c.dim(`(name: ${args.name})`)}`);
126
- await patchPattiesConfig(targetDir, args);
127
- step(`patched patties.config.ts ${c.dim(`(target: ${args.target})`)}`);
173
+
174
+ await applyProjectType(appRoot, args.type);
175
+ step(`applied project type ${c.dim(`(${args.type})`)}`);
176
+
177
+ await writeAppPackageJson(appRoot, appName, args);
178
+ await patchPattiesConfig(appRoot, args);
179
+ step(
180
+ `wrote package.json + patties.config.ts ${c.dim(`(target: ${args.target})`)}`,
181
+ );
128
182
 
129
183
  if (args.template !== "none") {
130
184
  const overlay = resolve(TEMPLATES_ROOT, `_${args.template}`);
@@ -134,27 +188,36 @@ export async function run(argv: string[]): Promise<number> {
134
188
  }
135
189
  }
136
190
 
137
- if (args.scaffold === "blank") {
138
- await applyBlankScaffold(targetDir);
139
- step("applied blank scaffold (no demo)");
140
- } else {
141
- step("included interactive todo demo");
191
+ if (args.monorepo) {
192
+ await setupMonorepoRoot(targetDir, appName);
193
+ step(`set up Bun workspace ${c.dim(`(apps/${appName})`)}`);
194
+ }
195
+
196
+ if (args.ui) {
197
+ await applyUiStarter(
198
+ appRoot,
199
+ args.theme,
200
+ resolve(TEMPLATES_ROOT, "ui-starter"),
201
+ );
202
+ step(`stamped Patties UI starter ${c.dim(`(theme: ${args.theme})`)}`);
203
+ }
204
+
205
+ if (args.target === "container") {
206
+ await applyContainer(appRoot, args.name);
207
+ step("emitted Dockerfile + .dockerignore");
142
208
  }
143
209
 
144
210
  await renderTemplatesInTree(targetDir, {
145
211
  name: args.name,
146
212
  agent: args.template,
213
+ type: args.type,
214
+ ui: args.ui ? "yes" : "no",
215
+ monorepo: args.monorepo ? "yes" : "no",
147
216
  target: args.target,
148
217
  deploy: args.deploy,
149
- scaffold: args.scaffold,
218
+ app_name: appName,
150
219
  });
151
- const manifestName =
152
- args.template === "codex"
153
- ? "AGENTS.md"
154
- : args.template === "claude"
155
- ? "CLAUDE.md"
156
- : "manifest";
157
- step(`rendered template variables (README, ${manifestName}, …)`);
220
+ step("rendered template variables (README, app files, …)");
158
221
 
159
222
  if (args.install) {
160
223
  pending("installing dependencies (bun install)…");
@@ -172,70 +235,16 @@ export async function run(argv: string[]): Promise<number> {
172
235
  step(`skipped ${c.dim("`bun install`")} (--no-install)`);
173
236
  }
174
237
 
175
- let gitSkippedReason: string | undefined;
176
- if (args.git) {
177
- if (hasGit()) {
178
- // Strip git hook env vars so these commands resolve relative to targetDir
179
- // rather than inheriting GIT_DIR/GIT_INDEX_FILE from a running commit hook.
180
- const gitEnv: NodeJS.ProcessEnv = { ...process.env };
181
- for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
182
- delete gitEnv[key];
183
- }
184
- const init = await Bun.$`git init`
185
- .cwd(targetDir)
186
- .env(gitEnv)
187
- .quiet()
188
- .nothrow();
189
- // Only stage/commit once `targetDir` is itself the git top-level. If
190
- // `git init` failed, or the scaffold landed inside an existing repo (e.g.
191
- // a git worktree), git resolves `.git` to a parent and `git add`/`commit`
192
- // would clobber that outer repo. Compare the resolved top-level to be sure.
193
- const top = await Bun.$`git rev-parse --show-toplevel`
194
- .cwd(targetDir)
195
- .env(gitEnv)
196
- .quiet()
197
- .nothrow();
198
- // `git rev-parse` reports a symlink-resolved path; resolve `targetDir` the
199
- // same way so the comparison holds on macOS (/var → /private/var).
200
- const ownsRepo =
201
- init.exitCode === 0 &&
202
- top.exitCode === 0 &&
203
- top.stdout.toString().trim() === realpathSync(targetDir);
204
- if (ownsRepo) {
205
- await Bun.$`git add -A`.cwd(targetDir).env(gitEnv).quiet().nothrow();
206
- await Bun.$`git commit -m ${"chore: initial commit from create-patties"}`
207
- .cwd(targetDir)
208
- .env(gitEnv)
209
- .quiet()
210
- .nothrow();
211
- step("initialized git and committed");
212
- } else {
213
- gitSkippedReason = "git-init-failed";
214
- }
215
- } else {
216
- gitSkippedReason = "git-missing";
217
- }
218
- }
238
+ const gitSkippedReason = args.git ? await initGit(targetDir) : undefined;
239
+ if (args.git && !gitSkippedReason) step("initialized git and committed");
219
240
 
220
- const nextSteps = args.git
221
- ? `\n cd ${args.name}\n bunx patties dev\n`
222
- : `\n cd ${args.name}\n bunx patties dev\n\n # when you're ready to track this in git:\n git init && git add -A && git commit -m "initial commit"\n`;
223
- process.stdout.write(`\n✓ created ${args.name}\n${nextSteps}`);
241
+ printNextSteps(args, appName);
224
242
  if (gitSkippedReason === "git-missing") {
225
243
  stderr("create-patties: `git` not found — skipping `git init`.");
226
244
  }
227
245
  if (gitSkippedReason === "git-init-failed") {
228
246
  stderr("create-patties: `git init` failed — skipping the initial commit.");
229
247
  }
230
- if (args.template === "claude") {
231
- process.stdout.write(
232
- "\nClaude Code is configured (CLAUDE.md). Run `claude` in the project to start a session.\n",
233
- );
234
- } else if (args.template === "codex") {
235
- process.stdout.write(
236
- "\nCodex is configured (AGENTS.md). Run `codex` in the project to start a session.\n",
237
- );
238
- }
239
248
  return 0;
240
249
  }
241
250
 
@@ -243,58 +252,78 @@ function parseArgs(argv: string[]): Args {
243
252
  const out: Args = {
244
253
  template: "claude",
245
254
  templateExplicit: false,
255
+ type: "fullstack",
256
+ typeExplicit: false,
257
+ ui: true,
258
+ uiExplicit: false,
259
+ monorepo: false,
260
+ monorepoExplicit: false,
246
261
  target: "bun",
247
262
  targetExplicit: false,
263
+ theme: "neutral",
248
264
  deploy: "none",
249
265
  deployExplicit: false,
250
266
  install: true,
251
267
  git: false,
252
268
  yes: false,
253
- scaffold: "demo",
254
- scaffoldExplicit: false,
255
269
  };
256
270
  const setTemplate = (raw: string) => {
257
271
  out.template = aliasTemplate(raw);
258
272
  out.templateExplicit = true;
259
273
  };
274
+ const setType = (raw: string) => {
275
+ out.type = raw as ProjectType;
276
+ out.typeExplicit = true;
277
+ };
278
+ const setTarget = (raw: string) => {
279
+ out.target = raw as Target;
280
+ out.targetExplicit = true;
281
+ };
282
+ const setDeploy = (raw: string) => {
283
+ out.deploy = raw as Deploy;
284
+ out.deployExplicit = true;
285
+ };
260
286
  for (let i = 0; i < argv.length; i++) {
261
287
  const a = argv[i];
262
288
  if (a === undefined) continue;
263
- if (a === "--template") setTemplate(next(argv, ++i));
289
+ // --agent is the spec-18 name; --template is the spec-05/09 alias.
290
+ if (a === "--template" || a === "--agent") setTemplate(next(argv, ++i));
264
291
  else if (a.startsWith("--template=")) setTemplate(a.slice(11));
265
- // --agent: spec-05 alias kept for backwards compatibility.
266
- else if (a === "--agent") setTemplate(next(argv, ++i));
267
292
  else if (a.startsWith("--agent=")) setTemplate(a.slice(8));
268
- else if (a === "--target") {
269
- out.target = next(argv, ++i) as Args["target"];
270
- out.targetExplicit = true;
271
- } else if (a.startsWith("--target=")) {
272
- out.target = a.slice(9) as Args["target"];
273
- out.targetExplicit = true;
274
- } else if (a === "--deploy") {
275
- out.deploy = next(argv, ++i) as Args["deploy"];
276
- out.deployExplicit = true;
277
- } else if (a.startsWith("--deploy=")) {
278
- out.deploy = a.slice(9) as Args["deploy"];
279
- out.deployExplicit = true;
280
- } else if (a === "--no-install") out.install = false;
293
+ else if (a === "--type") setType(next(argv, ++i));
294
+ else if (a.startsWith("--type=")) setType(a.slice(7));
295
+ else if (a === "--ui") {
296
+ out.ui = true;
297
+ out.uiExplicit = true;
298
+ } else if (a === "--no-ui") {
299
+ out.ui = false;
300
+ out.uiExplicit = true;
301
+ } else if (a === "--monorepo") {
302
+ out.monorepo = true;
303
+ out.monorepoExplicit = true;
304
+ } else if (a === "--no-monorepo") {
305
+ out.monorepo = false;
306
+ out.monorepoExplicit = true;
307
+ } else if (a === "--target") setTarget(next(argv, ++i));
308
+ else if (a.startsWith("--target=")) setTarget(a.slice(9));
309
+ else if (a === "--theme") out.theme = aliasTheme(next(argv, ++i));
310
+ else if (a.startsWith("--theme=")) out.theme = aliasTheme(a.slice(8));
311
+ else if (a === "--deploy") setDeploy(next(argv, ++i));
312
+ else if (a.startsWith("--deploy=")) setDeploy(a.slice(9));
313
+ else if (a === "--no-install") out.install = false;
281
314
  else if (a === "--git") out.git = true;
282
- // --no-git: kept as a no-op for back-compat. Git is now opt-in
283
- // (default off) so most users don't need either flag.
315
+ // --no-git: kept as a no-op for back-compat. Git is opt-in (default off).
284
316
  else if (a === "--no-git") out.git = false;
285
317
  else if (a === "--yes" || a === "-y") out.yes = true;
286
- else if (a === "--blank" || a === "--empty") {
287
- out.scaffold = "blank";
288
- out.scaffoldExplicit = true;
289
- } else if (a === "--demo") {
290
- out.scaffold = "demo";
291
- out.scaffoldExplicit = true;
292
- } else if (!out.name && !a.startsWith("-")) out.name = a;
318
+ // --blank / --demo: spec-09 scaffold flags, superseded by --type. Accepted
319
+ // as no-ops so old invocations don't error.
320
+ else if (a === "--blank" || a === "--empty" || a === "--demo") continue;
321
+ else if (!out.name && !a.startsWith("-")) out.name = a;
293
322
  }
294
323
  return out;
295
324
  }
296
325
 
297
- // Translate spec-05 --agent values (claude-code/none) and current --template
326
+ // Translate spec-05 --agent values (claude-code/none) and current --agent
298
327
  // values (claude/codex/none) into the unified AgentTemplate type.
299
328
  function aliasTemplate(raw: string): AgentTemplate {
300
329
  if (raw === "claude-code") return "claude";
@@ -302,6 +331,10 @@ function aliasTemplate(raw: string): AgentTemplate {
302
331
  return raw as AgentTemplate;
303
332
  }
304
333
 
334
+ function aliasTheme(raw: string): Theme {
335
+ return (VALID_THEMES as string[]).includes(raw) ? (raw as Theme) : "neutral";
336
+ }
337
+
305
338
  function next(argv: string[], i: number): string {
306
339
  return argv[i] ?? "";
307
340
  }
@@ -326,78 +359,220 @@ async function renameTemplateFiles(dir: string): Promise<void> {
326
359
  }
327
360
  }
328
361
 
329
- // --blank scaffold: drop the interactive demo and ship a single hello page.
330
- // We start from the same default template and prune so we keep exactly one
331
- // source-of-truth for things like patties.config.ts / tsconfig.json.
332
- async function applyBlankScaffold(dir: string): Promise<void> {
333
- await Bun.$`rm -rf ${dir}/app/islands`.quiet();
334
- await Bun.write(
335
- `${dir}/app/routes/index.tsx`,
336
- `export default function Index(): JSX.Element {
337
- return (
338
- <main>
339
- <h1>Hello from {{name}}</h1>
340
- <p>
341
- This page is server-rendered by Patties. Add more files under{" "}
342
- <code>app/routes/</code> to grow your app.
343
- </p>
344
- </main>
345
- );
362
+ // Shape the base (full-stack) template for the chosen project type. We prune
363
+ // from one source rather than keep three near-identical trees.
364
+ async function applyProjectType(
365
+ appRoot: string,
366
+ type: ProjectType,
367
+ ): Promise<void> {
368
+ if (type === "frontend") {
369
+ // No API surface.
370
+ await Bun.$`rm -rf ${appRoot}/app/routes/api`.quiet();
371
+ } else if (type === "backend") {
372
+ // API only — drop the page + island, add a sample resource.
373
+ await Bun.$`rm -rf ${appRoot}/app/islands`.quiet();
374
+ await Bun.$`rm -f ${appRoot}/app/routes/index.tsx`.quiet();
375
+ const overlay = resolve(TEMPLATES_ROOT, "_backend");
376
+ if (existsSync(overlay)) {
377
+ await Bun.$`cp -R ${overlay}/. ${appRoot}`.quiet();
378
+ }
379
+ }
346
380
  }
347
- `,
348
- );
381
+
382
+ function pkgScripts(): Record<string, string> {
383
+ return { dev: "patties dev", build: "patties build", start: "patties start" };
349
384
  }
350
385
 
351
- async function writePackageJson(dir: string, name: string): Promise<void> {
386
+ async function writeAppPackageJson(
387
+ dir: string,
388
+ name: string,
389
+ args: Args,
390
+ ): Promise<void> {
391
+ const dependencies: Record<string, string> = {
392
+ patties: "latest",
393
+ react: "^19.0.0",
394
+ "react-dom": "^19.0.0",
395
+ };
396
+ const devDependencies: Record<string, string> = {
397
+ "@types/react": "^19.0.0",
398
+ "@types/react-dom": "^19.0.0",
399
+ "bun-types": "latest",
400
+ typescript: "^5.5.0",
401
+ };
402
+ if (args.ui) {
403
+ Object.assign(dependencies, UI_DEPS);
404
+ Object.assign(devDependencies, UI_DEV_DEPS);
405
+ }
352
406
  const pkg = {
353
407
  name,
354
408
  version: "0.1.0",
355
409
  private: true,
356
410
  type: "module",
357
- scripts: {
358
- dev: "patties dev",
359
- build: "patties build",
360
- start: "patties start",
361
- },
362
- dependencies: sorted({
363
- patties: "latest",
364
- react: "^19.0.0",
365
- "react-dom": "^19.0.0",
366
- }),
367
- devDependencies: sorted({
368
- "@types/react": "^19.0.0",
369
- "@types/react-dom": "^19.0.0",
370
- "bun-types": "latest",
371
- typescript: "^5.5.0",
372
- }),
411
+ scripts: pkgScripts(),
412
+ dependencies: sorted(dependencies),
413
+ devDependencies: sorted(devDependencies),
373
414
  engines: { bun: ">=1.3.0" },
374
415
  };
375
416
  await Bun.write(`${dir}/package.json`, `${JSON.stringify(pkg, null, 2)}\n`);
376
417
  }
377
418
 
419
+ // Root workspace manifest + skeleton (spec 18 §Monorepo layout).
420
+ async function setupMonorepoRoot(
421
+ targetDir: string,
422
+ appName: string,
423
+ ): Promise<void> {
424
+ const root = {
425
+ name: `${appName}-monorepo`,
426
+ version: "0.1.0",
427
+ private: true,
428
+ type: "module",
429
+ workspaces: ["apps/*", "packages/*"],
430
+ engines: { bun: ">=1.3.0" },
431
+ };
432
+ await Bun.write(
433
+ `${targetDir}/package.json`,
434
+ `${JSON.stringify(root, null, 2)}\n`,
435
+ );
436
+ // biome.json is written here rather than shipped as a template file: a
437
+ // committed nested biome.json would be discovered as a conflicting root
438
+ // config by this repo's own Biome.
439
+ await Bun.write(`${targetDir}/biome.json`, `${MONOREPO_BIOME}\n`);
440
+ const skeleton = resolve(TEMPLATES_ROOT, "_monorepo");
441
+ if (existsSync(skeleton)) {
442
+ await Bun.$`cp -R ${skeleton}/. ${targetDir}`.quiet();
443
+ }
444
+ }
445
+
446
+ const MONOREPO_BIOME = JSON.stringify(
447
+ {
448
+ $schema: "https://biomejs.dev/schemas/2.4.15/schema.json",
449
+ vcs: { enabled: true, clientKind: "git", useIgnoreFile: true },
450
+ formatter: { enabled: true, indentStyle: "tab" },
451
+ linter: { enabled: true, rules: { recommended: true } },
452
+ javascript: { formatter: { quoteStyle: "double" } },
453
+ assist: {
454
+ enabled: true,
455
+ actions: { source: { organizeImports: "on" } },
456
+ },
457
+ },
458
+ null,
459
+ 2,
460
+ );
461
+
462
+ async function applyContainer(appRoot: string, name: string): Promise<void> {
463
+ const overlay = resolve(TEMPLATES_ROOT, "_container");
464
+ if (!existsSync(overlay)) return;
465
+ await Bun.$`cp -R ${overlay}/. ${appRoot}`.quiet();
466
+ // Dockerfile has no rendered extension, so interpolate {{name}} here.
467
+ const dockerfile = `${appRoot}/Dockerfile`;
468
+ if (await Bun.file(dockerfile).exists()) {
469
+ const text = await Bun.file(dockerfile).text();
470
+ await Bun.write(dockerfile, text.replaceAll("{{name}}", name));
471
+ }
472
+ }
473
+
378
474
  function sorted(deps: Record<string, string>): Record<string, string> {
379
475
  const out: Record<string, string> = {};
380
476
  for (const k of Object.keys(deps).sort()) out[k] = deps[k] as string;
381
477
  return out;
382
478
  }
383
479
 
480
+ // container packages the bun adapter, so patties.config keeps target "bun".
481
+ function configTarget(target: Target): "bun" | "edge" {
482
+ return target === "edge" ? "edge" : "bun";
483
+ }
484
+
384
485
  async function patchPattiesConfig(dir: string, args: Args): Promise<void> {
385
486
  const path = `${dir}/patties.config.ts`;
386
487
  if (!(await Bun.file(path).exists())) return;
387
488
  const current = await Bun.file(path).text();
388
- let next = current.replace(
489
+ let nextText = current.replace(
389
490
  /target:\s*"(bun|edge)"/,
390
- `target: "${args.target}"`,
491
+ `target: "${configTarget(args.target)}"`,
391
492
  );
392
493
  // Point the agent manifest at the file the chosen agent already reads.
393
494
  // Default (claude / none) leaves the framework default ("CLAUDE.md").
394
- if (args.template === "codex" && !/agentsMd:/.test(next)) {
395
- next = next.replace(
495
+ if (args.template === "codex" && !/agentsMd:/.test(nextText)) {
496
+ nextText = nextText.replace(
396
497
  /target:\s*"(bun|edge)",?\n/,
397
498
  (m) => `${m.replace(/,?\n$/, ",\n")}\tagentsMd: { path: "AGENTS.md" },\n`,
398
499
  );
399
500
  }
400
- if (next !== current) await Bun.write(path, next);
501
+ if (nextText !== current) await Bun.write(path, nextText);
502
+ }
503
+
504
+ async function initGit(targetDir: string): Promise<string | undefined> {
505
+ if (!hasGit()) return "git-missing";
506
+ // Strip git hook env vars so these commands resolve relative to targetDir
507
+ // rather than inheriting GIT_DIR/GIT_INDEX_FILE from a running commit hook.
508
+ const gitEnv: NodeJS.ProcessEnv = { ...process.env };
509
+ for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"]) {
510
+ delete gitEnv[key];
511
+ }
512
+ const init = await Bun.$`git init`
513
+ .cwd(targetDir)
514
+ .env(gitEnv)
515
+ .quiet()
516
+ .nothrow();
517
+ // Only stage/commit once `targetDir` is itself the git top-level. If
518
+ // `git init` failed, or the scaffold landed inside an existing repo (e.g.
519
+ // a git worktree), git resolves `.git` to a parent and `git add`/`commit`
520
+ // would clobber that outer repo. Compare the resolved top-level to be sure.
521
+ const top = await Bun.$`git rev-parse --show-toplevel`
522
+ .cwd(targetDir)
523
+ .env(gitEnv)
524
+ .quiet()
525
+ .nothrow();
526
+ // `git rev-parse` reports a symlink-resolved path; resolve `targetDir` the
527
+ // same way so the comparison holds on macOS (/var → /private/var).
528
+ const ownsRepo =
529
+ init.exitCode === 0 &&
530
+ top.exitCode === 0 &&
531
+ top.stdout.toString().trim() === realpathSync(targetDir);
532
+ if (!ownsRepo) return "git-init-failed";
533
+ await Bun.$`git add -A`.cwd(targetDir).env(gitEnv).quiet().nothrow();
534
+ await Bun.$`git commit -m ${"chore: initial commit from create-patties"}`
535
+ .cwd(targetDir)
536
+ .env(gitEnv)
537
+ .quiet()
538
+ .nothrow();
539
+ return undefined;
540
+ }
541
+
542
+ function printNextSteps(args: Args, appName: string): void {
543
+ const out = (s: string) => process.stdout.write(s);
544
+ const devCmd = args.monorepo
545
+ ? `bun --filter ${appName} dev`
546
+ : "bunx patties dev";
547
+ out(`\n✓ created ${args.name}\n\n`);
548
+ out(` cd ${args.name}\n`);
549
+ if (!args.install) out(" bun install\n");
550
+ out(
551
+ ` ${devCmd} → http://localhost:3000 (start the dev server)\n`,
552
+ );
553
+ if (args.ui) {
554
+ out("\nPatties UI is set up — components live in app/components/ui/.\n");
555
+ }
556
+ if (args.template === "claude") {
557
+ out(
558
+ "\nWant to scaffold features (auth, CRM, task board, dashboard, …)?\n" +
559
+ "Open a NEW terminal in this project and run:\n\n" +
560
+ ' claude --permission-mode plan "/patties-init"\n\n' +
561
+ "That starts an interactive, plan-mode session that designs and\n" +
562
+ "scaffolds your project with you before writing any files.\n",
563
+ );
564
+ } else if (args.template === "codex") {
565
+ out(
566
+ "\nWant to scaffold features (auth, CRM, task board, dashboard, …)?\n" +
567
+ "Open Codex in this project and ask it to scaffold a pattern — it reads\n" +
568
+ ".codex/rules/patties-patterns.md for the recipes.\n",
569
+ );
570
+ } else {
571
+ out(
572
+ "\nAdd UI components with `patties add <component>`. Feature patterns are\n" +
573
+ "agent-driven — re-scaffold with --agent claude or codex to get /patties.\n",
574
+ );
575
+ }
401
576
  }
402
577
 
403
578
  function stderr(msg: string): void {
@@ -411,19 +586,22 @@ Usage:
411
586
  bunx create-patties@latest <name> [options]
412
587
 
413
588
  Options:
414
- --template <claude|codex|none> Agent platform (default: claude)
415
- --target <bun|edge> Runtime target (default: bun)
416
- --deploy <cloudflare|vercel|deno|netlify|bun|none>
417
- --no-install Skip 'bun install'
418
- --git Run 'git init' + initial commit (opt-in)
419
- --yes, -y Accept all defaults, skip prompts
420
- --blank, --empty Scaffold a hello-world page only (no demo)
421
- --demo Scaffold the interactive todo demo (default)
589
+ --agent claude | codex | none (default claude)
590
+ --type frontend | backend | fullstack (default fullstack)
591
+ --ui | --no-ui (frontend/fullstack; default yes)
592
+ --monorepo | --no-monorepo (fullstack only; default no)
593
+ --target bun | edge | container (container = fullstack only; default bun)
594
+ --deploy cloudflare | vercel | deno | netlify | none (edge only)
595
+ --theme neutral | slate | stone | zinc (ui only, default neutral)
596
+ --yes, -y (accept all defaults)
597
+ --no-install (skip 'bun install')
598
+ --git (run 'git init' + initial commit)
422
599
 
423
600
  Examples:
424
601
  bunx create-patties@latest my-app
425
- bunx create-patties@latest my-app --template codex
426
- bunx create-patties@latest my-app --template none
427
- bunx create-patties@latest my-app --git # opt-in git init
602
+ bunx create-patties@latest my-app --type backend
603
+ bunx create-patties@latest my-app --type fullstack --monorepo --ui --theme slate
604
+ bunx create-patties@latest my-app --agent codex
605
+ bunx create-patties@latest my-app --agent none
428
606
  `);
429
607
  }