create-pracht 0.2.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,12 @@ npm run dev
16
16
  - Prompts for the target folder.
17
17
  - Detects the active package manager from the current environment.
18
18
  - Lets the user choose between the Node.js, Cloudflare, and Vercel adapters.
19
+ - Optionally wires up Tailwind CSS (`tailwindcss` + `@tailwindcss/vite`, a global stylesheet, and the shell import).
19
20
  - Scaffolds a minimal app with a route manifest or pages router, shell, home route, sample API route, runnable project README, and agent instructions.
21
+ - Manifest scaffolds include a commented-out `constraints` example in `src/routes.ts`, ready for `pracht verify`.
22
+ - The generated `.gitignore` keeps `.pracht/app-graph.json` committable, and the README and agent instructions cover the `pracht verify` / `pracht plan` / `pracht report` loop.
23
+ - Seeds the pracht Claude Code skills into `.claude/skills/` and writes a `.mcp.json` registering the `pracht mcp` server (yes-default prompt; skipped with `--no-agent-tools`).
24
+ - Initializes a git repository with an initial commit (skipped with `--no-git`, when git is unavailable, or when the target is already inside a repository).
20
25
  - `--dry-run` uses pinned fallback versions and does not require npm registry access.
21
26
 
22
27
  ## Usage
@@ -25,8 +30,23 @@ npm run dev
25
30
  node ./packages/start/bin/create-pracht.js
26
31
  node ./packages/start/bin/create-pracht.js my-app --adapter=node --skip-install
27
32
  node ./packages/start/bin/create-pracht.js my-app --adapter=vercel --skip-install
33
+ node ./packages/start/bin/create-pracht.js my-app --template=tailwind --yes
34
+ node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind --no-git --yes
28
35
  ```
29
36
 
37
+ ## Options
38
+
39
+ - `--adapter=node|cf|vercel` — choose the hosting adapter (default: node).
40
+ - `--router=manifest|pages` — choose the routing system (default: manifest).
41
+ - `--template=minimal|tailwind` — non-interactive template selection; `minimal` is the default output, `tailwind` is minimal plus Tailwind CSS wiring.
42
+ - `--tailwind` / `--no-tailwind` — enable or disable Tailwind CSS without going through the prompt.
43
+ - `--agent-tools` / `--no-agent-tools` — seed the Claude Code skills and `.mcp.json` (or skip them) without going through the prompt.
44
+ - `--no-git` — skip `git init` and the initial commit.
45
+ - `--skip-install` — skip dependency installation.
46
+ - `--yes`, `-y` — accept defaults (node adapter, manifest router, no Tailwind, agent tooling on) and skip all prompts.
47
+ - `--json` — output a JSON summary instead of prose.
48
+ - `--dry-run` — list the files that would be created without writing anything.
49
+
30
50
  ## Generated Files
31
51
 
32
52
  - `package.json`
@@ -35,6 +55,22 @@ node ./packages/start/bin/create-pracht.js my-app --adapter=vercel --skip-instal
35
55
  - `src/routes/home.tsx`
36
56
  - `src/shells/public.tsx`
37
57
  - `src/api/health.ts`
58
+ - `.gitignore`
59
+ - `.claude/skills/<name>/SKILL.md` — the pracht agent skills (unless `--no-agent-tools`)
60
+ - `.mcp.json` — registers the `pracht mcp` server for MCP clients (unless `--no-agent-tools`)
61
+
62
+ The skills are copied from the repo's [skills/](../../skills/README.md) directory into this
63
+ package at build/publish time (`scripts/sync-skills.js`), so the published npm tarball is
64
+ self-contained.
65
+
66
+ Node scaffolds also include:
67
+
68
+ - `Dockerfile` — multi-stage build (install → build → slim runtime) that runs `node dist/server/server.js`
69
+ - `.dockerignore`
70
+
71
+ Tailwind scaffolds also include:
72
+
73
+ - `src/styles/global.css` — the Tailwind entry stylesheet, imported by the shell
38
74
 
39
75
  Cloudflare scaffolds also include:
40
76
 
@@ -47,10 +83,12 @@ Cloudflare scaffolds also include:
47
83
 
48
84
  Node starters also include:
49
85
 
86
+ - `preview` -> `pracht preview`
50
87
  - `start` -> `node dist/server/server.js`
51
88
 
52
89
  Cloudflare starters also include:
53
90
 
91
+ - `preview` -> `pracht preview`
54
92
  - `deploy` -> `pracht build && wrangler deploy`
55
93
 
56
94
  Vercel starters also include:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pracht",
3
- "version": "0.2.6",
3
+ "version": "0.4.0",
4
4
  "description": "Interactive and scriptable starter CLI for creating full-stack Preact apps with Pracht.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -29,6 +29,7 @@
29
29
  "files": [
30
30
  "bin",
31
31
  "src",
32
+ "skills",
32
33
  "README.md"
33
34
  ],
34
35
  "type": "module",
@@ -36,6 +37,6 @@
36
37
  "provenance": true
37
38
  },
38
39
  "scripts": {
39
- "build": "node -e \"\""
40
+ "build": "node ./scripts/sync-skills.js"
40
41
  }
41
42
  }
@@ -0,0 +1,346 @@
1
+ ---
2
+ name: add-auth
3
+ version: 1.1.0
4
+ description: |
5
+ Drop session-based auth into a pracht app following the framework's
6
+ recommended pattern (middleware checks the session, loaders read user info,
7
+ API routes mutate it). Generates session utilities, the auth middleware,
8
+ login/logout/signup API routes, and the matching `<Form>`-driven pages —
9
+ then wires the manifest with public vs. protected groups.
10
+ Use when asked to "add auth", "set up login", "wire authentication",
11
+ "add session middleware", or "I need users".
12
+ allowed-tools:
13
+ - Bash
14
+ - Read
15
+ - Write
16
+ - Edit
17
+ - Grep
18
+ - Glob
19
+ - AskUserQuestion
20
+ ---
21
+
22
+ # Pracht Add Auth
23
+
24
+ Implements the auth pattern documented in
25
+ `examples/docs/src/routes/docs/recipes-auth.md`. This skill stamps out the
26
+ files; the user replaces `verifyCredentials()` with a real DB lookup.
27
+
28
+ If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
29
+ (`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`,
30
+ `generate_*`) over shelling out. Prerequisite: `pracht inspect` needs a vite
31
+ config with the pracht plugin registered.
32
+
33
+ ## Step 1: Confirm the scope
34
+
35
+ Use `AskUserQuestion`:
36
+
37
+ 1. **What flavor?** Session cookie + email/password (default) OR magic link
38
+ OR OAuth (out of scope — recommend a separate skill / library).
39
+ 2. **Where do credentials live?** A DB the user already has, or no DB yet?
40
+ If no DB, recommend running `add-db` first.
41
+ 3. **Cookie posture for CSRF**: `SameSite=Lax` (default, recommended) vs.
42
+ `SameSite=Strict` vs. `SameSite=None` + token. (Cross-link `audit-csrf`.)
43
+
44
+ This skill defaults to: session cookie + email/password + `SameSite=Lax`.
45
+
46
+ ## Step 2: Session utilities
47
+
48
+ `src/server/session.ts`:
49
+
50
+ ```ts
51
+ import { serverEnv } from "@pracht/core/env/server";
52
+
53
+ export interface Session {
54
+ userId: string;
55
+ email: string;
56
+ }
57
+
58
+ export async function getSession(request: Request): Promise<Session | null> {
59
+ const cookie = request.headers.get("cookie") ?? "";
60
+ const match = cookie.match(/session=([^;]+)/);
61
+ if (!match) return null;
62
+ try {
63
+ const [payload, signature] = match[1].split(".");
64
+ if (!payload || !signature) return null;
65
+ if (!(await verify(payload, signature))) return null;
66
+ return JSON.parse(atob(payload));
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ export async function createSessionCookie(session: Session): Promise<string> {
73
+ const payload = btoa(JSON.stringify(session));
74
+ const signature = await sign(payload);
75
+ return `session=${payload}.${signature}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=604800`;
76
+ }
77
+
78
+ export function clearSessionCookie(): string {
79
+ return "session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
80
+ }
81
+
82
+ async function getKey(usage: "sign" | "verify"): Promise<CryptoKey> {
83
+ // Read the secret INSIDE the function, never at module scope. On
84
+ // Cloudflare Workers env bindings only exist per request — a module-level
85
+ // `process.env.SESSION_SECRET` read (or throw) bricks the worker at import
86
+ // time. `serverEnv` resolves correctly per adapter (see docs/ENV.md).
87
+ const secret = serverEnv.SESSION_SECRET;
88
+ if (!secret) throw new Error("SESSION_SECRET is required");
89
+ return crypto.subtle.importKey(
90
+ "raw",
91
+ new TextEncoder().encode(secret),
92
+ { name: "HMAC", hash: "SHA-256" },
93
+ false,
94
+ [usage],
95
+ );
96
+ }
97
+
98
+ async function sign(data: string): Promise<string> {
99
+ const key = await getKey("sign");
100
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
101
+ return btoa(String.fromCharCode(...new Uint8Array(sig)));
102
+ }
103
+
104
+ async function verify(data: string, signature: string): Promise<boolean> {
105
+ let sig: Uint8Array;
106
+ try {
107
+ sig = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
108
+ } catch {
109
+ return false;
110
+ }
111
+ const key = await getKey("verify");
112
+ return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(data));
113
+ }
114
+ ```
115
+
116
+ Notes:
117
+ - `crypto.subtle` works in Node 18+, Cloudflare Workers, and Vercel Edge.
118
+ - Signature verification goes through `crypto.subtle.verify`, which is a
119
+ constant-time comparison. Never compare signature strings with `===` — that
120
+ leaks timing information an attacker can use to forge signatures.
121
+ - Drop `Secure` only if the user is on plain HTTP locally (recommend
122
+ conditionalizing on `NODE_ENV`).
123
+
124
+ ## Step 3: Auth middleware
125
+
126
+ `src/middleware/auth.ts`:
127
+
128
+ ```ts
129
+ import { redirect, type MiddlewareFn } from "@pracht/core";
130
+ import { getSession } from "../server/session";
131
+
132
+ export const middleware: MiddlewareFn = async ({ request, url }, next) => {
133
+ const session = await getSession(request);
134
+ if (!session) {
135
+ const target = encodeURIComponent(url.pathname + url.search);
136
+ return redirect(`/login?redirect=${target}`, { request });
137
+ }
138
+ request.headers.set("x-user-id", session.userId);
139
+ request.headers.set("x-user-email", session.email);
140
+ return next();
141
+ };
142
+ ```
143
+
144
+ This is a **Gate** (short-circuits with a redirect on failure). Cross-reference
145
+ `audit-auth` for the distinction between Gate and Augmenter.
146
+
147
+ ## Step 4: Login / logout API routes
148
+
149
+ `src/api/auth/login.ts`:
150
+
151
+ ```ts
152
+ import type { ApiRouteArgs } from "@pracht/core";
153
+ import { createSessionCookie } from "../../server/session";
154
+
155
+ export async function POST({ request }: ApiRouteArgs) {
156
+ const form = await request.formData();
157
+ const email = String(form.get("email") ?? "").trim();
158
+ const password = String(form.get("password") ?? "");
159
+ const requested = String(form.get("redirect") ?? "/dashboard");
160
+
161
+ // Enforce same-origin redirect (defense against open-redirect via form input).
162
+ const safeRedirect = requested.startsWith("/") && !requested.startsWith("//")
163
+ ? requested
164
+ : "/dashboard";
165
+
166
+ const user = await verifyCredentials(email, password);
167
+ if (!user) {
168
+ // Redirect back to /login with an error flag — do NOT return a 401 JSON
169
+ // body. Pracht's <Form> only acts on 3xx responses (it follows the
170
+ // `location` header); a non-redirect response is silently ignored by the
171
+ // client, so the user would see nothing happen. The login page loader
172
+ // reads `?error=1` and renders the message.
173
+ const back = new URLSearchParams({ error: "1", redirect: safeRedirect });
174
+ return new Response(null, {
175
+ status: 302,
176
+ headers: { location: `/login?${back}` },
177
+ });
178
+ }
179
+
180
+ const cookie = await createSessionCookie({ userId: user.id, email: user.email });
181
+ return new Response(null, {
182
+ status: 302,
183
+ headers: { location: safeRedirect, "set-cookie": cookie },
184
+ });
185
+ }
186
+
187
+ async function verifyCredentials(_email: string, _password: string) {
188
+ // TODO: replace with a real DB lookup + password hash check (argon2 / bcrypt).
189
+ return null as null | { id: string; email: string };
190
+ }
191
+ ```
192
+
193
+ `src/api/auth/logout.ts`:
194
+
195
+ ```ts
196
+ import type { ApiRouteArgs } from "@pracht/core";
197
+ import { clearSessionCookie } from "../../server/session";
198
+
199
+ export async function POST(_args: ApiRouteArgs) {
200
+ return new Response(null, {
201
+ status: 302,
202
+ headers: { location: "/", "set-cookie": clearSessionCookie() },
203
+ });
204
+ }
205
+ ```
206
+
207
+ `src/api/auth/signup.ts` (skeleton — user wires hashing + DB insert):
208
+
209
+ ```ts
210
+ import type { ApiRouteArgs } from "@pracht/core";
211
+ import { createSessionCookie } from "../../server/session";
212
+
213
+ export async function POST({ request }: ApiRouteArgs) {
214
+ const form = await request.formData();
215
+ const email = String(form.get("email") ?? "").trim();
216
+ const password = String(form.get("password") ?? "");
217
+ if (!email || password.length < 8) {
218
+ // Same redirect-with-flag pattern as login — <Form> ignores non-3xx.
219
+ return new Response(null, {
220
+ status: 302,
221
+ headers: { location: "/signup?error=1" },
222
+ });
223
+ }
224
+ // TODO: hash password, insert user, set session.
225
+ const user = { id: crypto.randomUUID(), email };
226
+ const cookie = await createSessionCookie({ userId: user.id, email: user.email });
227
+ return new Response(null, {
228
+ status: 302,
229
+ headers: { location: "/dashboard", "set-cookie": cookie },
230
+ });
231
+ }
232
+ ```
233
+
234
+ ## Step 5: Login & signup pages
235
+
236
+ `src/routes/login.tsx`:
237
+
238
+ ```tsx
239
+ import type { LoaderArgs, RouteComponentProps } from "@pracht/core";
240
+ import { Form } from "@pracht/core";
241
+
242
+ export async function loader({ url }: LoaderArgs) {
243
+ return {
244
+ redirect: url.searchParams.get("redirect") ?? "/dashboard",
245
+ // Set by the login API route on failed credentials (see Step 4 — the
246
+ // API redirects back here because <Form> only acts on 3xx responses).
247
+ error: url.searchParams.get("error") === "1",
248
+ };
249
+ }
250
+
251
+ export function head() {
252
+ return { title: "Log in" };
253
+ }
254
+
255
+ export function Component({ data }: RouteComponentProps<typeof loader>) {
256
+ return (
257
+ <section class="login">
258
+ <h1>Log in</h1>
259
+ {data.error && <p class="error">Invalid email or password.</p>}
260
+ <Form method="post" action="/api/auth/login">
261
+ <input type="hidden" name="redirect" value={data.redirect} />
262
+ <label>Email <input type="email" name="email" required /></label>
263
+ <label>Password <input type="password" name="password" required /></label>
264
+ <button type="submit">Log in</button>
265
+ </Form>
266
+ </section>
267
+ );
268
+ }
269
+ ```
270
+
271
+ Generate `signup.tsx` analogously, posting to `/api/auth/signup`.
272
+
273
+ ## Step 6: Wire the manifest
274
+
275
+ ```ts
276
+ import { defineApp, group, route } from "@pracht/core";
277
+
278
+ export const app = defineApp({
279
+ shells: {
280
+ public: "./shells/public.tsx",
281
+ app: "./shells/app.tsx",
282
+ },
283
+ middleware: {
284
+ auth: "./middleware/auth.ts",
285
+ },
286
+ routes: [
287
+ group({ shell: "public" }, [
288
+ route("/", "./routes/home.tsx", { render: "ssg" }),
289
+ route("/login", "./routes/login.tsx", { render: "ssr" }),
290
+ route("/signup", "./routes/signup.tsx", { render: "ssr" }),
291
+ ]),
292
+ group({ shell: "app", middleware: ["auth"] }, [
293
+ route("/dashboard", "./routes/dashboard.tsx", { render: "ssr" }),
294
+ // any other protected routes…
295
+ ]),
296
+ ],
297
+ });
298
+ ```
299
+
300
+ If the project already has `defineApp({...})`, merge — preserve existing
301
+ shells/middleware/routes.
302
+
303
+ ## Step 7: Env vars
304
+
305
+ Add to `.env.example`:
306
+
307
+ ```
308
+ SESSION_SECRET=<generate with: openssl rand -base64 32>
309
+ ```
310
+
311
+ Confirm `.env*` is gitignored.
312
+
313
+ ## Step 8: Verify
314
+
315
+ - Step 6 added routes — run `pracht typegen` to refresh
316
+ `src/pracht.d.ts` / `src/pracht-routes.ts` (use
317
+ `pracht typegen --check` in CI).
318
+ - `pracht dev`, navigate to `/dashboard` → redirects to
319
+ `/login?redirect=%2Fdashboard`.
320
+ - After successful login, lands on `/dashboard`. After a failed login, lands
321
+ back on `/login?error=1` with the error message rendered.
322
+ - Logout posts to `/api/auth/logout` and clears the cookie.
323
+ - Run `pnpm test` and `pnpm e2e`.
324
+ - Run `pracht verify --json` and confirm no failures.
325
+ - Run `audit-csrf` and `audit-auth` after wiring to confirm posture.
326
+
327
+ ## Rules
328
+
329
+ 1. Always set `HttpOnly`, `SameSite=Lax`, `Secure` on the session cookie.
330
+ 2. The login form's `redirect` input is user-supplied — gate it server-side
331
+ (`startsWith('/')` AND `!startsWith('//')`). Otherwise this is an open
332
+ redirect.
333
+ 3. `verifyCredentials` is a placeholder — never ship the skeleton without
334
+ real password hashing (argon2 or bcrypt).
335
+ 4. Read `SESSION_SECRET` via `serverEnv` (from `@pracht/core/env/server`)
336
+ inside the signing/verifying functions and fail loudly there if missing.
337
+ Never read or validate it at module scope — on Cloudflare Workers env
338
+ bindings only exist per request, so a module-level throw bricks the worker
339
+ at import time.
340
+ 5. Failed form posts must answer with a 3xx redirect carrying an error flag —
341
+ `<Form>` ignores non-redirect responses, so 4xx JSON bodies are invisible
342
+ to the user.
343
+ 6. After wiring, recommend running `audit-auth` to confirm protected routes
344
+ are gated and `audit-csrf` for CSRF posture.
345
+
346
+ $ARGUMENTS