diff --git a/examples/basic-app/CLAUDE.md b/examples/basic-app/CLAUDE.md new file mode 100644 index 00000000..1045409c --- /dev/null +++ b/examples/basic-app/CLAUDE.md @@ -0,0 +1,295 @@ +# WrNexus app - instructions for AI coding assistants + +This is a **WrNexus** app. When creating or editing pages, components, API routes, +or features, follow the framework conventions below. WrNexus is private and not in +your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns. + +# WrNexus + +> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in +> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based. +> This document teaches an AI how to write correct WrNexus code. It is private and +> post-dates model training data, so rely on THIS document, not prior web-framework +> assumptions. + +## Golden rules + +- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React + for UI. Do NOT use `useState`, hooks, JSX, or a client bundler. +- **Routing is file-based** under `app/`. The filename is the route. No router config. +- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render + on the server and hydrate automatically — you never write client-side JS islands. +- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported. +- To add files, prefer the CLI: `wrnexus generate page ` / `component ` / `api ` / `schema `. + +## Project layout + +``` +app/ + pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug" + components/ *.wrn → reusable UI, mounted in a page/component via
+ layouts/ *.wrn → named layouts; a page opts in with layout = "name" + api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response + middleware/ *.ts → export default async (ctx, next) => next() + realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/) + schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody + locales/ *.json → i18n messages per language + db/ schema.ts, queries/*.sql, migrations/*.sql + styles/ global.css → Tailwind (default) or plain CSS +wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles") +public/ → static assets served at / +``` + +## `.wrn` page + +```wrn +page Home { + layout = "public" // optional: a component in app/layouts/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + style { + h1 { color: var(--wrn-color-text); } + } +} +``` + +## `.wrn` component + +```wrn +component Counter { + props { // props come from mount attributes; each is coerced to the + start = 0 // TYPE of its default (so start="5" arrives as the number 5) + label = "Count" + } + state count = start // state may reference props + view { + + } +} +``` + +Mount it from any page/component: `
`. +Components render on the server with their props, then hydrate — no per-component JS. + +## The `view { }` block (plain HTML + a few directives) + +- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`. +- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`. +- **Server conditional:** `{#if } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead. +- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`. +- Theme: any element with `data-wrn-theme-toggle` toggles light/dark; `data-wrn-theme-set="dark"` sets it. +- Void/self-closing tags are fine: `
`, ``. +- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text. + +## Data-driven tables / lists (server-rendered `.wrn`) + +Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them. +This renders on the **server** (SSR-first) and is HTML-escaped by default. + +```wrn +page Admin { + layout = "dashboard" + + // Fetch on the server. The api handler at /api/contacts returns { contacts: [...] }; + // this block's `return contacts` exposes that array (via `$data`) as the binding `rows`. + ssr { + api rows GET /api/contacts { return contacts } + } + + view { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +The matching API returns the array under a key the `ssr` block reads: + +```ts +// app/api/contacts.ts → GET /api/contacts +import { getDb } from "@wrnexus/db"; +export const GET = async () => { + const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC"); + return Response.json({ contacts }); // ssr block does `return contacts` +}; +``` + +**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx` +pages returning an HTML string are also supported for fully-custom programmatic rendering, +but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.) + +## API routes (`app/api/*.ts`) + +```ts +// app/api/users/list.ts → GET /api/users/list +import { getDb } from "@wrnexus/db"; + +export const GET = async (ctx) => { + return Response.json({ users: await ListUsers(getDb()) }); +}; + +export const POST = async (ctx) => { + const body = await ctx.req.json(); + return Response.json({ ok: true, body }, { status: 201 }); +}; +``` + +`ctx` (the `Context` from `@wrnexus/core`) has: +`req: Request`, `url: URL`, `params: Record` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`), +`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`. + +When an SSO forward-auth verifier needs the URL that originally reached the gateway, use +`@wrnexus/helpers` instead of constructing it from untrusted headers: + +```ts +import { redirectToLogin } from "@wrnexus/helpers"; + +return redirectToLogin(ctx, "/login", { + allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"], +}); +``` + +The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`, +`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when +using forwarded gateway URLs; the helper rejects untrusted redirect destinations. + +## Middleware & realtime + +```ts +// app/middleware/logger.ts +export default async function logger(ctx, next) { + console.log(ctx.req.method, ctx.url.pathname); + return next(); // return a Response WITHOUT calling next() to short-circuit +} +``` + +```ts +// app/realtime/chat.ts → ws://host/realtime/chat +import { defineRoom } from "@wrnexus/core"; +export default defineRoom({ + onConnect(client) { + client.send({ type: "system", text: "connected" }); + }, + onMessage(client, msg) { + client.room.broadcast({ type: "message", data: msg }); + }, +}); +``` + +Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime). + +## Config (`wrnexus.config.ts`) + +```ts +import type { AppConfig } from "@wrnexus/styles"; +const config: AppConfig = { + seo: { title: "App", titleTemplate: "%s | App", description: "..." }, + styles: { + entry: "app/styles/global.css", + process: async ({ entryPath, mode }) => /* Tailwind */ "", + }, + fonts: { + sans: '"Inter", system-ui, sans-serif', + google: [{ family: "Inter", weights: [400, 600] }], + }, + theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } }, + i18n: { default: "en", locales: ["en", "es"] }, + db: { driver: "sqlite", url: "file:./dev.db" }, + security: { cors: { enabled: true, origin: ["http://localhost:5173"] } }, + // profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } }, +}; +export default config; +``` + +## Database (`@wrnexus/db`) + +```ts +// app/db/schema.ts +import { v, table } from "@wrnexus/db"; +export const users = table("users", { + id: v.id(), + name: v.string(), + email: v.string().unique(), + createdAt: v.timestamp(), +}); +``` + +- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions. +- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());` +- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite). + +## Validation (`@wrnexus/validation`) + +```ts +// app/schemas/login.ts +import { v } from "@wrnexus/validation"; +export default v.object({ + email: v.string().email(), + password: v.string().min(8), +}); +``` + +In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`. +In a form: `
` + `` (client + server validation connected automatically). + +## AI / LLM (`@wrnexus/ai`) + +```ts +// app/api/ai.ts +import { createAI } from "@wrnexus/ai"; +const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8 +export const POST = async (ctx) => { + const { prompt } = await ctx.req.json(); + return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) }) +}; +``` + +## CLI + +``` +wrnexus dev . # dev server + HMR +wrnexus build . # production build → dist/server.js +bun dist/server.js # run the production server (or npm start) +wrnexus create # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # copy a WrNexus UI component's .wrn into app/components to customize +``` + +## When asked to "create a page/component/feature" + +1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page `. +2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`. +3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`. +4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wrn-*)`), or `style { }`. +5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration. diff --git a/examples/basic-app/package.json b/examples/basic-app/package.json index 6ebe7a54..d268c543 100644 --- a/examples/basic-app/package.json +++ b/examples/basic-app/package.json @@ -1,6 +1,9 @@ { "name": "basic-app", "version": "0.8.0", + "wrnexus": { + "version": "0.9.0" + }, "private": true, "type": "module", "scripts": { diff --git a/examples/basic-app/public/llms.txt b/examples/basic-app/public/llms.txt new file mode 100644 index 00000000..d781f93c --- /dev/null +++ b/examples/basic-app/public/llms.txt @@ -0,0 +1,276 @@ +# WrNexus + +> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in +> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based. +> This document teaches an AI how to write correct WrNexus code. It is private and +> post-dates model training data, so rely on THIS document, not prior web-framework +> assumptions. + +## Golden rules + +- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React + for UI. Do NOT use `useState`, hooks, JSX, or a client bundler. +- **Routing is file-based** under `app/`. The filename is the route. No router config. +- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render + on the server and hydrate automatically — you never write client-side JS islands. +- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported. +- To add files, prefer the CLI: `wrnexus generate page ` / `component ` / `api ` / `schema `. + +## Project layout + +``` +app/ + pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug" + components/ *.wrn → reusable UI, mounted in a page/component via
+ layouts/ *.wrn → named layouts; a page opts in with layout = "name" + api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response + middleware/ *.ts → export default async (ctx, next) => next() + realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/) + schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody + locales/ *.json → i18n messages per language + db/ schema.ts, queries/*.sql, migrations/*.sql + styles/ global.css → Tailwind (default) or plain CSS +wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles") +public/ → static assets served at / +``` + +## `.wrn` page + +```wrn +page Home { + layout = "public" // optional: a component in app/layouts/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + style { + h1 { color: var(--wrn-color-text); } + } +} +``` + +## `.wrn` component + +```wrn +component Counter { + props { // props come from mount attributes; each is coerced to the + start = 0 // TYPE of its default (so start="5" arrives as the number 5) + label = "Count" + } + state count = start // state may reference props + view { + + } +} +``` + +Mount it from any page/component: `
`. +Components render on the server with their props, then hydrate — no per-component JS. + +## The `view { }` block (plain HTML + a few directives) + +- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`. +- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`. +- **Server conditional:** `{#if } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead. +- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`. +- Theme: any element with `data-wrn-theme-toggle` toggles light/dark; `data-wrn-theme-set="dark"` sets it. +- Void/self-closing tags are fine: `
`, ``. +- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text. + +## Data-driven tables / lists (server-rendered `.wrn`) + +Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them. +This renders on the **server** (SSR-first) and is HTML-escaped by default. + +```wrn +page Admin { + layout = "dashboard" + + // Fetch on the server. The api handler at /api/contacts returns { contacts: [...] }; + // this block's `return contacts` exposes that array (via `$data`) as the binding `rows`. + ssr { + api rows GET /api/contacts { return contacts } + } + + view { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +The matching API returns the array under a key the `ssr` block reads: + +```ts +// app/api/contacts.ts → GET /api/contacts +import { getDb } from "@wrnexus/db"; +export const GET = async () => { + const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC"); + return Response.json({ contacts }); // ssr block does `return contacts` +}; +``` + +**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx` +pages returning an HTML string are also supported for fully-custom programmatic rendering, +but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.) + +## API routes (`app/api/*.ts`) + +```ts +// app/api/users/list.ts → GET /api/users/list +import { getDb } from "@wrnexus/db"; + +export const GET = async (ctx) => { + return Response.json({ users: await ListUsers(getDb()) }); +}; + +export const POST = async (ctx) => { + const body = await ctx.req.json(); + return Response.json({ ok: true, body }, { status: 201 }); +}; +``` + +`ctx` (the `Context` from `@wrnexus/core`) has: +`req: Request`, `url: URL`, `params: Record` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`), +`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`. + +When an SSO forward-auth verifier needs the URL that originally reached the gateway, use +`@wrnexus/helpers` instead of constructing it from untrusted headers: + +```ts +import { redirectToLogin } from "@wrnexus/helpers"; + +return redirectToLogin(ctx, "/login", { + allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"], +}); +``` + +The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`, +`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when +using forwarded gateway URLs; the helper rejects untrusted redirect destinations. + +## Middleware & realtime + +```ts +// app/middleware/logger.ts +export default async function logger(ctx, next) { + console.log(ctx.req.method, ctx.url.pathname); + return next(); // return a Response WITHOUT calling next() to short-circuit +} +``` + +```ts +// app/realtime/chat.ts → ws://host/realtime/chat +import { defineRoom } from "@wrnexus/core"; +export default defineRoom({ + onConnect(client) { client.send({ type: "system", text: "connected" }); }, + onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); }, +}); +``` +Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime). + +## Config (`wrnexus.config.ts`) + +```ts +import type { AppConfig } from "@wrnexus/styles"; +const config: AppConfig = { + seo: { title: "App", titleTemplate: "%s | App", description: "..." }, + styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" }, + fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] }, + theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } }, + i18n: { default: "en", locales: ["en", "es"] }, + db: { driver: "sqlite", url: "file:./dev.db" }, + security: { cors: { enabled: true, origin: ["http://localhost:5173"] } }, + // profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } }, +}; +export default config; +``` + +## Database (`@wrnexus/db`) + +```ts +// app/db/schema.ts +import { v, table } from "@wrnexus/db"; +export const users = table("users", { + id: v.id(), + name: v.string(), + email: v.string().unique(), + createdAt: v.timestamp(), +}); +``` +- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions. +- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());` +- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite). + +## Validation (`@wrnexus/validation`) + +```ts +// app/schemas/login.ts +import { v } from "@wrnexus/validation"; +export default v.object({ + email: v.string().email(), + password: v.string().min(8), +}); +``` +In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`. +In a form: `` + `` (client + server validation connected automatically). + +## AI / LLM (`@wrnexus/ai`) + +```ts +// app/api/ai.ts +import { createAI } from "@wrnexus/ai"; +const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8 +export const POST = async (ctx) => { + const { prompt } = await ctx.req.json(); + return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) }) +}; +``` + +## CLI + +``` +wrnexus dev . # dev server + HMR +wrnexus build . # production build → dist/server.js +bun dist/server.js # run the production server (or npm start) +wrnexus create # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # copy a WrNexus UI component's .wrn into app/components to customize +``` + +## When asked to "create a page/component/feature" + +1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page `. +2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`. +3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`. +4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wrn-*)`), or `style { }`. +5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration. diff --git a/packages/cli/src/migrations/apis-block.ts b/packages/cli/src/migrations/apis-block.ts index 2df2f924..9d1e0d83 100644 --- a/packages/cli/src/migrations/apis-block.ts +++ b/packages/cli/src/migrations/apis-block.ts @@ -14,6 +14,50 @@ import { parse } from "@wrnexus/syntax"; +/** Blank out string/template literal contents and comments, preserving length. */ +function maskLiteralsAndComments(source: string): string { + let out = ""; + let index = 0; + while (index < source.length) { + const char = source[index]!; + if (char === '"' || char === "'" || char === "`") { + const quote = char; + let end = index + 1; + while (end < source.length) { + if (source[end] === "\\") { + end += 2; + continue; + } + if (source[end] === quote) { + end++; + break; + } + end++; + } + out += " ".repeat(end - index); + index = end; + continue; + } + if (char === "/" && source[index + 1] === "/") { + let end = index; + while (end < source.length && source[end] !== "\n") end++; + out += " ".repeat(end - index); + index = end; + continue; + } + if (char === "/" && source[index + 1] === "*") { + let end = source.indexOf("*/", index + 2); + end = end < 0 ? source.length : end + 2; + out += source.slice(index, end).replace(/[^\n]/g, " "); + index = end; + continue; + } + out += char; + index++; + } + return out; +} + /** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */ function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number { let depth = 0; @@ -76,7 +120,19 @@ interface ApiEntry { localEnd: number; } -/** Find `api { … }` entries inside a mode-block body. */ +/** + * Find `api { … }` entries inside a mode-block body. + * + * A legacy BARE-BODY entry (no `request`/`response`/`error` sections — its + * body is JS evaluated inside `with ($data ?? {})`) is deliberately excluded + * here. The `apis { }` grammar requires sections, so folding a bare body into + * it would produce source that fails to parse — a spurious "failed to parse" + * report for a file that is actually fine. `report-legacy-api-bodies` + * (`legacy-api-body.ts`) is the migration that surfaces these for manual + * review; leaving them out of this scan lets that block's leftover content + * fall through to the "mixes content" skip below when needed, or leaves the + * block entirely untouched when it holds only bare-body entries. + */ function findApiEntries(body: string): ApiEntry[] { const entries: ApiEntry[] = []; const header = /\bapi\s+([A-Za-z_$][\w$]*)\s+([A-Za-z]+)\s+(\S+?)\s*\{/g; @@ -89,6 +145,12 @@ function findApiEntries(body: string): ApiEntry[] { } const localStart = match.index; const localEnd = close + 1; + const bodyText = body.slice(open + 1, close); + const hasSections = /\b(request|response|error)\s*\{/.test(maskLiteralsAndComments(bodyText)); + if (!hasSections) { + header.lastIndex = localEnd; + continue; + } const text = body.slice(localStart, localEnd).replace(/^api\s+/, ""); entries.push({ name: match[1]!, diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index c6556769..f353c25f 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -1315,6 +1315,33 @@ export async function runUpdate(dir: string, args: string[]): Promise { } } + // Print the full report in a fixed order -- what changed, then what needs + // review and why, then what failed to parse -- so a scripted upgrade never + // has to guess at severity from console noise. A run with anything in + // needsReview or parseFailures exits non-zero: a project left half-migrated + // must never look like a clean success. + const allChangedAutomatically = [...reports.values()].flatMap((r) => r.changedAutomatically); + const allNeedsReview = [...reports.values()].flatMap((r) => r.needsReview); + const allParseFailures = [...reports.values()].flatMap((r) => r.parseFailures); + + console.log(`\n Changed automatically (${new Set(allChangedAutomatically).size}):`); + for (const line of new Set(allChangedAutomatically)) console.log(` - ${line}`); + + console.log(`\n Needs review (${allNeedsReview.length}):`); + for (const line of allNeedsReview) console.log(` - ${line}`); + + console.log(`\n Failed to parse (${allParseFailures.length}):`); + for (const line of allParseFailures) console.log(` - ${line}`); + + const needsAttention = allNeedsReview.length > 0 || allParseFailures.length > 0; + if (needsAttention) { + console.error( + `\n ✗ ${allNeedsReview.length} item(s) need review and ${allParseFailures.length} file(s) failed to parse. ` + + `Resolve these by hand, then re-run wrnexus update.`, + ); + process.exitCode = 1; + } + if (dryRun) { console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`); return; diff --git a/packages/cli/test/migrate-apis-block.test.ts b/packages/cli/test/migrate-apis-block.test.ts index 7c3cee0f..01494b8f 100644 --- a/packages/cli/test/migrate-apis-block.test.ts +++ b/packages/cli/test/migrate-apis-block.test.ts @@ -102,6 +102,27 @@ test("a mode block mixing api entries with other content is skipped, not partial expect(result.skip).toContain("functions"); }); +test("a legacy bare-body api entry is left alone instead of producing a bogus parse failure", () => { + // No request/response/error sections -- this is the legacy bare-body form + // that `report-legacy-api-bodies` handles for manual review. Folding it + // into `apis { }` as-is would produce source the grammar rejects, so this + // migration must leave it untouched rather than attempt the rewrite. + const source = `page Hello { + ssr { + api ssrUsers GET /api/users/ssr { + return userNames(users) + } + } + + view {
x
} +} +`; + const result = migrateApisBlock(source) as { source: string; changed: boolean }; + + expect(result.changed).toBe(false); + expect(result.source).toBe(source); +}); + test("a brace inside a string literal in a section body does not truncate the entry", () => { const source = `page P { client { diff --git a/packages/cli/test/migrate-legacy-api-body.test.ts b/packages/cli/test/migrate-legacy-api-body.test.ts index c82acc16..01cc3208 100644 --- a/packages/cli/test/migrate-legacy-api-body.test.ts +++ b/packages/cli/test/migrate-legacy-api-body.test.ts @@ -112,4 +112,5 @@ test("a full update run leaves a legacy bare body file byte-identical and report expect(after).toBe(SOURCE); expect(report.needsReview.some((entry) => entry.includes("ssrUsers"))).toBe(true); + expect(report.parseFailures).toEqual([]); }); diff --git a/packages/cli/test/update-exit-code.test.ts b/packages/cli/test/update-exit-code.test.ts new file mode 100644 index 00000000..4905bc49 --- /dev/null +++ b/packages/cli/test/update-exit-code.test.ts @@ -0,0 +1,81 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runUpdate } from "../src/update.ts"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function project(name: string): string { + const root = mkdtempSync(join(tmpdir(), `wrnexus-update-exit-${name}-`)); + roots.push(root); + mkdirSync(join(root, "app"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: `update-exit-${name}`, + dependencies: { "@wrnexus/core": "^0.8.0" }, + wrnexus: { version: "0.8.0" }, + }), + ); + return root; +} + +/** + * `runUpdate` reports failure via `process.exitCode` (never a real + * `process.exit()` call) on this path -- see the existing `--delegated` and + * verification-failure branches in `src/update.ts`. `--delegated` skips the + * "fetch a newer published CLI" handoff (there is no published 0.9.0 yet), + * matching how a real newer CLI re-invokes itself. `--dry-run` keeps the test + * offline too: the dry-run path returns before `bun install`/verification + * ever run, so no network access is needed to observe the exit code this + * task adds. + */ +test("a project with a legacy bare body exits non-zero", async () => { + const root = project("needs-review"); + writeFileSync( + join(root, "app", "Hello.wrn"), + `page Hello { + ssr { + api ssrUsers GET /api/users/ssr { + return userNames(users) + } + } + + view {
x
} +} +`, + ); + + const before = process.exitCode; + process.exitCode = 0; + try { + await runUpdate(root, ["--dry-run", "--version=0.9.0", "--delegated"]); + expect(process.exitCode).toBeTruthy(); + } finally { + process.exitCode = before ?? 0; + } +}); + +test("a fully-migratable project exits zero", async () => { + const root = project("clean"); + writeFileSync( + join(root, "app", "Hello.wrn"), + `page Hello { + view {
x
} +} +`, + ); + + const before = process.exitCode; + process.exitCode = 0; + try { + await runUpdate(root, ["--dry-run", "--version=0.9.0", "--delegated"]); + expect(process.exitCode ?? 0).toBe(0); + } finally { + process.exitCode = before ?? 0; + } +});