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.
- package/CHANGELOG.md +30 -0
- package/package.json +4 -2
- package/src/index.ts +345 -167
- package/src/prompts.ts +62 -17
- package/src/readme.ts +12 -5
- package/src/ui.ts +93 -0
- package/templates/_backend/app/routes/api/todos.ts +30 -0
- package/templates/_claude/.claude/commands/patties-init.md +33 -0
- package/templates/_claude/.claude/skills/patties/SKILL.md +104 -0
- package/templates/_codex/.codex/rules/patties-patterns.md +105 -0
- package/templates/_codex/AGENTS.md +1 -0
- package/templates/_container/.dockerignore +7 -0
- package/templates/_container/Dockerfile +27 -0
- package/templates/_monorepo/packages/README.md +18 -0
- package/templates/_shared/patties-patterns.md +105 -0
- package/templates/default/README-template.md +85 -71
- package/templates/default/app/routes/api/health.ts +8 -0
- package/templates/ui-starter/_internal/cn.ts +6 -0
- package/templates/ui-starter/_internal/slot.ts +50 -0
- package/templates/ui-starter/_internal/variants.ts +1 -0
- package/templates/ui-starter/button.tsx +60 -0
- package/templates/ui-starter/card.tsx +92 -0
- package/templates/ui-starter/demo/TodoApp.tsx +86 -0
- package/templates/ui-starter/demo/index.tsx +41 -0
- package/templates/ui-starter/input.tsx +20 -0
- package/templates/ui-starter/label.tsx +18 -0
- package/templates/ui-starter/themes/neutral/tokens.css +46 -0
- package/templates/ui-starter/themes/slate/tokens.css +46 -0
- package/templates/ui-starter/themes/stone/tokens.css +46 -0
- package/templates/ui-starter/themes/zinc/tokens.css +46 -0
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
85
|
-
if (
|
|
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 --
|
|
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 ${
|
|
118
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
138
|
-
await
|
|
139
|
-
step(
|
|
140
|
-
}
|
|
141
|
-
|
|
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
|
-
|
|
218
|
+
app_name: appName,
|
|
150
219
|
});
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 === "--
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
out.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
out.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
out.
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 --
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
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
|
-
|
|
359
|
-
|
|
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
|
|
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(
|
|
395
|
-
|
|
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 (
|
|
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
|
-
--
|
|
415
|
-
--
|
|
416
|
-
--
|
|
417
|
-
--no-
|
|
418
|
-
--
|
|
419
|
-
--
|
|
420
|
-
--
|
|
421
|
-
--
|
|
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 --
|
|
426
|
-
bunx create-patties@latest my-app --
|
|
427
|
-
bunx create-patties@latest my-app --
|
|
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
|
}
|