create-pracht 0.4.2 → 0.6.0

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