create-pracht 0.4.2 → 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 +19 -4
- package/package.json +1 -1
- package/skills/audit-auth/SKILL.md +32 -2
- 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 +18 -6
- package/skills/scaffold-tests/SKILL.md +34 -50
- package/skills/tune-render-mode/SKILL.md +4 -1
- package/src/index.js +516 -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,18 +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",
|
|
24
26
|
typescript: "^6.0.0",
|
|
25
27
|
vercel: "^56.5.0",
|
|
26
28
|
};
|
|
27
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
|
+
|
|
28
52
|
async function fetchLatestVersion(packageName) {
|
|
29
53
|
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
30
54
|
if (!res.ok) {
|
|
@@ -49,6 +73,13 @@ const ADAPTERS = {
|
|
|
49
73
|
packageName: "@pracht/adapter-cloudflare",
|
|
50
74
|
short: "cf",
|
|
51
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
|
+
},
|
|
52
83
|
vercel: {
|
|
53
84
|
description: "Vercel Edge Functions with prebuilt deploy",
|
|
54
85
|
id: "vercel",
|
|
@@ -60,6 +91,10 @@ const ADAPTERS = {
|
|
|
60
91
|
|
|
61
92
|
const DEFAULT_DIRECTORY = "pracht-app";
|
|
62
93
|
|
|
94
|
+
function readFileSyncSafe(path) {
|
|
95
|
+
return readFileSync(path, "utf-8");
|
|
96
|
+
}
|
|
97
|
+
|
|
63
98
|
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
64
99
|
|
|
65
100
|
// The published package bundles a copy of the repo skills (see
|
|
@@ -68,7 +103,9 @@ const SKILL_DIRS = [resolve(PACKAGE_ROOT, "skills"), resolve(PACKAGE_ROOT, "../.
|
|
|
68
103
|
|
|
69
104
|
export async function run(argv = process.argv.slice(2)) {
|
|
70
105
|
const options = parseArgs(argv);
|
|
71
|
-
const
|
|
106
|
+
const packageManagerUserAgent = process.env.npm_config_user_agent ?? "";
|
|
107
|
+
const packageManager = getPackageManager(packageManagerUserAgent);
|
|
108
|
+
const pnpmMajor = packageManager === "pnpm" ? getPnpmMajor(packageManagerUserAgent) : null;
|
|
72
109
|
const log = options.json ? () => {} : console.log.bind(console);
|
|
73
110
|
|
|
74
111
|
log("create-pracht");
|
|
@@ -115,14 +152,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
115
152
|
await ensureTargetDirectory(targetDir);
|
|
116
153
|
|
|
117
154
|
if (options.dryRun) {
|
|
118
|
-
const files = await buildProjectFiles({
|
|
155
|
+
const { files } = await buildProjectFiles({
|
|
119
156
|
adapter: ADAPTERS[resolvedAdapter],
|
|
120
157
|
agentTools: resolvedAgentTools,
|
|
121
158
|
packageManager,
|
|
159
|
+
pnpmMajor,
|
|
122
160
|
projectName: toPackageName(basename(targetDir)),
|
|
123
161
|
resolveRemoteVersions: false,
|
|
124
162
|
router: resolvedRouter,
|
|
125
163
|
tailwind: resolvedTailwind,
|
|
164
|
+
targetDir,
|
|
126
165
|
});
|
|
127
166
|
|
|
128
167
|
const fileList = Object.keys(files).sort();
|
|
@@ -150,10 +189,11 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
150
189
|
return;
|
|
151
190
|
}
|
|
152
191
|
|
|
153
|
-
await scaffoldProject({
|
|
192
|
+
const { pnpmWorkspaceNotice } = await scaffoldProject({
|
|
154
193
|
adapter: ADAPTERS[resolvedAdapter],
|
|
155
194
|
agentTools: resolvedAgentTools,
|
|
156
195
|
packageManager,
|
|
196
|
+
pnpmMajor,
|
|
157
197
|
router: resolvedRouter,
|
|
158
198
|
tailwind: resolvedTailwind,
|
|
159
199
|
targetDir,
|
|
@@ -184,14 +224,16 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
184
224
|
}
|
|
185
225
|
|
|
186
226
|
if (options.json) {
|
|
187
|
-
const files = await buildProjectFiles({
|
|
227
|
+
const { files } = await buildProjectFiles({
|
|
188
228
|
adapter: ADAPTERS[resolvedAdapter],
|
|
189
229
|
agentTools: resolvedAgentTools,
|
|
190
230
|
packageManager,
|
|
231
|
+
pnpmMajor,
|
|
191
232
|
projectName: toPackageName(basename(targetDir)),
|
|
192
233
|
resolveRemoteVersions: false,
|
|
193
234
|
router: resolvedRouter,
|
|
194
235
|
tailwind: resolvedTailwind,
|
|
236
|
+
targetDir,
|
|
195
237
|
});
|
|
196
238
|
|
|
197
239
|
console.log(
|
|
@@ -202,6 +244,10 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
202
244
|
files: Object.keys(files).sort(),
|
|
203
245
|
gitInitialized,
|
|
204
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,
|
|
205
251
|
router: resolvedRouter,
|
|
206
252
|
tailwind: resolvedTailwind,
|
|
207
253
|
}),
|
|
@@ -209,10 +255,14 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
209
255
|
} else {
|
|
210
256
|
printNextSteps({
|
|
211
257
|
adapter: ADAPTERS[resolvedAdapter],
|
|
258
|
+
agentTools: resolvedAgentTools,
|
|
212
259
|
dir: resolvedDir,
|
|
213
260
|
installSucceeded,
|
|
214
261
|
packageManager,
|
|
262
|
+
pnpmWorkspaceNotice,
|
|
263
|
+
router: resolvedRouter,
|
|
215
264
|
skipInstall: options.skipInstall,
|
|
265
|
+
tailwind: resolvedTailwind,
|
|
216
266
|
});
|
|
217
267
|
}
|
|
218
268
|
}
|
|
@@ -221,30 +271,42 @@ export async function scaffoldProject({
|
|
|
221
271
|
adapter,
|
|
222
272
|
agentTools = true,
|
|
223
273
|
packageManager,
|
|
274
|
+
pnpmMajor = 11,
|
|
224
275
|
resolveRemoteVersions = true,
|
|
225
276
|
router = "manifest",
|
|
226
277
|
tailwind = false,
|
|
227
278
|
targetDir,
|
|
228
279
|
}) {
|
|
229
280
|
const packageName = toPackageName(basename(targetDir));
|
|
230
|
-
const files = await buildProjectFiles({
|
|
281
|
+
const { files, pnpmWorkspaceNotice } = await buildProjectFiles({
|
|
231
282
|
adapter,
|
|
232
283
|
agentTools,
|
|
233
284
|
packageManager,
|
|
285
|
+
pnpmMajor,
|
|
234
286
|
projectName: packageName,
|
|
235
287
|
resolveRemoteVersions,
|
|
236
288
|
router,
|
|
237
289
|
tailwind,
|
|
290
|
+
targetDir,
|
|
238
291
|
});
|
|
239
292
|
|
|
240
293
|
await mkdir(targetDir, { recursive: true });
|
|
241
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.
|
|
242
299
|
for (const [relativePath, content] of Object.entries(files)) {
|
|
243
300
|
const filePath = resolve(targetDir, relativePath);
|
|
244
301
|
await mkdir(dirname(filePath), { recursive: true });
|
|
245
302
|
await writeFile(filePath, content, "utf-8");
|
|
246
303
|
}
|
|
247
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
|
+
|
|
248
310
|
try {
|
|
249
311
|
await symlink("AGENTS.md", resolve(targetDir, "CLAUDE.md"));
|
|
250
312
|
} catch (error) {
|
|
@@ -254,6 +316,8 @@ export async function scaffoldProject({
|
|
|
254
316
|
throw error;
|
|
255
317
|
}
|
|
256
318
|
}
|
|
319
|
+
|
|
320
|
+
return { pnpmWorkspaceNotice };
|
|
257
321
|
}
|
|
258
322
|
|
|
259
323
|
export function getPackageManager(userAgent = process.env.npm_config_user_agent ?? "") {
|
|
@@ -263,6 +327,11 @@ export function getPackageManager(userAgent = process.env.npm_config_user_agent
|
|
|
263
327
|
return "npm";
|
|
264
328
|
}
|
|
265
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
|
+
|
|
266
335
|
export function parseArgs(argv) {
|
|
267
336
|
const options = {
|
|
268
337
|
adapter: undefined,
|
|
@@ -338,7 +407,7 @@ export function parseArgs(argv) {
|
|
|
338
407
|
const value = normalizeAdapter(arg.slice("--adapter=".length));
|
|
339
408
|
if (!value) {
|
|
340
409
|
throw new ValidationError(
|
|
341
|
-
`Invalid adapter: ${arg.slice("--adapter=".length)}. Use node, cf, or vercel.`,
|
|
410
|
+
`Invalid adapter: ${arg.slice("--adapter=".length)}. Use node, cf, netlify, or vercel.`,
|
|
342
411
|
);
|
|
343
412
|
}
|
|
344
413
|
options.adapter = value;
|
|
@@ -392,6 +461,7 @@ async function promptForAdapter(readline) {
|
|
|
392
461
|
console.log(" 1. Node.js");
|
|
393
462
|
console.log(" 2. Cloudflare Workers");
|
|
394
463
|
console.log(" 3. Vercel");
|
|
464
|
+
console.log(" 4. Netlify");
|
|
395
465
|
|
|
396
466
|
while (true) {
|
|
397
467
|
const answer = await readline.question("Adapter (1): ");
|
|
@@ -401,14 +471,18 @@ async function promptForAdapter(readline) {
|
|
|
401
471
|
return normalized;
|
|
402
472
|
}
|
|
403
473
|
|
|
404
|
-
console.log("Choose 1/2/3 or node/cf/vercel.");
|
|
474
|
+
console.log("Choose 1/2/3/4 or node/cf/vercel/netlify.");
|
|
405
475
|
}
|
|
406
476
|
}
|
|
407
477
|
|
|
408
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.
|
|
409
481
|
console.log("Router:");
|
|
410
|
-
console.log(" 1. Manifest (explicit routes.ts)");
|
|
411
|
-
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)");
|
|
412
486
|
|
|
413
487
|
while (true) {
|
|
414
488
|
const answer = await readline.question("Router (1): ");
|
|
@@ -536,6 +610,10 @@ function normalizeAdapter(value) {
|
|
|
536
610
|
return "vercel";
|
|
537
611
|
}
|
|
538
612
|
|
|
613
|
+
if (normalized === "4" || normalized === "nf" || normalized === "netlify") {
|
|
614
|
+
return "netlify";
|
|
615
|
+
}
|
|
616
|
+
|
|
539
617
|
return null;
|
|
540
618
|
}
|
|
541
619
|
|
|
@@ -558,10 +636,12 @@ async function buildProjectFiles({
|
|
|
558
636
|
adapter,
|
|
559
637
|
agentTools = true,
|
|
560
638
|
packageManager,
|
|
639
|
+
pnpmMajor = 11,
|
|
561
640
|
projectName,
|
|
562
641
|
resolveRemoteVersions = true,
|
|
563
642
|
router,
|
|
564
643
|
tailwind = false,
|
|
644
|
+
targetDir,
|
|
565
645
|
}) {
|
|
566
646
|
const packagesToResolve = [
|
|
567
647
|
"@pracht/cli",
|
|
@@ -573,36 +653,66 @@ async function buildProjectFiles({
|
|
|
573
653
|
if (adapter.id === "vercel") {
|
|
574
654
|
packagesToResolve.push("vercel");
|
|
575
655
|
}
|
|
656
|
+
if (adapter.id === "netlify") {
|
|
657
|
+
packagesToResolve.push("netlify-cli");
|
|
658
|
+
}
|
|
576
659
|
if (tailwind) {
|
|
577
660
|
packagesToResolve.push("tailwindcss", "@tailwindcss/vite");
|
|
578
661
|
}
|
|
579
662
|
|
|
580
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;
|
|
581
673
|
|
|
582
674
|
const files = {
|
|
583
675
|
".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",
|
|
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",
|
|
585
677
|
"README.md": createReadme({
|
|
586
678
|
adapter,
|
|
587
679
|
agentTools,
|
|
588
680
|
packageManager,
|
|
681
|
+
pnpmMajor,
|
|
682
|
+
pnpmWorkspaceNotice,
|
|
589
683
|
projectName,
|
|
590
684
|
router,
|
|
591
685
|
tailwind,
|
|
592
686
|
}),
|
|
593
|
-
"package.json": createPackageJson({
|
|
687
|
+
"package.json": createPackageJson({
|
|
688
|
+
adapter,
|
|
689
|
+
projectName,
|
|
690
|
+
tailwind,
|
|
691
|
+
versions,
|
|
692
|
+
}),
|
|
594
693
|
"src/api/health.ts": createHealthRoute(adapter),
|
|
595
694
|
"vite.config.ts": createViteConfig(adapter, router, tailwind),
|
|
596
695
|
"tsconfig.json": createBaseTSConfig(adapter),
|
|
597
|
-
"AGENTS.md": createAgentInstructions({ adapter, agentTools, packageManager, router, tailwind }),
|
|
598
696
|
};
|
|
599
697
|
|
|
698
|
+
if (agentTools) {
|
|
699
|
+
files["AGENTS.md"] = createAgentInstructions({
|
|
700
|
+
adapter,
|
|
701
|
+
agentTools,
|
|
702
|
+
packageManager,
|
|
703
|
+
router,
|
|
704
|
+
tailwind,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
600
708
|
if (router === "pages") {
|
|
601
709
|
files["src/pages/_app.tsx"] = createShellFile(projectName, tailwind);
|
|
602
710
|
files["src/pages/index.tsx"] = createPagesHomeRoute(adapter);
|
|
711
|
+
files["src/pages/404.tsx"] = createNotFoundRoute();
|
|
603
712
|
} else {
|
|
604
713
|
files["src/routes.ts"] = createRoutesFile();
|
|
605
714
|
files["src/routes/home.tsx"] = createHomeRoute(adapter);
|
|
715
|
+
files["src/routes/not-found.tsx"] = createNotFoundRoute();
|
|
606
716
|
files["src/shells/public.tsx"] = createShellFile(projectName, tailwind);
|
|
607
717
|
}
|
|
608
718
|
|
|
@@ -615,6 +725,10 @@ async function buildProjectFiles({
|
|
|
615
725
|
files["src/env.d.ts"] = createCloudflareEnvDeclaration();
|
|
616
726
|
}
|
|
617
727
|
|
|
728
|
+
if (adapter.id === "netlify") {
|
|
729
|
+
files["netlify.toml"] = createNetlifyConfig(packageManager);
|
|
730
|
+
}
|
|
731
|
+
|
|
618
732
|
if (adapter.id === "node") {
|
|
619
733
|
files["Dockerfile"] = createDockerfile(packageManager);
|
|
620
734
|
files[".dockerignore"] = createDockerignore();
|
|
@@ -625,7 +739,16 @@ async function buildProjectFiles({
|
|
|
625
739
|
Object.assign(files, await readSkillFiles());
|
|
626
740
|
}
|
|
627
741
|
|
|
628
|
-
|
|
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 };
|
|
629
752
|
}
|
|
630
753
|
|
|
631
754
|
function createMcpConfig() {
|
|
@@ -634,7 +757,13 @@ function createMcpConfig() {
|
|
|
634
757
|
mcpServers: {
|
|
635
758
|
pracht: {
|
|
636
759
|
command: "npx",
|
|
637
|
-
|
|
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"],
|
|
638
767
|
},
|
|
639
768
|
},
|
|
640
769
|
},
|
|
@@ -689,6 +818,12 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
|
|
|
689
818
|
devDependencies.wrangler = "^4.81.0";
|
|
690
819
|
}
|
|
691
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
|
+
|
|
692
827
|
if (adapter.id === "vercel") {
|
|
693
828
|
scripts.deploy = "pracht build && vercel deploy --prebuilt";
|
|
694
829
|
devDependencies.vercel = versions["vercel"];
|
|
@@ -721,6 +856,7 @@ function createViteConfig(adapter, router, tailwind) {
|
|
|
721
856
|
const ADAPTER_IMPORTS = {
|
|
722
857
|
node: { fn: "nodeAdapter", pkg: "@pracht/adapter-node" },
|
|
723
858
|
cloudflare: { fn: "cloudflareAdapter", pkg: "@pracht/adapter-cloudflare" },
|
|
859
|
+
netlify: { fn: "netlifyAdapter", pkg: "@pracht/adapter-netlify" },
|
|
724
860
|
vercel: { fn: "vercelAdapter", pkg: "@pracht/adapter-vercel" },
|
|
725
861
|
};
|
|
726
862
|
|
|
@@ -761,8 +897,12 @@ function createRoutesFile() {
|
|
|
761
897
|
" routes: [",
|
|
762
898
|
' route("/", "./routes/home.tsx", { id: "home", render: "ssg", shell: "public" }),',
|
|
763
899
|
" ],",
|
|
764
|
-
" //
|
|
765
|
-
|
|
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
|
+
" },",
|
|
766
906
|
" // Declarative invariants enforced by `pracht verify` — uncomment to use",
|
|
767
907
|
" // (add the helpers to the @pracht/core import):",
|
|
768
908
|
" // constraints: [",
|
|
@@ -843,6 +983,33 @@ function createHomeRoute(adapter) {
|
|
|
843
983
|
].join("\n");
|
|
844
984
|
}
|
|
845
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
|
+
|
|
846
1013
|
function createPagesHomeRoute(adapter) {
|
|
847
1014
|
return [
|
|
848
1015
|
'import type { LoaderArgs, RouteComponentProps } from "@pracht/core";',
|
|
@@ -916,18 +1083,200 @@ function createHealthRoute(adapter) {
|
|
|
916
1083
|
].join("\n");
|
|
917
1084
|
}
|
|
918
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
|
+
|
|
919
1258
|
function createWranglerConfig(projectName) {
|
|
920
|
-
const compatibilityDate =
|
|
1259
|
+
const compatibilityDate = WRANGLER_COMPATIBILITY_DATE;
|
|
921
1260
|
|
|
922
1261
|
return [
|
|
923
1262
|
"{",
|
|
924
1263
|
' "$schema": "node_modules/wrangler/config-schema.json",',
|
|
925
1264
|
` "name": ${JSON.stringify(projectName)},`,
|
|
926
|
-
|
|
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",',
|
|
927
1270
|
` "compatibility_date": ${JSON.stringify(compatibilityDate)},`,
|
|
928
1271
|
' "assets": {',
|
|
929
1272
|
' "binding": "ASSETS",',
|
|
930
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",',
|
|
931
1280
|
' "run_worker_first": true',
|
|
932
1281
|
" }",
|
|
933
1282
|
"}",
|
|
@@ -935,6 +1284,23 @@ function createWranglerConfig(projectName) {
|
|
|
935
1284
|
].join("\n");
|
|
936
1285
|
}
|
|
937
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
|
+
|
|
938
1304
|
function createCloudflareEnvDeclaration() {
|
|
939
1305
|
return [
|
|
940
1306
|
'import "@pracht/core";',
|
|
@@ -1023,8 +1389,17 @@ function createDockerignore() {
|
|
|
1023
1389
|
].join("\n");
|
|
1024
1390
|
}
|
|
1025
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
|
+
|
|
1026
1398
|
function createAgentInstructions({ adapter, agentTools, packageManager, router, tailwind }) {
|
|
1027
|
-
|
|
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;
|
|
1028
1403
|
|
|
1029
1404
|
const lines = [
|
|
1030
1405
|
"# Pracht App",
|
|
@@ -1035,7 +1410,7 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1035
1410
|
`- \`${runCmd} build\` — production build`,
|
|
1036
1411
|
];
|
|
1037
1412
|
|
|
1038
|
-
if (adapter.id === "node" || adapter.id === "cloudflare") {
|
|
1413
|
+
if (adapter.id === "node" || adapter.id === "cloudflare" || adapter.id === "netlify") {
|
|
1039
1414
|
lines.push(`- \`${runCmd} preview\` — build and serve the production build locally`);
|
|
1040
1415
|
}
|
|
1041
1416
|
|
|
@@ -1043,7 +1418,7 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1043
1418
|
lines.push(`- \`${runCmd} start\` — run the built server`);
|
|
1044
1419
|
}
|
|
1045
1420
|
|
|
1046
|
-
if (adapter.id === "cloudflare" || adapter.id === "vercel") {
|
|
1421
|
+
if (adapter.id === "cloudflare" || adapter.id === "netlify" || adapter.id === "vercel") {
|
|
1047
1422
|
lines.push(`- \`${runCmd} deploy\` — build and deploy`);
|
|
1048
1423
|
}
|
|
1049
1424
|
|
|
@@ -1053,9 +1428,16 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1053
1428
|
lines.push("Use the CLI to generate new files:");
|
|
1054
1429
|
lines.push("");
|
|
1055
1430
|
lines.push("- `pracht generate route --path /about` — add a route");
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
+
}
|
|
1058
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
|
+
}
|
|
1059
1441
|
lines.push("- `pracht doctor` — check project health");
|
|
1060
1442
|
lines.push("- `pracht verify` — enforce route and constraint invariants");
|
|
1061
1443
|
lines.push(
|
|
@@ -1073,11 +1455,21 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1073
1455
|
lines.push("");
|
|
1074
1456
|
lines.push("- `src/pages/` — file-system routes (each file becomes a route)");
|
|
1075
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);
|
|
1076
1465
|
} else {
|
|
1077
1466
|
lines.push("This app uses **manifest routing**.");
|
|
1078
1467
|
lines.push("");
|
|
1079
1468
|
lines.push("- `src/routes.ts` — route manifest (defines all routes and shells)");
|
|
1080
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
|
+
);
|
|
1081
1473
|
lines.push("- `src/shells/` — shell components (layouts)");
|
|
1082
1474
|
}
|
|
1083
1475
|
|
|
@@ -1097,6 +1489,10 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1097
1489
|
lines.push("- `src/env.d.ts` — TypeScript types for Cloudflare bindings");
|
|
1098
1490
|
}
|
|
1099
1491
|
|
|
1492
|
+
if (adapter.id === "netlify") {
|
|
1493
|
+
lines.push("- `netlify.toml` — Netlify build, publish, and functions configuration");
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1100
1496
|
if (agentTools) {
|
|
1101
1497
|
lines.push("");
|
|
1102
1498
|
lines.push("## Agent tooling");
|
|
@@ -1114,9 +1510,24 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
|
|
|
1114
1510
|
return lines.join("\n");
|
|
1115
1511
|
}
|
|
1116
1512
|
|
|
1117
|
-
function createReadme({
|
|
1513
|
+
function createReadme({
|
|
1514
|
+
adapter,
|
|
1515
|
+
agentTools,
|
|
1516
|
+
packageManager,
|
|
1517
|
+
pnpmMajor,
|
|
1518
|
+
pnpmWorkspaceNotice,
|
|
1519
|
+
projectName,
|
|
1520
|
+
router,
|
|
1521
|
+
tailwind,
|
|
1522
|
+
}) {
|
|
1118
1523
|
const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
|
|
1119
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`;
|
|
1120
1531
|
const previewCommand = packageManager === "npm" ? "npm run preview" : `${packageManager} preview`;
|
|
1121
1532
|
const startCommand = packageManager === "npm" ? "npm run start" : `${packageManager} start`;
|
|
1122
1533
|
const deployCommand = packageManager === "npm" ? "npm run deploy" : `${packageManager} deploy`;
|
|
@@ -1132,6 +1543,7 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1132
1543
|
"",
|
|
1133
1544
|
`- \`${installCommand}\``,
|
|
1134
1545
|
`- \`${devCommand}\``,
|
|
1546
|
+
`- \`${buildCommand}\``,
|
|
1135
1547
|
`- \`${typecheckCommand}\``,
|
|
1136
1548
|
];
|
|
1137
1549
|
|
|
@@ -1149,6 +1561,15 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1149
1561
|
);
|
|
1150
1562
|
}
|
|
1151
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
|
+
|
|
1152
1573
|
if (adapter.id === "vercel") {
|
|
1153
1574
|
lines.push(`- \`${deployCommand}\``);
|
|
1154
1575
|
lines.push("");
|
|
@@ -1163,13 +1584,31 @@ function createReadme({ adapter, agentTools, packageManager, projectName, router
|
|
|
1163
1584
|
lines.push("- `src/pages/` contains your file-system routes.");
|
|
1164
1585
|
lines.push("- `src/pages/_app.tsx` is the app shell.");
|
|
1165
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);
|
|
1166
1594
|
} else {
|
|
1167
1595
|
lines.push("- `src/routes.ts` defines your app manifest.");
|
|
1168
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`.");
|
|
1169
1598
|
}
|
|
1170
1599
|
|
|
1171
1600
|
lines.push("- `src/api/health.ts` is a sample API route.");
|
|
1172
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
|
+
|
|
1173
1612
|
if (tailwind) {
|
|
1174
1613
|
lines.push("- `src/styles/global.css` is the Tailwind CSS entry, imported by the shell.");
|
|
1175
1614
|
}
|
|
@@ -1281,13 +1720,36 @@ async function installDependencies(targetDir, packageManager) {
|
|
|
1281
1720
|
});
|
|
1282
1721
|
}
|
|
1283
1722
|
|
|
1284
|
-
function printNextSteps({
|
|
1723
|
+
function printNextSteps({
|
|
1724
|
+
adapter,
|
|
1725
|
+
agentTools,
|
|
1726
|
+
dir,
|
|
1727
|
+
installSucceeded,
|
|
1728
|
+
packageManager,
|
|
1729
|
+
pnpmWorkspaceNotice,
|
|
1730
|
+
router,
|
|
1731
|
+
skipInstall,
|
|
1732
|
+
tailwind,
|
|
1733
|
+
}) {
|
|
1285
1734
|
const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
|
|
1286
1735
|
const devCommand = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
|
|
1287
1736
|
|
|
1288
1737
|
console.log("");
|
|
1289
1738
|
console.log(`Created a pracht app in ${dir}.`);
|
|
1290
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
|
+
}
|
|
1291
1753
|
console.log("");
|
|
1292
1754
|
console.log("Next steps:");
|
|
1293
1755
|
console.log(` cd ${dir}`);
|
|
@@ -1302,6 +1764,24 @@ function printNextSteps({ adapter, dir, installSucceeded, packageManager, skipIn
|
|
|
1302
1764
|
console.log("");
|
|
1303
1765
|
console.log("Dependency installation did not complete. The project files were still created.");
|
|
1304
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
|
+
}
|
|
1305
1785
|
}
|
|
1306
1786
|
|
|
1307
1787
|
function printHelp() {
|
|
@@ -1311,10 +1791,12 @@ Usage:
|
|
|
1311
1791
|
create-pracht [directory] [options]
|
|
1312
1792
|
|
|
1313
1793
|
Options:
|
|
1314
|
-
--adapter=node|cf|vercel
|
|
1794
|
+
--adapter=node|cf|netlify|vercel
|
|
1795
|
+
Choose hosting adapter (default: node)
|
|
1315
1796
|
--router=manifest|pages Choose routing system (default: manifest)
|
|
1316
1797
|
--template=minimal|tailwind Choose starter template (minimal, or minimal + Tailwind CSS)
|
|
1317
|
-
--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.
|
|
1318
1800
|
--agent-tools / --no-agent-tools
|
|
1319
1801
|
Seed Claude Code skills and a pracht MCP config (default: prompt, yes)
|
|
1320
1802
|
--no-git Skip git init and the initial commit
|