create-pracht 0.4.1 → 0.5.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 +20 -4
- package/package.json +1 -1
- package/skills/audit-auth/SKILL.md +36 -4
- package/skills/audit-csrf/SKILL.md +2 -1
- package/skills/audit-secrets/SKILL.md +6 -0
- package/skills/configure-isg/SKILL.md +30 -15
- package/skills/migrate-nextjs/SKILL.md +11 -2
- package/skills/pracht-deploy/SKILL.md +124 -7
- package/skills/pracht-test-api/SKILL.md +13 -19
- package/skills/pre-deploy/SKILL.md +21 -8
- package/skills/scaffold-tests/SKILL.md +34 -50
- package/skills/tune-render-mode/SKILL.md +4 -1
- package/src/index.js +523 -34
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,17 +13,42 @@ 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-vercel": "^0.2.8",
|
|
20
|
+
"@pracht/cli": "^1.9.0",
|
|
21
|
+
"@pracht/core": "^0.12.0",
|
|
22
|
+
"@pracht/vite-plugin": "^0.7.6",
|
|
22
23
|
"@tailwindcss/vite": "^4.1.0",
|
|
24
|
+
"netlify-cli": "^21.6.0",
|
|
23
25
|
tailwindcss: "^4.1.0",
|
|
26
|
+
typescript: "^6.0.0",
|
|
24
27
|
vercel: "^56.5.0",
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Cloudflare `compatibility_date` for scaffolded apps.
|
|
32
|
+
*
|
|
33
|
+
* This has to be a date the installed workerd already knows about — workerd
|
|
34
|
+
* refuses to start when asked for a date newer than the one its binary was
|
|
35
|
+
* built with ("This Worker requires compatibility date X, but the newest date
|
|
36
|
+
* supported by this server binary is Y"). Using today's date is therefore
|
|
37
|
+
* always wrong: it is, by construction, at or beyond the newest released
|
|
38
|
+
* workerd, so a freshly scaffolded app could not run `wrangler dev` on the day
|
|
39
|
+
* it was created.
|
|
40
|
+
*
|
|
41
|
+
* Keep it at or below the ceiling of the oldest wrangler this scaffold accepts
|
|
42
|
+
* (see `devDependencies.wrangler` below). That ceiling is *not* the workerd
|
|
43
|
+
* version date — it usually runs a little ahead of it — so check it rather
|
|
44
|
+
* than infer it: install that wrangler and start a worker with a candidate
|
|
45
|
+
* date; the error message names the newest date the binary supports.
|
|
46
|
+
*
|
|
47
|
+
* `packages/start/test/index.test.js` fails once this drifts too far behind, so
|
|
48
|
+
* a new app never silently opts out of years of default-on runtime behaviour.
|
|
49
|
+
*/
|
|
50
|
+
const WRANGLER_COMPATIBILITY_DATE = "2026-04-06";
|
|
51
|
+
|
|
27
52
|
async function fetchLatestVersion(packageName) {
|
|
28
53
|
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
29
54
|
if (!res.ok) {
|
|
@@ -48,6 +73,13 @@ const ADAPTERS = {
|
|
|
48
73
|
packageName: "@pracht/adapter-cloudflare",
|
|
49
74
|
short: "cf",
|
|
50
75
|
},
|
|
76
|
+
netlify: {
|
|
77
|
+
description: "Netlify Functions with durable CDN caching",
|
|
78
|
+
id: "netlify",
|
|
79
|
+
label: "Netlify",
|
|
80
|
+
packageName: "@pracht/adapter-netlify",
|
|
81
|
+
short: "netlify",
|
|
82
|
+
},
|
|
51
83
|
vercel: {
|
|
52
84
|
description: "Vercel Edge Functions with prebuilt deploy",
|
|
53
85
|
id: "vercel",
|
|
@@ -59,6 +91,10 @@ const ADAPTERS = {
|
|
|
59
91
|
|
|
60
92
|
const DEFAULT_DIRECTORY = "pracht-app";
|
|
61
93
|
|
|
94
|
+
function readFileSyncSafe(path) {
|
|
95
|
+
return readFileSync(path, "utf-8");
|
|
96
|
+
}
|
|
97
|
+
|
|
62
98
|
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
63
99
|
|
|
64
100
|
// The published package bundles a copy of the repo skills (see
|
|
@@ -67,7 +103,9 @@ const SKILL_DIRS = [resolve(PACKAGE_ROOT, "skills"), resolve(PACKAGE_ROOT, "../.
|
|
|
67
103
|
|
|
68
104
|
export async function run(argv = process.argv.slice(2)) {
|
|
69
105
|
const options = parseArgs(argv);
|
|
70
|
-
const
|
|
106
|
+
const packageManagerUserAgent = process.env.npm_config_user_agent ?? "";
|
|
107
|
+
const packageManager = getPackageManager(packageManagerUserAgent);
|
|
108
|
+
const pnpmMajor = packageManager === "pnpm" ? getPnpmMajor(packageManagerUserAgent) : null;
|
|
71
109
|
const log = options.json ? () => {} : console.log.bind(console);
|
|
72
110
|
|
|
73
111
|
log("create-pracht");
|
|
@@ -114,14 +152,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
114
152
|
await ensureTargetDirectory(targetDir);
|
|
115
153
|
|
|
116
154
|
if (options.dryRun) {
|
|
117
|
-
const files = await buildProjectFiles({
|
|
155
|
+
const { files } = await buildProjectFiles({
|
|
118
156
|
adapter: ADAPTERS[resolvedAdapter],
|
|
119
157
|
agentTools: resolvedAgentTools,
|
|
120
158
|
packageManager,
|
|
159
|
+
pnpmMajor,
|
|
121
160
|
projectName: toPackageName(basename(targetDir)),
|
|
122
161
|
resolveRemoteVersions: false,
|
|
123
162
|
router: resolvedRouter,
|
|
124
163
|
tailwind: resolvedTailwind,
|
|
164
|
+
targetDir,
|
|
125
165
|
});
|
|
126
166
|
|
|
127
167
|
const fileList = Object.keys(files).sort();
|
|
@@ -149,10 +189,11 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
149
189
|
return;
|
|
150
190
|
}
|
|
151
191
|
|
|
152
|
-
await scaffoldProject({
|
|
192
|
+
const { pnpmWorkspaceNotice } = await scaffoldProject({
|
|
153
193
|
adapter: ADAPTERS[resolvedAdapter],
|
|
154
194
|
agentTools: resolvedAgentTools,
|
|
155
195
|
packageManager,
|
|
196
|
+
pnpmMajor,
|
|
156
197
|
router: resolvedRouter,
|
|
157
198
|
tailwind: resolvedTailwind,
|
|
158
199
|
targetDir,
|
|
@@ -183,14 +224,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
183
224
|
}
|
|
184
225
|
|
|
185
226
|
if (options.json) {
|
|
186
|
-
const files = await buildProjectFiles({
|
|
227
|
+
const { files } = await buildProjectFiles({
|
|
187
228
|
adapter: ADAPTERS[resolvedAdapter],
|
|
188
229
|
agentTools: resolvedAgentTools,
|
|
189
230
|
packageManager,
|
|
231
|
+
pnpmMajor,
|
|
190
232
|
projectName: toPackageName(basename(targetDir)),
|
|
191
233
|
resolveRemoteVersions: false,
|
|
192
234
|
router: resolvedRouter,
|
|
193
235
|
tailwind: resolvedTailwind,
|
|
236
|
+
targetDir,
|
|
194
237
|
});
|
|
195
238
|
|
|
196
239
|
console.log(
|
|
@@ -201,6 +244,10 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
201
244
|
files: Object.keys(files).sort(),
|
|
202
245
|
gitInitialized,
|
|
203
246
|
installed: options.skipInstall ? false : installSucceeded,
|
|
247
|
+
// The automation path has to carry this too: an instruction printed to
|
|
248
|
+
// a terminal nobody reads is an instruction nobody applies, and the
|
|
249
|
+
// consequence is a Cloudflare app with no workerd binary.
|
|
250
|
+
pnpmWorkspaceNotice,
|
|
204
251
|
router: resolvedRouter,
|
|
205
252
|
tailwind: resolvedTailwind,
|
|
206
253
|
}),
|
|
@@ -208,10 +255,14 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
208
255
|
} else {
|
|
209
256
|
printNextSteps({
|
|
210
257
|
adapter: ADAPTERS[resolvedAdapter],
|
|
258
|
+
agentTools: resolvedAgentTools,
|
|
211
259
|
dir: resolvedDir,
|
|
212
260
|
installSucceeded,
|
|
213
261
|
packageManager,
|
|
262
|
+
pnpmWorkspaceNotice,
|
|
263
|
+
router: resolvedRouter,
|
|
214
264
|
skipInstall: options.skipInstall,
|
|
265
|
+
tailwind: resolvedTailwind,
|
|
215
266
|
});
|
|
216
267
|
}
|
|
217
268
|
}
|
|
@@ -220,30 +271,42 @@ export async function scaffoldProject({
|
|
|
220
271
|
adapter,
|
|
221
272
|
agentTools = true,
|
|
222
273
|
packageManager,
|
|
274
|
+
pnpmMajor = 11,
|
|
223
275
|
resolveRemoteVersions = true,
|
|
224
276
|
router = "manifest",
|
|
225
277
|
tailwind = false,
|
|
226
278
|
targetDir,
|
|
227
279
|
}) {
|
|
228
280
|
const packageName = toPackageName(basename(targetDir));
|
|
229
|
-
const files = await buildProjectFiles({
|
|
281
|
+
const { files, pnpmWorkspaceNotice } = await buildProjectFiles({
|
|
230
282
|
adapter,
|
|
231
283
|
agentTools,
|
|
232
284
|
packageManager,
|
|
285
|
+
pnpmMajor,
|
|
233
286
|
projectName: packageName,
|
|
234
287
|
resolveRemoteVersions,
|
|
235
288
|
router,
|
|
236
289
|
tailwind,
|
|
290
|
+
targetDir,
|
|
237
291
|
});
|
|
238
292
|
|
|
239
293
|
await mkdir(targetDir, { recursive: true });
|
|
240
294
|
|
|
295
|
+
// pnpm resolves build-script policy from the workspace root, so inside an existing
|
|
296
|
+
// monorepo our own file would be read by nobody — and `pnpm install` run from
|
|
297
|
+
// the app directory would find it first and re-root the workspace there,
|
|
298
|
+
// detaching the app from its siblings. Tell the user what to add instead.
|
|
241
299
|
for (const [relativePath, content] of Object.entries(files)) {
|
|
242
300
|
const filePath = resolve(targetDir, relativePath);
|
|
243
301
|
await mkdir(dirname(filePath), { recursive: true });
|
|
244
302
|
await writeFile(filePath, content, "utf-8");
|
|
245
303
|
}
|
|
246
304
|
|
|
305
|
+
// AGENTS.md (and the CLAUDE.md alias pointing at it) are agent tooling too —
|
|
306
|
+
// `--no-agent-tools` means a project with none of it, not "all of it except
|
|
307
|
+
// the instruction files". README.md carries the same commands for humans.
|
|
308
|
+
if (!agentTools) return { pnpmWorkspaceNotice };
|
|
309
|
+
|
|
247
310
|
try {
|
|
248
311
|
await symlink("AGENTS.md", resolve(targetDir, "CLAUDE.md"));
|
|
249
312
|
} catch (error) {
|
|
@@ -253,6 +316,8 @@ export async function scaffoldProject({
|
|
|
253
316
|
throw error;
|
|
254
317
|
}
|
|
255
318
|
}
|
|
319
|
+
|
|
320
|
+
return { pnpmWorkspaceNotice };
|
|
256
321
|
}
|
|
257
322
|
|
|
258
323
|
export function getPackageManager(userAgent = process.env.npm_config_user_agent ?? "") {
|
|
@@ -262,6 +327,11 @@ export function getPackageManager(userAgent = process.env.npm_config_user_agent
|
|
|
262
327
|
return "npm";
|
|
263
328
|
}
|
|
264
329
|
|
|
330
|
+
export function getPnpmMajor(userAgent = process.env.npm_config_user_agent ?? "") {
|
|
331
|
+
const match = /^pnpm\/(\d+)/.exec(userAgent);
|
|
332
|
+
return match ? Number(match[1]) : 11;
|
|
333
|
+
}
|
|
334
|
+
|
|
265
335
|
export function parseArgs(argv) {
|
|
266
336
|
const options = {
|
|
267
337
|
adapter: undefined,
|
|
@@ -337,7 +407,7 @@ export function parseArgs(argv) {
|
|
|
337
407
|
const value = normalizeAdapter(arg.slice("--adapter=".length));
|
|
338
408
|
if (!value) {
|
|
339
409
|
throw new ValidationError(
|
|
340
|
-
`Invalid adapter: ${arg.slice("--adapter=".length)}. Use node, cf, or vercel.`,
|
|
410
|
+
`Invalid adapter: ${arg.slice("--adapter=".length)}. Use node, cf, netlify, or vercel.`,
|
|
341
411
|
);
|
|
342
412
|
}
|
|
343
413
|
options.adapter = value;
|
|
@@ -391,6 +461,7 @@ async function promptForAdapter(readline) {
|
|
|
391
461
|
console.log(" 1. Node.js");
|
|
392
462
|
console.log(" 2. Cloudflare Workers");
|
|
393
463
|
console.log(" 3. Vercel");
|
|
464
|
+
console.log(" 4. Netlify");
|
|
394
465
|
|
|
395
466
|
while (true) {
|
|
396
467
|
const answer = await readline.question("Adapter (1): ");
|
|
@@ -400,14 +471,18 @@ async function promptForAdapter(readline) {
|
|
|
400
471
|
return normalized;
|
|
401
472
|
}
|
|
402
473
|
|
|
403
|
-
console.log("Choose 1/2/3 or node/cf/vercel.");
|
|
474
|
+
console.log("Choose 1/2/3/4 or node/cf/vercel/netlify.");
|
|
404
475
|
}
|
|
405
476
|
}
|
|
406
477
|
|
|
407
478
|
async function promptForRouter(readline) {
|
|
479
|
+
// The two routers are not equivalent, and the difference is invisible until
|
|
480
|
+
// you reach for a manifest-only feature. Say so at the point of choosing.
|
|
408
481
|
console.log("Router:");
|
|
409
|
-
console.log(" 1. Manifest (explicit routes.ts)");
|
|
410
|
-
console.log("
|
|
482
|
+
console.log(" 1. Manifest (explicit routes.ts) — supports middleware, capabilities,");
|
|
483
|
+
console.log(" MCP, Web Bot Auth, and constraints");
|
|
484
|
+
console.log(" 2. Pages (file-system routing) — pages and API routes only; no");
|
|
485
|
+
console.log(" middleware, capabilities, MCP, or agent trust (eject later to add them)");
|
|
411
486
|
|
|
412
487
|
while (true) {
|
|
413
488
|
const answer = await readline.question("Router (1): ");
|
|
@@ -535,6 +610,10 @@ function normalizeAdapter(value) {
|
|
|
535
610
|
return "vercel";
|
|
536
611
|
}
|
|
537
612
|
|
|
613
|
+
if (normalized === "4" || normalized === "nf" || normalized === "netlify") {
|
|
614
|
+
return "netlify";
|
|
615
|
+
}
|
|
616
|
+
|
|
538
617
|
return null;
|
|
539
618
|
}
|
|
540
619
|
|
|
@@ -557,50 +636,83 @@ async function buildProjectFiles({
|
|
|
557
636
|
adapter,
|
|
558
637
|
agentTools = true,
|
|
559
638
|
packageManager,
|
|
639
|
+
pnpmMajor = 11,
|
|
560
640
|
projectName,
|
|
561
641
|
resolveRemoteVersions = true,
|
|
562
642
|
router,
|
|
563
643
|
tailwind = false,
|
|
644
|
+
targetDir,
|
|
564
645
|
}) {
|
|
565
646
|
const packagesToResolve = [
|
|
566
647
|
"@pracht/cli",
|
|
567
648
|
"@pracht/vite-plugin",
|
|
568
649
|
"@pracht/core",
|
|
569
650
|
adapter.packageName,
|
|
651
|
+
"typescript",
|
|
570
652
|
];
|
|
571
653
|
if (adapter.id === "vercel") {
|
|
572
654
|
packagesToResolve.push("vercel");
|
|
573
655
|
}
|
|
656
|
+
if (adapter.id === "netlify") {
|
|
657
|
+
packagesToResolve.push("netlify-cli");
|
|
658
|
+
}
|
|
574
659
|
if (tailwind) {
|
|
575
660
|
packagesToResolve.push("tailwindcss", "@tailwindcss/vite");
|
|
576
661
|
}
|
|
577
662
|
|
|
578
663
|
const versions = await resolveVersions(packagesToResolve, { remote: resolveRemoteVersions });
|
|
664
|
+
const policyMajor = pnpmMajor ?? 11;
|
|
665
|
+
const ancestorWorkspace = targetDir ? findAncestorPnpmWorkspace(targetDir) : null;
|
|
666
|
+
const pnpmWorkspaceNotice = ancestorWorkspace
|
|
667
|
+
? {
|
|
668
|
+
packages: pnpmBuildAllowlist(adapter, tailwind),
|
|
669
|
+
policy: pnpmBuildPolicyName(policyMajor),
|
|
670
|
+
root: ancestorWorkspace,
|
|
671
|
+
}
|
|
672
|
+
: null;
|
|
579
673
|
|
|
580
674
|
const files = {
|
|
581
675
|
".gitignore":
|
|
582
|
-
"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",
|
|
676
|
+
"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",
|
|
583
677
|
"README.md": createReadme({
|
|
584
678
|
adapter,
|
|
585
679
|
agentTools,
|
|
586
680
|
packageManager,
|
|
681
|
+
pnpmMajor,
|
|
682
|
+
pnpmWorkspaceNotice,
|
|
587
683
|
projectName,
|
|
588
684
|
router,
|
|
589
685
|
tailwind,
|
|
590
686
|
}),
|
|
591
|
-
"package.json": createPackageJson({
|
|
687
|
+
"package.json": createPackageJson({
|
|
688
|
+
adapter,
|
|
689
|
+
projectName,
|
|
690
|
+
tailwind,
|
|
691
|
+
versions,
|
|
692
|
+
}),
|
|
592
693
|
"src/api/health.ts": createHealthRoute(adapter),
|
|
593
694
|
"vite.config.ts": createViteConfig(adapter, router, tailwind),
|
|
594
695
|
"tsconfig.json": createBaseTSConfig(adapter),
|
|
595
|
-
"AGENTS.md": createAgentInstructions({ adapter, agentTools, packageManager, router, tailwind }),
|
|
596
696
|
};
|
|
597
697
|
|
|
698
|
+
if (agentTools) {
|
|
699
|
+
files["AGENTS.md"] = createAgentInstructions({
|
|
700
|
+
adapter,
|
|
701
|
+
agentTools,
|
|
702
|
+
packageManager,
|
|
703
|
+
router,
|
|
704
|
+
tailwind,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
598
708
|
if (router === "pages") {
|
|
599
709
|
files["src/pages/_app.tsx"] = createShellFile(projectName, tailwind);
|
|
600
710
|
files["src/pages/index.tsx"] = createPagesHomeRoute(adapter);
|
|
711
|
+
files["src/pages/404.tsx"] = createNotFoundRoute();
|
|
601
712
|
} else {
|
|
602
713
|
files["src/routes.ts"] = createRoutesFile();
|
|
603
714
|
files["src/routes/home.tsx"] = createHomeRoute(adapter);
|
|
715
|
+
files["src/routes/not-found.tsx"] = createNotFoundRoute();
|
|
604
716
|
files["src/shells/public.tsx"] = createShellFile(projectName, tailwind);
|
|
605
717
|
}
|
|
606
718
|
|
|
@@ -613,6 +725,10 @@ async function buildProjectFiles({
|
|
|
613
725
|
files["src/env.d.ts"] = createCloudflareEnvDeclaration();
|
|
614
726
|
}
|
|
615
727
|
|
|
728
|
+
if (adapter.id === "netlify") {
|
|
729
|
+
files["netlify.toml"] = createNetlifyConfig(packageManager);
|
|
730
|
+
}
|
|
731
|
+
|
|
616
732
|
if (adapter.id === "node") {
|
|
617
733
|
files["Dockerfile"] = createDockerfile(packageManager);
|
|
618
734
|
files[".dockerignore"] = createDockerignore();
|
|
@@ -623,7 +739,16 @@ async function buildProjectFiles({
|
|
|
623
739
|
Object.assign(files, await readSkillFiles());
|
|
624
740
|
}
|
|
625
741
|
|
|
626
|
-
|
|
742
|
+
// pnpm resolves build-script policy from the workspace root, so inside an existing
|
|
743
|
+
// workspace our own file would be read by nobody — and `pnpm install` run
|
|
744
|
+
// from the app directory would find it first and re-root the workspace there,
|
|
745
|
+
// detaching the app from its siblings. Decided here so the `--json` and
|
|
746
|
+
// `--dry-run` listings match what is actually written.
|
|
747
|
+
if (!pnpmWorkspaceNotice) {
|
|
748
|
+
files["pnpm-workspace.yaml"] = createPnpmWorkspaceConfig(adapter, tailwind, policyMajor);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return { files, pnpmWorkspaceNotice };
|
|
627
752
|
}
|
|
628
753
|
|
|
629
754
|
function createMcpConfig() {
|
|
@@ -632,7 +757,13 @@ function createMcpConfig() {
|
|
|
632
757
|
mcpServers: {
|
|
633
758
|
pracht: {
|
|
634
759
|
command: "npx",
|
|
635
|
-
|
|
760
|
+
// `--no-install` pins this to the `@pracht/cli` the project depends
|
|
761
|
+
// on. `--yes @pracht/cli` fetched the registry's latest instead, so
|
|
762
|
+
// the MCP server an agent talked to could describe a different CLI
|
|
763
|
+
// than the one the app builds with. Not bare `npx pracht` either:
|
|
764
|
+
// that resolves to a registry package literally named `pracht`
|
|
765
|
+
// whenever the local bin is missing — `--no-install` fails loudly.
|
|
766
|
+
args: ["--no-install", "pracht", "mcp"],
|
|
636
767
|
},
|
|
637
768
|
},
|
|
638
769
|
},
|
|
@@ -664,6 +795,7 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
|
|
|
664
795
|
const scripts = {
|
|
665
796
|
build: "pracht build",
|
|
666
797
|
dev: "pracht dev",
|
|
798
|
+
typecheck: "tsc --noEmit",
|
|
667
799
|
};
|
|
668
800
|
|
|
669
801
|
if (adapter.id === "node") {
|
|
@@ -676,6 +808,7 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
|
|
|
676
808
|
"@pracht/vite-plugin": versions["@pracht/vite-plugin"],
|
|
677
809
|
preact: "^10.26.9",
|
|
678
810
|
"preact-render-to-string": "^6.5.13",
|
|
811
|
+
typescript: versions["typescript"],
|
|
679
812
|
vite: "^8.0.0",
|
|
680
813
|
};
|
|
681
814
|
|
|
@@ -685,6 +818,12 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
|
|
|
685
818
|
devDependencies.wrangler = "^4.81.0";
|
|
686
819
|
}
|
|
687
820
|
|
|
821
|
+
if (adapter.id === "netlify") {
|
|
822
|
+
scripts.deploy = "netlify deploy --build --prod";
|
|
823
|
+
scripts.preview = "pracht build && netlify dev";
|
|
824
|
+
devDependencies["netlify-cli"] = versions["netlify-cli"];
|
|
825
|
+
}
|
|
826
|
+
|
|
688
827
|
if (adapter.id === "vercel") {
|
|
689
828
|
scripts.deploy = "pracht build && vercel deploy --prebuilt";
|
|
690
829
|
devDependencies.vercel = versions["vercel"];
|
|
@@ -717,6 +856,7 @@ function createViteConfig(adapter, router, tailwind) {
|
|
|
717
856
|
const ADAPTER_IMPORTS = {
|
|
718
857
|
node: { fn: "nodeAdapter", pkg: "@pracht/adapter-node" },
|
|
719
858
|
cloudflare: { fn: "cloudflareAdapter", pkg: "@pracht/adapter-cloudflare" },
|
|
859
|
+
netlify: { fn: "netlifyAdapter", pkg: "@pracht/adapter-netlify" },
|
|
720
860
|
vercel: { fn: "vercelAdapter", pkg: "@pracht/adapter-vercel" },
|
|
721
861
|
};
|
|
722
862
|
|
|
@@ -757,8 +897,12 @@ function createRoutesFile() {
|
|
|
757
897
|
" routes: [",
|
|
758
898
|
' route("/", "./routes/home.tsx", { id: "home", render: "ssg", shell: "public" }),',
|
|
759
899
|
" ],",
|
|
760
|
-
" //
|
|
761
|
-
|
|
900
|
+
" // Rendered with a 404 status when nothing matches. Not a route: it never",
|
|
901
|
+
" // matches a URL, so it cannot shadow static assets or later pages.",
|
|
902
|
+
" notFound: {",
|
|
903
|
+
' component: "./routes/not-found.tsx",',
|
|
904
|
+
' shell: "public",',
|
|
905
|
+
" },",
|
|
762
906
|
" // Declarative invariants enforced by `pracht verify` — uncomment to use",
|
|
763
907
|
" // (add the helpers to the @pracht/core import):",
|
|
764
908
|
" // constraints: [",
|
|
@@ -839,6 +983,33 @@ function createHomeRoute(adapter) {
|
|
|
839
983
|
].join("\n");
|
|
840
984
|
}
|
|
841
985
|
|
|
986
|
+
function createNotFoundRoute() {
|
|
987
|
+
return [
|
|
988
|
+
"export function head() {",
|
|
989
|
+
" return {",
|
|
990
|
+
' title: "Page not found",',
|
|
991
|
+
' meta: [{ content: "noindex", name: "robots" }],',
|
|
992
|
+
" };",
|
|
993
|
+
"}",
|
|
994
|
+
"",
|
|
995
|
+
"export function Component() {",
|
|
996
|
+
" return (",
|
|
997
|
+
" <section>",
|
|
998
|
+
' <p style={{ color: "#555", marginBottom: "8px" }}>404</p>',
|
|
999
|
+
' <h1 style={{ fontSize: "2.5rem", lineHeight: 1.1, margin: "0 0 16px" }}>Page not found.</h1>',
|
|
1000
|
+
' <p style={{ fontSize: "1.1rem", lineHeight: 1.6, marginBottom: "24px" }}>',
|
|
1001
|
+
" The page you asked for does not exist. It may have moved, or the link may be wrong.",
|
|
1002
|
+
" </p>",
|
|
1003
|
+
" {/* A plain anchor keeps this page independent of the route table.",
|
|
1004
|
+
" Use a typed <Link> once you want client-side navigation. */}",
|
|
1005
|
+
' <a href="/">Back to home</a>',
|
|
1006
|
+
" </section>",
|
|
1007
|
+
" );",
|
|
1008
|
+
"}",
|
|
1009
|
+
"",
|
|
1010
|
+
].join("\n");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
842
1013
|
function createPagesHomeRoute(adapter) {
|
|
843
1014
|
return [
|
|
844
1015
|
'import type { LoaderArgs, RouteComponentProps } from "@pracht/core";',
|
|
@@ -912,18 +1083,200 @@ function createHealthRoute(adapter) {
|
|
|
912
1083
|
].join("\n");
|
|
913
1084
|
}
|
|
914
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* pnpm blocks dependency install scripts unless they are allowlisted, and
|
|
1088
|
+
* esbuild and workerd both need theirs — workerd's postinstall downloads the
|
|
1089
|
+
* runtime binary, so without this `wrangler dev` fails right after scaffolding
|
|
1090
|
+
* with `ERR_PNPM_IGNORED_BUILDS`.
|
|
1091
|
+
*
|
|
1092
|
+
* This has to live in `pnpm-workspace.yaml`: pnpm 10 uses
|
|
1093
|
+
* `onlyBuiltDependencies`, while pnpm 11 uses `allowBuilds` and no longer reads
|
|
1094
|
+
* the `pnpm` field in package.json. npm and yarn ignore this file entirely, so
|
|
1095
|
+
* it is inert for them. (npm has its own `allow-scripts` prompt, which it
|
|
1096
|
+
* drives interactively.)
|
|
1097
|
+
*/
|
|
1098
|
+
function pnpmBuildAllowlist(adapter, tailwind) {
|
|
1099
|
+
const packages = ["esbuild"];
|
|
1100
|
+
if (adapter.id === "cloudflare") packages.push("workerd");
|
|
1101
|
+
if (tailwind) packages.push("@tailwindcss/oxide");
|
|
1102
|
+
return packages.sort();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function pnpmBuildPolicyName(pnpmMajor) {
|
|
1106
|
+
return pnpmMajor <= 10 ? "onlyBuiltDependencies" : "allowBuilds";
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function createPnpmWorkspaceConfig(adapter, tailwind, pnpmMajor) {
|
|
1110
|
+
const policy = pnpmBuildPolicyName(pnpmMajor);
|
|
1111
|
+
const entries = pnpmBuildAllowlist(adapter, tailwind);
|
|
1112
|
+
|
|
1113
|
+
return [
|
|
1114
|
+
"packages:",
|
|
1115
|
+
' - "."',
|
|
1116
|
+
`${policy}:`,
|
|
1117
|
+
...(policy === "onlyBuiltDependencies"
|
|
1118
|
+
? entries.map((name) => ` - ${JSON.stringify(name)}`)
|
|
1119
|
+
: entries.map((name) => ` ${JSON.stringify(name)}: true`)),
|
|
1120
|
+
"",
|
|
1121
|
+
].join("\n");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Nearest ancestor `pnpm-workspace.yaml` above `dir`, or null.
|
|
1126
|
+
*
|
|
1127
|
+
* pnpm resolves settings from the workspace *root*, so writing our own file
|
|
1128
|
+
* inside an existing monorepo would be read by nobody — while also re-rooting
|
|
1129
|
+
* the workspace for anyone who runs `pnpm install` from the app directory,
|
|
1130
|
+
* which detaches it from its siblings.
|
|
1131
|
+
*/
|
|
1132
|
+
function findAncestorPnpmWorkspace(dir) {
|
|
1133
|
+
let current = resolve(dir, "..");
|
|
1134
|
+
for (;;) {
|
|
1135
|
+
const configPath = resolve(current, "pnpm-workspace.yaml");
|
|
1136
|
+
// An ancestor config only governs this app if its `packages:` globs cover
|
|
1137
|
+
// it. Suppressing our own file for a workspace the app is *not* a member of
|
|
1138
|
+
// leaves it with no install at all: pnpm re-roots to the ancestor and
|
|
1139
|
+
// installs that workspace's projects instead.
|
|
1140
|
+
if (existsSync(configPath) && workspaceCovers(configPath, current, dir)) return current;
|
|
1141
|
+
const parent = dirname(current);
|
|
1142
|
+
if (parent === current) return null;
|
|
1143
|
+
current = parent;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Whether `dir` matches one of the `packages:` globs in a pnpm workspace
|
|
1149
|
+
* config. A deliberately small YAML reader: the block and flow list forms pnpm
|
|
1150
|
+
* accepts, and `*` / `**` globs.
|
|
1151
|
+
*
|
|
1152
|
+
* Both failure directions matter, and they are not symmetric. Deciding "not a
|
|
1153
|
+
* member" for a directory that *is* one writes a nested `pnpm-workspace.yaml`
|
|
1154
|
+
* that re-roots the workspace at the app; deciding "member" for one that is
|
|
1155
|
+
* not only prints instructions. So anything this reader cannot confidently
|
|
1156
|
+
* decide answers `true`.
|
|
1157
|
+
*/
|
|
1158
|
+
function workspaceCovers(configPath, workspaceRoot, dir) {
|
|
1159
|
+
let contents;
|
|
1160
|
+
try {
|
|
1161
|
+
contents = readFileSyncSafe(configPath);
|
|
1162
|
+
} catch {
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const globs = [];
|
|
1167
|
+
let sawPackagesKey = false;
|
|
1168
|
+
let inBlockList = false;
|
|
1169
|
+
|
|
1170
|
+
for (const rawLine of contents.split("\n")) {
|
|
1171
|
+
const line = rawLine.replace(/#.*$/, "");
|
|
1172
|
+
const packagesKey = line.match(/^packages\s*:(.*)$/);
|
|
1173
|
+
if (packagesKey) {
|
|
1174
|
+
sawPackagesKey = true;
|
|
1175
|
+
// Flow form: `packages: ["apps/*", "tools/*"]`, which pnpm accepts.
|
|
1176
|
+
const flow = packagesKey[1].trim();
|
|
1177
|
+
if (flow.startsWith("[")) {
|
|
1178
|
+
for (const entry of flow.replace(/^\[|\]$/g, "").split(",")) {
|
|
1179
|
+
const value = entry.trim().replace(/^["']|["']$/g, "");
|
|
1180
|
+
if (value) globs.push(value);
|
|
1181
|
+
}
|
|
1182
|
+
inBlockList = false;
|
|
1183
|
+
} else {
|
|
1184
|
+
inBlockList = true;
|
|
1185
|
+
}
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (inBlockList) {
|
|
1189
|
+
const item = line.match(/^\s+-\s*["']?([^"'\s]+)["']?\s*$/);
|
|
1190
|
+
if (item) {
|
|
1191
|
+
globs.push(item[1]);
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
if (line.trim() !== "") inBlockList = false;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// No `packages:` key at all is a single-package workspace rooted there,
|
|
1199
|
+
// which does not cover a nested app. A key we could not read is a decision
|
|
1200
|
+
// we cannot make — fall to "member".
|
|
1201
|
+
if (!sawPackagesKey) return false;
|
|
1202
|
+
if (globs.length === 0) return true;
|
|
1203
|
+
|
|
1204
|
+
const relative = resolve(dir)
|
|
1205
|
+
.slice(resolve(workspaceRoot).length + 1)
|
|
1206
|
+
.split(/[\\/]/);
|
|
1207
|
+
// A negation (`!apps/legacy`) narrows the set; treat its presence as
|
|
1208
|
+
// undecidable rather than as an ordinary glob.
|
|
1209
|
+
if (globs.some((glob) => glob.startsWith("!"))) return true;
|
|
1210
|
+
// pnpm treats a workspace-root-relative `./apps/*` the same as `apps/*`.
|
|
1211
|
+
// Strip only that harmless prefix before comparing path segments.
|
|
1212
|
+
const normalizedGlobs = globs.map((glob) => glob.replace(/^(?:\.\/)+/, ""));
|
|
1213
|
+
// pnpm accepts the wider glob syntax supported by its workspace matcher.
|
|
1214
|
+
// This intentionally small matcher cannot safely decide braces, character
|
|
1215
|
+
// classes, extglobs, or single-character wildcards. Follow the conservative
|
|
1216
|
+
// contract above instead of creating a nested workspace for a real member.
|
|
1217
|
+
if (normalizedGlobs.some((glob) => /[?[\]{}()]/.test(glob))) return true;
|
|
1218
|
+
return normalizedGlobs.some((glob) => matchesGlobSegments(glob.split("/"), relative));
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Segment-wise glob match. `**` matches the rest; otherwise a segment may
|
|
1223
|
+
* contain `*` wildcards (`app-*`), which pnpm supports.
|
|
1224
|
+
*/
|
|
1225
|
+
function matchesGlobSegments(globSegments, pathSegments) {
|
|
1226
|
+
return matchGlobSegmentAt(globSegments, pathSegments, 0, 0);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function matchGlobSegmentAt(globSegments, pathSegments, globIndex, pathIndex) {
|
|
1230
|
+
if (globIndex === globSegments.length) return pathIndex === pathSegments.length;
|
|
1231
|
+
|
|
1232
|
+
const segment = globSegments[globIndex];
|
|
1233
|
+
if (segment === "**") {
|
|
1234
|
+
if (globIndex === globSegments.length - 1) return true;
|
|
1235
|
+
for (let nextPathIndex = pathIndex; nextPathIndex <= pathSegments.length; nextPathIndex += 1) {
|
|
1236
|
+
if (matchGlobSegmentAt(globSegments, pathSegments, globIndex + 1, nextPathIndex)) return true;
|
|
1237
|
+
}
|
|
1238
|
+
return false;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return (
|
|
1242
|
+
pathIndex < pathSegments.length &&
|
|
1243
|
+
segmentMatches(segment, pathSegments[pathIndex]) &&
|
|
1244
|
+
matchGlobSegmentAt(globSegments, pathSegments, globIndex + 1, pathIndex + 1)
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function segmentMatches(glob, value) {
|
|
1249
|
+
if (glob === "*") return true;
|
|
1250
|
+
if (!glob.includes("*")) return glob === value;
|
|
1251
|
+
const pattern = glob
|
|
1252
|
+
.split("*")
|
|
1253
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
1254
|
+
.join(".*");
|
|
1255
|
+
return new RegExp(`^${pattern}$`).test(value);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
915
1258
|
function createWranglerConfig(projectName) {
|
|
916
|
-
const compatibilityDate =
|
|
1259
|
+
const compatibilityDate = WRANGLER_COMPATIBILITY_DATE;
|
|
917
1260
|
|
|
918
1261
|
return [
|
|
919
1262
|
"{",
|
|
920
1263
|
' "$schema": "node_modules/wrangler/config-schema.json",',
|
|
921
1264
|
` "name": ${JSON.stringify(projectName)},`,
|
|
922
|
-
|
|
1265
|
+
// `pracht build` writes this thin wrapper next to server.js. It re-exports
|
|
1266
|
+
// only the default handler and any Worker entrypoint classes: workerd
|
|
1267
|
+
// validates every named export of the deployed entry module and rejects the
|
|
1268
|
+
// build metadata (buildTarget, manifests, ...) server.js also exports.
|
|
1269
|
+
' "main": "dist/server/worker.js",',
|
|
923
1270
|
` "compatibility_date": ${JSON.stringify(compatibilityDate)},`,
|
|
924
1271
|
' "assets": {',
|
|
925
1272
|
' "binding": "ASSETS",',
|
|
926
1273
|
' "directory": "dist/client",',
|
|
1274
|
+
// The assets binding defaults to redirecting a prerendered route to its
|
|
1275
|
+
// trailing-slash form, so `GET /about` would answer 307 on Cloudflare
|
|
1276
|
+
// where Node and Vercel answer 200 — for the same app, and for every URL
|
|
1277
|
+
// the generated llms.txt advertises. Drop the slash instead so one
|
|
1278
|
+
// canonical form works across adapters.
|
|
1279
|
+
' "html_handling": "drop-trailing-slash",',
|
|
927
1280
|
' "run_worker_first": true',
|
|
928
1281
|
" }",
|
|
929
1282
|
"}",
|
|
@@ -931,6 +1284,23 @@ function createWranglerConfig(projectName) {
|
|
|
931
1284
|
].join("\n");
|
|
932
1285
|
}
|
|
933
1286
|
|
|
1287
|
+
function createNetlifyConfig(packageManager) {
|
|
1288
|
+
const buildCommand =
|
|
1289
|
+
packageManager === "npm" || packageManager === "bun"
|
|
1290
|
+
? `${packageManager} run build`
|
|
1291
|
+
: `${packageManager} build`;
|
|
1292
|
+
|
|
1293
|
+
return [
|
|
1294
|
+
"[build]",
|
|
1295
|
+
` command = ${JSON.stringify(buildCommand)}`,
|
|
1296
|
+
' publish = "dist/client"',
|
|
1297
|
+
"",
|
|
1298
|
+
"[functions]",
|
|
1299
|
+
' directory = "netlify/functions"',
|
|
1300
|
+
"",
|
|
1301
|
+
].join("\n");
|
|
1302
|
+
}
|
|
1303
|
+
|
|
934
1304
|
function createCloudflareEnvDeclaration() {
|
|
935
1305
|
return [
|
|
936
1306
|
'import "@pracht/core";',
|
|
@@ -1019,8 +1389,17 @@ function createDockerignore() {
|
|
|
1019
1389
|
].join("\n");
|
|
1020
1390
|
}
|
|
1021
1391
|
|
|
1392
|
+
const PAGES_ROUTER_LIMITATIONS =
|
|
1393
|
+
"**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.";
|
|
1394
|
+
|
|
1395
|
+
const PAGES_ROUTER_ISG_POLICY =
|
|
1396
|
+
'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.';
|
|
1397
|
+
|
|
1022
1398
|
function createAgentInstructions({ adapter, agentTools, packageManager, router, tailwind }) {
|
|
1023
|
-
|
|
1399
|
+
// `bun build` is Bun's own bundler and shadows the package script, so bun
|
|
1400
|
+
// needs the explicit `run` form the same way npm does.
|
|
1401
|
+
const runCmd =
|
|
1402
|
+
packageManager === "npm" || packageManager === "bun" ? `${packageManager} run` : packageManager;
|
|
1024
1403
|
|
|
1025
1404
|
const lines = [
|
|
1026
1405
|
"# Pracht App",
|
|
@@ -1031,7 +1410,7 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1031
1410
|
`- \`${runCmd} build\` — production build`,
|
|
1032
1411
|
];
|
|
1033
1412
|
|
|
1034
|
-
if (adapter.id === "node" || adapter.id === "cloudflare") {
|
|
1413
|
+
if (adapter.id === "node" || adapter.id === "cloudflare" || adapter.id === "netlify") {
|
|
1035
1414
|
lines.push(`- \`${runCmd} preview\` — build and serve the production build locally`);
|
|
1036
1415
|
}
|
|
1037
1416
|
|
|
@@ -1039,7 +1418,7 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1039
1418
|
lines.push(`- \`${runCmd} start\` — run the built server`);
|
|
1040
1419
|
}
|
|
1041
1420
|
|
|
1042
|
-
if (adapter.id === "cloudflare" || adapter.id === "vercel") {
|
|
1421
|
+
if (adapter.id === "cloudflare" || adapter.id === "netlify" || adapter.id === "vercel") {
|
|
1043
1422
|
lines.push(`- \`${runCmd} deploy\` — build and deploy`);
|
|
1044
1423
|
}
|
|
1045
1424
|
|
|
@@ -1049,9 +1428,16 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1049
1428
|
lines.push("Use the CLI to generate new files:");
|
|
1050
1429
|
lines.push("");
|
|
1051
1430
|
lines.push("- `pracht generate route --path /about` — add a route");
|
|
1052
|
-
|
|
1053
|
-
|
|
1431
|
+
if (router !== "pages") {
|
|
1432
|
+
lines.push("- `pracht generate shell --name app` — add a shell");
|
|
1433
|
+
lines.push("- `pracht generate middleware --name auth` — add middleware");
|
|
1434
|
+
}
|
|
1054
1435
|
lines.push("- `pracht generate api --path /health --methods GET` — add an API route");
|
|
1436
|
+
if (router !== "pages") {
|
|
1437
|
+
lines.push(
|
|
1438
|
+
"- `pracht generate capability --name notes.search --effect read --expose http` — add a capability (agent-callable operation)",
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1055
1441
|
lines.push("- `pracht doctor` — check project health");
|
|
1056
1442
|
lines.push("- `pracht verify` — enforce route and constraint invariants");
|
|
1057
1443
|
lines.push(
|
|
@@ -1069,11 +1455,21 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1069
1455
|
lines.push("");
|
|
1070
1456
|
lines.push("- `src/pages/` — file-system routes (each file becomes a route)");
|
|
1071
1457
|
lines.push("- `src/pages/_app.tsx` — app shell (layout and head)");
|
|
1458
|
+
lines.push(
|
|
1459
|
+
"- `src/pages/404.tsx` — not-found page, wired automatically (never a URL of its own)",
|
|
1460
|
+
);
|
|
1461
|
+
lines.push("");
|
|
1462
|
+
lines.push(PAGES_ROUTER_LIMITATIONS);
|
|
1463
|
+
lines.push("");
|
|
1464
|
+
lines.push(PAGES_ROUTER_ISG_POLICY);
|
|
1072
1465
|
} else {
|
|
1073
1466
|
lines.push("This app uses **manifest routing**.");
|
|
1074
1467
|
lines.push("");
|
|
1075
1468
|
lines.push("- `src/routes.ts` — route manifest (defines all routes and shells)");
|
|
1076
1469
|
lines.push("- `src/routes/` — route components and loaders");
|
|
1470
|
+
lines.push(
|
|
1471
|
+
"- `src/routes/not-found.tsx` — not-found page, wired via `notFound` in the manifest",
|
|
1472
|
+
);
|
|
1077
1473
|
lines.push("- `src/shells/` — shell components (layouts)");
|
|
1078
1474
|
}
|
|
1079
1475
|
|
|
@@ -1093,6 +1489,10 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1093
1489
|
lines.push("- `src/env.d.ts` — TypeScript types for Cloudflare bindings");
|
|
1094
1490
|
}
|
|
1095
1491
|
|
|
1492
|
+
if (adapter.id === "netlify") {
|
|
1493
|
+
lines.push("- `netlify.toml` — Netlify build, publish, and functions configuration");
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1096
1496
|
if (agentTools) {
|
|
1097
1497
|
lines.push("");
|
|
1098
1498
|
lines.push("## Agent tooling");
|
|
@@ -1110,12 +1510,29 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1110
1510
|
return lines.join("\n");
|
|
1111
1511
|
}
|
|
1112
1512
|
|
|
1113
|
-
function createReadme({
|
|
1513
|
+
function createReadme({
|
|
1514
|
+
adapter,
|
|
1515
|
+
agentTools,
|
|
1516
|
+
packageManager,
|
|
1517
|
+
pnpmMajor,
|
|
1518
|
+
pnpmWorkspaceNotice,
|
|
1519
|
+
projectName,
|
|
1520
|
+
router,
|
|
1521
|
+
tailwind,
|
|
1522
|
+
}) {
|
|
1114
1523
|
const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
|
|
1115
1524
|
const devCommand = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
|
|
1525
|
+
// `bun build` is Bun's own bundler and shadows the package script, unlike
|
|
1526
|
+
// `bun dev` / `bun start` / `bun preview`, which fall through to it.
|
|
1527
|
+
const buildCommand =
|
|
1528
|
+
packageManager === "npm" || packageManager === "bun"
|
|
1529
|
+
? `${packageManager} run build`
|
|
1530
|
+
: `${packageManager} build`;
|
|
1116
1531
|
const previewCommand = packageManager === "npm" ? "npm run preview" : `${packageManager} preview`;
|
|
1117
1532
|
const startCommand = packageManager === "npm" ? "npm run start" : `${packageManager} start`;
|
|
1118
1533
|
const deployCommand = packageManager === "npm" ? "npm run deploy" : `${packageManager} deploy`;
|
|
1534
|
+
const typecheckCommand =
|
|
1535
|
+
packageManager === "npm" ? "npm run typecheck" : `${packageManager} typecheck`;
|
|
1119
1536
|
|
|
1120
1537
|
const lines = [
|
|
1121
1538
|
`# ${projectName}`,
|
|
@@ -1126,6 +1543,8 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1126
1543
|
"",
|
|
1127
1544
|
`- \`${installCommand}\``,
|
|
1128
1545
|
`- \`${devCommand}\``,
|
|
1546
|
+
`- \`${buildCommand}\``,
|
|
1547
|
+
`- \`${typecheckCommand}\``,
|
|
1129
1548
|
];
|
|
1130
1549
|
|
|
1131
1550
|
if (adapter.id === "node") {
|
|
@@ -1142,6 +1561,15 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1142
1561
|
);
|
|
1143
1562
|
}
|
|
1144
1563
|
|
|
1564
|
+
if (adapter.id === "netlify") {
|
|
1565
|
+
lines.push(`- \`${previewCommand}\``);
|
|
1566
|
+
lines.push(`- \`${deployCommand}\``);
|
|
1567
|
+
lines.push("");
|
|
1568
|
+
lines.push(
|
|
1569
|
+
"`netlify.toml` publishes `dist/client` and discovers the Pracht function generated during the build.",
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1145
1573
|
if (adapter.id === "vercel") {
|
|
1146
1574
|
lines.push(`- \`${deployCommand}\``);
|
|
1147
1575
|
lines.push("");
|
|
@@ -1156,13 +1584,31 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1156
1584
|
lines.push("- `src/pages/` contains your file-system routes.");
|
|
1157
1585
|
lines.push("- `src/pages/_app.tsx` is the app shell.");
|
|
1158
1586
|
lines.push("- `src/pages/index.tsx` is the home page.");
|
|
1587
|
+
lines.push("- `src/pages/404.tsx` is the not-found page; pracht wires it automatically.");
|
|
1588
|
+
lines.push("");
|
|
1589
|
+
lines.push("## Pages-router boundaries");
|
|
1590
|
+
lines.push("");
|
|
1591
|
+
lines.push(PAGES_ROUTER_LIMITATIONS);
|
|
1592
|
+
lines.push("");
|
|
1593
|
+
lines.push(PAGES_ROUTER_ISG_POLICY);
|
|
1159
1594
|
} else {
|
|
1160
1595
|
lines.push("- `src/routes.ts` defines your app manifest.");
|
|
1161
1596
|
lines.push("- `src/routes/home.tsx` is the first page.");
|
|
1597
|
+
lines.push("- `src/routes/not-found.tsx` is the not-found page, wired via `notFound`.");
|
|
1162
1598
|
}
|
|
1163
1599
|
|
|
1164
1600
|
lines.push("- `src/api/health.ts` is a sample API route.");
|
|
1165
1601
|
|
|
1602
|
+
if (packageManager === "pnpm") {
|
|
1603
|
+
lines.push(
|
|
1604
|
+
pnpmWorkspaceNotice
|
|
1605
|
+
? `- The containing pnpm workspace owns build-script policy. Add the listed dependencies to its \`${pnpmWorkspaceNotice.policy}\` block; no nested \`pnpm-workspace.yaml\` is generated.`
|
|
1606
|
+
: pnpmMajor <= 10
|
|
1607
|
+
? "- `pnpm-workspace.yaml#onlyBuiltDependencies` allows only the dependency build scripts required by this starter."
|
|
1608
|
+
: "- `pnpm-workspace.yaml#allowBuilds` allows only the dependency build scripts required by this starter.",
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1166
1612
|
if (tailwind) {
|
|
1167
1613
|
lines.push("- `src/styles/global.css` is the Tailwind CSS entry, imported by the shell.");
|
|
1168
1614
|
}
|
|
@@ -1274,13 +1720,36 @@ async function installDependencies(targetDir, packageManager) {
|
|
|
1274
1720
|
});
|
|
1275
1721
|
}
|
|
1276
1722
|
|
|
1277
|
-
function printNextSteps({
|
|
1723
|
+
function printNextSteps({
|
|
1724
|
+
adapter,
|
|
1725
|
+
agentTools,
|
|
1726
|
+
dir,
|
|
1727
|
+
installSucceeded,
|
|
1728
|
+
packageManager,
|
|
1729
|
+
pnpmWorkspaceNotice,
|
|
1730
|
+
router,
|
|
1731
|
+
skipInstall,
|
|
1732
|
+
tailwind,
|
|
1733
|
+
}) {
|
|
1278
1734
|
const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
|
|
1279
1735
|
const devCommand = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
|
|
1280
1736
|
|
|
1281
1737
|
console.log("");
|
|
1282
1738
|
console.log(`Created a pracht app in ${dir}.`);
|
|
1283
1739
|
console.log(`Adapter: ${adapter.label}`);
|
|
1740
|
+
console.log(
|
|
1741
|
+
`Router: ${router === "pages" ? "pages (file-system)" : "manifest (src/routes.ts)"}`,
|
|
1742
|
+
);
|
|
1743
|
+
console.log(`Tailwind: ${tailwind ? "yes" : "no"}`);
|
|
1744
|
+
console.log(`Agent tooling: ${agentTools ? "skills, .mcp.json, AGENTS.md" : "none"}`);
|
|
1745
|
+
if (router === "pages") {
|
|
1746
|
+
console.log("");
|
|
1747
|
+
console.log(
|
|
1748
|
+
"Note: the pages router has no manifest, so middleware, capabilities, constraints, and\n" +
|
|
1749
|
+
"the agent surface (capability endpoints, WebMCP, remote MCP, `pracht eval`) are not\n" +
|
|
1750
|
+
"available. Scaffold with --router=manifest if you need them.",
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1284
1753
|
console.log("");
|
|
1285
1754
|
console.log("Next steps:");
|
|
1286
1755
|
console.log(` cd ${dir}`);
|
|
@@ -1295,6 +1764,24 @@ function printNextSteps({ adapter, dir, installSucceeded, packageManager, skipIn
|
|
|
1295
1764
|
console.log("");
|
|
1296
1765
|
console.log("Dependency installation did not complete. The project files were still created.");
|
|
1297
1766
|
}
|
|
1767
|
+
|
|
1768
|
+
if (pnpmWorkspaceNotice) {
|
|
1769
|
+
console.log("");
|
|
1770
|
+
console.log(
|
|
1771
|
+
`This app is inside the pnpm workspace at ${pnpmWorkspaceNotice.root}, which owns build\n` +
|
|
1772
|
+
"permissions for every package. Add the following to its pnpm-workspace.yaml, or\n" +
|
|
1773
|
+
"the starter's required dependency install scripts will not run:",
|
|
1774
|
+
);
|
|
1775
|
+
console.log("");
|
|
1776
|
+
console.log(` ${pnpmWorkspaceNotice.policy}:`);
|
|
1777
|
+
for (const name of pnpmWorkspaceNotice.packages) {
|
|
1778
|
+
console.log(
|
|
1779
|
+
pnpmWorkspaceNotice.policy === "onlyBuiltDependencies"
|
|
1780
|
+
? ` - ${JSON.stringify(name)}`
|
|
1781
|
+
: ` ${JSON.stringify(name)}: true`,
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1298
1785
|
}
|
|
1299
1786
|
|
|
1300
1787
|
function printHelp() {
|
|
@@ -1304,10 +1791,12 @@ Usage:
|
|
|
1304
1791
|
create-pracht [directory] [options]
|
|
1305
1792
|
|
|
1306
1793
|
Options:
|
|
1307
|
-
--adapter=node|cf|vercel
|
|
1794
|
+
--adapter=node|cf|netlify|vercel
|
|
1795
|
+
Choose hosting adapter (default: node)
|
|
1308
1796
|
--router=manifest|pages Choose routing system (default: manifest)
|
|
1309
1797
|
--template=minimal|tailwind Choose starter template (minimal, or minimal + Tailwind CSS)
|
|
1310
|
-
--tailwind / --no-tailwind Enable or disable Tailwind CSS wiring (default: prompt)
|
|
1798
|
+
--tailwind / --no-tailwind Enable or disable Tailwind CSS wiring (default: prompt).
|
|
1799
|
+
Sets the same thing as --template; the last one wins.
|
|
1311
1800
|
--agent-tools / --no-agent-tools
|
|
1312
1801
|
Seed Claude Code skills and a pracht MCP config (default: prompt, yes)
|
|
1313
1802
|
--no-git Skip git init and the initial commit
|