create-pracht 0.4.2 → 0.6.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 +24 -6
- package/package.json +1 -1
- package/skills/add-i18n/SKILL.md +308 -151
- package/skills/audit-auth/SKILL.md +32 -2
- package/skills/audit-headers/SKILL.md +14 -5
- package/skills/audit-secrets/SKILL.md +7 -4
- package/skills/configure-isg/SKILL.md +37 -18
- package/skills/migrate-nextjs/SKILL.md +17 -7
- package/skills/pracht-debug/SKILL.md +1 -1
- package/skills/pracht-deploy/SKILL.md +227 -8
- package/skills/pracht-scaffold/SKILL.md +11 -1
- package/skills/pracht-test-api/SKILL.md +13 -19
- package/skills/pre-deploy/SKILL.md +92 -15
- package/skills/scaffold-tests/SKILL.md +34 -50
- package/skills/tune-render-mode/SKILL.md +4 -1
- package/src/index.js +583 -42
package/src/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { copyFile, mkdir, readFile, readdir, stat, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, resolve } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
@@ -13,18 +13,43 @@ export class ValidationError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const FALLBACK_VERSION_RANGES = {
|
|
16
|
-
"@pracht/adapter-cloudflare": "^0.
|
|
17
|
-
"@pracht/adapter-
|
|
18
|
-
"@pracht/adapter-
|
|
19
|
-
"@pracht/
|
|
20
|
-
"@pracht/
|
|
21
|
-
"@pracht/
|
|
16
|
+
"@pracht/adapter-cloudflare": "^0.5.8",
|
|
17
|
+
"@pracht/adapter-netlify": "^0.1.0",
|
|
18
|
+
"@pracht/adapter-node": "^0.3.8",
|
|
19
|
+
"@pracht/adapter-static": "^0.1.0",
|
|
20
|
+
"@pracht/adapter-vercel": "^0.2.8",
|
|
21
|
+
"@pracht/cli": "^1.11.0",
|
|
22
|
+
"@pracht/core": "^0.14.0",
|
|
23
|
+
"@pracht/vite-plugin": "^0.9.0",
|
|
22
24
|
"@tailwindcss/vite": "^4.1.0",
|
|
25
|
+
"netlify-cli": "^21.6.0",
|
|
23
26
|
tailwindcss: "^4.1.0",
|
|
24
27
|
typescript: "^6.0.0",
|
|
25
28
|
vercel: "^56.5.0",
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Cloudflare `compatibility_date` for scaffolded apps.
|
|
33
|
+
*
|
|
34
|
+
* This has to be a date the installed workerd already knows about — workerd
|
|
35
|
+
* refuses to start when asked for a date newer than the one its binary was
|
|
36
|
+
* built with ("This Worker requires compatibility date X, but the newest date
|
|
37
|
+
* supported by this server binary is Y"). Using today's date is therefore
|
|
38
|
+
* always wrong: it is, by construction, at or beyond the newest released
|
|
39
|
+
* workerd, so a freshly scaffolded app could not run `wrangler dev` on the day
|
|
40
|
+
* it was created.
|
|
41
|
+
*
|
|
42
|
+
* Keep it at or below the ceiling of the oldest wrangler this scaffold accepts
|
|
43
|
+
* (see `devDependencies.wrangler` below). That ceiling is *not* the workerd
|
|
44
|
+
* version date — it usually runs a little ahead of it — so check it rather
|
|
45
|
+
* than infer it: install that wrangler and start a worker with a candidate
|
|
46
|
+
* date; the error message names the newest date the binary supports.
|
|
47
|
+
*
|
|
48
|
+
* `packages/start/test/index.test.js` fails once this drifts too far behind, so
|
|
49
|
+
* a new app never silently opts out of years of default-on runtime behaviour.
|
|
50
|
+
*/
|
|
51
|
+
const WRANGLER_COMPATIBILITY_DATE = "2026-04-06";
|
|
52
|
+
|
|
28
53
|
async function fetchLatestVersion(packageName) {
|
|
29
54
|
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
30
55
|
if (!res.ok) {
|
|
@@ -49,6 +74,13 @@ const ADAPTERS = {
|
|
|
49
74
|
packageName: "@pracht/adapter-cloudflare",
|
|
50
75
|
short: "cf",
|
|
51
76
|
},
|
|
77
|
+
netlify: {
|
|
78
|
+
description: "Netlify Functions with durable CDN caching",
|
|
79
|
+
id: "netlify",
|
|
80
|
+
label: "Netlify",
|
|
81
|
+
packageName: "@pracht/adapter-netlify",
|
|
82
|
+
short: "netlify",
|
|
83
|
+
},
|
|
52
84
|
vercel: {
|
|
53
85
|
description: "Vercel Edge Functions with prebuilt deploy",
|
|
54
86
|
id: "vercel",
|
|
@@ -56,10 +88,21 @@ const ADAPTERS = {
|
|
|
56
88
|
packageName: "@pracht/adapter-vercel",
|
|
57
89
|
short: "vercel",
|
|
58
90
|
},
|
|
91
|
+
static: {
|
|
92
|
+
description: "Pure static export — deploy dist/client to any static host",
|
|
93
|
+
id: "static",
|
|
94
|
+
label: "Static export",
|
|
95
|
+
packageName: "@pracht/adapter-static",
|
|
96
|
+
short: "static",
|
|
97
|
+
},
|
|
59
98
|
};
|
|
60
99
|
|
|
61
100
|
const DEFAULT_DIRECTORY = "pracht-app";
|
|
62
101
|
|
|
102
|
+
function readFileSyncSafe(path) {
|
|
103
|
+
return readFileSync(path, "utf-8");
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
64
107
|
|
|
65
108
|
// The published package bundles a copy of the repo skills (see
|
|
@@ -68,7 +111,9 @@ const SKILL_DIRS = [resolve(PACKAGE_ROOT, "skills"), resolve(PACKAGE_ROOT, "../.
|
|
|
68
111
|
|
|
69
112
|
export async function run(argv = process.argv.slice(2)) {
|
|
70
113
|
const options = parseArgs(argv);
|
|
71
|
-
const
|
|
114
|
+
const packageManagerUserAgent = process.env.npm_config_user_agent ?? "";
|
|
115
|
+
const packageManager = getPackageManager(packageManagerUserAgent);
|
|
116
|
+
const pnpmMajor = packageManager === "pnpm" ? getPnpmMajor(packageManagerUserAgent) : null;
|
|
72
117
|
const log = options.json ? () => {} : console.log.bind(console);
|
|
73
118
|
|
|
74
119
|
log("create-pracht");
|
|
@@ -115,14 +160,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
115
160
|
await ensureTargetDirectory(targetDir);
|
|
116
161
|
|
|
117
162
|
if (options.dryRun) {
|
|
118
|
-
const files = await buildProjectFiles({
|
|
163
|
+
const { files } = await buildProjectFiles({
|
|
119
164
|
adapter: ADAPTERS[resolvedAdapter],
|
|
120
165
|
agentTools: resolvedAgentTools,
|
|
121
166
|
packageManager,
|
|
167
|
+
pnpmMajor,
|
|
122
168
|
projectName: toPackageName(basename(targetDir)),
|
|
123
169
|
resolveRemoteVersions: false,
|
|
124
170
|
router: resolvedRouter,
|
|
125
171
|
tailwind: resolvedTailwind,
|
|
172
|
+
targetDir,
|
|
126
173
|
});
|
|
127
174
|
|
|
128
175
|
const fileList = Object.keys(files).sort();
|
|
@@ -150,10 +197,11 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
150
197
|
return;
|
|
151
198
|
}
|
|
152
199
|
|
|
153
|
-
await scaffoldProject({
|
|
200
|
+
const { pnpmWorkspaceNotice } = await scaffoldProject({
|
|
154
201
|
adapter: ADAPTERS[resolvedAdapter],
|
|
155
202
|
agentTools: resolvedAgentTools,
|
|
156
203
|
packageManager,
|
|
204
|
+
pnpmMajor,
|
|
157
205
|
router: resolvedRouter,
|
|
158
206
|
tailwind: resolvedTailwind,
|
|
159
207
|
targetDir,
|
|
@@ -184,14 +232,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
184
232
|
}
|
|
185
233
|
|
|
186
234
|
if (options.json) {
|
|
187
|
-
const files = await buildProjectFiles({
|
|
235
|
+
const { files } = await buildProjectFiles({
|
|
188
236
|
adapter: ADAPTERS[resolvedAdapter],
|
|
189
237
|
agentTools: resolvedAgentTools,
|
|
190
238
|
packageManager,
|
|
239
|
+
pnpmMajor,
|
|
191
240
|
projectName: toPackageName(basename(targetDir)),
|
|
192
241
|
resolveRemoteVersions: false,
|
|
193
242
|
router: resolvedRouter,
|
|
194
243
|
tailwind: resolvedTailwind,
|
|
244
|
+
targetDir,
|
|
195
245
|
});
|
|
196
246
|
|
|
197
247
|
console.log(
|
|
@@ -202,6 +252,10 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
202
252
|
files: Object.keys(files).sort(),
|
|
203
253
|
gitInitialized,
|
|
204
254
|
installed: options.skipInstall ? false : installSucceeded,
|
|
255
|
+
// The automation path has to carry this too: an instruction printed to
|
|
256
|
+
// a terminal nobody reads is an instruction nobody applies, and the
|
|
257
|
+
// consequence is a Cloudflare app with no workerd binary.
|
|
258
|
+
pnpmWorkspaceNotice,
|
|
205
259
|
router: resolvedRouter,
|
|
206
260
|
tailwind: resolvedTailwind,
|
|
207
261
|
}),
|
|
@@ -209,10 +263,14 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
209
263
|
} else {
|
|
210
264
|
printNextSteps({
|
|
211
265
|
adapter: ADAPTERS[resolvedAdapter],
|
|
266
|
+
agentTools: resolvedAgentTools,
|
|
212
267
|
dir: resolvedDir,
|
|
213
268
|
installSucceeded,
|
|
214
269
|
packageManager,
|
|
270
|
+
pnpmWorkspaceNotice,
|
|
271
|
+
router: resolvedRouter,
|
|
215
272
|
skipInstall: options.skipInstall,
|
|
273
|
+
tailwind: resolvedTailwind,
|
|
216
274
|
});
|
|
217
275
|
}
|
|
218
276
|
}
|
|
@@ -221,30 +279,42 @@ export async function scaffoldProject({
|
|
|
221
279
|
adapter,
|
|
222
280
|
agentTools = true,
|
|
223
281
|
packageManager,
|
|
282
|
+
pnpmMajor = 11,
|
|
224
283
|
resolveRemoteVersions = true,
|
|
225
284
|
router = "manifest",
|
|
226
285
|
tailwind = false,
|
|
227
286
|
targetDir,
|
|
228
287
|
}) {
|
|
229
288
|
const packageName = toPackageName(basename(targetDir));
|
|
230
|
-
const files = await buildProjectFiles({
|
|
289
|
+
const { files, pnpmWorkspaceNotice } = await buildProjectFiles({
|
|
231
290
|
adapter,
|
|
232
291
|
agentTools,
|
|
233
292
|
packageManager,
|
|
293
|
+
pnpmMajor,
|
|
234
294
|
projectName: packageName,
|
|
235
295
|
resolveRemoteVersions,
|
|
236
296
|
router,
|
|
237
297
|
tailwind,
|
|
298
|
+
targetDir,
|
|
238
299
|
});
|
|
239
300
|
|
|
240
301
|
await mkdir(targetDir, { recursive: true });
|
|
241
302
|
|
|
303
|
+
// pnpm resolves build-script policy from the workspace root, so inside an existing
|
|
304
|
+
// monorepo our own file would be read by nobody — and `pnpm install` run from
|
|
305
|
+
// the app directory would find it first and re-root the workspace there,
|
|
306
|
+
// detaching the app from its siblings. Tell the user what to add instead.
|
|
242
307
|
for (const [relativePath, content] of Object.entries(files)) {
|
|
243
308
|
const filePath = resolve(targetDir, relativePath);
|
|
244
309
|
await mkdir(dirname(filePath), { recursive: true });
|
|
245
310
|
await writeFile(filePath, content, "utf-8");
|
|
246
311
|
}
|
|
247
312
|
|
|
313
|
+
// AGENTS.md (and the CLAUDE.md alias pointing at it) are agent tooling too —
|
|
314
|
+
// `--no-agent-tools` means a project with none of it, not "all of it except
|
|
315
|
+
// the instruction files". README.md carries the same commands for humans.
|
|
316
|
+
if (!agentTools) return { pnpmWorkspaceNotice };
|
|
317
|
+
|
|
248
318
|
try {
|
|
249
319
|
await symlink("AGENTS.md", resolve(targetDir, "CLAUDE.md"));
|
|
250
320
|
} catch (error) {
|
|
@@ -254,6 +324,8 @@ export async function scaffoldProject({
|
|
|
254
324
|
throw error;
|
|
255
325
|
}
|
|
256
326
|
}
|
|
327
|
+
|
|
328
|
+
return { pnpmWorkspaceNotice };
|
|
257
329
|
}
|
|
258
330
|
|
|
259
331
|
export function getPackageManager(userAgent = process.env.npm_config_user_agent ?? "") {
|
|
@@ -263,6 +335,11 @@ export function getPackageManager(userAgent = process.env.npm_config_user_agent
|
|
|
263
335
|
return "npm";
|
|
264
336
|
}
|
|
265
337
|
|
|
338
|
+
export function getPnpmMajor(userAgent = process.env.npm_config_user_agent ?? "") {
|
|
339
|
+
const match = /^pnpm\/(\d+)/.exec(userAgent);
|
|
340
|
+
return match ? Number(match[1]) : 11;
|
|
341
|
+
}
|
|
342
|
+
|
|
266
343
|
export function parseArgs(argv) {
|
|
267
344
|
const options = {
|
|
268
345
|
adapter: undefined,
|
|
@@ -338,7 +415,7 @@ export function parseArgs(argv) {
|
|
|
338
415
|
const value = normalizeAdapter(arg.slice("--adapter=".length));
|
|
339
416
|
if (!value) {
|
|
340
417
|
throw new ValidationError(
|
|
341
|
-
`Invalid adapter: ${arg.slice("--adapter=".length)}. Use node, cf, or
|
|
418
|
+
`Invalid adapter: ${arg.slice("--adapter=".length)}. Use node, cf, netlify, vercel, or static.`,
|
|
342
419
|
);
|
|
343
420
|
}
|
|
344
421
|
options.adapter = value;
|
|
@@ -392,6 +469,8 @@ async function promptForAdapter(readline) {
|
|
|
392
469
|
console.log(" 1. Node.js");
|
|
393
470
|
console.log(" 2. Cloudflare Workers");
|
|
394
471
|
console.log(" 3. Vercel");
|
|
472
|
+
console.log(" 4. Netlify");
|
|
473
|
+
console.log(" 5. Static export (no server)");
|
|
395
474
|
|
|
396
475
|
while (true) {
|
|
397
476
|
const answer = await readline.question("Adapter (1): ");
|
|
@@ -401,14 +480,18 @@ async function promptForAdapter(readline) {
|
|
|
401
480
|
return normalized;
|
|
402
481
|
}
|
|
403
482
|
|
|
404
|
-
console.log("Choose 1/2/3 or node/cf/vercel.");
|
|
483
|
+
console.log("Choose 1/2/3/4/5 or node/cf/vercel/netlify/static.");
|
|
405
484
|
}
|
|
406
485
|
}
|
|
407
486
|
|
|
408
487
|
async function promptForRouter(readline) {
|
|
488
|
+
// The two routers are not equivalent, and the difference is invisible until
|
|
489
|
+
// you reach for a manifest-only feature. Say so at the point of choosing.
|
|
409
490
|
console.log("Router:");
|
|
410
|
-
console.log(" 1. Manifest (explicit routes.ts)");
|
|
411
|
-
console.log("
|
|
491
|
+
console.log(" 1. Manifest (explicit routes.ts) — supports middleware, capabilities,");
|
|
492
|
+
console.log(" MCP, Web Bot Auth, and constraints");
|
|
493
|
+
console.log(" 2. Pages (file-system routing) — pages and API routes only; no");
|
|
494
|
+
console.log(" middleware, capabilities, MCP, or agent trust (eject later to add them)");
|
|
412
495
|
|
|
413
496
|
while (true) {
|
|
414
497
|
const answer = await readline.question("Router (1): ");
|
|
@@ -536,6 +619,14 @@ function normalizeAdapter(value) {
|
|
|
536
619
|
return "vercel";
|
|
537
620
|
}
|
|
538
621
|
|
|
622
|
+
if (normalized === "4" || normalized === "nf" || normalized === "netlify") {
|
|
623
|
+
return "netlify";
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (normalized === "5" || normalized === "static" || normalized === "export") {
|
|
627
|
+
return "static";
|
|
628
|
+
}
|
|
629
|
+
|
|
539
630
|
return null;
|
|
540
631
|
}
|
|
541
632
|
|
|
@@ -558,10 +649,12 @@ async function buildProjectFiles({
|
|
|
558
649
|
adapter,
|
|
559
650
|
agentTools = true,
|
|
560
651
|
packageManager,
|
|
652
|
+
pnpmMajor = 11,
|
|
561
653
|
projectName,
|
|
562
654
|
resolveRemoteVersions = true,
|
|
563
655
|
router,
|
|
564
656
|
tailwind = false,
|
|
657
|
+
targetDir,
|
|
565
658
|
}) {
|
|
566
659
|
const packagesToResolve = [
|
|
567
660
|
"@pracht/cli",
|
|
@@ -573,36 +666,71 @@ async function buildProjectFiles({
|
|
|
573
666
|
if (adapter.id === "vercel") {
|
|
574
667
|
packagesToResolve.push("vercel");
|
|
575
668
|
}
|
|
669
|
+
if (adapter.id === "netlify") {
|
|
670
|
+
packagesToResolve.push("netlify-cli");
|
|
671
|
+
}
|
|
576
672
|
if (tailwind) {
|
|
577
673
|
packagesToResolve.push("tailwindcss", "@tailwindcss/vite");
|
|
578
674
|
}
|
|
579
675
|
|
|
580
676
|
const versions = await resolveVersions(packagesToResolve, { remote: resolveRemoteVersions });
|
|
677
|
+
const policyMajor = pnpmMajor ?? 11;
|
|
678
|
+
const ancestorWorkspace = targetDir ? findAncestorPnpmWorkspace(targetDir) : null;
|
|
679
|
+
const pnpmWorkspaceNotice = ancestorWorkspace
|
|
680
|
+
? {
|
|
681
|
+
packages: pnpmBuildAllowlist(adapter, tailwind),
|
|
682
|
+
policy: pnpmBuildPolicyName(policyMajor),
|
|
683
|
+
root: ancestorWorkspace,
|
|
684
|
+
}
|
|
685
|
+
: null;
|
|
581
686
|
|
|
582
687
|
const files = {
|
|
583
688
|
".gitignore":
|
|
584
|
-
"dist\nnode_modules\n.wrangler\n.vercel\n.env*\n!.env.example\n.dev.vars\n# Keep .pracht/app-graph.json committed — it is the `pracht plan` snapshot.\n",
|
|
689
|
+
"dist\nnode_modules\n.netlify\n.wrangler\n.vercel\n.env*\n!.env.example\n.dev.vars\n# Keep .pracht/app-graph.json committed — it is the `pracht plan` snapshot.\n",
|
|
585
690
|
"README.md": createReadme({
|
|
586
691
|
adapter,
|
|
587
692
|
agentTools,
|
|
588
693
|
packageManager,
|
|
694
|
+
pnpmMajor,
|
|
695
|
+
pnpmWorkspaceNotice,
|
|
589
696
|
projectName,
|
|
590
697
|
router,
|
|
591
698
|
tailwind,
|
|
592
699
|
}),
|
|
593
|
-
"package.json": createPackageJson({
|
|
594
|
-
|
|
700
|
+
"package.json": createPackageJson({
|
|
701
|
+
adapter,
|
|
702
|
+
projectName,
|
|
703
|
+
tailwind,
|
|
704
|
+
versions,
|
|
705
|
+
}),
|
|
595
706
|
"vite.config.ts": createViteConfig(adapter, router, tailwind),
|
|
596
707
|
"tsconfig.json": createBaseTSConfig(adapter),
|
|
597
|
-
"AGENTS.md": createAgentInstructions({ adapter, agentTools, packageManager, router, tailwind }),
|
|
598
708
|
};
|
|
599
709
|
|
|
710
|
+
// A static export has no server, so an API route would be a hard build
|
|
711
|
+
// error — the starter must not scaffold one it cannot build.
|
|
712
|
+
if (adapter.id !== "static") {
|
|
713
|
+
files["src/api/health.ts"] = createHealthRoute(adapter);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (agentTools) {
|
|
717
|
+
files["AGENTS.md"] = createAgentInstructions({
|
|
718
|
+
adapter,
|
|
719
|
+
agentTools,
|
|
720
|
+
packageManager,
|
|
721
|
+
router,
|
|
722
|
+
tailwind,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
600
726
|
if (router === "pages") {
|
|
601
727
|
files["src/pages/_app.tsx"] = createShellFile(projectName, tailwind);
|
|
602
728
|
files["src/pages/index.tsx"] = createPagesHomeRoute(adapter);
|
|
729
|
+
files["src/pages/404.tsx"] = createNotFoundRoute();
|
|
603
730
|
} else {
|
|
604
731
|
files["src/routes.ts"] = createRoutesFile();
|
|
605
732
|
files["src/routes/home.tsx"] = createHomeRoute(adapter);
|
|
733
|
+
files["src/routes/not-found.tsx"] = createNotFoundRoute();
|
|
606
734
|
files["src/shells/public.tsx"] = createShellFile(projectName, tailwind);
|
|
607
735
|
}
|
|
608
736
|
|
|
@@ -615,6 +743,10 @@ async function buildProjectFiles({
|
|
|
615
743
|
files["src/env.d.ts"] = createCloudflareEnvDeclaration();
|
|
616
744
|
}
|
|
617
745
|
|
|
746
|
+
if (adapter.id === "netlify") {
|
|
747
|
+
files["netlify.toml"] = createNetlifyConfig(packageManager);
|
|
748
|
+
}
|
|
749
|
+
|
|
618
750
|
if (adapter.id === "node") {
|
|
619
751
|
files["Dockerfile"] = createDockerfile(packageManager);
|
|
620
752
|
files[".dockerignore"] = createDockerignore();
|
|
@@ -625,7 +757,16 @@ async function buildProjectFiles({
|
|
|
625
757
|
Object.assign(files, await readSkillFiles());
|
|
626
758
|
}
|
|
627
759
|
|
|
628
|
-
|
|
760
|
+
// pnpm resolves build-script policy from the workspace root, so inside an existing
|
|
761
|
+
// workspace our own file would be read by nobody — and `pnpm install` run
|
|
762
|
+
// from the app directory would find it first and re-root the workspace there,
|
|
763
|
+
// detaching the app from its siblings. Decided here so the `--json` and
|
|
764
|
+
// `--dry-run` listings match what is actually written.
|
|
765
|
+
if (!pnpmWorkspaceNotice) {
|
|
766
|
+
files["pnpm-workspace.yaml"] = createPnpmWorkspaceConfig(adapter, tailwind, policyMajor);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return { files, pnpmWorkspaceNotice };
|
|
629
770
|
}
|
|
630
771
|
|
|
631
772
|
function createMcpConfig() {
|
|
@@ -634,7 +775,13 @@ function createMcpConfig() {
|
|
|
634
775
|
mcpServers: {
|
|
635
776
|
pracht: {
|
|
636
777
|
command: "npx",
|
|
637
|
-
|
|
778
|
+
// `--no-install` pins this to the `@pracht/cli` the project depends
|
|
779
|
+
// on. `--yes @pracht/cli` fetched the registry's latest instead, so
|
|
780
|
+
// the MCP server an agent talked to could describe a different CLI
|
|
781
|
+
// than the one the app builds with. Not bare `npx pracht` either:
|
|
782
|
+
// that resolves to a registry package literally named `pracht`
|
|
783
|
+
// whenever the local bin is missing — `--no-install` fails loudly.
|
|
784
|
+
args: ["--no-install", "pracht", "mcp"],
|
|
638
785
|
},
|
|
639
786
|
},
|
|
640
787
|
},
|
|
@@ -674,6 +821,10 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
|
|
|
674
821
|
scripts.start = "node dist/server/server.js";
|
|
675
822
|
}
|
|
676
823
|
|
|
824
|
+
if (adapter.id === "static") {
|
|
825
|
+
scripts.preview = "pracht preview";
|
|
826
|
+
}
|
|
827
|
+
|
|
677
828
|
const devDependencies = {
|
|
678
829
|
"@pracht/cli": versions["@pracht/cli"],
|
|
679
830
|
"@pracht/vite-plugin": versions["@pracht/vite-plugin"],
|
|
@@ -689,6 +840,12 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
|
|
|
689
840
|
devDependencies.wrangler = "^4.81.0";
|
|
690
841
|
}
|
|
691
842
|
|
|
843
|
+
if (adapter.id === "netlify") {
|
|
844
|
+
scripts.deploy = "netlify deploy --build --prod";
|
|
845
|
+
scripts.preview = "pracht build && netlify dev";
|
|
846
|
+
devDependencies["netlify-cli"] = versions["netlify-cli"];
|
|
847
|
+
}
|
|
848
|
+
|
|
692
849
|
if (adapter.id === "vercel") {
|
|
693
850
|
scripts.deploy = "pracht build && vercel deploy --prebuilt";
|
|
694
851
|
devDependencies.vercel = versions["vercel"];
|
|
@@ -721,7 +878,9 @@ function createViteConfig(adapter, router, tailwind) {
|
|
|
721
878
|
const ADAPTER_IMPORTS = {
|
|
722
879
|
node: { fn: "nodeAdapter", pkg: "@pracht/adapter-node" },
|
|
723
880
|
cloudflare: { fn: "cloudflareAdapter", pkg: "@pracht/adapter-cloudflare" },
|
|
881
|
+
netlify: { fn: "netlifyAdapter", pkg: "@pracht/adapter-netlify" },
|
|
724
882
|
vercel: { fn: "vercelAdapter", pkg: "@pracht/adapter-vercel" },
|
|
883
|
+
static: { fn: "staticAdapter", pkg: "@pracht/adapter-static" },
|
|
725
884
|
};
|
|
726
885
|
|
|
727
886
|
const info = ADAPTER_IMPORTS[adapter.id] ?? ADAPTER_IMPORTS.node;
|
|
@@ -761,8 +920,12 @@ function createRoutesFile() {
|
|
|
761
920
|
" routes: [",
|
|
762
921
|
' route("/", "./routes/home.tsx", { id: "home", render: "ssg", shell: "public" }),',
|
|
763
922
|
" ],",
|
|
764
|
-
" //
|
|
765
|
-
|
|
923
|
+
" // Rendered with a 404 status when nothing matches. Not a route: it never",
|
|
924
|
+
" // matches a URL, so it cannot shadow static assets or later pages.",
|
|
925
|
+
" notFound: {",
|
|
926
|
+
' component: "./routes/not-found.tsx",',
|
|
927
|
+
' shell: "public",',
|
|
928
|
+
" },",
|
|
766
929
|
" // Declarative invariants enforced by `pracht verify` — uncomment to use",
|
|
767
930
|
" // (add the helpers to the @pracht/core import):",
|
|
768
931
|
" // constraints: [",
|
|
@@ -815,7 +978,9 @@ function createHomeRoute(adapter) {
|
|
|
815
978
|
" steps: [",
|
|
816
979
|
' "Edit src/routes/home.tsx to change this page.",',
|
|
817
980
|
' "Add more routes in src/routes.ts.",',
|
|
818
|
-
|
|
981
|
+
adapter.id === "static"
|
|
982
|
+
? ' "Fetch live data from the browser — a static export runs no server.",'
|
|
983
|
+
: ' "Add API handlers in src/api/*.ts.",',
|
|
819
984
|
" ],",
|
|
820
985
|
" };",
|
|
821
986
|
"}",
|
|
@@ -834,8 +999,37 @@ function createHomeRoute(adapter) {
|
|
|
834
999
|
" ))}",
|
|
835
1000
|
" </ul>",
|
|
836
1001
|
' <p style={{ marginTop: "24px" }}>',
|
|
837
|
-
|
|
1002
|
+
adapter.id === "static"
|
|
1003
|
+
? " Run <code>pracht build</code>, then deploy <code>dist/client</code> anywhere."
|
|
1004
|
+
: " Check <code>/api/health</code> for a simple API route.",
|
|
1005
|
+
" </p>",
|
|
1006
|
+
" </section>",
|
|
1007
|
+
" );",
|
|
1008
|
+
"}",
|
|
1009
|
+
"",
|
|
1010
|
+
].join("\n");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function createNotFoundRoute() {
|
|
1014
|
+
return [
|
|
1015
|
+
"export function head() {",
|
|
1016
|
+
" return {",
|
|
1017
|
+
' title: "Page not found",',
|
|
1018
|
+
' meta: [{ content: "noindex", name: "robots" }],',
|
|
1019
|
+
" };",
|
|
1020
|
+
"}",
|
|
1021
|
+
"",
|
|
1022
|
+
"export function Component() {",
|
|
1023
|
+
" return (",
|
|
1024
|
+
" <section>",
|
|
1025
|
+
' <p style={{ color: "#555", marginBottom: "8px" }}>404</p>',
|
|
1026
|
+
' <h1 style={{ fontSize: "2.5rem", lineHeight: 1.1, margin: "0 0 16px" }}>Page not found.</h1>',
|
|
1027
|
+
' <p style={{ fontSize: "1.1rem", lineHeight: 1.6, marginBottom: "24px" }}>',
|
|
1028
|
+
" The page you asked for does not exist. It may have moved, or the link may be wrong.",
|
|
838
1029
|
" </p>",
|
|
1030
|
+
" {/* A plain anchor keeps this page independent of the route table.",
|
|
1031
|
+
" Use a typed <Link> once you want client-side navigation. */}",
|
|
1032
|
+
' <a href="/">Back to home</a>',
|
|
839
1033
|
" </section>",
|
|
840
1034
|
" );",
|
|
841
1035
|
"}",
|
|
@@ -855,7 +1049,9 @@ function createPagesHomeRoute(adapter) {
|
|
|
855
1049
|
" steps: [",
|
|
856
1050
|
' "Edit src/pages/index.tsx to change this page.",',
|
|
857
1051
|
' "Add more pages in src/pages/.",',
|
|
858
|
-
|
|
1052
|
+
adapter.id === "static"
|
|
1053
|
+
? ' "Fetch live data from the browser — a static export runs no server.",'
|
|
1054
|
+
: ' "Add API handlers in src/api/*.ts.",',
|
|
859
1055
|
" ],",
|
|
860
1056
|
" };",
|
|
861
1057
|
"}",
|
|
@@ -874,7 +1070,9 @@ function createPagesHomeRoute(adapter) {
|
|
|
874
1070
|
" ))}",
|
|
875
1071
|
" </ul>",
|
|
876
1072
|
' <p style={{ marginTop: "24px" }}>',
|
|
877
|
-
|
|
1073
|
+
adapter.id === "static"
|
|
1074
|
+
? " Run <code>pracht build</code>, then deploy <code>dist/client</code> anywhere."
|
|
1075
|
+
: " Check <code>/api/health</code> for a simple API route.",
|
|
878
1076
|
" </p>",
|
|
879
1077
|
" </section>",
|
|
880
1078
|
" );",
|
|
@@ -916,18 +1114,200 @@ function createHealthRoute(adapter) {
|
|
|
916
1114
|
].join("\n");
|
|
917
1115
|
}
|
|
918
1116
|
|
|
1117
|
+
/**
|
|
1118
|
+
* pnpm blocks dependency install scripts unless they are allowlisted, and
|
|
1119
|
+
* esbuild and workerd both need theirs — workerd's postinstall downloads the
|
|
1120
|
+
* runtime binary, so without this `wrangler dev` fails right after scaffolding
|
|
1121
|
+
* with `ERR_PNPM_IGNORED_BUILDS`.
|
|
1122
|
+
*
|
|
1123
|
+
* This has to live in `pnpm-workspace.yaml`: pnpm 10 uses
|
|
1124
|
+
* `onlyBuiltDependencies`, while pnpm 11 uses `allowBuilds` and no longer reads
|
|
1125
|
+
* the `pnpm` field in package.json. npm and yarn ignore this file entirely, so
|
|
1126
|
+
* it is inert for them. (npm has its own `allow-scripts` prompt, which it
|
|
1127
|
+
* drives interactively.)
|
|
1128
|
+
*/
|
|
1129
|
+
function pnpmBuildAllowlist(adapter, tailwind) {
|
|
1130
|
+
const packages = ["esbuild"];
|
|
1131
|
+
if (adapter.id === "cloudflare") packages.push("workerd");
|
|
1132
|
+
if (tailwind) packages.push("@tailwindcss/oxide");
|
|
1133
|
+
return packages.sort();
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function pnpmBuildPolicyName(pnpmMajor) {
|
|
1137
|
+
return pnpmMajor <= 10 ? "onlyBuiltDependencies" : "allowBuilds";
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function createPnpmWorkspaceConfig(adapter, tailwind, pnpmMajor) {
|
|
1141
|
+
const policy = pnpmBuildPolicyName(pnpmMajor);
|
|
1142
|
+
const entries = pnpmBuildAllowlist(adapter, tailwind);
|
|
1143
|
+
|
|
1144
|
+
return [
|
|
1145
|
+
"packages:",
|
|
1146
|
+
' - "."',
|
|
1147
|
+
`${policy}:`,
|
|
1148
|
+
...(policy === "onlyBuiltDependencies"
|
|
1149
|
+
? entries.map((name) => ` - ${JSON.stringify(name)}`)
|
|
1150
|
+
: entries.map((name) => ` ${JSON.stringify(name)}: true`)),
|
|
1151
|
+
"",
|
|
1152
|
+
].join("\n");
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Nearest ancestor `pnpm-workspace.yaml` above `dir`, or null.
|
|
1157
|
+
*
|
|
1158
|
+
* pnpm resolves settings from the workspace *root*, so writing our own file
|
|
1159
|
+
* inside an existing monorepo would be read by nobody — while also re-rooting
|
|
1160
|
+
* the workspace for anyone who runs `pnpm install` from the app directory,
|
|
1161
|
+
* which detaches it from its siblings.
|
|
1162
|
+
*/
|
|
1163
|
+
function findAncestorPnpmWorkspace(dir) {
|
|
1164
|
+
let current = resolve(dir, "..");
|
|
1165
|
+
for (;;) {
|
|
1166
|
+
const configPath = resolve(current, "pnpm-workspace.yaml");
|
|
1167
|
+
// An ancestor config only governs this app if its `packages:` globs cover
|
|
1168
|
+
// it. Suppressing our own file for a workspace the app is *not* a member of
|
|
1169
|
+
// leaves it with no install at all: pnpm re-roots to the ancestor and
|
|
1170
|
+
// installs that workspace's projects instead.
|
|
1171
|
+
if (existsSync(configPath) && workspaceCovers(configPath, current, dir)) return current;
|
|
1172
|
+
const parent = dirname(current);
|
|
1173
|
+
if (parent === current) return null;
|
|
1174
|
+
current = parent;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Whether `dir` matches one of the `packages:` globs in a pnpm workspace
|
|
1180
|
+
* config. A deliberately small YAML reader: the block and flow list forms pnpm
|
|
1181
|
+
* accepts, and `*` / `**` globs.
|
|
1182
|
+
*
|
|
1183
|
+
* Both failure directions matter, and they are not symmetric. Deciding "not a
|
|
1184
|
+
* member" for a directory that *is* one writes a nested `pnpm-workspace.yaml`
|
|
1185
|
+
* that re-roots the workspace at the app; deciding "member" for one that is
|
|
1186
|
+
* not only prints instructions. So anything this reader cannot confidently
|
|
1187
|
+
* decide answers `true`.
|
|
1188
|
+
*/
|
|
1189
|
+
function workspaceCovers(configPath, workspaceRoot, dir) {
|
|
1190
|
+
let contents;
|
|
1191
|
+
try {
|
|
1192
|
+
contents = readFileSyncSafe(configPath);
|
|
1193
|
+
} catch {
|
|
1194
|
+
return true;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const globs = [];
|
|
1198
|
+
let sawPackagesKey = false;
|
|
1199
|
+
let inBlockList = false;
|
|
1200
|
+
|
|
1201
|
+
for (const rawLine of contents.split("\n")) {
|
|
1202
|
+
const line = rawLine.replace(/#.*$/, "");
|
|
1203
|
+
const packagesKey = line.match(/^packages\s*:(.*)$/);
|
|
1204
|
+
if (packagesKey) {
|
|
1205
|
+
sawPackagesKey = true;
|
|
1206
|
+
// Flow form: `packages: ["apps/*", "tools/*"]`, which pnpm accepts.
|
|
1207
|
+
const flow = packagesKey[1].trim();
|
|
1208
|
+
if (flow.startsWith("[")) {
|
|
1209
|
+
for (const entry of flow.replace(/^\[|\]$/g, "").split(",")) {
|
|
1210
|
+
const value = entry.trim().replace(/^["']|["']$/g, "");
|
|
1211
|
+
if (value) globs.push(value);
|
|
1212
|
+
}
|
|
1213
|
+
inBlockList = false;
|
|
1214
|
+
} else {
|
|
1215
|
+
inBlockList = true;
|
|
1216
|
+
}
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
if (inBlockList) {
|
|
1220
|
+
const item = line.match(/^\s+-\s*["']?([^"'\s]+)["']?\s*$/);
|
|
1221
|
+
if (item) {
|
|
1222
|
+
globs.push(item[1]);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
if (line.trim() !== "") inBlockList = false;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// No `packages:` key at all is a single-package workspace rooted there,
|
|
1230
|
+
// which does not cover a nested app. A key we could not read is a decision
|
|
1231
|
+
// we cannot make — fall to "member".
|
|
1232
|
+
if (!sawPackagesKey) return false;
|
|
1233
|
+
if (globs.length === 0) return true;
|
|
1234
|
+
|
|
1235
|
+
const relative = resolve(dir)
|
|
1236
|
+
.slice(resolve(workspaceRoot).length + 1)
|
|
1237
|
+
.split(/[\\/]/);
|
|
1238
|
+
// A negation (`!apps/legacy`) narrows the set; treat its presence as
|
|
1239
|
+
// undecidable rather than as an ordinary glob.
|
|
1240
|
+
if (globs.some((glob) => glob.startsWith("!"))) return true;
|
|
1241
|
+
// pnpm treats a workspace-root-relative `./apps/*` the same as `apps/*`.
|
|
1242
|
+
// Strip only that harmless prefix before comparing path segments.
|
|
1243
|
+
const normalizedGlobs = globs.map((glob) => glob.replace(/^(?:\.\/)+/, ""));
|
|
1244
|
+
// pnpm accepts the wider glob syntax supported by its workspace matcher.
|
|
1245
|
+
// This intentionally small matcher cannot safely decide braces, character
|
|
1246
|
+
// classes, extglobs, or single-character wildcards. Follow the conservative
|
|
1247
|
+
// contract above instead of creating a nested workspace for a real member.
|
|
1248
|
+
if (normalizedGlobs.some((glob) => /[?[\]{}()]/.test(glob))) return true;
|
|
1249
|
+
return normalizedGlobs.some((glob) => matchesGlobSegments(glob.split("/"), relative));
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Segment-wise glob match. `**` matches the rest; otherwise a segment may
|
|
1254
|
+
* contain `*` wildcards (`app-*`), which pnpm supports.
|
|
1255
|
+
*/
|
|
1256
|
+
function matchesGlobSegments(globSegments, pathSegments) {
|
|
1257
|
+
return matchGlobSegmentAt(globSegments, pathSegments, 0, 0);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function matchGlobSegmentAt(globSegments, pathSegments, globIndex, pathIndex) {
|
|
1261
|
+
if (globIndex === globSegments.length) return pathIndex === pathSegments.length;
|
|
1262
|
+
|
|
1263
|
+
const segment = globSegments[globIndex];
|
|
1264
|
+
if (segment === "**") {
|
|
1265
|
+
if (globIndex === globSegments.length - 1) return true;
|
|
1266
|
+
for (let nextPathIndex = pathIndex; nextPathIndex <= pathSegments.length; nextPathIndex += 1) {
|
|
1267
|
+
if (matchGlobSegmentAt(globSegments, pathSegments, globIndex + 1, nextPathIndex)) return true;
|
|
1268
|
+
}
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
return (
|
|
1273
|
+
pathIndex < pathSegments.length &&
|
|
1274
|
+
segmentMatches(segment, pathSegments[pathIndex]) &&
|
|
1275
|
+
matchGlobSegmentAt(globSegments, pathSegments, globIndex + 1, pathIndex + 1)
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function segmentMatches(glob, value) {
|
|
1280
|
+
if (glob === "*") return true;
|
|
1281
|
+
if (!glob.includes("*")) return glob === value;
|
|
1282
|
+
const pattern = glob
|
|
1283
|
+
.split("*")
|
|
1284
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
1285
|
+
.join(".*");
|
|
1286
|
+
return new RegExp(`^${pattern}$`).test(value);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
919
1289
|
function createWranglerConfig(projectName) {
|
|
920
|
-
const compatibilityDate =
|
|
1290
|
+
const compatibilityDate = WRANGLER_COMPATIBILITY_DATE;
|
|
921
1291
|
|
|
922
1292
|
return [
|
|
923
1293
|
"{",
|
|
924
1294
|
' "$schema": "node_modules/wrangler/config-schema.json",',
|
|
925
1295
|
` "name": ${JSON.stringify(projectName)},`,
|
|
926
|
-
|
|
1296
|
+
// `pracht build` writes this thin wrapper next to server.js. It re-exports
|
|
1297
|
+
// only the default handler and any Worker entrypoint classes: workerd
|
|
1298
|
+
// validates every named export of the deployed entry module and rejects the
|
|
1299
|
+
// build metadata (buildTarget, manifests, ...) server.js also exports.
|
|
1300
|
+
' "main": "dist/server/worker.js",',
|
|
927
1301
|
` "compatibility_date": ${JSON.stringify(compatibilityDate)},`,
|
|
928
1302
|
' "assets": {',
|
|
929
1303
|
' "binding": "ASSETS",',
|
|
930
1304
|
' "directory": "dist/client",',
|
|
1305
|
+
// The assets binding defaults to redirecting a prerendered route to its
|
|
1306
|
+
// trailing-slash form, so `GET /about` would answer 307 on Cloudflare
|
|
1307
|
+
// where Node and Vercel answer 200 — for the same app, and for every URL
|
|
1308
|
+
// the generated llms.txt advertises. Drop the slash instead so one
|
|
1309
|
+
// canonical form works across adapters.
|
|
1310
|
+
' "html_handling": "drop-trailing-slash",',
|
|
931
1311
|
' "run_worker_first": true',
|
|
932
1312
|
" }",
|
|
933
1313
|
"}",
|
|
@@ -935,6 +1315,23 @@ function createWranglerConfig(projectName) {
|
|
|
935
1315
|
].join("\n");
|
|
936
1316
|
}
|
|
937
1317
|
|
|
1318
|
+
function createNetlifyConfig(packageManager) {
|
|
1319
|
+
const buildCommand =
|
|
1320
|
+
packageManager === "npm" || packageManager === "bun"
|
|
1321
|
+
? `${packageManager} run build`
|
|
1322
|
+
: `${packageManager} build`;
|
|
1323
|
+
|
|
1324
|
+
return [
|
|
1325
|
+
"[build]",
|
|
1326
|
+
` command = ${JSON.stringify(buildCommand)}`,
|
|
1327
|
+
' publish = "dist/client"',
|
|
1328
|
+
"",
|
|
1329
|
+
"[functions]",
|
|
1330
|
+
' directory = "netlify/functions"',
|
|
1331
|
+
"",
|
|
1332
|
+
].join("\n");
|
|
1333
|
+
}
|
|
1334
|
+
|
|
938
1335
|
function createCloudflareEnvDeclaration() {
|
|
939
1336
|
return [
|
|
940
1337
|
'import "@pracht/core";',
|
|
@@ -1023,8 +1420,17 @@ function createDockerignore() {
|
|
|
1023
1420
|
].join("\n");
|
|
1024
1421
|
}
|
|
1025
1422
|
|
|
1423
|
+
const PAGES_ROUTER_LIMITATIONS =
|
|
1424
|
+
"**The pages router has no manifest**, so these manifest-only features are unavailable: named shells (there is one, `_app.tsx`), route middleware, capabilities (and therefore capability HTTP endpoints, WebMCP, remote MCP, and `pracht eval`), `defineApp({ constraints })`, and `agents`. If the app needs auth policy or a runtime agent surface, eject with `generateRoutesFile` from `@pracht/vite-plugin/pages-router`, remove `pagesDir`, and customize the generated manifest.";
|
|
1425
|
+
|
|
1426
|
+
const PAGES_ROUTER_ISG_POLICY =
|
|
1427
|
+
'Pages-router ISG supports time revalidation only: pair `export const RENDER_MODE = "isg"` with a positive integer such as `export const REVALIDATE = 3600`. Missing or misplaced policies fail `pracht build`, `doctor`, and `verify`. Webhook revalidation and combined policies require an explicit manifest.';
|
|
1428
|
+
|
|
1026
1429
|
function createAgentInstructions({ adapter, agentTools, packageManager, router, tailwind }) {
|
|
1027
|
-
|
|
1430
|
+
// `bun build` is Bun's own bundler and shadows the package script, so bun
|
|
1431
|
+
// needs the explicit `run` form the same way npm does.
|
|
1432
|
+
const runCmd =
|
|
1433
|
+
packageManager === "npm" || packageManager === "bun" ? `${packageManager} run` : packageManager;
|
|
1028
1434
|
|
|
1029
1435
|
const lines = [
|
|
1030
1436
|
"# Pracht App",
|
|
@@ -1035,7 +1441,12 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1035
1441
|
`- \`${runCmd} build\` — production build`,
|
|
1036
1442
|
];
|
|
1037
1443
|
|
|
1038
|
-
if (
|
|
1444
|
+
if (
|
|
1445
|
+
adapter.id === "node" ||
|
|
1446
|
+
adapter.id === "cloudflare" ||
|
|
1447
|
+
adapter.id === "netlify" ||
|
|
1448
|
+
adapter.id === "static"
|
|
1449
|
+
) {
|
|
1039
1450
|
lines.push(`- \`${runCmd} preview\` — build and serve the production build locally`);
|
|
1040
1451
|
}
|
|
1041
1452
|
|
|
@@ -1043,7 +1454,7 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1043
1454
|
lines.push(`- \`${runCmd} start\` — run the built server`);
|
|
1044
1455
|
}
|
|
1045
1456
|
|
|
1046
|
-
if (adapter.id === "cloudflare" || adapter.id === "vercel") {
|
|
1457
|
+
if (adapter.id === "cloudflare" || adapter.id === "netlify" || adapter.id === "vercel") {
|
|
1047
1458
|
lines.push(`- \`${runCmd} deploy\` — build and deploy`);
|
|
1048
1459
|
}
|
|
1049
1460
|
|
|
@@ -1053,9 +1464,20 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1053
1464
|
lines.push("Use the CLI to generate new files:");
|
|
1054
1465
|
lines.push("");
|
|
1055
1466
|
lines.push("- `pracht generate route --path /about` — add a route");
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1467
|
+
if (router !== "pages") {
|
|
1468
|
+
lines.push("- `pracht generate shell --name app` — add a shell");
|
|
1469
|
+
if (adapter.id !== "static") {
|
|
1470
|
+
lines.push("- `pracht generate middleware --name auth` — add middleware");
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (adapter.id !== "static") {
|
|
1474
|
+
lines.push("- `pracht generate api --path /health --methods GET` — add an API route");
|
|
1475
|
+
}
|
|
1476
|
+
if (router !== "pages" && adapter.id !== "static") {
|
|
1477
|
+
lines.push(
|
|
1478
|
+
"- `pracht generate capability --name notes.search --effect read --expose http` — add a capability (agent-callable operation)",
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1059
1481
|
lines.push("- `pracht doctor` — check project health");
|
|
1060
1482
|
lines.push("- `pracht verify` — enforce route and constraint invariants");
|
|
1061
1483
|
lines.push(
|
|
@@ -1073,15 +1495,27 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1073
1495
|
lines.push("");
|
|
1074
1496
|
lines.push("- `src/pages/` — file-system routes (each file becomes a route)");
|
|
1075
1497
|
lines.push("- `src/pages/_app.tsx` — app shell (layout and head)");
|
|
1498
|
+
lines.push(
|
|
1499
|
+
"- `src/pages/404.tsx` — not-found page, wired automatically (never a URL of its own)",
|
|
1500
|
+
);
|
|
1501
|
+
lines.push("");
|
|
1502
|
+
lines.push(PAGES_ROUTER_LIMITATIONS);
|
|
1503
|
+
lines.push("");
|
|
1504
|
+
lines.push(PAGES_ROUTER_ISG_POLICY);
|
|
1076
1505
|
} else {
|
|
1077
1506
|
lines.push("This app uses **manifest routing**.");
|
|
1078
1507
|
lines.push("");
|
|
1079
1508
|
lines.push("- `src/routes.ts` — route manifest (defines all routes and shells)");
|
|
1080
1509
|
lines.push("- `src/routes/` — route components and loaders");
|
|
1510
|
+
lines.push(
|
|
1511
|
+
"- `src/routes/not-found.tsx` — not-found page, wired via `notFound` in the manifest",
|
|
1512
|
+
);
|
|
1081
1513
|
lines.push("- `src/shells/` — shell components (layouts)");
|
|
1082
1514
|
}
|
|
1083
1515
|
|
|
1084
|
-
|
|
1516
|
+
if (adapter.id !== "static") {
|
|
1517
|
+
lines.push("- `src/api/` — API route handlers");
|
|
1518
|
+
}
|
|
1085
1519
|
lines.push(`- \`vite.config.ts\` — Vite config with the ${adapter.label} adapter`);
|
|
1086
1520
|
|
|
1087
1521
|
if (tailwind) {
|
|
@@ -1097,6 +1531,10 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1097
1531
|
lines.push("- `src/env.d.ts` — TypeScript types for Cloudflare bindings");
|
|
1098
1532
|
}
|
|
1099
1533
|
|
|
1534
|
+
if (adapter.id === "netlify") {
|
|
1535
|
+
lines.push("- `netlify.toml` — Netlify build, publish, and functions configuration");
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1100
1538
|
if (agentTools) {
|
|
1101
1539
|
lines.push("");
|
|
1102
1540
|
lines.push("## Agent tooling");
|
|
@@ -1114,9 +1552,24 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1114
1552
|
return lines.join("\n");
|
|
1115
1553
|
}
|
|
1116
1554
|
|
|
1117
|
-
function createReadme({
|
|
1555
|
+
function createReadme({
|
|
1556
|
+
adapter,
|
|
1557
|
+
agentTools,
|
|
1558
|
+
packageManager,
|
|
1559
|
+
pnpmMajor,
|
|
1560
|
+
pnpmWorkspaceNotice,
|
|
1561
|
+
projectName,
|
|
1562
|
+
router,
|
|
1563
|
+
tailwind,
|
|
1564
|
+
}) {
|
|
1118
1565
|
const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
|
|
1119
1566
|
const devCommand = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
|
|
1567
|
+
// `bun build` is Bun's own bundler and shadows the package script, unlike
|
|
1568
|
+
// `bun dev` / `bun start` / `bun preview`, which fall through to it.
|
|
1569
|
+
const buildCommand =
|
|
1570
|
+
packageManager === "npm" || packageManager === "bun"
|
|
1571
|
+
? `${packageManager} run build`
|
|
1572
|
+
: `${packageManager} build`;
|
|
1120
1573
|
const previewCommand = packageManager === "npm" ? "npm run preview" : `${packageManager} preview`;
|
|
1121
1574
|
const startCommand = packageManager === "npm" ? "npm run start" : `${packageManager} start`;
|
|
1122
1575
|
const deployCommand = packageManager === "npm" ? "npm run deploy" : `${packageManager} deploy`;
|
|
@@ -1132,6 +1585,7 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1132
1585
|
"",
|
|
1133
1586
|
`- \`${installCommand}\``,
|
|
1134
1587
|
`- \`${devCommand}\``,
|
|
1588
|
+
`- \`${buildCommand}\``,
|
|
1135
1589
|
`- \`${typecheckCommand}\``,
|
|
1136
1590
|
];
|
|
1137
1591
|
|
|
@@ -1149,12 +1603,36 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1149
1603
|
);
|
|
1150
1604
|
}
|
|
1151
1605
|
|
|
1606
|
+
if (adapter.id === "netlify") {
|
|
1607
|
+
lines.push(`- \`${previewCommand}\``);
|
|
1608
|
+
lines.push(`- \`${deployCommand}\``);
|
|
1609
|
+
lines.push("");
|
|
1610
|
+
lines.push(
|
|
1611
|
+
"`netlify.toml` publishes `dist/client` and discovers the Pracht function generated during the build.",
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1152
1615
|
if (adapter.id === "vercel") {
|
|
1153
1616
|
lines.push(`- \`${deployCommand}\``);
|
|
1154
1617
|
lines.push("");
|
|
1155
1618
|
lines.push("Run the deploy command after linking or logging into your Vercel account.");
|
|
1156
1619
|
}
|
|
1157
1620
|
|
|
1621
|
+
if (adapter.id === "static") {
|
|
1622
|
+
lines.push(`- \`${previewCommand}\``);
|
|
1623
|
+
lines.push("");
|
|
1624
|
+
lines.push(
|
|
1625
|
+
"`pracht build` writes the whole site to `dist/client`. Upload that directory to any " +
|
|
1626
|
+
"static host — there is no server to run. Configure the host to serve `index.html` " +
|
|
1627
|
+
"for directory URLs and to use `404.html` as its error document.",
|
|
1628
|
+
);
|
|
1629
|
+
lines.push("");
|
|
1630
|
+
lines.push(
|
|
1631
|
+
"A static export runs no server, so API routes, middleware, and `ssr`/`isg` routes are " +
|
|
1632
|
+
"build errors. Fetch live data from the browser instead, or switch to a serverful adapter.",
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1158
1636
|
lines.push("");
|
|
1159
1637
|
lines.push("## Files");
|
|
1160
1638
|
lines.push("");
|
|
@@ -1163,12 +1641,32 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1163
1641
|
lines.push("- `src/pages/` contains your file-system routes.");
|
|
1164
1642
|
lines.push("- `src/pages/_app.tsx` is the app shell.");
|
|
1165
1643
|
lines.push("- `src/pages/index.tsx` is the home page.");
|
|
1644
|
+
lines.push("- `src/pages/404.tsx` is the not-found page; pracht wires it automatically.");
|
|
1645
|
+
lines.push("");
|
|
1646
|
+
lines.push("## Pages-router boundaries");
|
|
1647
|
+
lines.push("");
|
|
1648
|
+
lines.push(PAGES_ROUTER_LIMITATIONS);
|
|
1649
|
+
lines.push("");
|
|
1650
|
+
lines.push(PAGES_ROUTER_ISG_POLICY);
|
|
1166
1651
|
} else {
|
|
1167
1652
|
lines.push("- `src/routes.ts` defines your app manifest.");
|
|
1168
1653
|
lines.push("- `src/routes/home.tsx` is the first page.");
|
|
1654
|
+
lines.push("- `src/routes/not-found.tsx` is the not-found page, wired via `notFound`.");
|
|
1169
1655
|
}
|
|
1170
1656
|
|
|
1171
|
-
|
|
1657
|
+
if (adapter.id !== "static") {
|
|
1658
|
+
lines.push("- `src/api/health.ts` is a sample API route.");
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if (packageManager === "pnpm") {
|
|
1662
|
+
lines.push(
|
|
1663
|
+
pnpmWorkspaceNotice
|
|
1664
|
+
? `- The containing pnpm workspace owns build-script policy. Add the listed dependencies to its \`${pnpmWorkspaceNotice.policy}\` block; no nested \`pnpm-workspace.yaml\` is generated.`
|
|
1665
|
+
: pnpmMajor <= 10
|
|
1666
|
+
? "- `pnpm-workspace.yaml#onlyBuiltDependencies` allows only the dependency build scripts required by this starter."
|
|
1667
|
+
: "- `pnpm-workspace.yaml#allowBuilds` allows only the dependency build scripts required by this starter.",
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1172
1670
|
|
|
1173
1671
|
if (tailwind) {
|
|
1174
1672
|
lines.push("- `src/styles/global.css` is the Tailwind CSS entry, imported by the shell.");
|
|
@@ -1281,13 +1779,36 @@ async function installDependencies(targetDir, packageManager) {
|
|
|
1281
1779
|
});
|
|
1282
1780
|
}
|
|
1283
1781
|
|
|
1284
|
-
function printNextSteps({
|
|
1782
|
+
function printNextSteps({
|
|
1783
|
+
adapter,
|
|
1784
|
+
agentTools,
|
|
1785
|
+
dir,
|
|
1786
|
+
installSucceeded,
|
|
1787
|
+
packageManager,
|
|
1788
|
+
pnpmWorkspaceNotice,
|
|
1789
|
+
router,
|
|
1790
|
+
skipInstall,
|
|
1791
|
+
tailwind,
|
|
1792
|
+
}) {
|
|
1285
1793
|
const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
|
|
1286
1794
|
const devCommand = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
|
|
1287
1795
|
|
|
1288
1796
|
console.log("");
|
|
1289
1797
|
console.log(`Created a pracht app in ${dir}.`);
|
|
1290
1798
|
console.log(`Adapter: ${adapter.label}`);
|
|
1799
|
+
console.log(
|
|
1800
|
+
`Router: ${router === "pages" ? "pages (file-system)" : "manifest (src/routes.ts)"}`,
|
|
1801
|
+
);
|
|
1802
|
+
console.log(`Tailwind: ${tailwind ? "yes" : "no"}`);
|
|
1803
|
+
console.log(`Agent tooling: ${agentTools ? "skills, .mcp.json, AGENTS.md" : "none"}`);
|
|
1804
|
+
if (router === "pages") {
|
|
1805
|
+
console.log("");
|
|
1806
|
+
console.log(
|
|
1807
|
+
"Note: the pages router has no manifest, so middleware, capabilities, constraints, and\n" +
|
|
1808
|
+
"the agent surface (capability endpoints, WebMCP, remote MCP, `pracht eval`) are not\n" +
|
|
1809
|
+
"available. Scaffold with --router=manifest if you need them.",
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1291
1812
|
console.log("");
|
|
1292
1813
|
console.log("Next steps:");
|
|
1293
1814
|
console.log(` cd ${dir}`);
|
|
@@ -1302,6 +1823,24 @@ function printNextSteps({ adapter, dir, installSucceeded, packageManager, skipIn
|
|
|
1302
1823
|
console.log("");
|
|
1303
1824
|
console.log("Dependency installation did not complete. The project files were still created.");
|
|
1304
1825
|
}
|
|
1826
|
+
|
|
1827
|
+
if (pnpmWorkspaceNotice) {
|
|
1828
|
+
console.log("");
|
|
1829
|
+
console.log(
|
|
1830
|
+
`This app is inside the pnpm workspace at ${pnpmWorkspaceNotice.root}, which owns build\n` +
|
|
1831
|
+
"permissions for every package. Add the following to its pnpm-workspace.yaml, or\n" +
|
|
1832
|
+
"the starter's required dependency install scripts will not run:",
|
|
1833
|
+
);
|
|
1834
|
+
console.log("");
|
|
1835
|
+
console.log(` ${pnpmWorkspaceNotice.policy}:`);
|
|
1836
|
+
for (const name of pnpmWorkspaceNotice.packages) {
|
|
1837
|
+
console.log(
|
|
1838
|
+
pnpmWorkspaceNotice.policy === "onlyBuiltDependencies"
|
|
1839
|
+
? ` - ${JSON.stringify(name)}`
|
|
1840
|
+
: ` ${JSON.stringify(name)}: true`,
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1305
1844
|
}
|
|
1306
1845
|
|
|
1307
1846
|
function printHelp() {
|
|
@@ -1311,10 +1850,12 @@ Usage:
|
|
|
1311
1850
|
create-pracht [directory] [options]
|
|
1312
1851
|
|
|
1313
1852
|
Options:
|
|
1314
|
-
--adapter=node|cf|vercel
|
|
1853
|
+
--adapter=node|cf|netlify|vercel|static
|
|
1854
|
+
Choose hosting adapter (default: node)
|
|
1315
1855
|
--router=manifest|pages Choose routing system (default: manifest)
|
|
1316
1856
|
--template=minimal|tailwind Choose starter template (minimal, or minimal + Tailwind CSS)
|
|
1317
|
-
--tailwind / --no-tailwind Enable or disable Tailwind CSS wiring (default: prompt)
|
|
1857
|
+
--tailwind / --no-tailwind Enable or disable Tailwind CSS wiring (default: prompt).
|
|
1858
|
+
Sets the same thing as --template; the last one wins.
|
|
1318
1859
|
--agent-tools / --no-agent-tools
|
|
1319
1860
|
Seed Claude Code skills and a pracht MCP config (default: prompt, yes)
|
|
1320
1861
|
--no-git Skip git init and the initial commit
|