create-pracht 0.4.1 → 0.5.0

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