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/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.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",
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 packageManager = getPackageManager();
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(" 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)");
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({ adapter, projectName, tailwind, versions }),
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
- 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 };
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
- 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"],
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
- " // Custom 404 page any module in ./routes, rendered when nothing matches:",
765
- ' // 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
+ " },",
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 = new Date().toISOString().slice(0, 10);
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
- ' "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",',
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
- 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;
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
- lines.push("- `pracht generate shell --name app` — add a shell");
1057
- 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
+ }
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({ 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
+ }) {
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({ 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
+ }) {
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 Choose hosting adapter (default: node)
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