@pyreon/create-zero 0.14.0 → 0.15.0

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 (65) hide show
  1. package/README.md +85 -22
  2. package/bin/create-pyreon-app.js +2 -0
  3. package/lib/index.js +1254 -191
  4. package/package.json +5 -2
  5. package/templates/{default → app}/src/routes/_layout.tsx +5 -2
  6. package/templates/blog/.mcp.json +8 -0
  7. package/templates/blog/CLAUDE.md +59 -0
  8. package/templates/blog/index.html +18 -0
  9. package/templates/blog/public/favicon.svg +4 -0
  10. package/templates/blog/src/content/posts/static-vs-ssr.tsx +54 -0
  11. package/templates/blog/src/content/posts/welcome.tsx +70 -0
  12. package/templates/blog/src/content/posts/why-signals.tsx +57 -0
  13. package/templates/blog/src/entry-client.ts +5 -0
  14. package/templates/blog/src/global.css +292 -0
  15. package/templates/blog/src/lib/posts.ts +45 -0
  16. package/templates/blog/src/routes/_layout.tsx +40 -0
  17. package/templates/blog/src/routes/about.tsx +28 -0
  18. package/templates/blog/src/routes/api/rss.ts +55 -0
  19. package/templates/blog/src/routes/blog/[slug].tsx +67 -0
  20. package/templates/blog/src/routes/blog/index.tsx +43 -0
  21. package/templates/blog/src/routes/index.tsx +52 -0
  22. package/templates/blog/tsconfig.json +16 -0
  23. package/templates/dashboard/.mcp.json +8 -0
  24. package/templates/dashboard/CLAUDE.md +50 -0
  25. package/templates/dashboard/index.html +16 -0
  26. package/templates/dashboard/public/favicon.svg +4 -0
  27. package/templates/dashboard/src/entry-client.ts +5 -0
  28. package/templates/dashboard/src/global.css +451 -0
  29. package/templates/dashboard/src/lib/auth.ts +106 -0
  30. package/templates/dashboard/src/lib/db.ts +118 -0
  31. package/templates/dashboard/src/routes/_layout.tsx +28 -0
  32. package/templates/dashboard/src/routes/api/signout.ts +15 -0
  33. package/templates/dashboard/src/routes/app/_layout.tsx +76 -0
  34. package/templates/dashboard/src/routes/app/dashboard.tsx +92 -0
  35. package/templates/dashboard/src/routes/app/invoices/[id].tsx +197 -0
  36. package/templates/dashboard/src/routes/app/invoices/index.tsx +61 -0
  37. package/templates/dashboard/src/routes/app/settings/account.tsx +31 -0
  38. package/templates/dashboard/src/routes/app/settings/billing.tsx +28 -0
  39. package/templates/dashboard/src/routes/app/settings/index.tsx +29 -0
  40. package/templates/dashboard/src/routes/app/users.tsx +50 -0
  41. package/templates/dashboard/src/routes/index.tsx +40 -0
  42. package/templates/dashboard/src/routes/login.tsx +79 -0
  43. package/templates/dashboard/src/routes/signup.tsx +78 -0
  44. package/templates/dashboard/tsconfig.json +16 -0
  45. package/lib/index.js.map +0 -1
  46. /package/templates/{default → app}/.mcp.json +0 -0
  47. /package/templates/{default → app}/CLAUDE.md +0 -0
  48. /package/templates/{default → app}/index.html +0 -0
  49. /package/templates/{default → app}/public/favicon.svg +0 -0
  50. /package/templates/{default → app}/src/entry-client.ts +0 -0
  51. /package/templates/{default → app}/src/features/posts.ts +0 -0
  52. /package/templates/{default → app}/src/global.css +0 -0
  53. /package/templates/{default → app}/src/routes/(admin)/dashboard.tsx +0 -0
  54. /package/templates/{default → app}/src/routes/_error.tsx +0 -0
  55. /package/templates/{default → app}/src/routes/_loading.tsx +0 -0
  56. /package/templates/{default → app}/src/routes/about.tsx +0 -0
  57. /package/templates/{default → app}/src/routes/api/health.ts +0 -0
  58. /package/templates/{default → app}/src/routes/api/posts.ts +0 -0
  59. /package/templates/{default → app}/src/routes/counter.tsx +0 -0
  60. /package/templates/{default → app}/src/routes/index.tsx +0 -0
  61. /package/templates/{default → app}/src/routes/posts/[id].tsx +0 -0
  62. /package/templates/{default → app}/src/routes/posts/index.tsx +0 -0
  63. /package/templates/{default → app}/src/routes/posts/new.tsx +0 -0
  64. /package/templates/{default → app}/src/stores/app.ts +0 -0
  65. /package/templates/{default → app}/tsconfig.json +0 -0
package/lib/index.js CHANGED
@@ -1,9 +1,172 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { cp, readFile, writeFile } from "node:fs/promises";
3
- import { basename, join, resolve } from "node:path";
4
1
  import * as p from "@clack/prompts";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { cp, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
5
5
 
6
- //#region src/index.ts
6
+ //#region src/args.ts
7
+ const TEMPLATE_VALUES = [
8
+ "app",
9
+ "blog",
10
+ "dashboard"
11
+ ];
12
+ const ADAPTER_VALUES = [
13
+ "vercel",
14
+ "cloudflare",
15
+ "netlify",
16
+ "node",
17
+ "bun",
18
+ "static"
19
+ ];
20
+ const MODE_VALUES = [
21
+ "ssr-stream",
22
+ "ssr-string",
23
+ "ssg",
24
+ "spa"
25
+ ];
26
+ const INTEGRATION_VALUES = ["supabase", "email"];
27
+ const AI_VALUES = [
28
+ "mcp",
29
+ "claude",
30
+ "cursor",
31
+ "copilot",
32
+ "agents"
33
+ ];
34
+ const COMPAT_VALUES = [
35
+ "none",
36
+ "react",
37
+ "vue",
38
+ "solid",
39
+ "preact"
40
+ ];
41
+ const PKG_STRATEGY_VALUES = ["meta", "individual"];
42
+ function parseArgs(argv) {
43
+ const out = {
44
+ name: void 0,
45
+ yes: false,
46
+ help: false,
47
+ template: void 0,
48
+ adapter: void 0,
49
+ mode: void 0,
50
+ features: void 0,
51
+ integrations: void 0,
52
+ ai: void 0,
53
+ compat: void 0,
54
+ packageStrategy: void 0,
55
+ lint: void 0
56
+ };
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const a = argv[i];
59
+ if (a === void 0) continue;
60
+ if (a.startsWith("--") || a === "-h") {
61
+ const eq = a.indexOf("=");
62
+ const key = eq >= 0 ? a.slice(2, eq) : a.startsWith("--") ? a.slice(2) : "h";
63
+ const inlineValue = eq >= 0 ? a.slice(eq + 1) : void 0;
64
+ const consumeValue = () => {
65
+ if (inlineValue !== void 0) return inlineValue;
66
+ const next = argv[i + 1];
67
+ if (next === void 0 || next.startsWith("--")) return void 0;
68
+ i++;
69
+ return next;
70
+ };
71
+ switch (key) {
72
+ case "help":
73
+ case "h":
74
+ out.help = true;
75
+ break;
76
+ case "yes":
77
+ out.yes = true;
78
+ break;
79
+ case "lint":
80
+ out.lint = true;
81
+ break;
82
+ case "no-lint":
83
+ out.lint = false;
84
+ break;
85
+ case "template":
86
+ out.template = pickEnum(consumeValue(), TEMPLATE_VALUES, "--template");
87
+ break;
88
+ case "adapter":
89
+ out.adapter = pickEnum(consumeValue(), ADAPTER_VALUES, "--adapter");
90
+ break;
91
+ case "mode":
92
+ out.mode = pickEnum(consumeValue(), MODE_VALUES, "--mode");
93
+ break;
94
+ case "features":
95
+ out.features = parseCsv(consumeValue());
96
+ break;
97
+ case "integrations":
98
+ out.integrations = parseEnumCsv(consumeValue(), INTEGRATION_VALUES, "--integrations");
99
+ break;
100
+ case "ai":
101
+ out.ai = parseEnumCsv(consumeValue(), AI_VALUES, "--ai");
102
+ break;
103
+ case "compat":
104
+ out.compat = pickEnum(consumeValue(), COMPAT_VALUES, "--compat");
105
+ break;
106
+ case "pm":
107
+ case "packages":
108
+ case "package-strategy":
109
+ out.packageStrategy = pickEnum(consumeValue(), PKG_STRATEGY_VALUES, "--packages");
110
+ break;
111
+ default: throw new Error(`Unknown flag: ${a}. Run with --help for usage.`);
112
+ }
113
+ continue;
114
+ }
115
+ if (out.name === void 0) out.name = a;
116
+ else throw new Error(`Unexpected extra positional argument: ${a}`);
117
+ }
118
+ return out;
119
+ }
120
+ function parseCsv(raw) {
121
+ if (raw === void 0) return void 0;
122
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
123
+ }
124
+ function parseEnumCsv(raw, allowed, flag) {
125
+ const parts = parseCsv(raw);
126
+ if (parts === void 0) return void 0;
127
+ for (const p of parts) if (!allowed.includes(p)) throw new Error(`Invalid value "${p}" for ${flag}. Expected one of: ${allowed.join(", ")}.`);
128
+ return parts;
129
+ }
130
+ function pickEnum(raw, allowed, flag) {
131
+ if (raw === void 0) return void 0;
132
+ if (!allowed.includes(raw)) throw new Error(`Invalid value "${raw}" for ${flag}. Expected one of: ${allowed.join(", ")}.`);
133
+ return raw;
134
+ }
135
+ function helpText(invokedAs) {
136
+ return `Usage: ${invokedAs} [name] [flags]
137
+
138
+ Scaffold a new Pyreon Zero project.
139
+
140
+ Templates:
141
+ --template <id> app | blog | dashboard
142
+
143
+ Deployment:
144
+ --adapter <id> vercel | cloudflare | netlify | node | bun | static
145
+
146
+ Rendering:
147
+ --mode <id> ssr-stream | ssr-string | ssg | spa
148
+
149
+ Features (csv):
150
+ --features <list> store,query,forms,table,virtual,i18n,charts,…
151
+ --integrations <list> supabase,email
152
+ --ai <list> mcp,claude,cursor,copilot,agents
153
+
154
+ Other:
155
+ --compat <id> none | react | vue | solid | preact
156
+ --packages <id> meta | individual
157
+ --lint / --no-lint toggle @pyreon/lint
158
+ --yes skip prompts, accept defaults
159
+ --help, -h show this help
160
+
161
+ Examples:
162
+ ${invokedAs} my-app
163
+ ${invokedAs} my-app --template dashboard --adapter vercel --integrations supabase,email --yes
164
+ ${invokedAs} my-blog --template blog --adapter cloudflare --yes
165
+ `;
166
+ }
167
+
168
+ //#endregion
169
+ //#region src/templates.ts
7
170
  const FEATURES = {
8
171
  store: {
9
172
  label: "State Management (@pyreon/store)",
@@ -103,168 +266,1032 @@ const FEATURES = {
103
266
  deps: ["@pyreon/rx"]
104
267
  }
105
268
  };
106
- const TEMPLATE_DIR = resolve(import.meta.dirname, "../templates/default");
107
- async function main() {
108
- const argName = process.argv.slice(2)[0];
109
- if (argName === "--help" || argName === "-h") {
110
- console.log("Usage: create-zero [project-name]");
111
- process.exit(0);
269
+ const TEMPLATES = {
270
+ app: {
271
+ id: "app",
272
+ label: "App",
273
+ hint: "full-featured starter — counter, posts, layout, admin route group",
274
+ defaultMode: "ssr-stream",
275
+ defaultFeatures: [
276
+ "store",
277
+ "query",
278
+ "forms"
279
+ ],
280
+ forcesMode: false,
281
+ adapters: [
282
+ "vercel",
283
+ "cloudflare",
284
+ "netlify",
285
+ "node",
286
+ "bun",
287
+ "static"
288
+ ],
289
+ defaultAdapter: "vercel",
290
+ defaultIntegrations: []
291
+ },
292
+ blog: {
293
+ id: "blog",
294
+ label: "Blog",
295
+ hint: "SSG markdown blog with RSS feed and SEO",
296
+ defaultMode: "ssg",
297
+ defaultFeatures: [],
298
+ forcesMode: true,
299
+ adapters: [
300
+ "static",
301
+ "vercel",
302
+ "cloudflare",
303
+ "netlify"
304
+ ],
305
+ defaultAdapter: "static",
306
+ defaultIntegrations: []
307
+ },
308
+ dashboard: {
309
+ id: "dashboard",
310
+ label: "Dashboard",
311
+ hint: "SaaS-shape SSR app — auth-gated routes, full integration suite available",
312
+ defaultMode: "ssr-stream",
313
+ defaultFeatures: [
314
+ "store",
315
+ "query",
316
+ "forms",
317
+ "table"
318
+ ],
319
+ forcesMode: true,
320
+ adapters: [
321
+ "vercel",
322
+ "cloudflare",
323
+ "netlify",
324
+ "node",
325
+ "bun"
326
+ ],
327
+ defaultAdapter: "vercel",
328
+ defaultIntegrations: ["supabase", "email"]
112
329
  }
113
- p.intro("Create a new Pyreon Zero project");
114
- const name = argName ?? await p.text({
115
- message: "Project name",
116
- placeholder: "my-zero-app",
117
- validate: (v) => {
118
- if (!v?.trim()) return "Project name is required";
119
- if (existsSync(resolve(process.cwd(), v))) return `Directory "${v}" already exists`;
330
+ };
331
+ const TEMPLATES_ROOT = resolve(import.meta.dirname, "../templates");
332
+ function templateDir(id) {
333
+ return resolve(TEMPLATES_ROOT, id);
334
+ }
335
+
336
+ //#endregion
337
+ //#region src/prompts.ts
338
+ const ADAPTER_LABELS = {
339
+ vercel: {
340
+ label: "Vercel",
341
+ hint: "serverless / edge — vercel.json + one-click deploy badge"
342
+ },
343
+ cloudflare: {
344
+ label: "Cloudflare Pages",
345
+ hint: "workers — wrangler.toml + _routes.json"
346
+ },
347
+ netlify: {
348
+ label: "Netlify",
349
+ hint: "netlify functions — netlify.toml"
350
+ },
351
+ node: {
352
+ label: "Node.js",
353
+ hint: "Dockerfile + start script for self-hosting"
354
+ },
355
+ bun: {
356
+ label: "Bun",
357
+ hint: "Dockerfile (bun-based) for self-hosting on Bun runtimes"
358
+ },
359
+ static: {
360
+ label: "Static (no server)",
361
+ hint: "works with any static host (GitHub Pages, S3, …)"
362
+ }
363
+ };
364
+ /**
365
+ * Run the interactive prompt flow. Every prompt is skipped when its
366
+ * corresponding CLI flag is set or when `--yes` was passed; in that
367
+ * case the template-default (or flag-supplied) value is used.
368
+ *
369
+ * Cancellation at any prompt aborts via `process.exit(0)`.
370
+ */
371
+ async function runPrompts(args) {
372
+ const yes = args.yes;
373
+ let name;
374
+ if (args.name !== void 0) name = args.name;
375
+ else if (yes) {
376
+ p.cancel("Project name is required when using --yes (pass it as the first argument).");
377
+ process.exit(2);
378
+ } else {
379
+ const value = await p.text({
380
+ message: "Project name",
381
+ placeholder: "my-zero-app",
382
+ validate: (v) => {
383
+ if (!v?.trim()) return "Project name is required";
384
+ if (existsSync(resolve(process.cwd(), v))) return `Directory "${v}" already exists`;
385
+ }
386
+ });
387
+ if (p.isCancel(value)) {
388
+ p.cancel("Cancelled.");
389
+ process.exit(0);
120
390
  }
121
- });
122
- if (p.isCancel(name)) {
123
- p.cancel("Cancelled.");
124
- process.exit(0);
391
+ name = value;
125
392
  }
126
393
  const targetDir = resolve(process.cwd(), name);
127
394
  if (existsSync(targetDir)) {
128
395
  p.cancel(`Directory "${name}" already exists.`);
129
396
  process.exit(1);
130
397
  }
131
- const renderMode = await p.select({
132
- message: "Rendering mode",
133
- options: [
134
- {
135
- value: "ssr-stream",
136
- label: "SSR Streaming",
137
- hint: "recommended progressive HTML with Suspense"
138
- },
139
- {
140
- value: "ssr-string",
141
- label: "SSR String",
142
- hint: "buffered HTML, simpler but slower TTFB"
143
- },
144
- {
145
- value: "ssg",
146
- label: "Static (SSG)",
147
- hint: "pre-rendered at build time"
148
- },
149
- {
150
- value: "spa",
151
- label: "SPA",
152
- hint: "client-only, no server rendering"
153
- }
154
- ]
155
- });
156
- if (p.isCancel(renderMode)) {
157
- p.cancel("Cancelled.");
158
- process.exit(0);
398
+ let template;
399
+ if (args.template) template = args.template;
400
+ else if (yes) template = "app";
401
+ else {
402
+ const value = await p.select({
403
+ message: "Template",
404
+ options: Object.values(TEMPLATES).map((t) => ({
405
+ value: t.id,
406
+ label: t.label,
407
+ hint: t.hint
408
+ }))
409
+ });
410
+ if (p.isCancel(value)) {
411
+ p.cancel("Cancelled.");
412
+ process.exit(0);
413
+ }
414
+ template = value;
159
415
  }
160
- const features = await p.multiselect({
161
- message: "Select features (space to toggle, enter to confirm)",
162
- options: Object.entries(FEATURES).map(([key, { label }]) => ({
163
- value: key,
164
- label
165
- })),
166
- initialValues: [
167
- "store",
168
- "query",
169
- "forms"
170
- ],
171
- required: false
172
- });
173
- if (p.isCancel(features)) {
174
- p.cancel("Cancelled.");
175
- process.exit(0);
416
+ const tmpl = TEMPLATES[template];
417
+ let renderMode;
418
+ if (tmpl.forcesMode) renderMode = tmpl.defaultMode;
419
+ else if (args.mode) renderMode = args.mode;
420
+ else if (yes) renderMode = tmpl.defaultMode;
421
+ else {
422
+ const value = await p.select({
423
+ message: "Rendering mode",
424
+ options: [
425
+ {
426
+ value: "ssr-stream",
427
+ label: "SSR Streaming",
428
+ hint: "recommended — progressive HTML with Suspense"
429
+ },
430
+ {
431
+ value: "ssr-string",
432
+ label: "SSR String",
433
+ hint: "buffered HTML, simpler but slower TTFB"
434
+ },
435
+ {
436
+ value: "ssg",
437
+ label: "Static (SSG)",
438
+ hint: "pre-rendered at build time"
439
+ },
440
+ {
441
+ value: "spa",
442
+ label: "SPA",
443
+ hint: "client-only, no server rendering"
444
+ }
445
+ ],
446
+ initialValue: tmpl.defaultMode
447
+ });
448
+ if (p.isCancel(value)) {
449
+ p.cancel("Cancelled.");
450
+ process.exit(0);
451
+ }
452
+ renderMode = value;
176
453
  }
177
- const packageStrategy = await p.select({
178
- message: "Package imports",
179
- options: [{
180
- value: "meta",
181
- label: "@pyreon/meta (single barrel)",
182
- hint: "one import for everything — simpler, tree-shaken at build"
183
- }, {
184
- value: "individual",
185
- label: "Individual packages",
186
- hint: "only install what you selected — smaller node_modules"
187
- }]
188
- });
189
- if (p.isCancel(packageStrategy)) {
190
- p.cancel("Cancelled.");
191
- process.exit(0);
454
+ let adapter;
455
+ if (args.adapter) {
456
+ if (!tmpl.adapters.includes(args.adapter)) {
457
+ p.cancel(`Adapter "${args.adapter}" is not supported by template "${template}". Allowed: ${tmpl.adapters.join(", ")}.`);
458
+ process.exit(2);
459
+ }
460
+ adapter = args.adapter;
461
+ } else if (yes) adapter = tmpl.defaultAdapter;
462
+ else {
463
+ const value = await p.select({
464
+ message: "Deployment target",
465
+ options: tmpl.adapters.map((id) => ({
466
+ value: id,
467
+ label: ADAPTER_LABELS[id].label,
468
+ hint: ADAPTER_LABELS[id].hint
469
+ })),
470
+ initialValue: tmpl.defaultAdapter
471
+ });
472
+ if (p.isCancel(value)) {
473
+ p.cancel("Cancelled.");
474
+ process.exit(0);
475
+ }
476
+ adapter = value;
192
477
  }
193
- const aiToolchain = await p.confirm({
194
- message: "Include AI toolchain? (MCP server, CLAUDE.md, doctor)",
195
- initialValue: true
196
- });
197
- if (p.isCancel(aiToolchain)) {
198
- p.cancel("Cancelled.");
199
- process.exit(0);
478
+ let features;
479
+ if (args.features !== void 0) features = args.features;
480
+ else if (yes) features = [...tmpl.defaultFeatures];
481
+ else {
482
+ const value = await p.multiselect({
483
+ message: "Select features (space to toggle, enter to confirm)",
484
+ options: Object.entries(FEATURES).map(([key, { label }]) => ({
485
+ value: key,
486
+ label
487
+ })),
488
+ initialValues: [...tmpl.defaultFeatures],
489
+ required: false
490
+ });
491
+ if (p.isCancel(value)) {
492
+ p.cancel("Cancelled.");
493
+ process.exit(0);
494
+ }
495
+ features = value;
200
496
  }
201
- const compat = await p.select({
202
- message: "Migrating from another framework?",
203
- options: [
204
- {
205
- value: "none",
206
- label: "No — native Pyreon",
207
- hint: "recommended"
208
- },
209
- {
210
- value: "react",
211
- label: "React",
212
- hint: "use useState, useEffect, etc."
213
- },
214
- {
215
- value: "vue",
216
- label: "Vue",
217
- hint: "use ref, computed, watch, etc."
218
- },
219
- {
220
- value: "solid",
221
- label: "Solid",
222
- hint: "use createSignal, createEffect, etc."
223
- },
224
- {
225
- value: "preact",
226
- label: "Preact",
227
- hint: "use useState, signals, etc."
228
- }
229
- ]
230
- });
231
- if (p.isCancel(compat)) {
232
- p.cancel("Cancelled.");
233
- process.exit(0);
497
+ let packageStrategy;
498
+ if (args.packageStrategy) packageStrategy = args.packageStrategy;
499
+ else if (yes) packageStrategy = "meta";
500
+ else {
501
+ const value = await p.select({
502
+ message: "Package imports",
503
+ options: [{
504
+ value: "meta",
505
+ label: "@pyreon/meta (single barrel)",
506
+ hint: "one import for everything — simpler, tree-shaken at build"
507
+ }, {
508
+ value: "individual",
509
+ label: "Individual packages",
510
+ hint: "only install what you selected — smaller node_modules"
511
+ }]
512
+ });
513
+ if (p.isCancel(value)) {
514
+ p.cancel("Cancelled.");
515
+ process.exit(0);
516
+ }
517
+ packageStrategy = value;
234
518
  }
235
- const lint = await p.confirm({
236
- message: "Include @pyreon/lint? (59 Pyreon-specific rules)",
237
- initialValue: true
238
- });
239
- if (p.isCancel(lint)) {
240
- p.cancel("Cancelled.");
241
- process.exit(0);
519
+ let integrations;
520
+ if (args.integrations !== void 0) integrations = args.integrations;
521
+ else if (yes) integrations = [...tmpl.defaultIntegrations];
522
+ else {
523
+ const value = await p.multiselect({
524
+ message: "Backend integrations (space to toggle)",
525
+ options: [{
526
+ value: "supabase",
527
+ label: "Supabase",
528
+ hint: "Postgres + auth + storage — replaces dashboard auth/db stubs"
529
+ }, {
530
+ value: "email",
531
+ label: "Email (Resend)",
532
+ hint: "Resend transport + document-primitives email templates"
533
+ }],
534
+ initialValues: [...tmpl.defaultIntegrations],
535
+ required: false
536
+ });
537
+ if (p.isCancel(value)) {
538
+ p.cancel("Cancelled.");
539
+ process.exit(0);
540
+ }
541
+ integrations = value;
542
+ }
543
+ let aiTools;
544
+ if (args.ai !== void 0) aiTools = args.ai;
545
+ else if (yes) aiTools = ["mcp", "claude"];
546
+ else {
547
+ const value = await p.multiselect({
548
+ message: "AI tooling (space to toggle, enter to confirm)",
549
+ options: [
550
+ {
551
+ value: "mcp",
552
+ label: "MCP server",
553
+ hint: ".mcp.json — Claude Code, Continue.dev"
554
+ },
555
+ {
556
+ value: "claude",
557
+ label: "CLAUDE.md",
558
+ hint: "Claude Code project rules"
559
+ },
560
+ {
561
+ value: "cursor",
562
+ label: "Cursor rules",
563
+ hint: ".cursor/rules/pyreon.md"
564
+ },
565
+ {
566
+ value: "copilot",
567
+ label: "GitHub Copilot",
568
+ hint: ".github/copilot-instructions.md"
569
+ },
570
+ {
571
+ value: "agents",
572
+ label: "AGENTS.md",
573
+ hint: "Aider, Continue, editor agents"
574
+ }
575
+ ],
576
+ initialValues: ["mcp", "claude"],
577
+ required: false
578
+ });
579
+ if (p.isCancel(value)) {
580
+ p.cancel("Cancelled.");
581
+ process.exit(0);
582
+ }
583
+ aiTools = value;
584
+ }
585
+ let compat;
586
+ if (args.compat) compat = args.compat;
587
+ else if (yes) compat = "none";
588
+ else {
589
+ const value = await p.select({
590
+ message: "Migrating from another framework?",
591
+ options: [
592
+ {
593
+ value: "none",
594
+ label: "No — native Pyreon",
595
+ hint: "recommended"
596
+ },
597
+ {
598
+ value: "react",
599
+ label: "React",
600
+ hint: "use useState, useEffect, etc."
601
+ },
602
+ {
603
+ value: "vue",
604
+ label: "Vue",
605
+ hint: "use ref, computed, watch, etc."
606
+ },
607
+ {
608
+ value: "solid",
609
+ label: "Solid",
610
+ hint: "use createSignal, createEffect, etc."
611
+ },
612
+ {
613
+ value: "preact",
614
+ label: "Preact",
615
+ hint: "use useState, signals, etc."
616
+ }
617
+ ]
618
+ });
619
+ if (p.isCancel(value)) {
620
+ p.cancel("Cancelled.");
621
+ process.exit(0);
622
+ }
623
+ compat = value;
242
624
  }
243
- const config = {
625
+ let lint;
626
+ if (args.lint !== void 0) lint = args.lint;
627
+ else if (yes) lint = true;
628
+ else {
629
+ const value = await p.confirm({
630
+ message: "Include @pyreon/lint? (59 Pyreon-specific rules)",
631
+ initialValue: true
632
+ });
633
+ if (p.isCancel(value)) {
634
+ p.cancel("Cancelled.");
635
+ process.exit(0);
636
+ }
637
+ lint = value;
638
+ }
639
+ return {
244
640
  name,
245
641
  targetDir,
642
+ template,
246
643
  renderMode,
644
+ adapter,
247
645
  features,
248
646
  packageStrategy,
249
- aiToolchain,
647
+ integrations,
648
+ aiTools,
250
649
  compat,
251
650
  lint
252
651
  };
253
- const s = p.spinner();
254
- s.start("Scaffolding project...");
255
- await scaffold(config);
256
- s.stop("Project created!");
257
- p.note([
258
- `cd ${config.name}`,
259
- "bun install",
260
- "bun run dev"
261
- ].join("\n"), "Next steps");
262
- p.outro("Happy building!");
263
652
  }
653
+
654
+ //#endregion
655
+ //#region src/adapters.ts
656
+ const vercel = {
657
+ id: "vercel",
658
+ viteFactory: "vercelAdapter",
659
+ async apply(config) {
660
+ await writeFile(join(config.targetDir, "vercel.json"), JSON.stringify({
661
+ $schema: "https://openapi.vercel.sh/vercel.json",
662
+ buildCommand: "bun run build",
663
+ outputDirectory: "dist",
664
+ framework: null
665
+ }, null, 2) + "\n");
666
+ },
667
+ badge() {
668
+ return "[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=)";
669
+ },
670
+ envKeys() {
671
+ return [];
672
+ }
673
+ };
674
+ const cloudflare = {
675
+ id: "cloudflare",
676
+ viteFactory: "cloudflareAdapter",
677
+ async apply(config) {
678
+ const wranglerToml = `name = "${slug(config.name)}"
679
+ compatibility_date = "2026-01-01"
680
+ compatibility_flags = ["nodejs_compat"]
681
+ pages_build_output_dir = "dist"
682
+
683
+ [vars]
684
+ # NODE_ENV = "production"
685
+ `;
686
+ await writeFile(join(config.targetDir, "wrangler.toml"), wranglerToml);
687
+ await writeFile(join(config.targetDir, "_routes.json"), JSON.stringify({
688
+ version: 1,
689
+ include: ["/*"],
690
+ exclude: ["/build/*"]
691
+ }, null, 2) + "\n");
692
+ },
693
+ badge() {
694
+ return "[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=)";
695
+ },
696
+ envKeys() {
697
+ return [];
698
+ }
699
+ };
700
+ const netlify = {
701
+ id: "netlify",
702
+ viteFactory: "netlifyAdapter",
703
+ async apply(config) {
704
+ await writeFile(join(config.targetDir, "netlify.toml"), `[build]
705
+ command = "bun run build"
706
+ publish = "dist"
707
+
708
+ [functions]
709
+ directory = "dist/.netlify/functions"
710
+ node_bundler = "esbuild"
711
+
712
+ [[redirects]]
713
+ from = "/*"
714
+ to = "/.netlify/functions/server/:splat"
715
+ status = 200
716
+ force = false
717
+ `);
718
+ },
719
+ badge() {
720
+ return "[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=)";
721
+ },
722
+ envKeys() {
723
+ return [];
724
+ }
725
+ };
726
+ const node = {
727
+ id: "node",
728
+ viteFactory: "nodeAdapter",
729
+ async apply(config) {
730
+ await writeFile(join(config.targetDir, "Dockerfile"), `FROM node:22-alpine AS build
731
+ WORKDIR /app
732
+ COPY package.json bun.lock* ./
733
+ RUN corepack enable && corepack prepare bun@latest --activate && bun install --frozen-lockfile
734
+ COPY . .
735
+ RUN bun run build
736
+
737
+ FROM node:22-alpine
738
+ WORKDIR /app
739
+ COPY --from=build /app/dist ./dist
740
+ COPY --from=build /app/package.json ./
741
+ COPY --from=build /app/node_modules ./node_modules
742
+ EXPOSE 3000
743
+ CMD ["node", "dist/server.js"]
744
+ `);
745
+ await writeFile(join(config.targetDir, ".dockerignore"), "node_modules\ndist\n.git\n.env\n.env.*\n");
746
+ },
747
+ badge() {
748
+ return "";
749
+ },
750
+ envKeys() {
751
+ return ["PORT"];
752
+ }
753
+ };
754
+ const bun = {
755
+ id: "bun",
756
+ viteFactory: "bunAdapter",
757
+ async apply(config) {
758
+ await writeFile(join(config.targetDir, "Dockerfile"), `FROM oven/bun:1 AS build
759
+ WORKDIR /app
760
+ COPY package.json bun.lock* ./
761
+ RUN bun install --frozen-lockfile
762
+ COPY . .
763
+ RUN bun run build
764
+
765
+ FROM oven/bun:1
766
+ WORKDIR /app
767
+ COPY --from=build /app/dist ./dist
768
+ COPY --from=build /app/package.json ./
769
+ COPY --from=build /app/node_modules ./node_modules
770
+ EXPOSE 3000
771
+ CMD ["bun", "run", "dist/server.js"]
772
+ `);
773
+ await writeFile(join(config.targetDir, ".dockerignore"), "node_modules\ndist\n.git\n.env\n.env.*\n");
774
+ },
775
+ badge() {
776
+ return "";
777
+ },
778
+ envKeys() {
779
+ return ["PORT"];
780
+ }
781
+ };
782
+ const staticAdapter = {
783
+ id: "static",
784
+ viteFactory: null,
785
+ async apply() {},
786
+ badge() {
787
+ return "";
788
+ },
789
+ envKeys() {
790
+ return [];
791
+ }
792
+ };
793
+ const ADAPTERS = {
794
+ vercel,
795
+ cloudflare,
796
+ netlify,
797
+ node,
798
+ bun,
799
+ static: staticAdapter
800
+ };
801
+ function adapterFor(id) {
802
+ return ADAPTERS[id];
803
+ }
804
+ function slug(name) {
805
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "");
806
+ }
807
+
808
+ //#endregion
809
+ //#region src/ai-tools.ts
810
+ /**
811
+ * Pyreon-specific guidance shared across every AI-tool rule file. Each tool
812
+ * wraps this in its own preamble (frontmatter, header, etc.) so the styling
813
+ * matches the tool's conventions, but the substance is identical: this is
814
+ * Pyreon, not React; here is how reactivity, JSX, and routing actually work.
815
+ *
816
+ * `doctorLine` is appended to the Commands section when the consumer is the
817
+ * primary "knows about doctor" file (CLAUDE.md). Other tools omit it.
818
+ */
819
+ function pyreonPrinciples(opts) {
820
+ return `## Reactivity (Pyreon, not React)
821
+
822
+ - \`signal()\` not \`useState\`; \`computed()\` not \`useMemo\`; \`effect()\` not \`useEffect\`.
823
+ - Write signals via \`signal.set(value)\` or \`signal.update(fn)\`. Calling \`signal(value)\` does NOT write — it reads.
824
+ - Components run **once** at mount. Reactivity comes from signals reading themselves at use sites; the framework subscribes the surrounding DOM node, not the whole component.
825
+ - In JSX, signals auto-call: \`{count}\` (compiler inserts \`()\`). Outside JSX, call explicitly: \`count()\`.
826
+ - Don't destructure props (\`const { x } = props\` captures getters once and loses reactivity). Read \`props.x\` directly, or use \`splitProps(props, ['x'])\`.
827
+
828
+ ## JSX
829
+
830
+ - \`class=\` not \`className\`; \`for=\` not \`htmlFor\`; camelCase events (\`onClick\`, \`onMouseEnter\`).
831
+ - Lists: \`<For each={items} by={r => r.id}>{r => <li>...</li>}</For>\`. The prop is \`by\` (not \`key\`) — JSX extracts \`key\` for VNode reconciliation.
832
+ - Conditionals: \`<Show when={cond}>...</Show>\` or accessor form \`{() => cond() ? <A /> : null}\`.
833
+ - \`onChange\` → \`onInput\` for keypress-by-keypress text updates.
834
+
835
+ ## File-Based Routing
836
+
837
+ - \`src/routes/index.tsx\` → \`/\`
838
+ - \`src/routes/about.tsx\` → \`/about\`
839
+ - \`src/routes/[id].tsx\` → \`/:id\`
840
+ - \`src/routes/_layout.tsx\` → layout wrapper
841
+ - \`(group)/\` → route group (no URL segment)
842
+
843
+ Per-route exports: \`default\` (component), \`loader\` (server data), \`guard\` (nav guard), \`middleware\`, \`meta\`, \`renderMode\`.
844
+
845
+ ## Don't reach for raw DOM APIs
846
+
847
+ - Use \`useEventListener\` / \`useClickOutside\` / \`useScrollLock\` from \`@pyreon/hooks\` instead of \`addEventListener\` / \`removeEventListener\`. The hook handles cleanup on unmount.
848
+ - For controlled state in primitives, use \`useControllableState({ value, defaultValue, onChange })\`.
849
+
850
+ ## Don't paste React patterns
851
+
852
+ - No \`useState\` / \`useEffect\` / \`useMemo\` / \`useCallback\` / \`useRef\`. None of those exist.
853
+ - No \`React.Fragment\` — just \`<></>\`.
854
+ - No "children as function" trick — Pyreon supports JSX children directly.
855
+
856
+ ## Commands
857
+
858
+ - \`bun run dev\` — dev server with HMR (signals preserve across reload)
859
+ - \`bun run build\` — production build
860
+ - \`bun run preview\` — serve build${opts.doctorLine ? "\n- `bun run doctor` — checks for React patterns and other anti-patterns" : ""}
861
+ `;
862
+ }
863
+ const RULE_FILES = [
864
+ {
865
+ id: "claude",
866
+ path: "CLAUDE.md",
867
+ render: () => `# Project
868
+
869
+ This project uses Pyreon Zero, a signal-based full-stack meta-framework. Do NOT use React patterns.
870
+
871
+ ${pyreonPrinciples({ doctorLine: true })}`
872
+ },
873
+ {
874
+ id: "cursor",
875
+ path: ".cursor/rules/pyreon.md",
876
+ render: () => `---
877
+ description: Pyreon Zero project rules
878
+ globs:
879
+ - "**/*.{ts,tsx}"
880
+ alwaysApply: true
881
+ ---
882
+
883
+ # Pyreon Zero
884
+
885
+ This is a Pyreon Zero project — a signal-based full-stack meta-framework. **Do not use React patterns** (useState, useEffect, className, etc.).
886
+
887
+ ${pyreonPrinciples({ doctorLine: false })}
888
+
889
+ ## When in doubt
890
+
891
+ The MCP server at \`.mcp.json\` exposes a \`validate\` tool that statically catches React→Pyreon mistakes. Run it on suspicious snippets before committing.
892
+ `
893
+ },
894
+ {
895
+ id: "copilot",
896
+ path: ".github/copilot-instructions.md",
897
+ render: () => `# Copilot Instructions
898
+
899
+ This repository uses **Pyreon Zero** — a signal-based meta-framework. Do not generate React code.
900
+
901
+ ${pyreonPrinciples({ doctorLine: false })}
902
+
903
+ ## Quick reference
904
+
905
+ | Need | Use |
906
+ |---|---|
907
+ | Reactive value | \`signal()\` |
908
+ | Derived value | \`computed()\` |
909
+ | Side effect | \`effect()\` or \`onMount(() => { … return cleanup })\` |
910
+ | Form state | \`useForm()\` from \`@pyreon/form\` |
911
+ | Server data | \`useQuery()\` from \`@pyreon/query\` |
912
+ | Global state | \`defineStore()\` from \`@pyreon/store\` |
913
+ `
914
+ },
915
+ {
916
+ id: "agents",
917
+ path: "AGENTS.md",
918
+ render: () => `# AGENTS.md
919
+
920
+ A generic AI-agent instruction file picked up by Aider, Continue.dev, and various editor agents that read \`AGENTS.md\` at the project root.
921
+
922
+ This is a Pyreon Zero project. Do not use React patterns (no useState / useEffect / className).
923
+
924
+ ${pyreonPrinciples({ doctorLine: false })}`
925
+ }
926
+ ];
927
+ async function applyAiTools(config) {
928
+ const selected = new Set(config.aiTools);
929
+ for (const gen of RULE_FILES) if (selected.has(gen.id)) {
930
+ const target = join(config.targetDir, gen.path);
931
+ await mkdir(dirname(target), { recursive: true });
932
+ await writeFile(target, gen.render(config));
933
+ }
934
+ if (!selected.has("claude")) await removeIfExists$1(join(config.targetDir, "CLAUDE.md"));
935
+ if (!selected.has("mcp")) await removeIfExists$1(join(config.targetDir, ".mcp.json"));
936
+ }
937
+ async function removeIfExists$1(path) {
938
+ if (!existsSync(path)) return;
939
+ await unlink(path);
940
+ }
941
+
942
+ //#endregion
943
+ //#region src/integrations.ts
944
+ const REGISTRY = {
945
+ supabase: {
946
+ id: "supabase",
947
+ deps() {
948
+ return { "@supabase/supabase-js": "^2.49.0" };
949
+ },
950
+ envKeys() {
951
+ return ["SUPABASE_URL", "SUPABASE_ANON_KEY"];
952
+ },
953
+ async apply(config) {
954
+ await writeFileEnsuringDir(join(config.targetDir, "src/lib/supabase.ts"), supabaseClient());
955
+ if (config.template === "dashboard") {
956
+ await writeFile(join(config.targetDir, "src/lib/auth.ts"), supabaseAuth());
957
+ await writeFile(join(config.targetDir, "src/lib/db.ts"), supabaseDb());
958
+ }
959
+ }
960
+ },
961
+ email: {
962
+ id: "email",
963
+ deps() {
964
+ return {
965
+ resend: "^4.0.0",
966
+ "@pyreon/document-primitives": "workspace:^",
967
+ "@pyreon/document": "workspace:^",
968
+ "@pyreon/connector-document": "workspace:^"
969
+ };
970
+ },
971
+ envKeys() {
972
+ return ["RESEND_API_KEY", "EMAIL_FROM"];
973
+ },
974
+ async apply(config) {
975
+ await writeFileEnsuringDir(join(config.targetDir, "src/lib/email.ts"), emailLib());
976
+ await writeFileEnsuringDir(join(config.targetDir, "src/emails/welcome.tsx"), welcomeEmailTemplate());
977
+ await writeFileEnsuringDir(join(config.targetDir, "src/routes/api/email/welcome.ts"), welcomeEmailEndpoint());
978
+ }
979
+ }
980
+ };
981
+ async function applyIntegrations(config) {
982
+ for (const id of config.integrations) await REGISTRY[id].apply(config);
983
+ await appendEnvExample(config);
984
+ }
985
+ function integrationDeps(config) {
986
+ const out = {};
987
+ for (const id of config.integrations) Object.assign(out, REGISTRY[id].deps());
988
+ return out;
989
+ }
990
+ async function appendEnvExample(config) {
991
+ if (config.integrations.length === 0) return;
992
+ const lines = [];
993
+ for (const id of config.integrations) {
994
+ const keys = REGISTRY[id].envKeys();
995
+ if (keys.length === 0) continue;
996
+ lines.push(`# ─── ${id} ───`);
997
+ for (const k of keys) lines.push(`${k}=`);
998
+ lines.push("");
999
+ }
1000
+ const envPath = join(config.targetDir, ".env.example");
1001
+ const existing = existsSync(envPath) ? await readFile(envPath, "utf-8") : "";
1002
+ await writeFile(envPath, existing ? `${existing.trimEnd()}\n\n${lines.join("\n")}` : lines.join("\n"));
1003
+ }
1004
+ async function writeFileEnsuringDir(path, content) {
1005
+ await mkdir(dirname(path), { recursive: true });
1006
+ await writeFile(path, content);
1007
+ }
1008
+ function supabaseClient() {
1009
+ return `import { createClient, type SupabaseClient } from '@supabase/supabase-js'
1010
+
1011
+ /**
1012
+ * Server-side Supabase client. Uses the anon key by default; swap for the
1013
+ * service-role key inside trusted server contexts (route loaders that
1014
+ * need to bypass RLS) and pair with row-level policies in your Postgres
1015
+ * schema.
1016
+ *
1017
+ * The server reads SUPABASE_URL / SUPABASE_ANON_KEY from \`process.env\`.
1018
+ * For the browser bundle, expose the SAME values via \`publicEnv()\` so
1019
+ * client-side fetch / realtime subscriptions can connect.
1020
+ */
1021
+ export const supabase: SupabaseClient = createClient(
1022
+ process.env.SUPABASE_URL ?? '',
1023
+ process.env.SUPABASE_ANON_KEY ?? '',
1024
+ { auth: { persistSession: false, detectSessionInUrl: false } },
1025
+ )
1026
+ `;
1027
+ }
1028
+ function supabaseAuth() {
1029
+ return `import { supabase } from './supabase'
1030
+
1031
+ /**
1032
+ * Supabase-backed auth implementation. Mirrors the in-memory stub's
1033
+ * exported surface (signIn / signUp / getSession / signOut / SessionInfo)
1034
+ * so route guards don't change when swapping backends.
1035
+ */
1036
+
1037
+ export interface SessionInfo {
1038
+ userId: string
1039
+ email: string
1040
+ }
1041
+
1042
+ export async function signUp(
1043
+ email: string,
1044
+ password: string,
1045
+ ): Promise<{ sessionId: string } | { error: string }> {
1046
+ const { data, error } = await supabase.auth.signUp({ email, password })
1047
+ if (error) return { error: error.message }
1048
+ if (!data.session) return { error: 'Email confirmation required — check your inbox.' }
1049
+ return { sessionId: data.session.access_token }
1050
+ }
1051
+
1052
+ export async function signIn(
1053
+ email: string,
1054
+ password: string,
1055
+ ): Promise<{ sessionId: string } | { error: string }> {
1056
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password })
1057
+ if (error) return { error: error.message }
1058
+ return { sessionId: data.session.access_token }
1059
+ }
1060
+
1061
+ export async function getSession(sessionId: string | undefined): Promise<SessionInfo | null> {
1062
+ if (!sessionId) return null
1063
+ const { data, error } = await supabase.auth.getUser(sessionId)
1064
+ if (error || !data.user) return null
1065
+ return { userId: data.user.id, email: data.user.email ?? '' }
1066
+ }
1067
+
1068
+ export async function signOut(sessionId: string): Promise<void> {
1069
+ // Supabase tokens are JWTs; revocation is server-mediated. We invalidate
1070
+ // the access token by calling \`supabase.auth.admin.signOut(sessionId)\`
1071
+ // when the service-role key is available; otherwise fall back to a
1072
+ // client-side cookie clear (handled by the route).
1073
+ if (process.env.SUPABASE_SERVICE_ROLE_KEY) {
1074
+ await supabase.auth.admin.signOut(sessionId)
1075
+ }
1076
+ }
1077
+ `;
1078
+ }
1079
+ function supabaseDb() {
1080
+ return `import { supabase } from './supabase'
1081
+
1082
+ /**
1083
+ * Supabase-backed data layer. Mirrors the in-memory stub's exported
1084
+ * surface (User / Invoice / listUsers / listInvoices / invoiceById /
1085
+ * invoiceTotal) so dashboard routes don't change when swapping backends.
1086
+ *
1087
+ * Schema expected in your Supabase Postgres:
1088
+ *
1089
+ * create table public.users (
1090
+ * id uuid primary key,
1091
+ * email text not null,
1092
+ * name text not null,
1093
+ * role text not null check (role in ('admin','member')),
1094
+ * created_at timestamptz not null default now()
1095
+ * );
1096
+ *
1097
+ * create table public.invoices (
1098
+ * id text primary key,
1099
+ * number text not null,
1100
+ * customer jsonb not null,
1101
+ * items jsonb not null,
1102
+ * status text not null check (status in ('draft','pending','paid')),
1103
+ * issued_at timestamptz not null default now()
1104
+ * );
1105
+ */
1106
+
1107
+ export interface User {
1108
+ id: string
1109
+ email: string
1110
+ name: string
1111
+ role: 'admin' | 'member'
1112
+ createdAt: Date
1113
+ }
1114
+
1115
+ export interface InvoiceItem {
1116
+ description: string
1117
+ qty: number
1118
+ unitPrice: number
1119
+ }
1120
+
1121
+ export interface Invoice {
1122
+ id: string
1123
+ number: string
1124
+ customer: { name: string; email: string; address: string }
1125
+ items: InvoiceItem[]
1126
+ status: 'draft' | 'pending' | 'paid'
1127
+ issuedAt: Date
1128
+ }
1129
+
1130
+ export async function listUsers(): Promise<User[]> {
1131
+ const { data, error } = await supabase.from('users').select('*')
1132
+ if (error) throw error
1133
+ return data.map((row) => ({
1134
+ id: row.id,
1135
+ email: row.email,
1136
+ name: row.name,
1137
+ role: row.role,
1138
+ createdAt: new Date(row.created_at),
1139
+ }))
1140
+ }
1141
+
1142
+ export async function listInvoices(): Promise<Invoice[]> {
1143
+ const { data, error } = await supabase.from('invoices').select('*')
1144
+ if (error) throw error
1145
+ return data.map(rowToInvoice)
1146
+ }
1147
+
1148
+ export async function invoiceById(id: string): Promise<Invoice | undefined> {
1149
+ const { data, error } = await supabase.from('invoices').select('*').eq('id', id).maybeSingle()
1150
+ if (error) throw error
1151
+ return data ? rowToInvoice(data) : undefined
1152
+ }
1153
+
1154
+ export function invoiceTotal(inv: Invoice): number {
1155
+ return inv.items.reduce((sum, i) => sum + i.qty * i.unitPrice, 0)
1156
+ }
1157
+
1158
+ function rowToInvoice(row: any): Invoice {
1159
+ return {
1160
+ id: row.id,
1161
+ number: row.number,
1162
+ customer: row.customer,
1163
+ items: row.items,
1164
+ status: row.status,
1165
+ issuedAt: new Date(row.issued_at),
1166
+ }
1167
+ }
1168
+ `;
1169
+ }
1170
+ function emailLib() {
1171
+ return `import { Resend } from 'resend'
1172
+ import { extractDocNode } from '@pyreon/document-primitives'
1173
+ import { render } from '@pyreon/document'
1174
+ import type { ComponentFn } from '@pyreon/core'
1175
+
1176
+ const resend = new Resend(process.env.RESEND_API_KEY)
1177
+ const FROM = process.env.EMAIL_FROM ?? 'noreply@example.com'
1178
+
1179
+ /**
1180
+ * Send an email rendered from a Pyreon \`@pyreon/document-primitives\`
1181
+ * template. The same template renders in the browser preview AND exports
1182
+ * to email HTML — that is the headline Pyreon angle: one component tree,
1183
+ * many output formats.
1184
+ */
1185
+ export async function sendEmail<TProps>(opts: {
1186
+ to: string | string[]
1187
+ subject: string
1188
+ template: ComponentFn<TProps>
1189
+ data: TProps
1190
+ }): Promise<{ id: string } | { error: string }> {
1191
+ const node = extractDocNode(() => opts.template(opts.data))
1192
+ const html = (await render(node, 'email')) as string
1193
+
1194
+ const { data, error } = await resend.emails.send({
1195
+ from: FROM,
1196
+ to: opts.to,
1197
+ subject: opts.subject,
1198
+ html,
1199
+ })
1200
+
1201
+ if (error) return { error: error.message }
1202
+ return { id: data?.id ?? 'unknown' }
1203
+ }
1204
+ `;
1205
+ }
1206
+ function welcomeEmailTemplate() {
1207
+ return `import {
1208
+ DocDocument,
1209
+ DocPage,
1210
+ DocSection,
1211
+ DocHeading,
1212
+ DocText,
1213
+ DocSpacer,
1214
+ } from '@pyreon/document-primitives'
1215
+
1216
+ export interface WelcomeEmailProps {
1217
+ name: string
1218
+ appUrl: string
1219
+ }
1220
+
1221
+ /**
1222
+ * Welcome email template. Renders in the browser AND exports to email
1223
+ * HTML via \`@pyreon/document-primitives\` — the SAME component tree.
1224
+ *
1225
+ * Try it: in dev, visit \`/api/email/welcome?to=you@example.com\`. In
1226
+ * production, call \`sendEmail({ to, subject, template: WelcomeEmail,
1227
+ * data: { name, appUrl } })\` from any server route.
1228
+ */
1229
+ export default function WelcomeEmail(props: WelcomeEmailProps) {
1230
+ return (
1231
+ <DocDocument title="Welcome" subject="Welcome to your new account">
1232
+ <DocPage>
1233
+ <DocSection>
1234
+ <DocHeading level="h1">Welcome, {props.name}.</DocHeading>
1235
+ </DocSection>
1236
+
1237
+ <DocSpacer />
1238
+
1239
+ <DocSection>
1240
+ <DocText>
1241
+ Your account is ready. The dashboard is the fastest way to get started — log
1242
+ in any time at:
1243
+ </DocText>
1244
+ <DocText>{props.appUrl}</DocText>
1245
+ </DocSection>
1246
+
1247
+ <DocSpacer />
1248
+
1249
+ <DocSection>
1250
+ <DocText>If you didn't create this account, ignore this email.</DocText>
1251
+ </DocSection>
1252
+ </DocPage>
1253
+ </DocDocument>
1254
+ )
1255
+ }
1256
+ `;
1257
+ }
1258
+ function welcomeEmailEndpoint() {
1259
+ return `import { sendEmail } from '../../../lib/email'
1260
+ import WelcomeEmail from '../../../emails/welcome'
1261
+
1262
+ export async function GET(request: Request) {
1263
+ const url = new URL(request.url)
1264
+ const to = url.searchParams.get('to')
1265
+ if (!to) {
1266
+ return new Response(JSON.stringify({ error: 'Missing ?to=' }), {
1267
+ status: 400,
1268
+ headers: { 'content-type': 'application/json' },
1269
+ })
1270
+ }
1271
+
1272
+ const appUrl = url.origin
1273
+
1274
+ const result = await sendEmail({
1275
+ to,
1276
+ subject: 'Welcome',
1277
+ template: WelcomeEmail,
1278
+ data: { name: to.split('@')[0] ?? 'friend', appUrl },
1279
+ })
1280
+
1281
+ return new Response(JSON.stringify(result), {
1282
+ headers: { 'content-type': 'application/json' },
1283
+ })
1284
+ }
1285
+ `;
1286
+ }
1287
+
1288
+ //#endregion
1289
+ //#region src/scaffold.ts
264
1290
  async function scaffold(config) {
265
- await cp(TEMPLATE_DIR, config.targetDir, { recursive: true });
1291
+ await cp(templateDir(config.template), config.targetDir, { recursive: true });
266
1292
  await writeFile(join(config.targetDir, "package.json"), generatePackageJson(config));
267
1293
  await writeFile(join(config.targetDir, "vite.config.ts"), generateViteConfig(config));
1294
+ await adapterFor(config.adapter).apply(config);
268
1295
  await writeFile(join(config.targetDir, "src/entry-server.ts"), generateEntryServer(config));
269
1296
  await writeFile(join(config.targetDir, "env.d.ts"), generateEnvDts(config));
270
1297
  await writeFile(join(config.targetDir, ".gitignore"), "node_modules\ndist\n.DS_Store\n*.local\n.pyreon\n");
@@ -272,28 +1299,19 @@ async function scaffold(config) {
272
1299
  $schema: "node_modules/@pyreon/lint/schema/pyreonlintrc.schema.json",
273
1300
  preset: "recommended"
274
1301
  }, null, 2) + "\n");
275
- if (config.aiToolchain) await writeFile(join(config.targetDir, ".mcp.json"), JSON.stringify({ mcpServers: { pyreon: {
276
- command: "bunx",
277
- args: ["@pyreon/mcp"]
278
- } } }, null, 2));
279
- else for (const f of [".mcp.json", "CLAUDE.md"]) {
280
- const path = join(config.targetDir, f);
281
- if (existsSync(path)) {
282
- const { unlink } = await import("node:fs/promises");
283
- await unlink(path);
284
- }
285
- }
286
- if (!config.features.includes("feature") && !config.features.includes("forms")) {
287
- await removeIfExists(join(config.targetDir, "src/routes/posts/new.tsx"));
288
- await removeIfExists(join(config.targetDir, "src/features"));
289
- }
290
- if (!config.features.includes("store")) await removeIfExists(join(config.targetDir, "src/stores"));
291
- if (!config.features.includes("store")) {
292
- const layoutPath = join(config.targetDir, "src/routes/_layout.tsx");
293
- if (existsSync(layoutPath)) {
294
- let layout = await readFile(layoutPath, "utf-8");
295
- layout = layout.replace(/import .* from '\.\.\/stores\/app'\n/g, "").replace(/.*useAppStore.*\n/g, "").replace(/\s*<button[\s\S]*?sidebar-toggle[\s\S]*?<\/button>\n/g, "");
296
- await writeFile(layoutPath, layout);
1302
+ await applyAiTools(config);
1303
+ await applyIntegrations(config);
1304
+ if (config.template === "app") {
1305
+ if (!config.features.includes("feature")) await removeIfExists(join(config.targetDir, "src/features"));
1306
+ if (!config.features.includes("feature") || !config.features.includes("forms")) await removeIfExists(join(config.targetDir, "src/routes/posts/new.tsx"));
1307
+ if (!config.features.includes("store")) {
1308
+ await removeIfExists(join(config.targetDir, "src/stores"));
1309
+ const layoutPath = join(config.targetDir, "src/routes/_layout.tsx");
1310
+ if (existsSync(layoutPath)) {
1311
+ let layout = await readFile(layoutPath, "utf-8");
1312
+ layout = layout.replace(/import .* from '\.\.\/stores\/app'\n/g, "").replace(/.*useAppStore.*\n/g, "").replace(/\s*<button[\s\S]*?sidebar-toggle[\s\S]*?<\/button>\n/g, "");
1313
+ await writeFile(layoutPath, layout);
1314
+ }
297
1315
  }
298
1316
  }
299
1317
  }
@@ -312,34 +1330,32 @@ function generatePackageJson(config) {
312
1330
  "@pyreon/server": pyreonVersion("@pyreon/server"),
313
1331
  "@pyreon/zero": pyreonVersion("@pyreon/zero")
314
1332
  };
315
- if (config.packageStrategy === "meta") {
316
- deps["@pyreon/meta"] = pyreonVersion("@pyreon/meta");
317
- for (const key of config.features) {
318
- const feature = FEATURES[key];
319
- if (feature) {
320
- for (const dep of feature.deps) if (!dep.startsWith("@pyreon/")) {
321
- if (dep.startsWith("@tanstack/")) deps[dep] = dep.includes("query") ? "^5.90.0" : dep.includes("table") ? "^8.21.0" : "^3.13.0";
322
- else if (dep === "zod") deps[dep] = "^4.0.0";
323
- }
324
- }
325
- }
326
- } else {
327
- const allDeps = /* @__PURE__ */ new Set();
328
- for (const key of config.features) {
329
- const feature = FEATURES[key];
330
- if (feature) for (const dep of feature.deps) allDeps.add(dep);
331
- }
332
- for (const dep of allDeps) if (dep.startsWith("@pyreon/")) deps[dep] = pyreonVersion(dep);
333
- else if (dep.startsWith("@tanstack/")) deps[dep] = dep.includes("query") ? "^5.90.0" : dep.includes("table") ? "^8.21.0" : "^3.13.0";
334
- else if (dep === "zod") deps[dep] = "^4.0.0";
1333
+ const allFeatureDeps = /* @__PURE__ */ new Set();
1334
+ for (const key of config.features) {
1335
+ const feature = FEATURES[key];
1336
+ if (feature) for (const dep of feature.deps) allFeatureDeps.add(dep);
1337
+ }
1338
+ if (config.template === "app") {
1339
+ allFeatureDeps.add("@pyreon/query");
1340
+ allFeatureDeps.add("@tanstack/query-core");
1341
+ allFeatureDeps.add("@pyreon/store");
335
1342
  }
1343
+ if (config.template === "dashboard") {
1344
+ allFeatureDeps.add("@pyreon/document-primitives");
1345
+ allFeatureDeps.add("@pyreon/document");
1346
+ allFeatureDeps.add("@pyreon/connector-document");
1347
+ }
1348
+ for (const dep of allFeatureDeps) if (dep.startsWith("@pyreon/")) deps[dep] = pyreonVersion(dep);
1349
+ else if (dep.startsWith("@tanstack/")) deps[dep] = dep.includes("query") ? "^5.90.0" : dep.includes("table") ? "^8.21.0" : "^3.13.0";
1350
+ else if (dep === "zod") deps[dep] = "^4.0.0";
1351
+ if (config.packageStrategy === "meta") deps["@pyreon/meta"] = pyreonVersion("@pyreon/meta");
336
1352
  const devDeps = {
337
1353
  "@pyreon/vite-plugin": pyreonVersion("@pyreon/vite-plugin"),
338
1354
  "@pyreon/zero-cli": pyreonVersion("@pyreon/zero-cli"),
339
1355
  typescript: "^6.0.2",
340
1356
  vite: "^8.0.3"
341
1357
  };
342
- if (config.aiToolchain) devDeps["@pyreon/mcp"] = pyreonVersion("@pyreon/mcp");
1358
+ if (config.aiTools.includes("mcp")) devDeps["@pyreon/mcp"] = pyreonVersion("@pyreon/mcp");
343
1359
  const compatPkgMap = {
344
1360
  react: "@pyreon/react-compat",
345
1361
  vue: "@pyreon/vue-compat",
@@ -348,6 +1364,7 @@ function generatePackageJson(config) {
348
1364
  };
349
1365
  if (config.compat !== "none" && compatPkgMap[config.compat]) deps[compatPkgMap[config.compat]] = pyreonVersion(compatPkgMap[config.compat]);
350
1366
  if (config.lint) devDeps["@pyreon/lint"] = pyreonVersion("@pyreon/lint");
1367
+ for (const [name, version] of Object.entries(integrationDeps(config))) deps[name] = version === "workspace:^" ? pyreonVersion(name) : version;
351
1368
  const scripts = {
352
1369
  dev: "zero dev",
353
1370
  build: "zero build",
@@ -369,20 +1386,25 @@ function generatePackageJson(config) {
369
1386
  return `${JSON.stringify(pkg, null, 2)}\n`;
370
1387
  }
371
1388
  function generateViteConfig(config) {
1389
+ const modeMap = {
1390
+ "ssr-stream": `mode: 'ssr', ssr: { mode: 'stream' }`,
1391
+ "ssr-string": `mode: 'ssr'`,
1392
+ ssg: `mode: 'ssg'`,
1393
+ spa: `mode: 'spa'`
1394
+ };
1395
+ const pyreonOpts = config.compat !== "none" ? `{ compat: '${config.compat}' }` : "";
1396
+ const adapter = adapterFor(config.adapter);
1397
+ const adapterImport = adapter.viteFactory ? `\nimport { ${adapter.viteFactory} } from '@pyreon/zero/server'` : "";
1398
+ const adapterArg = adapter.viteFactory ? `, adapter: ${adapter.viteFactory}()` : "";
372
1399
  return `import pyreon from '@pyreon/vite-plugin'
373
- import zero from '@pyreon/zero/server'
1400
+ import zero from '@pyreon/zero/server'${adapterImport}
374
1401
  import { fontPlugin } from '@pyreon/zero/font'
375
1402
  import { seoPlugin } from '@pyreon/zero/seo'
376
1403
 
377
1404
  export default {
378
1405
  plugins: [
379
- pyreon(${config.compat !== "none" ? `{ compat: '${config.compat}' }` : ""}),
380
- zero({ ${{
381
- "ssr-stream": `mode: 'ssr', ssr: { mode: 'stream' }`,
382
- "ssr-string": `mode: 'ssr'`,
383
- ssg: `mode: 'ssg'`,
384
- spa: `mode: 'spa'`
385
- }[config.renderMode]} }),
1406
+ pyreon(${pyreonOpts}),
1407
+ zero({ ${modeMap[config.renderMode]}${adapterArg} }),
386
1408
 
387
1409
  // Google Fonts — self-hosted at build time, CDN in dev
388
1410
  fontPlugin({
@@ -461,6 +1483,47 @@ async function removeIfExists(path) {
461
1483
  const { rm } = await import("node:fs/promises");
462
1484
  await rm(path, { recursive: true });
463
1485
  }
1486
+
1487
+ //#endregion
1488
+ //#region src/index.ts
1489
+ /**
1490
+ * Detect which bin alias the user invoked. The same package ships two
1491
+ * entry points: `create-pyreon-app` (canonical, discoverable via
1492
+ * `bunx create-pyreon-app`) and `create-zero` (back-compat alias for
1493
+ * users following older docs / `bun create @pyreon/zero` flow).
1494
+ *
1495
+ * The --help text echoes the alias the user actually typed so docs
1496
+ * links and copy-paste invocations stay consistent.
1497
+ */
1498
+ function detectInvocation() {
1499
+ return (process.argv[1] ?? "").includes("create-pyreon-app") ? "create-pyreon-app" : "create-zero";
1500
+ }
1501
+ async function main() {
1502
+ const invokedAs = detectInvocation();
1503
+ let args;
1504
+ try {
1505
+ args = parseArgs(process.argv.slice(2));
1506
+ } catch (err) {
1507
+ console.error(err instanceof Error ? err.message : err);
1508
+ process.exit(2);
1509
+ }
1510
+ if (args.help) {
1511
+ console.log(helpText(invokedAs));
1512
+ process.exit(0);
1513
+ }
1514
+ p.intro(invokedAs === "create-pyreon-app" ? "Create a new Pyreon project" : "Pyreon Zero");
1515
+ const config = await runPrompts(args);
1516
+ const s = p.spinner();
1517
+ s.start("Scaffolding project...");
1518
+ await scaffold(config);
1519
+ s.stop("Project created!");
1520
+ p.note([
1521
+ `cd ${config.name}`,
1522
+ "bun install",
1523
+ "bun run dev"
1524
+ ].join("\n"), "Next steps");
1525
+ p.outro("Happy building!");
1526
+ }
464
1527
  main().catch((err) => {
465
1528
  console.error(err);
466
1529
  process.exit(1);