@pyreon/create-zero 0.13.1 → 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.
- package/README.md +85 -22
- package/bin/create-pyreon-app.js +2 -0
- package/lib/index.js +1296 -159
- package/package.json +5 -2
- package/templates/{default → app}/CLAUDE.md +5 -5
- package/templates/{default → app}/src/routes/_layout.tsx +5 -2
- package/templates/{default → app}/src/routes/counter.tsx +17 -15
- package/templates/blog/.mcp.json +8 -0
- package/templates/blog/CLAUDE.md +59 -0
- package/templates/blog/index.html +18 -0
- package/templates/blog/public/favicon.svg +4 -0
- package/templates/blog/src/content/posts/static-vs-ssr.tsx +54 -0
- package/templates/blog/src/content/posts/welcome.tsx +70 -0
- package/templates/blog/src/content/posts/why-signals.tsx +57 -0
- package/templates/blog/src/entry-client.ts +5 -0
- package/templates/blog/src/global.css +292 -0
- package/templates/blog/src/lib/posts.ts +45 -0
- package/templates/blog/src/routes/_layout.tsx +40 -0
- package/templates/blog/src/routes/about.tsx +28 -0
- package/templates/blog/src/routes/api/rss.ts +55 -0
- package/templates/blog/src/routes/blog/[slug].tsx +67 -0
- package/templates/blog/src/routes/blog/index.tsx +43 -0
- package/templates/blog/src/routes/index.tsx +52 -0
- package/templates/blog/tsconfig.json +16 -0
- package/templates/dashboard/.mcp.json +8 -0
- package/templates/dashboard/CLAUDE.md +50 -0
- package/templates/dashboard/index.html +16 -0
- package/templates/dashboard/public/favicon.svg +4 -0
- package/templates/dashboard/src/entry-client.ts +5 -0
- package/templates/dashboard/src/global.css +451 -0
- package/templates/dashboard/src/lib/auth.ts +106 -0
- package/templates/dashboard/src/lib/db.ts +118 -0
- package/templates/dashboard/src/routes/_layout.tsx +28 -0
- package/templates/dashboard/src/routes/api/signout.ts +15 -0
- package/templates/dashboard/src/routes/app/_layout.tsx +76 -0
- package/templates/dashboard/src/routes/app/dashboard.tsx +92 -0
- package/templates/dashboard/src/routes/app/invoices/[id].tsx +197 -0
- package/templates/dashboard/src/routes/app/invoices/index.tsx +61 -0
- package/templates/dashboard/src/routes/app/settings/account.tsx +31 -0
- package/templates/dashboard/src/routes/app/settings/billing.tsx +28 -0
- package/templates/dashboard/src/routes/app/settings/index.tsx +29 -0
- package/templates/dashboard/src/routes/app/users.tsx +50 -0
- package/templates/dashboard/src/routes/index.tsx +40 -0
- package/templates/dashboard/src/routes/login.tsx +79 -0
- package/templates/dashboard/src/routes/signup.tsx +78 -0
- package/templates/dashboard/tsconfig.json +16 -0
- package/lib/index.js.map +0 -1
- /package/templates/{default → app}/.mcp.json +0 -0
- /package/templates/{default → app}/index.html +0 -0
- /package/templates/{default → app}/public/favicon.svg +0 -0
- /package/templates/{default → app}/src/entry-client.ts +0 -0
- /package/templates/{default → app}/src/features/posts.ts +0 -0
- /package/templates/{default → app}/src/global.css +0 -0
- /package/templates/{default → app}/src/routes/(admin)/dashboard.tsx +0 -0
- /package/templates/{default → app}/src/routes/_error.tsx +0 -0
- /package/templates/{default → app}/src/routes/_loading.tsx +0 -0
- /package/templates/{default → app}/src/routes/about.tsx +0 -0
- /package/templates/{default → app}/src/routes/api/health.ts +0 -0
- /package/templates/{default → app}/src/routes/api/posts.ts +0 -0
- /package/templates/{default → app}/src/routes/index.tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/[id].tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/index.tsx +0 -0
- /package/templates/{default → app}/src/routes/posts/new.tsx +0 -0
- /package/templates/{default → app}/src/stores/app.ts +0 -0
- /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/
|
|
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)",
|
|
@@ -85,151 +248,1070 @@ const FEATURES = {
|
|
|
85
248
|
code: {
|
|
86
249
|
label: "Code Editor (@pyreon/code — CodeMirror 6)",
|
|
87
250
|
deps: ["@pyreon/code"]
|
|
251
|
+
},
|
|
252
|
+
toast: {
|
|
253
|
+
label: "Toast Notifications (@pyreon/toast)",
|
|
254
|
+
deps: ["@pyreon/toast"]
|
|
255
|
+
},
|
|
256
|
+
permissions: {
|
|
257
|
+
label: "Permissions (@pyreon/permissions — RBAC, feature flags)",
|
|
258
|
+
deps: ["@pyreon/permissions"]
|
|
259
|
+
},
|
|
260
|
+
"url-state": {
|
|
261
|
+
label: "URL State (@pyreon/url-state — URL-synced params)",
|
|
262
|
+
deps: ["@pyreon/url-state"]
|
|
263
|
+
},
|
|
264
|
+
rx: {
|
|
265
|
+
label: "Reactive Transforms (@pyreon/rx — filter, map, sortBy, groupBy)",
|
|
266
|
+
deps: ["@pyreon/rx"]
|
|
88
267
|
}
|
|
89
268
|
};
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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"]
|
|
329
|
+
}
|
|
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, …)"
|
|
96
362
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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);
|
|
104
390
|
}
|
|
105
|
-
|
|
106
|
-
if (p.isCancel(name)) {
|
|
107
|
-
p.cancel("Cancelled.");
|
|
108
|
-
process.exit(0);
|
|
391
|
+
name = value;
|
|
109
392
|
}
|
|
110
393
|
const targetDir = resolve(process.cwd(), name);
|
|
111
394
|
if (existsSync(targetDir)) {
|
|
112
395
|
p.cancel(`Directory "${name}" already exists.`);
|
|
113
396
|
process.exit(1);
|
|
114
397
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
value: "spa",
|
|
135
|
-
label: "SPA",
|
|
136
|
-
hint: "client-only, no server rendering"
|
|
137
|
-
}
|
|
138
|
-
]
|
|
139
|
-
});
|
|
140
|
-
if (p.isCancel(renderMode)) {
|
|
141
|
-
p.cancel("Cancelled.");
|
|
142
|
-
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;
|
|
143
415
|
}
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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;
|
|
160
453
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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;
|
|
176
477
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
496
|
+
}
|
|
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;
|
|
518
|
+
}
|
|
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;
|
|
184
624
|
}
|
|
185
|
-
|
|
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 {
|
|
186
640
|
name,
|
|
187
641
|
targetDir,
|
|
642
|
+
template,
|
|
188
643
|
renderMode,
|
|
644
|
+
adapter,
|
|
189
645
|
features,
|
|
190
646
|
packageStrategy,
|
|
191
|
-
|
|
647
|
+
integrations,
|
|
648
|
+
aiTools,
|
|
649
|
+
compat,
|
|
650
|
+
lint
|
|
192
651
|
};
|
|
193
|
-
const s = p.spinner();
|
|
194
|
-
s.start("Scaffolding project...");
|
|
195
|
-
await scaffold(config);
|
|
196
|
-
s.stop("Project created!");
|
|
197
|
-
p.note([
|
|
198
|
-
`cd ${config.name}`,
|
|
199
|
-
"bun install",
|
|
200
|
-
"bun run dev"
|
|
201
|
-
].join("\n"), "Next steps");
|
|
202
|
-
p.outro("Happy building!");
|
|
203
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 "[](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 "[](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 "[](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
|
|
204
1290
|
async function scaffold(config) {
|
|
205
|
-
await cp(
|
|
1291
|
+
await cp(templateDir(config.template), config.targetDir, { recursive: true });
|
|
206
1292
|
await writeFile(join(config.targetDir, "package.json"), generatePackageJson(config));
|
|
207
1293
|
await writeFile(join(config.targetDir, "vite.config.ts"), generateViteConfig(config));
|
|
1294
|
+
await adapterFor(config.adapter).apply(config);
|
|
208
1295
|
await writeFile(join(config.targetDir, "src/entry-server.ts"), generateEntryServer(config));
|
|
209
1296
|
await writeFile(join(config.targetDir, "env.d.ts"), generateEnvDts(config));
|
|
210
1297
|
await writeFile(join(config.targetDir, ".gitignore"), "node_modules\ndist\n.DS_Store\n*.local\n.pyreon\n");
|
|
211
|
-
if (config.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const layoutPath = join(config.targetDir, "src/routes/_layout.tsx");
|
|
229
|
-
if (existsSync(layoutPath)) {
|
|
230
|
-
let layout = await readFile(layoutPath, "utf-8");
|
|
231
|
-
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, "");
|
|
232
|
-
await writeFile(layoutPath, layout);
|
|
1298
|
+
if (config.lint) await writeFile(join(config.targetDir, ".pyreonlintrc.json"), JSON.stringify({
|
|
1299
|
+
$schema: "node_modules/@pyreon/lint/schema/pyreonlintrc.schema.json",
|
|
1300
|
+
preset: "recommended"
|
|
1301
|
+
}, null, 2) + "\n");
|
|
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
|
+
}
|
|
233
1315
|
}
|
|
234
1316
|
}
|
|
235
1317
|
}
|
|
@@ -248,67 +1330,81 @@ function generatePackageJson(config) {
|
|
|
248
1330
|
"@pyreon/server": pyreonVersion("@pyreon/server"),
|
|
249
1331
|
"@pyreon/zero": pyreonVersion("@pyreon/zero")
|
|
250
1332
|
};
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
} else {
|
|
263
|
-
const allDeps = /* @__PURE__ */ new Set();
|
|
264
|
-
for (const key of config.features) {
|
|
265
|
-
const feature = FEATURES[key];
|
|
266
|
-
if (feature) for (const dep of feature.deps) allDeps.add(dep);
|
|
267
|
-
}
|
|
268
|
-
for (const dep of allDeps) if (dep.startsWith("@pyreon/")) deps[dep] = pyreonVersion(dep);
|
|
269
|
-
else if (dep.startsWith("@tanstack/")) deps[dep] = dep.includes("query") ? "^5.90.0" : dep.includes("table") ? "^8.21.0" : "^3.13.0";
|
|
270
|
-
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");
|
|
271
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");
|
|
272
1352
|
const devDeps = {
|
|
273
1353
|
"@pyreon/vite-plugin": pyreonVersion("@pyreon/vite-plugin"),
|
|
274
1354
|
"@pyreon/zero-cli": pyreonVersion("@pyreon/zero-cli"),
|
|
275
1355
|
typescript: "^6.0.2",
|
|
276
1356
|
vite: "^8.0.3"
|
|
277
1357
|
};
|
|
278
|
-
if (config.
|
|
1358
|
+
if (config.aiTools.includes("mcp")) devDeps["@pyreon/mcp"] = pyreonVersion("@pyreon/mcp");
|
|
1359
|
+
const compatPkgMap = {
|
|
1360
|
+
react: "@pyreon/react-compat",
|
|
1361
|
+
vue: "@pyreon/vue-compat",
|
|
1362
|
+
solid: "@pyreon/solid-compat",
|
|
1363
|
+
preact: "@pyreon/preact-compat"
|
|
1364
|
+
};
|
|
1365
|
+
if (config.compat !== "none" && compatPkgMap[config.compat]) deps[compatPkgMap[config.compat]] = pyreonVersion(compatPkgMap[config.compat]);
|
|
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;
|
|
1368
|
+
const scripts = {
|
|
1369
|
+
dev: "zero dev",
|
|
1370
|
+
build: "zero build",
|
|
1371
|
+
preview: "zero preview",
|
|
1372
|
+
doctor: "zero doctor",
|
|
1373
|
+
"doctor:fix": "zero doctor --fix",
|
|
1374
|
+
"doctor:ci": "zero doctor --ci"
|
|
1375
|
+
};
|
|
1376
|
+
if (config.lint) scripts.lint = "pyreon-lint .";
|
|
279
1377
|
const pkg = {
|
|
280
1378
|
name: basename(config.name),
|
|
281
1379
|
version: "0.0.1",
|
|
282
1380
|
private: true,
|
|
283
1381
|
type: "module",
|
|
284
|
-
scripts
|
|
285
|
-
dev: "zero dev",
|
|
286
|
-
build: "zero build",
|
|
287
|
-
preview: "zero preview",
|
|
288
|
-
doctor: "zero doctor",
|
|
289
|
-
"doctor:fix": "zero doctor --fix",
|
|
290
|
-
"doctor:ci": "zero doctor --ci"
|
|
291
|
-
},
|
|
1382
|
+
scripts,
|
|
292
1383
|
dependencies: Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))),
|
|
293
1384
|
devDependencies: Object.fromEntries(Object.entries(devDeps).sort(([a], [b]) => a.localeCompare(b)))
|
|
294
1385
|
};
|
|
295
1386
|
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
296
1387
|
}
|
|
297
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}()` : "";
|
|
298
1399
|
return `import pyreon from '@pyreon/vite-plugin'
|
|
299
|
-
import zero from '@pyreon/zero/server'
|
|
1400
|
+
import zero from '@pyreon/zero/server'${adapterImport}
|
|
300
1401
|
import { fontPlugin } from '@pyreon/zero/font'
|
|
301
1402
|
import { seoPlugin } from '@pyreon/zero/seo'
|
|
302
1403
|
|
|
303
1404
|
export default {
|
|
304
1405
|
plugins: [
|
|
305
|
-
pyreon(),
|
|
306
|
-
zero({ ${{
|
|
307
|
-
"ssr-stream": `mode: 'ssr', ssr: { mode: 'stream' }`,
|
|
308
|
-
"ssr-string": `mode: 'ssr'`,
|
|
309
|
-
ssg: `mode: 'ssg'`,
|
|
310
|
-
spa: `mode: 'spa'`
|
|
311
|
-
}[config.renderMode]} }),
|
|
1406
|
+
pyreon(${pyreonOpts}),
|
|
1407
|
+
zero({ ${modeMap[config.renderMode]}${adapterArg} }),
|
|
312
1408
|
|
|
313
1409
|
// Google Fonts — self-hosted at build time, CDN in dev
|
|
314
1410
|
fontPlugin({
|
|
@@ -387,6 +1483,47 @@ async function removeIfExists(path) {
|
|
|
387
1483
|
const { rm } = await import("node:fs/promises");
|
|
388
1484
|
await rm(path, { recursive: true });
|
|
389
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
|
+
}
|
|
390
1527
|
main().catch((err) => {
|
|
391
1528
|
console.error(err);
|
|
392
1529
|
process.exit(1);
|