first commit
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
# @wrnexus/cli
|
||||
|
||||
> The `wrnexus` command-line tool that scaffolds, runs, builds, tests, and manages WrNexus apps.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/cli` provides the `wrnexus` executable — the single entry point for developing a WrNexus app. It runs the HMR dev server, produces a self-contained production build, scaffolds apps/pages/components, drives database migrations, regenerates typed routes and queries, runs tests, and manages configuration profiles. It also scaffolds multi-app monorepos and serves them behind a domain-routing gateway. This is a CLI/build-time package (it shells out to the Bun binary for the dev child and tests) and it also exports the workspace config types via a subpath.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/cli
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
Once installed, invoke it from an app directory:
|
||||
|
||||
```bash
|
||||
bunx wrnexus dev
|
||||
# or add scripts: "dev": "wrnexus dev .", "build": "wrnexus build ."
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)).
|
||||
|
||||
| Command | Purpose |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `wrnexus dev [app-dir] [--port=3000]` | Start the development server with live reload / HMR. |
|
||||
| `wrnexus build [app-dir]` | Build a self-contained production server bundle + assets into `dist/`. |
|
||||
| `wrnexus create <app-name>` | Scaffold a new single app from an inline template. |
|
||||
| `wrnexus workspace <name>` | Scaffold a monorepo (`apps/*` + shared `packages/*`). |
|
||||
| `wrnexus gateway [--port=3000]` | Serve every workspace app behind one port, routed by domain. |
|
||||
| `wrnexus generate <type> <name>` | Scaffold a `page` \| `component` \| `api` \| `schema`. |
|
||||
| `wrnexus generate routes` | Regenerate the typed routes file (`app/routes.gen.ts`). |
|
||||
| `wrnexus generate docker` | Scaffold `Dockerfile`, `.dockerignore`, and `docker-compose.yml`. |
|
||||
| `wrnexus generate mobile` | Scaffold a Capacitor shell for iOS and Android. |
|
||||
| `wrnexus mobile add <package...>` | Install Capacitor plugins and sync native projects. |
|
||||
| `wrnexus eject <name...>` | Copy Wire UI component `.wrn` sources into `app/components/`. |
|
||||
| `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). |
|
||||
| `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). |
|
||||
| `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. |
|
||||
| `wrnexus help` | Print usage. |
|
||||
|
||||
`wrnexus g` is an alias for `wrnexus generate`.
|
||||
|
||||
### `wrnexus dev`
|
||||
|
||||
Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`).
|
||||
|
||||
```bash
|
||||
wrnexus dev . --port=8080
|
||||
```
|
||||
|
||||
### `wrnexus build`
|
||||
|
||||
Emits into `<app-dir>/dist/`:
|
||||
|
||||
- `server.js` — a single, minified, self-contained Bun server with a **static** manifest of every page / api / realtime / middleware / component / layout module (no runtime filesystem scan or on-the-fly bundling).
|
||||
- `reactive.js`, `theme.css`, `theme.js`, `ui.css`, and (if present) `styles.css` — hashed, minified browser assets.
|
||||
- `public/` — copied verbatim.
|
||||
|
||||
Before bundling, it regenerates typed queries for the default and every named database. Run the output with:
|
||||
|
||||
```bash
|
||||
bun dist/server.js # PORT env var optional
|
||||
```
|
||||
|
||||
### `wrnexus create`
|
||||
|
||||
Scaffolds a new app from an inline (dependency-free) template — `package.json`, config, and starter `app/` files. Run `wrnexus dev` in the new directory to start.
|
||||
|
||||
```bash
|
||||
wrnexus create my-app
|
||||
```
|
||||
|
||||
### `wrnexus generate`
|
||||
|
||||
Scaffolds a single file from a template, refusing to overwrite an existing file. Types (with aliases): `page`/`p`, `component`/`c`, `api`/`a`, `schema`/`s`. Nested names create nested paths.
|
||||
|
||||
```bash
|
||||
wrnexus generate page about # app/pages/about.wrn
|
||||
wrnexus generate component user-card # app/components/user-card.wrn
|
||||
wrnexus generate api users/list # app/api/users/list.ts
|
||||
wrnexus generate schema signup # app/schemas/signup.ts
|
||||
wrnexus generate routes # regenerate app/routes.gen.ts
|
||||
wrnexus generate docker # Dockerfile + compose + .dockerignore
|
||||
wrnexus generate mobile --mode=webview --app-id=com.example.app --app-name="Example" --url=https://app.example.com
|
||||
wrnexus generate mobile --mode=native
|
||||
```
|
||||
|
||||
The mobile generator creates a separate `mobile/` package and reads
|
||||
`config.mobile.mode`. `webview` creates a Capacitor shell that renders the hosted
|
||||
WrNexus application. `native` creates a WebView-free Expo/React Native app whose
|
||||
screens call the shared backend through `mobile/src/wrnexus.ts`. Native screens
|
||||
do not render `.wrn` HTML. In either mode, run `bun install` in `mobile/`; iOS
|
||||
device builds require macOS and Xcode.
|
||||
|
||||
Install official or community Capacitor plugins through the root CLI:
|
||||
|
||||
```bash
|
||||
wrnexus mobile add @capacitor/camera @capacitor/haptics
|
||||
wrnexus mobile sync
|
||||
wrnexus mobile assets # generate native icons from config.mobile.icon
|
||||
```
|
||||
|
||||
In native mode, `mobile add` runs `expo install` and `mobile sync` runs Expo
|
||||
prebuild. In WebView mode they retain the Capacitor install/sync behavior.
|
||||
`wrnexus mobile compile` maps portable `app/pages/**/*.wrn` pages to Expo Router
|
||||
TSX routes. Native `bun run start` invokes this compilation automatically.
|
||||
|
||||
Browser code can access installed plugins through the SSR-safe
|
||||
`@wrnexus/mobile` bridge. The command adds each plugin to both the WrNexus app
|
||||
(JavaScript proxy) and `mobile/` (native synchronization).
|
||||
|
||||
`wrnexus mobile sync` also configures Android so only true network failures use
|
||||
the local connection-error screen. HTTP errors such as 404 and 500 keep their
|
||||
WrNexus response pages.
|
||||
|
||||
### `wrnexus eject`
|
||||
|
||||
Copies a Wire UI component's `.wrn` source out of `@wrnexus/ui` into `app/components/`, so the app owns and can edit it (the app copy shadows the library one by name). Run with no names to list available components. It skips components that already exist in the app.
|
||||
|
||||
```bash
|
||||
wrnexus eject button card modal
|
||||
```
|
||||
|
||||
### `wrnexus db`
|
||||
|
||||
Database migrations and tooling. Without a flag, commands target the **default** database (`db` in `wrnexus.config.ts`, files under `app/db/`). Pass `--db=<name>` to target a named database (`databases.<name>`, files under `app/db/<name>/`).
|
||||
|
||||
| Subcommand | Purpose |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `db new <name> [--from-models]` | Scaffold a migration; `--from-models` derives it from the TS models in `schema.ts`. |
|
||||
| `db migrate` | Apply all pending migrations. |
|
||||
| `db rollback` | Revert the last applied migration. |
|
||||
| `db status` | List applied / pending migrations. |
|
||||
| `db generate` | Regenerate typed queries (`queries/*.sql` → `queries.gen.ts`). |
|
||||
| `db seed` | Run the database's `seed.ts` (default export / `seed` function). |
|
||||
| `db studio [table]` | Inspect tables — list row counts, or dump the first 50 rows of one table. |
|
||||
|
||||
```bash
|
||||
wrnexus db new create_users --from-models
|
||||
wrnexus db migrate
|
||||
wrnexus db studio users
|
||||
wrnexus db status --db=analytics
|
||||
```
|
||||
|
||||
### `wrnexus workspace` and `wrnexus gateway`
|
||||
|
||||
`workspace <name>` scaffolds a monorepo: several WrNexus apps under `apps/*` and shared libraries under `packages/*`, plus a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log).
|
||||
|
||||
```bash
|
||||
wrnexus workspace acme
|
||||
wrnexus gateway --port=3000
|
||||
```
|
||||
|
||||
### `wrnexus test`
|
||||
|
||||
Runs the app's tests with `bun test`. Defaults to the `test` profile (config + `.env.test`). Pass `--watch` to re-run on change; extra flags pass straight through to `bun test`.
|
||||
|
||||
```bash
|
||||
wrnexus test . --watch
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
Pass `--profile=<name>` to `dev`, `build`, `db` (or set `WRNEXUS_PROFILE`) to select a config profile. The CLI publishes `WRNEXUS_PROFILE` so config loaders and the dev child pick it up, and loads that profile's `.env` cascade (`.env`, `.env.local`, `.env.<profile>`, `.env.<profile>.local`) into `process.env`.
|
||||
|
||||
```bash
|
||||
wrnexus dev --profile=uat
|
||||
wrnexus profiles # ● development (config, .env.development)
|
||||
# ○ production
|
||||
# ○ uat (config, .env.uat)
|
||||
```
|
||||
|
||||
## Subpath exports
|
||||
|
||||
`@wrnexus/cli/workspace` exposes the workspace configuration types used by `wrnexus.workspace.ts`:
|
||||
|
||||
```ts
|
||||
import type { WorkspaceConfig, WorkspaceApp } from "@wrnexus/cli/workspace";
|
||||
|
||||
const config: WorkspaceConfig = {
|
||||
security: { trustedHostsOnly: true, headers: true, accessLog: true },
|
||||
apps: [{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] }],
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** The CLI runs on Bun, spawns the Bun binary for the dev child and `bun test`, and the production build uses `Bun.build`. Node is not supported.
|
||||
- Orchestrates the rest of the framework: `@wrnexus/dev-server` (dev/prod server + gateway), `@wrnexus/router` (route + typed-routes codegen), `@wrnexus/compiler` (`.wrn` → `.ts`), `@wrnexus/db` (migrations, typed queries), `@wrnexus/styles` (config, profiles, `.env`, themes, styles), `@wrnexus/ui` (ejectable Wire UI components), `@wrnexus/validation`, `@wrnexus/csr`, and `@wrnexus/i18n`.
|
||||
- Reads `wrnexus.config.ts` for `db` / `databases`, `theme`, `styles`, `seo`, `security`, `i18n`, and `profiles`, and `wrnexus.workspace.ts` for the gateway.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.12",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./workspace": "./src/workspace.ts"
|
||||
},
|
||||
"bin": {
|
||||
"wrnexus": "src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/router": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/compiler": "workspace:*",
|
||||
"@wrnexus/styles": "workspace:*",
|
||||
"@wrnexus/dev-server": "workspace:*",
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Framework conventions shipped into scaffolded apps as CLAUDE.md and llms.txt so
|
||||
* AI coding tools (Claude Code, Cursor, Copilot) generate correct WrNexus code.
|
||||
* Generated from the repo-root llms.txt - do not edit by hand.
|
||||
*/
|
||||
|
||||
/** The canonical WrNexus conventions reference (shipped as llms.txt). */
|
||||
export const AI_GUIDE = `# 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 <Name>\` / \`component <name>\` / \`api <path>\` / \`schema <name>\`.
|
||||
|
||||
## 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 <div data-component="name" ...props>
|
||||
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/<name>)
|
||||
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/<name>.wrn ("none" to skip)
|
||||
|
||||
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||
|
||||
seo {
|
||||
title = "Home"
|
||||
description = "..."
|
||||
canonical = "/"
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Hello</h1>
|
||||
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||
<button @click="count++">Increment</button>
|
||||
<div data-component="counter" start="5" label="Clicks"></div>
|
||||
}
|
||||
|
||||
style {
|
||||
h1 { color: var(--wire-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 {
|
||||
<button @click="count++">{label}: {count}</button>
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Mount it from any page/component: \`<div data-component="counter" start="0" label="Clicks"></div>\`.
|
||||
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"\`.
|
||||
- \`<div data-component="name" prop="v">\` — mount a component (attrs become string props, coerced).
|
||||
- \`<slot></slot>\` / \`<slot name="x"></slot>\` — component/layout slots; fill with \`<div data-slot="x">…</div>\`.
|
||||
- **Server loop (DB/list/table):** \`{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}\` — iterates SSR data on the server and renders markup per item. \`{item.field}\` interpolates (HTML-escaped, XSS-safe). \`<list>\` 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 <expr>} … {:else if <expr>} … {:else} … {/if}\` — renders the first truthy branch on the server. \`<expr>\` can reference \`ssr\` data, or the \`item\`/\`index\` of an enclosing \`{#each}\`. Works at page level and inside loops (e.g. \`{#if r.active}<span>●</span>{:else}<span>○</span>{/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-wire-theme-toggle\` toggles light/dark; \`data-wire-theme-set="dark"\` sets it.
|
||||
- Void/self-closing tags are fine: \`<br />\`, \`<img src="..." />\`.
|
||||
- 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 {
|
||||
<table>
|
||||
<tbody>
|
||||
{#each rows as r, i}
|
||||
<tr>
|
||||
<td>#{i}</td>
|
||||
<td>{r.name}</td>
|
||||
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||
</tr>
|
||||
{:empty}
|
||||
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
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<string,string>\` (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\`.
|
||||
|
||||
## 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: \`<form data-schema="login" action="/api/login" method="post">\` + \`<span data-error="email"></span>\` (client + server validation wired 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
|
||||
wrnexus create <name> # scaffold a new app
|
||||
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||
wrnexus generate component <name> | api <path> | schema <name>
|
||||
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||
wrnexus eject <component> # copy a Wire 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 <Name>\`.
|
||||
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(--wire-*)\`), or \`style { }\`.
|
||||
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.\n`;
|
||||
|
||||
/** Agent-oriented instructions (shipped as CLAUDE.md): a preamble + the full guide. */
|
||||
export const CLAUDE_MD =
|
||||
`# 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.
|
||||
\n` + AI_GUIDE;
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* `wrnexus build` — production build (Point 4).
|
||||
*
|
||||
* Emits into `<appRoot>/dist`:
|
||||
* islands/<name>.js pre-built, minified island chunks
|
||||
* server.js a self-contained Bun server with a STATIC manifest of
|
||||
* every page/api/realtime/middleware module (no runtime
|
||||
* filesystem scan, no on-the-fly bundling)
|
||||
*
|
||||
* Run it with: bun dist/server.js (PORT env var optional)
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getReactiveRuntime } from "@wrnexus/csr";
|
||||
import { compileWireFile } from "@wrnexus/compiler";
|
||||
import {
|
||||
loadAppConfig,
|
||||
headToString,
|
||||
renderFontHead,
|
||||
findStyleEntry,
|
||||
renderStyles,
|
||||
resolveThemeConfig,
|
||||
renderThemeCss,
|
||||
renderThemeRuntime,
|
||||
} from "@wrnexus/styles";
|
||||
import { uiComponentsDir, uiCss } from "@wrnexus/ui";
|
||||
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
|
||||
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// Import the production server from the package specifier (not a source path) so
|
||||
// the generated entry resolves whether @wrnexus/dev-server is a workspace or an
|
||||
// installed dependency. Bun.build bundles it into a self-contained server.js.
|
||||
const PROD_MODULE = "@wrnexus/dev-server";
|
||||
const INLINE_CSS_LIMIT_BYTES = 4096;
|
||||
|
||||
const fwd = (p: string) => p.replace(/\\/g, "/");
|
||||
|
||||
export async function runBuild(appRoot: string): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appDir = join(root, "app");
|
||||
const distDir = join(root, "dist");
|
||||
const compiledDir = join(distDir, "compiled");
|
||||
const reactivePath = join(distDir, "reactive.js");
|
||||
const publicDir = join(root, "public");
|
||||
const distPublicDir = join(distDir, "public");
|
||||
|
||||
console.log(`Building ${appDir} -> ${distDir}`);
|
||||
|
||||
// Clean output.
|
||||
rmSync(distDir, { recursive: true, force: true });
|
||||
mkdirSync(compiledDir, { recursive: true });
|
||||
if (existsSync(publicDir)) {
|
||||
cpSync(publicDir, distPublicDir, { recursive: true });
|
||||
console.log(`✓ Public: ${distPublicDir}`);
|
||||
}
|
||||
|
||||
// `.wrn` route files are compiled to `.ts` so Bun.build can bundle them.
|
||||
let compiledCount = 0;
|
||||
const importPathFor = (file: string): string => {
|
||||
if (!file.endsWith(".wrn")) return fwd(file);
|
||||
const ts = compileWireFile(readFileSync(file, "utf8"));
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
writeFileSync(out, ts, "utf8");
|
||||
return fwd(out);
|
||||
};
|
||||
|
||||
const config = await loadAppConfig(root);
|
||||
// Regenerate typed DB queries (app/db/queries/*.sql → queries.gen.ts) first, so
|
||||
// any page/API importing them is built against the current SQL.
|
||||
const { regenerateQueries } = await import("./db.ts");
|
||||
const generated = await regenerateQueries(appDir, config.db?.driver);
|
||||
if (generated >= 0) console.log(`✓ Queries: ${generated} (db/queries.gen.ts)`);
|
||||
for (const [name, cfg] of Object.entries(config.databases ?? {})) {
|
||||
const n = await regenerateQueries(appDir, cfg.driver, name);
|
||||
if (n >= 0) console.log(`✓ Queries: ${n} (db/${name}/queries.gen.ts)`);
|
||||
}
|
||||
|
||||
// Bundle DB migrations into the build so the production server can auto-apply
|
||||
// them on startup (dev auto-migrates from app/db/migrations; prod needs the
|
||||
// .sql files inside dist/). The default db's migrations go to dist/migrations;
|
||||
// each named db's to dist/db/<name>/migrations.
|
||||
const defaultMigrationsSrc = join(appDir, "db", "migrations");
|
||||
const hasDefaultMigrations = !!config.db && existsSync(defaultMigrationsSrc);
|
||||
if (hasDefaultMigrations) {
|
||||
cpSync(defaultMigrationsSrc, join(distDir, "migrations"), { recursive: true });
|
||||
console.log(`✓ Migrations: dist/migrations`);
|
||||
}
|
||||
const namedMigrationDbs: string[] = [];
|
||||
for (const name of Object.keys(config.databases ?? {})) {
|
||||
const src = join(appDir, "db", name, "migrations");
|
||||
if (!existsSync(src)) continue;
|
||||
cpSync(src, join(distDir, "db", name, "migrations"), { recursive: true });
|
||||
namedMigrationDbs.push(name);
|
||||
console.log(`✓ Migrations: dist/db/${name}/migrations`);
|
||||
}
|
||||
|
||||
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
|
||||
const assetHash = createHash("sha256");
|
||||
|
||||
// 1) Components are `.wrn` modules rendered server-side — no browser chunks.
|
||||
// They are compiled + statically imported into the manifest below.
|
||||
const reactiveCode = await buildBrowserRuntime(
|
||||
getReactiveRuntime(),
|
||||
reactivePath,
|
||||
join(compiledDir, "reactive.entry.js"),
|
||||
);
|
||||
assetHash.update(reactiveCode);
|
||||
console.log(`✓ Runtime: ${reactivePath}`);
|
||||
|
||||
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
|
||||
const theme = resolveThemeConfig(config.theme);
|
||||
const themeCss = renderThemeCss(theme);
|
||||
const themeJs = renderThemeRuntime(theme);
|
||||
writeFileSync(join(distDir, "theme.css"), themeCss, "utf8");
|
||||
writeFileSync(join(distDir, "theme.js"), themeJs, "utf8");
|
||||
assetHash.update(themeCss);
|
||||
assetHash.update(themeJs);
|
||||
console.log(`✓ Theme: ${theme.names.length} themes (default: ${theme.default})`);
|
||||
|
||||
// 1a2) Wire UI stylesheet (all component classes, themed via tokens).
|
||||
const uiStyles = uiCss();
|
||||
writeFileSync(join(distDir, "ui.css"), uiStyles, "utf8");
|
||||
assetHash.update(uiStyles);
|
||||
console.log(`✓ UI: dist/ui.css`);
|
||||
|
||||
// 1a3) Validation: bake schema descriptors into the client script.
|
||||
const descriptors: Record<string, SchemaDescriptor> = {};
|
||||
for (const s of router.schemas) {
|
||||
const mod = (await import(pathToFileURL(s.file).href)) as { default?: ObjectSchema };
|
||||
if (mod.default && typeof mod.default.describe === "function") {
|
||||
descriptors[s.name] = mod.default.describe();
|
||||
}
|
||||
}
|
||||
const schemasJs = renderSchemasScript(descriptors);
|
||||
assetHash.update(schemasJs);
|
||||
if (router.schemas.length) console.log(`✓ Schemas: ${router.schemas.length}`);
|
||||
|
||||
// 1a4) i18n: bake locale messages into the manifest (opt-in via app/locales).
|
||||
const localeMessages = loadLocales(join(appDir, "locales"));
|
||||
const i18n = Object.keys(localeMessages).length
|
||||
? resolveI18n(localeMessages, config.i18n)
|
||||
: undefined;
|
||||
if (i18n) console.log(`✓ i18n: ${i18n.langs.length} locales (default: ${i18n.default})`);
|
||||
|
||||
// 1b) Build the global stylesheet, if any.
|
||||
const styleEntry = findStyleEntry(appDir, root, config.styles?.entry);
|
||||
let hasStyles = false;
|
||||
let inlineStyles = "";
|
||||
if (styleEntry) {
|
||||
const css = await renderStyles(
|
||||
{ entryPath: styleEntry, appDir, appRoot: root, mode: "production" },
|
||||
config.styles,
|
||||
);
|
||||
assetHash.update(css);
|
||||
writeFileSync(join(distDir, "styles.css"), css, "utf8");
|
||||
hasStyles = true;
|
||||
if (Buffer.byteLength(css, "utf8") <= INLINE_CSS_LIMIT_BYTES) {
|
||||
inlineStyles = css;
|
||||
}
|
||||
console.log(`✓ Styles: ${join(distDir, "styles.css")}`);
|
||||
}
|
||||
const assetVersion = assetHash.digest("hex").slice(0, 12);
|
||||
const headStr = [renderFontHead(config.fonts), headToString(config.head)]
|
||||
.filter(Boolean)
|
||||
.join("\n ");
|
||||
|
||||
// 2) Generate a server entry with STATIC imports + a manifest.
|
||||
const imports: string[] = [];
|
||||
let counter = 0;
|
||||
|
||||
const manifestRoutes = (routes: Route[]): string => {
|
||||
const parts = routes.map((r) => {
|
||||
const v = `m${counter++}`;
|
||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(r.file))};`);
|
||||
return ` { raw: ${JSON.stringify(r.raw)}, mod: ${v} },`;
|
||||
});
|
||||
return parts.length ? `\n${parts.join("\n")}\n ` : "";
|
||||
};
|
||||
|
||||
const pagesLit = manifestRoutes(router.pages);
|
||||
const apiLit = manifestRoutes(router.api);
|
||||
const realtimeLit = manifestRoutes(router.realtime);
|
||||
|
||||
const mwVars = router.middlewareFiles.map((file) => {
|
||||
const v = `mw${counter++}`;
|
||||
imports.push(`import ${v} from ${JSON.stringify(fwd(file))};`);
|
||||
return v;
|
||||
});
|
||||
|
||||
// Components: compile each `.wrn` to a module and statically import it,
|
||||
// keyed by name so the production runtime can render it on demand.
|
||||
const componentsLit = router.components
|
||||
.map((c) => {
|
||||
const v = `c${counter++}`;
|
||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(c.file))};`);
|
||||
return `{ name: ${JSON.stringify(c.name)}, mod: ${v} }`;
|
||||
})
|
||||
.join(", ");
|
||||
console.log(`✓ Components: ${router.components.length}`);
|
||||
|
||||
// Named page layouts (app/layouts/*.wrn), compiled + imported like components.
|
||||
const layoutsLit = router.layouts
|
||||
.map((l) => {
|
||||
const v = `c${counter++}`;
|
||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(l.file))};`);
|
||||
return `{ name: ${JSON.stringify(l.name)}, mod: ${v} }`;
|
||||
})
|
||||
.join(", ");
|
||||
if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`);
|
||||
|
||||
const entry = `// AUTO-GENERATED production server entry — do not edit.
|
||||
import { join } from "node:path";
|
||||
import { createProductionServer } from ${JSON.stringify(PROD_MODULE)};
|
||||
${imports.join("\n")}
|
||||
|
||||
await createProductionServer(
|
||||
{
|
||||
pages: [${pagesLit}],
|
||||
api: [${apiLit}],
|
||||
realtime: [${realtimeLit}],
|
||||
middleware: [${mwVars.join(", ")}],
|
||||
components: [${componentsLit}],
|
||||
layouts: [${layoutsLit}],
|
||||
},
|
||||
{
|
||||
reactivePath: join(import.meta.dir, "reactive.js"),
|
||||
themePath: join(import.meta.dir, "theme.css"),
|
||||
themeJsPath: join(import.meta.dir, "theme.js"),
|
||||
theme: ${JSON.stringify(theme)},
|
||||
uiCssPath: join(import.meta.dir, "ui.css"),
|
||||
schemasJs: ${JSON.stringify(schemasJs)},
|
||||
i18n: ${i18n ? JSON.stringify(i18n) : "undefined"},
|
||||
db: ${config.db ? JSON.stringify(config.db) : "undefined"},
|
||||
databases: ${config.databases ? JSON.stringify(config.databases) : "undefined"},
|
||||
storage: ${config.storage ? JSON.stringify(config.storage) : "undefined"},
|
||||
${hasDefaultMigrations ? `migrationsDir: join(import.meta.dir, "migrations"),` : ""}
|
||||
${
|
||||
namedMigrationDbs.length
|
||||
? `databaseMigrationDirs: { ${namedMigrationDbs
|
||||
.map(
|
||||
(n) =>
|
||||
`${JSON.stringify(n)}: join(import.meta.dir, "db", ${JSON.stringify(n)}, "migrations")`,
|
||||
)
|
||||
.join(", ")} },`
|
||||
: ""
|
||||
}
|
||||
realtime: ${config.realtime ? JSON.stringify(config.realtime) : "undefined"},
|
||||
publicDir: join(import.meta.dir, "public"),
|
||||
${hasStyles ? `stylesPath: join(import.meta.dir, "styles.css"),` : ""}
|
||||
${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""}
|
||||
assetVersion: ${JSON.stringify(assetVersion)},
|
||||
head: ${JSON.stringify(headStr)},
|
||||
seo: ${JSON.stringify(config.seo ?? {})},
|
||||
mobile: ${JSON.stringify(config.mobile ?? {})},
|
||||
pwa: ${JSON.stringify(config.pwa ?? {})},
|
||||
security: ${JSON.stringify(config.security ?? {})},
|
||||
},
|
||||
);
|
||||
`;
|
||||
|
||||
const entryPath = join(distDir, ".server-entry.ts");
|
||||
writeFileSync(entryPath, entry, "utf8");
|
||||
|
||||
// 3) Bundle the entry into a single self-contained, minified server.js
|
||||
// (target bun). This also minifies every bundled page/component/route module.
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entryPath],
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
minify: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("Server build failed:\n" + result.logs.map(String).join("\n"));
|
||||
}
|
||||
writeFileSync(join(distDir, "server.js"), await result.outputs[0]!.text(), "utf8");
|
||||
|
||||
console.log(`✓ Server: ${join(distDir, "server.js")}`);
|
||||
console.log(
|
||||
`✓ Routes: ${router.pages.length} pages, ${router.api.length} api, ${router.realtime.length} realtime, ${mwVars.length} middleware`,
|
||||
);
|
||||
console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
|
||||
}
|
||||
|
||||
async function buildBrowserRuntime(
|
||||
source: string,
|
||||
outFile: string,
|
||||
entryFile: string,
|
||||
): Promise<string> {
|
||||
writeFileSync(entryFile, source, "utf8");
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entryFile],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
minify: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("Runtime build failed:\n" + result.logs.map(String).join("\n"));
|
||||
}
|
||||
const code = await result.outputs[0]!.text();
|
||||
writeFileSync(outFile, code, "utf8");
|
||||
return code;
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* `wrnexus create <app-name>` — scaffold a new app from an inline template.
|
||||
*
|
||||
* Kept dependency-free and explicit: the template files live here as strings so
|
||||
* scaffolding works without copying from anywhere on disk.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
|
||||
/** Map of file (relative to app root) -> contents. */
|
||||
const TEMPLATE: Record<string, string> = {
|
||||
// AI/agent context: teaches Claude Code / Cursor / Copilot the WrNexus conventions.
|
||||
"CLAUDE.md": CLAUDE_MD,
|
||||
"llms.txt": AI_GUIDE,
|
||||
"package.json": `{
|
||||
"name": "APP_NAME",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrnexus dev .",
|
||||
"build": "wrnexus build .",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"check": "bun run lint && bun run format:check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "^0.2.0",
|
||||
"@wrnexus/core": "^0.2.0",
|
||||
"@wrnexus/styles": "^0.2.0",
|
||||
"@wrnexus/validation": "^0.2.0",
|
||||
"@wrnexus/db": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "^0.2.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"@types/bun": "latest",
|
||||
"eslint": "^9.0.0",
|
||||
"prettier": "latest",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "latest"
|
||||
}
|
||||
}
|
||||
`,
|
||||
"tsconfig.json": `{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@wrnexus/core"
|
||||
},
|
||||
"include": ["app", "wrnexus.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
|
||||
}
|
||||
`,
|
||||
"eslint.config.js": `import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", ".wrnexus/**", "**/.wrnexus/**", "mobile/android/**", "mobile/ios/**"],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-undef": "off",
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
`,
|
||||
".prettierrc.json": `{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
`,
|
||||
".prettierignore": `node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
`,
|
||||
".editorconfig": `root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
`,
|
||||
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
mobile: {
|
||||
enabled: true,
|
||||
appId: "com.example.APP_SLUG",
|
||||
appName: "APP_NAME",
|
||||
userAgent: "WrNexusMobile",
|
||||
backgroundColor: "#0f172a",
|
||||
// layout: "mobile", // app/layouts/mobile.wrn
|
||||
// icon: "resources/icon.png",
|
||||
},
|
||||
|
||||
// PWA support is enabled automatically. Override any install metadata here.
|
||||
pwa: {
|
||||
name: "APP_NAME",
|
||||
shortName: "APP_NAME",
|
||||
display: "standalone",
|
||||
themeColor: "#6366f1",
|
||||
backgroundColor: "#0f172a",
|
||||
},
|
||||
|
||||
seo: {
|
||||
title: "APP_NAME",
|
||||
titleTemplate: "%s | APP_NAME",
|
||||
description: "An SSR-first WrNexus app.",
|
||||
robots: "index,follow",
|
||||
themeColor: "#6366f1",
|
||||
},
|
||||
|
||||
styles: {
|
||||
entry: "app/styles/global.css",
|
||||
|
||||
// Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart)
|
||||
// and at \`wrnexus build\`. \`@tailwindcss/cli\` writes to stdout, so we capture
|
||||
// and return the final CSS. Delete this hook to drop Tailwind — global.css is
|
||||
// still bundled and served as-is.
|
||||
process: async ({ entryPath, mode }) => {
|
||||
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||||
if (mode === "production") args.push("--minify");
|
||||
return await Bun.$\`bunx \${args}\`.text();
|
||||
},
|
||||
},
|
||||
|
||||
// Fonts — the framework emits optimized <head> markup (preconnect, subsetted
|
||||
// Google Fonts with font-display, self-hosted @font-face with preload) and
|
||||
// auto-extends the CSP for Google Fonts. Uncomment to use a custom font:
|
||||
//
|
||||
// fonts: {
|
||||
// sans: '"Inter", ui-sans-serif, system-ui, sans-serif',
|
||||
// google: [{ family: "Inter", weights: [400, 500, 600, 700] }],
|
||||
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
|
||||
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
|
||||
// },
|
||||
|
||||
// security: {
|
||||
// cors: {
|
||||
// enabled: true,
|
||||
// origin: ["http://localhost:5173"],
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
export default config;
|
||||
`,
|
||||
"public/robots.txt": `User-agent: *
|
||||
Allow: /
|
||||
`,
|
||||
"app/styles/global.css": `/*
|
||||
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
|
||||
* wrnexus.config.ts and served at /__wrnexus/styles.css on every page.
|
||||
*
|
||||
* @source tells Tailwind which files to scan for class names.
|
||||
*/
|
||||
@import "tailwindcss";
|
||||
@source "../**/*.wrn";
|
||||
@source "../**/*.tsx";
|
||||
|
||||
/* Make Tailwind's \`dark:\` variant follow the framework's data-theme attribute
|
||||
* (set on <html> by the theme system), not the OS setting. Any element with
|
||||
* data-wire-theme-toggle flips it. */
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
`,
|
||||
"app/pages/index.wrn": `// Home page (route: /). SSR-first: the view is server-rendered, then components
|
||||
// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind.
|
||||
page Home {
|
||||
seo {
|
||||
title = "Home"
|
||||
description = "APP_NAME — built with WrNexus, an SSR-first Bun framework."
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="relative min-h-screen overflow-hidden bg-white text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 -top-40 mx-auto h-96 max-w-2xl rounded-full bg-indigo-500/20 blur-3xl"></div>
|
||||
|
||||
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col px-6">
|
||||
<header class="flex items-center justify-between py-6">
|
||||
<span class="flex items-center gap-2.5 font-semibold tracking-tight">
|
||||
<span class="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">W</span>
|
||||
APP_NAME
|
||||
</span>
|
||||
<button data-wire-theme-toggle class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:text-slate-400 dark:hover:border-white/20 dark:hover:text-white">
|
||||
Toggle theme
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="flex flex-1 flex-col items-center justify-center py-16 text-center">
|
||||
<p class="font-mono text-xs uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400">SSR-first · Bun-native</p>
|
||||
|
||||
<h1 class="mt-5 text-4xl font-bold leading-[1.1] tracking-tight sm:text-6xl">
|
||||
Server-rendered.<br />
|
||||
Instantly <span class="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent">interactive</span>.
|
||||
</h1>
|
||||
|
||||
<p class="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
APP_NAME runs on WrNexus — write <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-white/10 dark:text-slate-200">.wrn</code> components, ship no client boilerplate, and let the server do the work.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="/about" class="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200">Get started</a>
|
||||
<a href="/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View demo</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-14 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div class="flex items-center gap-2 font-mono text-xs text-slate-400">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||
live · hydrated on the server
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between gap-4">
|
||||
<div data-component="counter" start="0" label="Clicks"></div>
|
||||
<span class="max-w-[10rem] text-right text-xs leading-snug text-slate-500">This button works. You wrote zero client JavaScript.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-10 font-mono text-xs text-slate-400 dark:text-slate-600">
|
||||
edit <span class="text-slate-600 dark:text-slate-400">app/pages/index.wrn</span> to make it yours
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer class="border-t border-slate-100 py-6 text-center text-xs text-slate-400 dark:border-white/5 dark:text-slate-600">
|
||||
Built with <a href="https://www.npmjs.com/package/@wrnexus/cli" class="text-slate-600 underline-offset-2 hover:underline dark:text-slate-400">WrNexus</a>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
`,
|
||||
"app/components/counter.wrn": `// A reusable component. Route: none — mounted inside a page with
|
||||
// <div data-component="counter" ...props></div>.
|
||||
//
|
||||
// Components render on the SERVER (with their props applied) and are hydrated in
|
||||
// the browser by the generic reactive runtime — they ship no JS of their own.
|
||||
component Counter {
|
||||
// Props arrive as mount attributes, each coerced to the type of its default
|
||||
// (so start="5" arrives as the number 5).
|
||||
props {
|
||||
start = 0
|
||||
label = "Count"
|
||||
}
|
||||
|
||||
// State can reference props. \`count\` seeds the reactive scope.
|
||||
state count = start
|
||||
|
||||
view {
|
||||
<button @click="count++" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-500 active:scale-[0.98]">{label}: {count}</button>
|
||||
}
|
||||
}
|
||||
`,
|
||||
"app/api/hello.ts": `export const GET = async () => {
|
||||
return Response.json({ message: "Hello API" });
|
||||
};
|
||||
`,
|
||||
"app/api/ai.ts": `// POST /api/ai { "prompt": "..." } → Claude's reply.
|
||||
// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this.
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
|
||||
}
|
||||
const { prompt } = await ctx.req.json().catch(() => ({}));
|
||||
if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 });
|
||||
|
||||
// Stream the reply back as plain text. Use \`ai.generate(prompt)\` for a one-shot string.
|
||||
return ai.streamResponse(prompt);
|
||||
};
|
||||
`,
|
||||
"app/middleware/logger.ts": `export default async function logger(ctx, next) {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next();
|
||||
}
|
||||
`,
|
||||
"app/realtime/chat.ts": `// ws://<host>/realtime/chat — a simple broadcast room.
|
||||
//
|
||||
// The client side is the framework's realtime runtime; a page opts in with
|
||||
// \`data-room="chat"\`. Here we only handle room events.
|
||||
//
|
||||
// client.send(msg) → just this connection
|
||||
// client.broadcast(msg) → everyone else in the room
|
||||
// client.room.broadcast(msg) → everyone, including the sender
|
||||
import { defineRoom } from "@wrnexus/core";
|
||||
|
||||
export default defineRoom({
|
||||
onConnect(client) {
|
||||
client.send({ type: "system", text: "connected" });
|
||||
},
|
||||
|
||||
onMessage(client, msg) {
|
||||
// Echo each message to the whole room so every tab stays in sync.
|
||||
client.room.broadcast({ type: "message", data: msg });
|
||||
},
|
||||
});
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the app template into `root` (absolute), substituting the app name.
|
||||
* Reused by `wrnexus create` and the workspace scaffolder. Refuses to overwrite.
|
||||
*/
|
||||
export function scaffoldApp(root: string, appName: string): void {
|
||||
if (existsSync(root)) {
|
||||
console.error(`Refusing to overwrite existing directory: ${root}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const [rel, contents] of Object.entries(TEMPLATE)) {
|
||||
const target = join(root, rel);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
const appSlug = appName.toLowerCase().replace(/[^a-z0-9]+/g, "") || "app";
|
||||
writeFileSync(
|
||||
target,
|
||||
contents.replaceAll("APP_NAME", appName).replaceAll("APP_SLUG", appSlug),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp(name: string): void {
|
||||
if (!name) {
|
||||
console.error("Usage: wrnexus create <app-name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
scaffoldApp(resolve(process.cwd(), name), name);
|
||||
|
||||
console.log(`✓ Created ${name}`);
|
||||
console.log(`\nNext steps:`);
|
||||
console.log(` cd ${name}`);
|
||||
console.log(` bun install`);
|
||||
console.log(` bun run dev`);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* `wrnexus db <cmd> [--db=<name>]` — database migrations & tooling.
|
||||
*
|
||||
* wrnexus db new <name> [--from-models] scaffold a migration (from TS models)
|
||||
* wrnexus db migrate apply all pending migrations
|
||||
* wrnexus db rollback revert the last applied migration
|
||||
* wrnexus db status list applied / pending migrations
|
||||
* wrnexus db generate regenerate typed queries
|
||||
* wrnexus db seed run the seed script
|
||||
* wrnexus db studio [table] inspect tables
|
||||
*
|
||||
* Without `--db`, commands target the DEFAULT database (`db` in wrnexus.config.ts),
|
||||
* with files under `app/db/`. With `--db=<name>`, they target the named database
|
||||
* (`databases.<name>`), with files under `app/db/<name>/`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
|
||||
import {
|
||||
generateQueriesFile,
|
||||
migrate,
|
||||
parseQueries,
|
||||
rollback,
|
||||
scaffoldMigration,
|
||||
status,
|
||||
type Dialect,
|
||||
type Model,
|
||||
type ModelRef,
|
||||
} from "@wrnexus/db";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
|
||||
function dialectOf(driver: string | undefined): Dialect {
|
||||
return driver === "postgres" || driver === "mysql" ? driver : "sqlite";
|
||||
}
|
||||
|
||||
/** The directory holding a database's schema/migrations/queries. */
|
||||
function dbBaseOf(appDir: string, dbName: string | null): string {
|
||||
return dbName ? join(appDir, "db", dbName) : join(appDir, "db");
|
||||
}
|
||||
|
||||
/** List user tables for the connected database (dialect-aware introspection). */
|
||||
async function listTables(db: import("@wrnexus/db").Db): Promise<string[]> {
|
||||
const dialect = db.driver.dialect;
|
||||
const sql =
|
||||
dialect === "postgres"
|
||||
? "SELECT tablename AS name FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
||||
: dialect === "mysql"
|
||||
? "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name"
|
||||
: "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name";
|
||||
const rows = await db.all<{ name: string }>(sql);
|
||||
return rows.map((r) => r.name).filter((n) => n !== "_wire_migrations");
|
||||
}
|
||||
|
||||
function isModel(value: unknown): value is Model {
|
||||
const m = value as Partial<Model> | null;
|
||||
return (
|
||||
!!m &&
|
||||
typeof m === "object" &&
|
||||
typeof m.name === "string" &&
|
||||
typeof m.parse === "function" &&
|
||||
typeof m.describe === "function" &&
|
||||
!!m.columns
|
||||
);
|
||||
}
|
||||
|
||||
/** Load model refs from a database's `schema.ts` (dbBase is app/db or app/db/<name>). */
|
||||
async function loadModelRefs(dbBase: string): Promise<ModelRef[]> {
|
||||
const schemaFile = join(dbBase, "schema.ts");
|
||||
if (!existsSync(schemaFile)) return [];
|
||||
const mod = (await import(pathToFileURL(schemaFile).href)) as Record<string, unknown>;
|
||||
return Object.entries(mod)
|
||||
.filter(([, value]) => isModel(value))
|
||||
.map(([varName, model]) => ({ varName, model: model as Model }));
|
||||
}
|
||||
|
||||
async function loadModels(dbBase: string): Promise<Model[]> {
|
||||
return (await loadModelRefs(dbBase)).map((r) => r.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate one database's `queries.gen.ts` from its `queries/*.sql`. Returns the
|
||||
* number of queries generated, or -1 if there is no queries directory. `dbName`
|
||||
* selects a named database (files under app/db/<name>/); null = the default.
|
||||
*/
|
||||
export async function regenerateQueries(
|
||||
appDir: string,
|
||||
driver: string | undefined,
|
||||
dbName: string | null = null,
|
||||
): Promise<number> {
|
||||
const dbBase = dbBaseOf(appDir, dbName);
|
||||
const queriesDir = join(dbBase, "queries");
|
||||
if (!existsSync(queriesDir)) return -1;
|
||||
const queries = readdirSync(queriesDir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.flatMap((f) => parseQueries(readFileSync(join(queriesDir, f), "utf8")));
|
||||
const refs = await loadModelRefs(dbBase);
|
||||
const code = generateQueriesFile(queries, refs, dialectOf(driver));
|
||||
writeFileSync(join(dbBase, "queries.gen.ts"), code, "utf8");
|
||||
return queries.length;
|
||||
}
|
||||
|
||||
/** Regenerate typed queries for the default database and every named one. */
|
||||
export async function regenerateAllQueries(appDir: string, config: AppConfig): Promise<void> {
|
||||
await regenerateQueries(appDir, config.db?.driver, null);
|
||||
for (const [name, cfg] of Object.entries(config.databases ?? {})) {
|
||||
await regenerateQueries(appDir, cfg.driver, name);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDbCommand(
|
||||
appRoot: string,
|
||||
sub: string | undefined,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appDir = join(root, "app");
|
||||
const config = await loadAppConfig(root);
|
||||
|
||||
// --db=<name> targets a named database + its app/db/<name>/ folder.
|
||||
const dbFlag = args.find((a) => a.startsWith("--db="));
|
||||
const dbName = dbFlag ? (dbFlag.split("=")[1] ?? "") : null;
|
||||
const dbConfig = dbName ? config.databases?.[dbName] : config.db;
|
||||
const dbBase = dbBaseOf(appDir, dbName);
|
||||
const migrationsDir = join(dbBase, "migrations");
|
||||
const label = dbName ? ` (db: ${dbName})` : "";
|
||||
|
||||
if (dbName && !config.databases?.[dbName]) {
|
||||
console.error(
|
||||
`No database named '${dbName}' in wrnexus.config.ts. ` +
|
||||
`Add it under databases: { ${dbName}: { driver, url } }.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (sub === "new") {
|
||||
const name = args.find((a) => !a.startsWith("--")) ?? "migration";
|
||||
const fromModels = args.includes("--from-models");
|
||||
const models = fromModels ? await loadModels(dbBase) : [];
|
||||
if (fromModels && models.length === 0) {
|
||||
console.warn(`[wrnexus] no models found in ${join(dbBase, "schema.ts")}`);
|
||||
}
|
||||
console.log(
|
||||
`✓ Created ${scaffoldMigration(migrationsDir, name, dialectOf(dbConfig?.driver), models)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "generate") {
|
||||
const count = await regenerateQueries(appDir, dbConfig?.driver, dbName);
|
||||
if (count < 0) console.warn(`[wrnexus] no ${join(dbBase, "queries")} directory`);
|
||||
else console.log(`✓ Generated ${join(dbBase, "queries.gen.ts")} (${count} queries)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dbConfig) {
|
||||
console.error(
|
||||
"No `db` config in wrnexus.config.ts. Add: db: { driver: 'sqlite', url: 'file:./dev.db' }",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = connectFromConfig(dbConfig, root);
|
||||
try {
|
||||
switch (sub) {
|
||||
case "seed": {
|
||||
const seedFile = join(dbBase, "seed.ts");
|
||||
if (!existsSync(seedFile)) {
|
||||
console.warn(`[wrnexus] no ${seedFile}`);
|
||||
break;
|
||||
}
|
||||
const mod = (await import(pathToFileURL(seedFile).href)) as {
|
||||
default?: (db: unknown) => Promise<void>;
|
||||
seed?: (db: unknown) => Promise<void>;
|
||||
};
|
||||
const fn = mod.default ?? mod.seed;
|
||||
if (typeof fn !== "function") {
|
||||
console.error(`${seedFile} must export a default async function(db).`);
|
||||
process.exit(1);
|
||||
}
|
||||
await fn(db);
|
||||
console.log(`✓ Seeded${label}`);
|
||||
break;
|
||||
}
|
||||
case "studio": {
|
||||
const target = args.find((a) => !a.startsWith("--"));
|
||||
const tables = await listTables(db);
|
||||
if (target) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(target)) {
|
||||
console.error(`Invalid table name: ${target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!tables.includes(target)) {
|
||||
console.error(`No such table: ${target}. Available: ${tables.join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const rows = await db.all(`SELECT * FROM ${target} LIMIT 50`);
|
||||
console.log(`\n${target}${label} — first ${rows.length} row(s):`);
|
||||
console.table(rows);
|
||||
} else if (tables.length === 0) {
|
||||
console.log(
|
||||
`No tables found${label}. Run \`wrnexus db migrate${dbFlag ? " " + dbFlag : ""}\` first.`,
|
||||
);
|
||||
} else {
|
||||
console.log(`\nTables${label}:`);
|
||||
for (const t of tables) {
|
||||
const count = await db.one<{ n: number }>(`SELECT COUNT(*) AS n FROM ${t}`);
|
||||
console.log(` ${t.padEnd(24)} ${Number(count?.n ?? 0)} rows`);
|
||||
}
|
||||
console.log("\nInspect one with: wrnexus db studio <table>");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "migrate": {
|
||||
const applied = await migrate(db, migrationsDir);
|
||||
console.log(
|
||||
applied.length
|
||||
? `✓ Applied ${applied.length}${label}:\n ${applied.join("\n ")}`
|
||||
: `Already up to date${label}.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "rollback": {
|
||||
const name = await rollback(db, migrationsDir);
|
||||
console.log(name ? `✓ Rolled back ${name}${label}` : `Nothing to roll back${label}.`);
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
const rows = await status(db, migrationsDir);
|
||||
if (rows.length === 0) console.log(`No migrations found in ${migrationsDir}.`);
|
||||
else for (const r of rows) console.log(` [${r.applied ? "x" : " "}] ${r.name}`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
"Usage: wrnexus db <migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* `wrnexus dev` — the development supervisor.
|
||||
*
|
||||
* The child server process owns file watching and HMR now (see @wrnexus/dev-server):
|
||||
* - CSS and client-island edits update the live page over a WebSocket with no
|
||||
* process restart and no full reload.
|
||||
* - When a server module changes (it can't be re-imported in-process), the
|
||||
* child exits with RESTART_EXIT_CODE and this supervisor respawns it. The
|
||||
* browser reconnects and morphs in the new HTML — no visible refresh.
|
||||
*
|
||||
* The supervisor therefore only (re)launches the child; it does not watch files.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { RESTART_EXIT_CODE } from "@wrnexus/dev-server";
|
||||
|
||||
// Resolve the dev server child entry through the package (not a source path) so
|
||||
// it works whether @wrnexus/dev-server is a workspace or an installed dependency.
|
||||
const SERVE_ENTRY = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
|
||||
|
||||
export function runDev(appRoot: string, port: number, hostname = "::"): void {
|
||||
const appDir = join(resolve(appRoot), "app");
|
||||
let child: ChildProcess | null = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
const spawnChild = (): void => {
|
||||
child = spawn(
|
||||
process.execPath, // the Bun binary
|
||||
[SERVE_ENTRY, appDir, String(port), "development", hostname],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (shuttingDown || signal) return;
|
||||
if (code === RESTART_EXIT_CODE) {
|
||||
spawnChild(); // requested restart — respawn immediately
|
||||
return;
|
||||
}
|
||||
if (code && code !== 0) {
|
||||
// Crash (e.g. a syntax error). Respawn after a short delay so the
|
||||
// watcher comes back and the server auto-recovers once it's fixed.
|
||||
console.error(`[wrnexus] server exited (code ${code}); retrying in 1.2s…`);
|
||||
setTimeout(() => {
|
||||
if (!shuttingDown) spawnChild();
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
console.log(`\n ⚡ WrNexus dev (HMR) — ${appDir}`);
|
||||
|
||||
// Regenerate typed DB queries + typed routes once before starting, then launch.
|
||||
// (Best effort; rerun `wrnexus db generate` after editing .sql.)
|
||||
void (async () => {
|
||||
try {
|
||||
const { loadAppConfig } = await import("@wrnexus/styles");
|
||||
const { regenerateAllQueries } = await import("./db.ts");
|
||||
const config = await loadAppConfig(resolve(appRoot));
|
||||
await regenerateAllQueries(appDir, config); // default + every named database
|
||||
console.log(" ↻ db queries generated");
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
try {
|
||||
const { regenerateRoutes } = await import("./routes.ts");
|
||||
const n = regenerateRoutes(appDir);
|
||||
console.log(` ↻ ${n} typed routes generated`);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
spawnChild();
|
||||
})();
|
||||
|
||||
const shutdown = () => {
|
||||
shuttingDown = true;
|
||||
child?.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `wrnexus generate docker` — scaffold containerization for a WrNexus app:
|
||||
* a multi-stage Dockerfile (build with Bun → slim runtime), a .dockerignore,
|
||||
* and a docker-compose.yml (app + Postgres). Uses the app's `/healthz` endpoint
|
||||
* for the container health check.
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const DOCKERFILE = `# syntax=docker/dockerfile:1
|
||||
# --- build stage: install deps + produce dist/server.js ---
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock* bun.lockb* ./
|
||||
RUN bun install
|
||||
COPY . .
|
||||
RUN bun run build
|
||||
|
||||
# --- runtime stage: slim image with only the built server + migrations ---
|
||||
FROM oven/bun:1-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/app/db/migrations ./app/db/migrations
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \\
|
||||
CMD bun -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
CMD ["bun", "dist/server.js"]
|
||||
`;
|
||||
|
||||
const DOCKERIGNORE = `node_modules
|
||||
dist
|
||||
**/.wrnexus
|
||||
.git
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.DS_Store
|
||||
`;
|
||||
|
||||
const COMPOSE = `services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgres://wire:wire@db:5432/app
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: wire
|
||||
POSTGRES_PASSWORD: wire
|
||||
POSTGRES_DB: app
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wire -d app"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
`;
|
||||
|
||||
function writeIfAbsent(path: string, content: string, name: string): void {
|
||||
if (existsSync(path)) {
|
||||
console.warn(` • ${name} already exists — skipped`);
|
||||
return;
|
||||
}
|
||||
writeFileSync(path, content, "utf8");
|
||||
console.log(` ✓ ${name}`);
|
||||
}
|
||||
|
||||
/** Scaffold Dockerfile, .dockerignore, and docker-compose.yml into `appRoot`. */
|
||||
export function generateDocker(appRoot: string): void {
|
||||
const root = resolve(appRoot);
|
||||
console.log("Scaffolding containerization:");
|
||||
writeIfAbsent(join(root, "Dockerfile"), DOCKERFILE, "Dockerfile");
|
||||
writeIfAbsent(join(root, ".dockerignore"), DOCKERIGNORE, ".dockerignore");
|
||||
writeIfAbsent(join(root, "docker-compose.yml"), COMPOSE, "docker-compose.yml");
|
||||
console.log("\nBuild + run: docker compose up --build");
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export interface DoctorCheck {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
const root = resolve(appRoot);
|
||||
const checks: DoctorCheck[] = [];
|
||||
const pkgPath = join(root, "package.json");
|
||||
checks.push({
|
||||
name: "Bun runtime",
|
||||
ok: typeof Bun !== "undefined",
|
||||
detail: typeof Bun !== "undefined" ? `v${Bun.version}` : "Bun is required",
|
||||
});
|
||||
checks.push({
|
||||
name: "package.json",
|
||||
ok: existsSync(pkgPath),
|
||||
detail: existsSync(pkgPath) ? pkgPath : "Run this command from a WrNexus project root",
|
||||
});
|
||||
const app = join(root, "app");
|
||||
checks.push({
|
||||
name: "app/pages",
|
||||
ok: existsSync(join(app, "pages")),
|
||||
detail: existsSync(join(app, "pages")) ? "page directory found" : "Create app/pages",
|
||||
});
|
||||
const config = ["wrnexus.config.ts", "wrnexus.config.mjs", "wrnexus.config.js"].find((name) =>
|
||||
existsSync(join(root, name)),
|
||||
);
|
||||
checks.push({
|
||||
name: "configuration",
|
||||
ok: !!config,
|
||||
detail: config ?? "No wrnexus.config file; framework defaults will be used",
|
||||
});
|
||||
const mobilePkg = join(root, "mobile", "package.json");
|
||||
if (existsSync(mobilePkg)) {
|
||||
try {
|
||||
const mobile = JSON.parse(readFileSync(mobilePkg, "utf8")) as {
|
||||
wrnexus?: { mode?: string };
|
||||
};
|
||||
checks.push({
|
||||
name: "mobile project",
|
||||
ok: mobile.wrnexus?.mode === "webview" || mobile.wrnexus?.mode === "native",
|
||||
detail: `mode: ${mobile.wrnexus?.mode ?? "missing"}`,
|
||||
});
|
||||
} catch {
|
||||
checks.push({
|
||||
name: "mobile project",
|
||||
ok: false,
|
||||
detail: "mobile/package.json is invalid JSON",
|
||||
});
|
||||
}
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function runDoctor(appRoot: string): boolean {
|
||||
const checks = inspectProject(appRoot);
|
||||
console.log("WrNexus doctor\n");
|
||||
for (const check of checks)
|
||||
console.log(` ${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`);
|
||||
console.log("\n Security dependencies: run `bun audit`");
|
||||
console.log(" Complete verification: run `bun run check`");
|
||||
return checks.every((check) => check.ok || check.name === "configuration");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* `wrnexus eject <name...>` — copy a Wire UI component's `.wrn` source into the
|
||||
* app's `app/components/`, so you fully own and can edit it. The auto-discovered
|
||||
* library version is shadowed by the app copy (same name → app wins).
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { uiComponentsDir, uiComponentNames } from "@wrnexus/ui";
|
||||
|
||||
export function runEject(appRoot: string, names: string[]): void {
|
||||
const root = resolve(appRoot);
|
||||
const dest = join(root, "app", "components");
|
||||
const available = uiComponentNames();
|
||||
|
||||
if (names.length === 0) {
|
||||
console.log("Usage: wrnexus eject <name...>\n\nAvailable components:");
|
||||
console.log(" " + available.join(", "));
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const name of names) {
|
||||
if (!available.includes(name)) {
|
||||
console.error(`✗ Unknown component "${name}". Available: ${available.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
const src = join(uiComponentsDir(), `${name}.wrn`);
|
||||
const out = join(dest, `${name}.wrn`);
|
||||
if (existsSync(out)) {
|
||||
console.error(`✗ ${name}: app/components/${name}.wrn already exists — skipped`);
|
||||
continue;
|
||||
}
|
||||
copyFileSync(src, out);
|
||||
console.log(`✓ Ejected ${name} -> app/components/${name}.wrn`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* `wrnexus generate <type> <name>` — scaffold a page, component, API route, or
|
||||
* schema from a template. Keeps new files consistent and gets users moving fast.
|
||||
*
|
||||
* wrnexus generate page about
|
||||
* wrnexus generate component user-card
|
||||
* wrnexus generate api users/list
|
||||
* wrnexus generate schema signup
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
export type GenerateType = "page" | "component" | "api" | "schema";
|
||||
|
||||
const ALIASES: Record<string, GenerateType> = {
|
||||
page: "page",
|
||||
p: "page",
|
||||
component: "component",
|
||||
c: "component",
|
||||
api: "api",
|
||||
a: "api",
|
||||
schema: "schema",
|
||||
s: "schema",
|
||||
};
|
||||
|
||||
export interface GeneratedFile {
|
||||
/** Path relative to the `app/` directory. */
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function toPascalCase(name: string): string {
|
||||
return name
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function baseName(name: string): string {
|
||||
const parts = name.split("/");
|
||||
return parts[parts.length - 1] ?? name;
|
||||
}
|
||||
|
||||
/** Produce the file (relative path + content) for a generate request. */
|
||||
export function scaffold(type: GenerateType, name: string): GeneratedFile {
|
||||
const clean = name.replace(/\.(wrn|ts)$/, "").replace(/^\/+|\/+$/g, "");
|
||||
const pascal = toPascalCase(baseName(clean));
|
||||
|
||||
switch (type) {
|
||||
case "page":
|
||||
return {
|
||||
path: `pages/${clean}.wrn`,
|
||||
content: `page ${pascal} {
|
||||
layout = "public"
|
||||
|
||||
seo {
|
||||
title = "${pascal}"
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>${pascal}</h1>
|
||||
<p>Edit app/pages/${clean}.wrn to build this page.</p>
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
case "component":
|
||||
return {
|
||||
path: `components/${clean}.wrn`,
|
||||
content: `component ${pascal} {
|
||||
props {
|
||||
label = "${pascal}"
|
||||
}
|
||||
|
||||
view {
|
||||
<div class="wire-${baseName(clean)}">{label}</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
case "api":
|
||||
return {
|
||||
path: `api/${clean}.ts`,
|
||||
content: `import type { Context } from "@wrnexus/core";
|
||||
|
||||
export async function GET(ctx: Context): Promise<Response> {
|
||||
return Response.json({ ok: true, route: ctx.url.pathname });
|
||||
}
|
||||
`,
|
||||
};
|
||||
case "schema":
|
||||
return {
|
||||
path: `schemas/${clean}.ts`,
|
||||
content: `import { v } from "@wrnexus/validation";
|
||||
|
||||
export default v.object({
|
||||
name: v.string().min(1, "Required"),
|
||||
});
|
||||
`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a scaffolded file under `<appRoot>/app`, refusing to overwrite. */
|
||||
export function runGenerate(
|
||||
appRoot: string,
|
||||
typeArg: string | undefined,
|
||||
name: string | undefined,
|
||||
): void {
|
||||
const type = typeArg ? ALIASES[typeArg] : undefined;
|
||||
if (!type || !name) {
|
||||
console.error("Usage: wrnexus generate <page|component|api|schema> <name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const file = scaffold(type, name);
|
||||
const target = join(resolve(appRoot), "app", file.path);
|
||||
if (existsSync(target)) {
|
||||
console.error(`Refusing to overwrite existing file: app/${file.path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, file.content, "utf8");
|
||||
console.log(`✓ Created app/${file.path}`);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* @wrnexus/cli — the `wrnexus` command line.
|
||||
*
|
||||
* wrnexus dev [app-dir] [--port=3000] start the dev server (live reload)
|
||||
* wrnexus build [app-dir] build a production server + assets
|
||||
* wrnexus create <app-name> scaffold a new app
|
||||
* wrnexus eject <name...> copy a Wire UI component into your app
|
||||
* wrnexus db <migrate|rollback|status|new> database migrations
|
||||
*/
|
||||
|
||||
import { join, resolve } from "node:path";
|
||||
import { resolveProfile, loadEnv } from "@wrnexus/styles";
|
||||
import { runDev } from "./dev.ts";
|
||||
import { createApp } from "./create.ts";
|
||||
|
||||
/**
|
||||
* Resolve the active profile from `--profile=<name>` (or WRNEXUS_PROFILE / mode),
|
||||
* publish it as WRNEXUS_PROFILE (so config loaders + the dev child pick it up),
|
||||
* and load its `.env` cascade into process.env. Returns the profile name.
|
||||
*/
|
||||
function bootstrapProfile(
|
||||
appRoot: string,
|
||||
mode: "development" | "production",
|
||||
args: string[],
|
||||
): string {
|
||||
const flag = args.find((a) => a.startsWith("--profile="));
|
||||
const profile = resolveProfile({ explicit: flag?.split("=")[1], mode });
|
||||
process.env.WRNEXUS_PROFILE = profile;
|
||||
const loaded = loadEnv(resolve(appRoot), profile);
|
||||
const count = Object.keys(loaded).length;
|
||||
console.log(` ▸ profile: ${profile}${count ? ` (${count} env vars loaded)` : ""}`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
function help(): void {
|
||||
console.log(`wrnexus — WrNexus CLI
|
||||
|
||||
Usage:
|
||||
wrnexus dev [app-dir] [--port=3000] [--host=::]
|
||||
Start the development server (live reload)
|
||||
wrnexus build [app-dir] Build a production server bundle + assets
|
||||
wrnexus create <app-name> Scaffold a new app
|
||||
wrnexus workspace <name> Scaffold a monorepo (apps/* + shared packages/*)
|
||||
wrnexus gateway [--port=3000] Serve every workspace app behind one port, routed by domain
|
||||
wrnexus generate <type> <name> Scaffold a page | component | api | schema
|
||||
wrnexus generate routes | docker | mobile
|
||||
Generate routes or scaffold deployment targets
|
||||
wrnexus mobile add <package...> Install Capacitor or Expo native packages
|
||||
wrnexus mobile compile Compile .wrn pages into native Expo routes
|
||||
wrnexus native list List cross-platform native capabilities
|
||||
wrnexus native add <capability...> Install capability packages for the configured mobile mode
|
||||
wrnexus eject <name...> Copy a Wire UI component into app/components
|
||||
wrnexus update [dir] [--latest] Upgrade @wrnexus/* deps + apply config/file migrations
|
||||
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
|
||||
wrnexus test [app-dir] [--watch] Run the app's tests (bun test, 'test' profile)
|
||||
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
||||
wrnexus doctor [app-dir] Check project structure, runtime, mobile config, and next fixes
|
||||
|
||||
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
|
||||
that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
switch (command) {
|
||||
case "dev": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const portArg = rest.find((a) => a.startsWith("--port="));
|
||||
const hostArg = rest.find((a) => a.startsWith("--host="));
|
||||
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
|
||||
const host = hostArg?.split("=")[1] || "::";
|
||||
bootstrapProfile(appRoot, "development", rest);
|
||||
runDev(appRoot, port, host);
|
||||
break;
|
||||
}
|
||||
case "build": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
bootstrapProfile(appRoot, "production", rest);
|
||||
const { runBuild } = await import("./build.ts");
|
||||
await runBuild(appRoot);
|
||||
break;
|
||||
}
|
||||
case "create":
|
||||
createApp(rest[0] ?? "");
|
||||
break;
|
||||
case "workspace": {
|
||||
const { createWorkspace } = await import("./workspace.ts");
|
||||
createWorkspace(rest.find((a) => !a.startsWith("--")) ?? "");
|
||||
break;
|
||||
}
|
||||
case "gateway": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const { runGateway } = await import("./workspace.ts");
|
||||
await runGateway(appRoot, rest);
|
||||
break;
|
||||
}
|
||||
case "generate":
|
||||
case "g": {
|
||||
if (rest[0] === "routes") {
|
||||
const { regenerateRoutes } = await import("./routes.ts");
|
||||
const n = regenerateRoutes(join(process.cwd(), "app"));
|
||||
console.log(`✓ Generated app/routes.gen.ts (${n} routes)`);
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "docker") {
|
||||
const { generateDocker } = await import("./docker.ts");
|
||||
generateDocker(process.cwd());
|
||||
break;
|
||||
}
|
||||
if (rest[0] === "mobile") {
|
||||
const { generateMobile, mobileOptions } = await import("./mobile.ts");
|
||||
await generateMobile(process.cwd(), mobileOptions(rest.slice(1)));
|
||||
break;
|
||||
}
|
||||
const { runGenerate } = await import("./generate.ts");
|
||||
runGenerate(".", rest[0], rest[1]);
|
||||
break;
|
||||
}
|
||||
case "eject": {
|
||||
const { runEject } = await import("./eject.ts");
|
||||
const args = rest.filter((a) => !a.startsWith("--"));
|
||||
// First arg may be an app dir; treat known component names as names.
|
||||
runEject(".", args);
|
||||
break;
|
||||
}
|
||||
case "mobile": {
|
||||
const { runMobileCommand } = await import("./mobile-command.ts");
|
||||
await runMobileCommand(".", rest[0], rest.slice(1));
|
||||
break;
|
||||
}
|
||||
case "native": {
|
||||
const { runNativeCommand } = await import("./native-command.ts");
|
||||
await runNativeCommand(".", rest[0], rest.slice(1));
|
||||
break;
|
||||
}
|
||||
case "update":
|
||||
case "upgrade": {
|
||||
const dir = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const { runUpdate } = await import("./update.ts");
|
||||
await runUpdate(dir, rest);
|
||||
break;
|
||||
}
|
||||
case "db": {
|
||||
bootstrapProfile(".", "development", rest);
|
||||
const { runDbCommand } = await import("./db.ts");
|
||||
const [sub, ...dbArgs] = rest.filter((a) => !a.startsWith("--profile="));
|
||||
await runDbCommand(".", sub, dbArgs);
|
||||
break;
|
||||
}
|
||||
case "profiles": {
|
||||
const { listProfiles } = await import("./profiles.ts");
|
||||
await listProfiles(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||
break;
|
||||
}
|
||||
case "doctor": {
|
||||
const { runDoctor } = await import("./doctor.ts");
|
||||
const healthy = runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "test": {
|
||||
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
const flag = rest.find((a) => a.startsWith("--profile="));
|
||||
// Tests default to the `test` profile (config + .env.test), unless overridden.
|
||||
process.env.WRNEXUS_PROFILE = resolveProfile({ explicit: flag?.split("=")[1] ?? "test" });
|
||||
loadEnv(resolve(appRoot), process.env.WRNEXUS_PROFILE);
|
||||
const { runTests } = await import("./test.ts");
|
||||
runTests(appRoot, rest);
|
||||
break;
|
||||
}
|
||||
case undefined:
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
help();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}\n`);
|
||||
help();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/** Commands for maintaining the generated Capacitor package. */
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { compileNativeWireFile } from "@wrnexus/compiler";
|
||||
|
||||
function validPackageName(value: string): boolean {
|
||||
return /^(?:@[a-z0-9._~-]+\/)?[a-z0-9._~-]+(?:@[a-zA-Z0-9._~^<>=|*+-]+)?$/.test(value);
|
||||
}
|
||||
|
||||
function run(command: string, args: string[], cwd: string): void {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => {
|
||||
const entities: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return entities[char]!;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateMobileErrorPage(root: string, mobileDir: string): Promise<void> {
|
||||
const config = await loadAppConfig(root);
|
||||
const mobile = config.mobile ?? {};
|
||||
const title = mobile.errorTitle ?? "Connection unavailable";
|
||||
const message =
|
||||
mobile.errorMessage ?? "Check your Wi-Fi and make sure the WrNexus server is running.";
|
||||
const serverUrl = mobile.serverUrl ?? "http://localhost:3000";
|
||||
const background = mobile.backgroundColor ?? "#0f172a";
|
||||
const retryUrl = JSON.stringify(serverUrl).replaceAll("<", "\\u003c");
|
||||
const page = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>
|
||||
<style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px system-ui;background:${background};color:#e2e8f0}.card{max-width:28rem;padding:2rem;text-align:center}button{padding:.8rem 1.2rem;border:0;border-radius:.75rem;background:#6366f1;color:white;font-weight:700}</style></head>
|
||||
<body><main class="card"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p><button id="retry">Try again</button></main><script>document.getElementById("retry").onclick=()=>location.replace(${retryUrl});</script></body>
|
||||
</html>
|
||||
`;
|
||||
const path = join(mobileDir, "web", "error.html");
|
||||
mkdirSync(resolve(path, ".."), { recursive: true });
|
||||
writeFileSync(path, page, "utf8");
|
||||
console.log(" ✓ mobile/web/error.html updated from wrnexus.config.ts");
|
||||
}
|
||||
|
||||
function configureAndroidNetworkErrors(mobileDir: string): void {
|
||||
const javaRoot = join(mobileDir, "android", "app", "src", "main", "java");
|
||||
if (!existsSync(javaRoot)) return;
|
||||
const file = (readdirSync(javaRoot, { recursive: true }) as string[])
|
||||
.map((entry) => join(javaRoot, entry))
|
||||
.find((entry) => entry.endsWith("MainActivity.java"));
|
||||
if (!file) return;
|
||||
const current = readFileSync(file, "utf8");
|
||||
if (current.includes("WRNEXUS_NETWORK_ERROR_ONLY")) return;
|
||||
const packageName = /^package\s+([\w.]+);/m.exec(current)?.[1];
|
||||
if (
|
||||
!packageName ||
|
||||
!/public\s+class\s+MainActivity\s+extends\s+BridgeActivity\s*\{\s*\}/s.test(current)
|
||||
) {
|
||||
console.warn(" • MainActivity.java is customized — network-error handling was not changed");
|
||||
return;
|
||||
}
|
||||
writeFileSync(
|
||||
file,
|
||||
`package ${packageName};
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebResourceError;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebView;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
import com.getcapacitor.BridgeWebViewClient;
|
||||
|
||||
// WRNEXUS_NETWORK_ERROR_ONLY
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
bridge.setWebViewClient(new BridgeWebViewClient(bridge) {
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
|
||||
if (request.isForMainFrame()) view.loadUrl("file:///android_asset/public/error.html");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse response) {
|
||||
// Preserve WrNexus HTTP error pages (404, 500, etc.).
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
console.log(" ✓ Android network-only error page configured");
|
||||
}
|
||||
|
||||
export async function runMobileCommand(
|
||||
appRoot: string,
|
||||
subcommand?: string,
|
||||
args: string[] = [],
|
||||
): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const mobileDir = join(root, "mobile");
|
||||
if (!existsSync(join(mobileDir, "package.json"))) {
|
||||
throw new Error("No mobile project found. Run `wrnexus generate mobile` first.");
|
||||
}
|
||||
const mobilePackage = JSON.parse(readFileSync(join(mobileDir, "package.json"), "utf8")) as {
|
||||
wrnexus?: { mode?: "webview" | "native" };
|
||||
};
|
||||
const mode = mobilePackage.wrnexus?.mode ?? "webview";
|
||||
|
||||
if (subcommand === "compile") {
|
||||
if (mode !== "native") throw new Error("`wrnexus mobile compile` requires native mode.");
|
||||
const pagesDir = join(root, "app", "pages");
|
||||
if (!existsSync(pagesDir)) throw new Error(`Pages directory not found: ${pagesDir}`);
|
||||
let count = 0;
|
||||
for (const entry of readdirSync(pagesDir, { recursive: true }) as string[]) {
|
||||
if (!entry.endsWith(".wrn")) continue;
|
||||
const source = join(pagesDir, entry);
|
||||
const relative = entry.replace(/\.wrn$/, ".tsx");
|
||||
const output = join(mobileDir, "app", relative);
|
||||
mkdirSync(resolve(output, ".."), { recursive: true });
|
||||
try {
|
||||
writeFileSync(output, compileNativeWireFile(readFileSync(source, "utf8")), "utf8");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Native compilation failed for app/pages/${entry}: ${(error as Error).message}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
console.log(` ✓ compiled ${count} .wrn page${count === 1 ? "" : "s"} to mobile/app`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "add") {
|
||||
const packages = args.filter((arg) => !arg.startsWith("--"));
|
||||
if (!packages.length || packages.some((name) => !validPackageName(name))) {
|
||||
throw new Error("Usage: wrnexus mobile add <native-package...>");
|
||||
}
|
||||
if (mode === "native") {
|
||||
run("bunx", ["expo", "install", ...packages], mobileDir);
|
||||
return;
|
||||
}
|
||||
// The root app needs the JavaScript proxy for its future browser bundle;
|
||||
// the mobile package needs the dependency so Capacitor can sync native code.
|
||||
run("bun", ["add", ...packages], root);
|
||||
run("bun", ["add", ...packages], mobileDir);
|
||||
await updateMobileErrorPage(root, mobileDir);
|
||||
run("bun", ["run", "sync"], mobileDir);
|
||||
configureAndroidNetworkErrors(mobileDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "sync") {
|
||||
if (mode === "native") {
|
||||
run("bunx", ["expo", "prebuild"], mobileDir);
|
||||
return;
|
||||
}
|
||||
await updateMobileErrorPage(root, mobileDir);
|
||||
run("bun", ["run", "sync"], mobileDir);
|
||||
configureAndroidNetworkErrors(mobileDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "assets") {
|
||||
const config = await loadAppConfig(root);
|
||||
if (!config.mobile?.icon) {
|
||||
throw new Error("Set `mobile.icon` in wrnexus.config.ts before generating native assets.");
|
||||
}
|
||||
const source = resolve(root, config.mobile.icon);
|
||||
if (!existsSync(source)) throw new Error(`Mobile icon not found: ${source}`);
|
||||
const resources = join(mobileDir, "resources");
|
||||
mkdirSync(resources, { recursive: true });
|
||||
copyFileSync(source, join(resources, "icon.png"));
|
||||
if (mode === "native") {
|
||||
console.log(" ✓ mobile/resources/icon.png copied (reference it with `mobile.expo.icon`)");
|
||||
return;
|
||||
}
|
||||
run("bunx", ["capacitor-assets", "generate"], mobileDir);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Usage: wrnexus mobile <compile|add <package...>|sync|assets>");
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* `wrnexus generate mobile` — scaffold a WebView or fully native mobile app.
|
||||
*
|
||||
* WrNexus remains the hosted SSR/API/WebSocket server. Capacitor loads that
|
||||
* server in a native WebView and provides the bridge for native plugins.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
|
||||
export type MobileMode = "webview" | "native";
|
||||
|
||||
export interface MobileOptions {
|
||||
appId?: string;
|
||||
appName?: string;
|
||||
serverUrl?: string;
|
||||
mode?: MobileMode;
|
||||
}
|
||||
|
||||
function slug(value: string): string {
|
||||
const cleaned = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.replace(/^\d+/, "");
|
||||
return cleaned || "app";
|
||||
}
|
||||
|
||||
function projectName(root: string): string {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { name?: string };
|
||||
if (pkg.name) return pkg.name.split("/").pop() || basename(root);
|
||||
} catch {
|
||||
// A package manifest is helpful but not required to generate the shell.
|
||||
}
|
||||
return basename(root);
|
||||
}
|
||||
|
||||
function writeIfAbsent(path: string, content: string, label: string): void {
|
||||
if (existsSync(path)) {
|
||||
console.warn(` • ${label} already exists — skipped`);
|
||||
return;
|
||||
}
|
||||
mkdirSync(resolve(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content, "utf8");
|
||||
console.log(` ✓ ${label}`);
|
||||
}
|
||||
|
||||
/** Scaffold a Capacitor mobile wrapper under `<appRoot>/mobile`. */
|
||||
export async function generateMobile(appRoot: string, options: MobileOptions = {}): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appConfig = await loadAppConfig(root);
|
||||
const configured = appConfig.mobile ?? {};
|
||||
const mode = options.mode ?? configured.mode ?? "webview";
|
||||
if (mode !== "webview" && mode !== "native") {
|
||||
throw new Error('mobile.mode must be either "webview" or "native"');
|
||||
}
|
||||
const name = options.appName || configured.appName || projectName(root);
|
||||
const appId = options.appId || configured.appId || `com.example.${slug(name)}`;
|
||||
const serverUrl = options.serverUrl || configured.serverUrl || "http://localhost:3000";
|
||||
const mobile = join(root, "mobile");
|
||||
|
||||
if (mode === "native") {
|
||||
generateNativeMobile(mobile, {
|
||||
name,
|
||||
appId,
|
||||
apiUrl: configured.apiUrl ?? serverUrl,
|
||||
configured,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pkg = {
|
||||
name: `${slug(name)}-mobile`,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
wrnexus: { mode: "webview" },
|
||||
type: "module",
|
||||
scripts: {
|
||||
"add:ios": "cap add ios",
|
||||
"add:android": "cap add android",
|
||||
sync: "cap sync",
|
||||
"open:ios": "cap open ios",
|
||||
"open:android": "cap open android",
|
||||
},
|
||||
dependencies: {
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/ios": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@capacitor/assets": "^3.0.0",
|
||||
typescript: "^5.5.0",
|
||||
},
|
||||
};
|
||||
|
||||
const config = `import type { CapacitorConfig } from "@capacitor/cli";
|
||||
import appConfig from "../wrnexus.config.ts";
|
||||
|
||||
const mobile = appConfig.mobile ?? {};
|
||||
const serverUrl = process.env.WRNEXUS_MOBILE_URL ?? mobile.serverUrl ?? ${JSON.stringify(serverUrl)};
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: mobile.appId ?? ${JSON.stringify(appId)},
|
||||
appName: mobile.appName ?? ${JSON.stringify(name)},
|
||||
webDir: "web",
|
||||
appendUserAgent: mobile.userAgent ?? " WrNexusMobile",
|
||||
backgroundColor: mobile.backgroundColor,
|
||||
...(mobile.capacitor ?? {}),
|
||||
server: {
|
||||
...((mobile.capacitor?.server as CapacitorConfig["server"]) ?? {}),
|
||||
// Development bridge only: Capacitor does not recommend server.url in production.
|
||||
url: serverUrl,
|
||||
cleartext: serverUrl.startsWith("http://"),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
`;
|
||||
|
||||
const errorPage = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Connection unavailable</title>
|
||||
<style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px system-ui;background:#0f172a;color:#e2e8f0}.card{max-width:28rem;padding:2rem;text-align:center}button{padding:.8rem 1.2rem;border:0;border-radius:.75rem;background:#6c8cff;color:white;font-weight:700}</style></head>
|
||||
<body><main class="card"><h1>Connection unavailable</h1><p>Check your Wi-Fi and make sure the WrNexus server is running.</p><button id="retry">Try again</button></main><script>document.getElementById("retry").onclick=()=>location.replace(${JSON.stringify(serverUrl)});</script></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const fallback = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${name}</title></head>
|
||||
<body><p>Run the WrNexus server and then sync this mobile project.</p></body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const readme = `# ${name} mobile
|
||||
|
||||
Capacitor development shell for the hosted WrNexus application. The Bun server,
|
||||
SSR, APIs, database, and WebSockets continue to run on your server; this directory
|
||||
contains the native iOS and Android projects and native plugin dependencies.
|
||||
|
||||
> Capacitor documents \`server.url\` as a live-reload option that is not intended
|
||||
> for production. This shell is useful for native development and testing, but a
|
||||
> store release needs a bundled client build/static export that WrNexus does not
|
||||
> currently produce.
|
||||
|
||||
## Setup
|
||||
|
||||
\`\`\`bash
|
||||
node --version # Capacitor 8 requires Node.js 22+
|
||||
bun install
|
||||
bun run add:android
|
||||
bun run add:ios # macOS with Xcode is required
|
||||
bun run sync
|
||||
\`\`\`
|
||||
|
||||
Set \`WRNEXUS_MOBILE_URL\` to a reachable URL before syncing the development shell:
|
||||
|
||||
\`\`\`bash
|
||||
WRNEXUS_MOBILE_URL=https://app.example.com bun run sync
|
||||
\`\`\`
|
||||
|
||||
For a physical device, \`localhost\` refers to the device, not your computer.
|
||||
Use your computer's LAN URL during development. Do not ship the generated
|
||||
\`server.url\` configuration as a production store build.
|
||||
|
||||
Open the native projects with \`bun run open:android\` or \`bun run open:ios\`.
|
||||
Add native features with Capacitor plugins and run \`bun run sync\` afterward.
|
||||
`;
|
||||
|
||||
console.log("Scaffolding Capacitor mobile app:");
|
||||
writeIfAbsent(
|
||||
join(mobile, "package.json"),
|
||||
`${JSON.stringify(pkg, null, 2)}\n`,
|
||||
"mobile/package.json",
|
||||
);
|
||||
writeIfAbsent(join(mobile, "capacitor.config.ts"), config, "mobile/capacitor.config.ts");
|
||||
writeIfAbsent(join(mobile, "web", "index.html"), fallback, "mobile/web/index.html");
|
||||
writeIfAbsent(join(mobile, "web", "error.html"), errorPage, "mobile/web/error.html");
|
||||
writeIfAbsent(
|
||||
join(mobile, ".gitignore"),
|
||||
"node_modules\nandroid/.gradle\nios/App/Pods\n",
|
||||
"mobile/.gitignore",
|
||||
);
|
||||
writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md");
|
||||
console.log("\nNext: cd mobile && bun install && bun run add:android");
|
||||
console.log("iOS generation requires macOS with Xcode: bun run add:ios");
|
||||
}
|
||||
|
||||
/** Convert CLI flags into generator options. */
|
||||
export function mobileOptions(args: string[]): MobileOptions {
|
||||
const value = (flag: string) =>
|
||||
args.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
||||
const modeValue = value("--mode");
|
||||
if (modeValue && modeValue !== "webview" && modeValue !== "native") {
|
||||
throw new Error('--mode must be either "webview" or "native"');
|
||||
}
|
||||
const mode: MobileMode | undefined =
|
||||
modeValue === "webview" || modeValue === "native" ? modeValue : undefined;
|
||||
return {
|
||||
appId: value("--app-id"),
|
||||
appName: value("--app-name"),
|
||||
serverUrl: value("--url"),
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
function generateNativeMobile(
|
||||
mobile: string,
|
||||
input: { name: string; appId: string; apiUrl: string; configured: { scheme?: string } },
|
||||
): void {
|
||||
const { name, appId, apiUrl, configured } = input;
|
||||
const scheme = configured.scheme ?? slug(name);
|
||||
const pkg = {
|
||||
name: `${slug(name)}-mobile`,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
main: "expo-router/entry",
|
||||
wrnexus: { mode: "native" },
|
||||
scripts: {
|
||||
compile: "cd .. && wrnexus mobile compile",
|
||||
prestart: "bun run compile",
|
||||
start: "expo start",
|
||||
android: "expo run:android",
|
||||
ios: "expo run:ios",
|
||||
web: "expo start --web",
|
||||
prebuild: "expo prebuild",
|
||||
},
|
||||
dependencies: {
|
||||
expo: "^57.0.0",
|
||||
"expo-router": "~57.0.4",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
react: "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-safe-area-context": "^5.6.0",
|
||||
"react-native-screens": "^4.23.0",
|
||||
},
|
||||
devDependencies: { "@types/react": "^19.2.0", typescript: "^5.9.0" },
|
||||
};
|
||||
const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`;
|
||||
const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`;
|
||||
const screen = `import { StyleSheet, Text, View } from "react-native";\nimport { StatusBar } from "expo-status-bar";\n\nexport default function Home() {\n return <View style={styles.container}><StatusBar style="auto" /><Text style={styles.title}>${name}</Text><Text>Fully native WrNexus client</Text></View>;\n}\nconst styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, title: { fontSize: 28, fontWeight: "700", marginBottom: 8 } });\n`;
|
||||
const readme = `# ${name} native mobile\n\nThis is a fully native Expo/React Native client: it does not use a WebView. Portable pages from \`../app/pages/**/*.wrn\` compile into Expo routes with \`bun run compile\` (also run automatically before \`start\`). You can edit or add native-only TSX screens in \`app/\`, and call the shared WrNexus backend through \`src/wrnexus.ts\`.\n\n\`\`\`bash\nbun install\nbun run compile\nbun run start\nbun run android\n# macOS/Xcode: bun run ios\n\`\`\`\n\nServer API routes, authentication endpoints, uploads, and WebSockets remain reusable. Unsupported DOM-only markup fails compilation with a specific error. Override the backend per environment with \`EXPO_PUBLIC_WRNEXUS_URL\`. Add native modules with \`bunx expo install <package>\`.\n`;
|
||||
console.log("Scaffolding fully native Expo mobile app:");
|
||||
writeIfAbsent(
|
||||
join(mobile, "package.json"),
|
||||
`${JSON.stringify(pkg, null, 2)}\n`,
|
||||
"mobile/package.json",
|
||||
);
|
||||
writeIfAbsent(join(mobile, "app.config.ts"), expo, "mobile/app.config.ts");
|
||||
writeIfAbsent(join(mobile, "app", "index.tsx"), screen, "mobile/app/index.tsx");
|
||||
writeIfAbsent(join(mobile, "src", "wrnexus.ts"), env, "mobile/src/wrnexus.ts");
|
||||
writeIfAbsent(
|
||||
join(mobile, "tsconfig.json"),
|
||||
`${JSON.stringify({ extends: "expo/tsconfig.base", compilerOptions: { strict: true } }, null, 2)}\n`,
|
||||
"mobile/tsconfig.json",
|
||||
);
|
||||
writeIfAbsent(
|
||||
join(mobile, ".gitignore"),
|
||||
"node_modules\n.expo\nandroid\nios\n",
|
||||
"mobile/.gitignore",
|
||||
);
|
||||
writeIfAbsent(join(mobile, "README.md"), readme, "mobile/README.md");
|
||||
console.log("\nNext: cd mobile && bun install && bun run start");
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { runMobileCommand } from "./mobile-command.ts";
|
||||
|
||||
interface CapabilityPackage {
|
||||
browser: string;
|
||||
capacitor?: string;
|
||||
expo?: string;
|
||||
}
|
||||
|
||||
export const nativeCatalog: Record<string, CapabilityPackage> = {
|
||||
camera: {
|
||||
browser: "MediaDevices / file input",
|
||||
capacitor: "@capacitor/camera",
|
||||
expo: "expo-camera",
|
||||
},
|
||||
clipboard: {
|
||||
browser: "Clipboard API",
|
||||
capacitor: "@capacitor/clipboard",
|
||||
expo: "expo-clipboard",
|
||||
},
|
||||
device: {
|
||||
browser: "Browser device information",
|
||||
capacitor: "@capacitor/device",
|
||||
expo: "expo-device",
|
||||
},
|
||||
filesystem: {
|
||||
browser: "File System Access API",
|
||||
capacitor: "@capacitor/filesystem",
|
||||
expo: "expo-file-system",
|
||||
},
|
||||
geolocation: {
|
||||
browser: "Geolocation API",
|
||||
capacitor: "@capacitor/geolocation",
|
||||
expo: "expo-location",
|
||||
},
|
||||
haptics: { browser: "Vibration API", capacitor: "@capacitor/haptics", expo: "expo-haptics" },
|
||||
network: {
|
||||
browser: "Navigator online status",
|
||||
capacitor: "@capacitor/network",
|
||||
expo: "expo-network",
|
||||
},
|
||||
notifications: {
|
||||
browser: "Notifications API",
|
||||
capacitor: "@capacitor/local-notifications",
|
||||
expo: "expo-notifications",
|
||||
},
|
||||
share: { browser: "Web Share API", capacitor: "@capacitor/share" },
|
||||
storage: {
|
||||
browser: "Web Storage",
|
||||
capacitor: "@capacitor/preferences",
|
||||
expo: "expo-secure-store",
|
||||
},
|
||||
};
|
||||
|
||||
function mode(root: string): "webview" | "native" {
|
||||
const file = join(root, "mobile", "package.json");
|
||||
if (!existsSync(file))
|
||||
throw new Error("No mobile project found. Run `wrnexus generate mobile` first.");
|
||||
return (
|
||||
(JSON.parse(readFileSync(file, "utf8")) as { wrnexus?: { mode?: "webview" | "native" } })
|
||||
.wrnexus?.mode ?? "webview"
|
||||
);
|
||||
}
|
||||
|
||||
export async function runNativeCommand(
|
||||
appRoot: string,
|
||||
subcommand?: string,
|
||||
args: string[] = [],
|
||||
): Promise<void> {
|
||||
if (subcommand === "list" || !subcommand) {
|
||||
console.log("WrNexus native capabilities:\n");
|
||||
for (const [name, entry] of Object.entries(nativeCatalog)) {
|
||||
console.log(
|
||||
` ${name.padEnd(14)} browser: ${entry.browser}; Capacitor: ${entry.capacitor ?? "built in"}; Expo: ${entry.expo ?? "built in"}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (subcommand === "add") {
|
||||
const names = args.filter((arg) => !arg.startsWith("--"));
|
||||
if (!names.length) throw new Error("Usage: wrnexus native add <capability...>");
|
||||
const unknown = names.filter((name) => !nativeCatalog[name]);
|
||||
if (unknown.length)
|
||||
throw new Error(
|
||||
`Unknown native capability: ${unknown.join(", ")}. Run \`wrnexus native list\`.`,
|
||||
);
|
||||
const root = resolve(appRoot);
|
||||
const target = mode(root);
|
||||
const packages = names
|
||||
.map((name) => nativeCatalog[name]![target === "native" ? "expo" : "capacitor"])
|
||||
.filter((value): value is string => !!value);
|
||||
if (!packages.length) {
|
||||
console.log(` ✓ ${names.join(", ")} use built-in APIs; no package installation required`);
|
||||
return;
|
||||
}
|
||||
await runMobileCommand(root, "add", packages);
|
||||
return;
|
||||
}
|
||||
throw new Error("Usage: wrnexus native <list|add <capability...>>");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* `wrnexus profiles` — list the config profiles defined in `wrnexus.config.ts`,
|
||||
* mark the active one, and show which `.env` files exist for each.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { loadRawConfig, resolveProfile } from "@wrnexus/styles";
|
||||
|
||||
export async function listProfiles(appRoot: string): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadRawConfig(root);
|
||||
const active = resolveProfile();
|
||||
const defined = Object.keys(config.profiles ?? {});
|
||||
// Always show the two conventional profiles plus any custom ones.
|
||||
const names = Array.from(new Set(["development", "production", ...defined]));
|
||||
|
||||
console.log("Profiles (select with --profile=<name> or WRNEXUS_PROFILE):\n");
|
||||
for (const name of names) {
|
||||
const marker = name === active ? "●" : "○";
|
||||
const hasConfig = defined.includes(name) ? "config" : "";
|
||||
const envFiles = [`.env.${name}`, `.env.${name}.local`].filter((f) =>
|
||||
existsSync(join(root, f)),
|
||||
);
|
||||
const bits = [hasConfig, ...envFiles].filter(Boolean).join(", ");
|
||||
console.log(` ${marker} ${name.padEnd(14)}${bits ? " (" + bits + ")" : ""}`);
|
||||
}
|
||||
const baseEnv = [".env", ".env.local"].filter((f) => existsSync(join(root, f)));
|
||||
if (baseEnv.length) console.log(`\n base env: ${baseEnv.join(", ")} (loaded for every profile)`);
|
||||
console.log(`\n active: ${active}`);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* `wrnexus` typed-routes codegen. Scans the app's pages and writes
|
||||
* `app/routes.gen.ts` (a `Routes` map + `href()` builder). Run at `wrnexus dev`
|
||||
* startup; also exposed via `wrnexus generate routes`.
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { buildRouter, generateRoutesFile } from "@wrnexus/router";
|
||||
|
||||
/** Regenerate `app/routes.gen.ts`. Returns the number of page routes. */
|
||||
export function regenerateRoutes(appDir: string): number {
|
||||
const router = buildRouter(appDir);
|
||||
const code = generateRoutesFile(router.pages);
|
||||
writeFileSync(join(appDir, "routes.gen.ts"), code, "utf8");
|
||||
return router.pages.length;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* `wrnexus test [app-dir] [--watch] [--profile=test]` — run the app's test files
|
||||
* with `bun test`. Defaults to the `test` profile (config + .env.test). Extra
|
||||
* args after `--` (or bun test flags) pass straight through.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function runTests(appRoot: string, args: string[]): void {
|
||||
const root = resolve(appRoot);
|
||||
const watch = args.includes("--watch");
|
||||
const passthrough = args.filter(
|
||||
(a) => !a.startsWith("--profile=") && a !== "--watch" && a !== appRoot,
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
process.execPath, // the Bun binary
|
||||
["test", ...(watch ? ["--watch"] : []), ...passthrough],
|
||||
{ stdio: "inherit", cwd: root },
|
||||
);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) return;
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* `wrnexus update [dir] [--version=x.y.z | --latest] [--dry-run]`
|
||||
*
|
||||
* Upgrade an app (or every app in a workspace) to a WrNexus release:
|
||||
* 1. Bump every `@wrnexus/*` dependency to the target version.
|
||||
* 2. `bun install`.
|
||||
* 3. Refresh framework-owned reference files (llms.txt) and apply any
|
||||
* versioned, idempotent migrations that newer releases introduce.
|
||||
* 4. Record the applied version in package.json (`"wrnexus": { version }`).
|
||||
*
|
||||
* Target version resolution: `--version=x.y.z` > `--latest` (queries npm) >
|
||||
* the running CLI's own version (the default — pair with `bunx @wrnexus/cli@latest
|
||||
* update` to jump to the newest release with no network guesswork).
|
||||
*
|
||||
* Migrations are CONSERVATIVE: they only add/refresh framework-owned things and
|
||||
* never clobber your own code or edited CLAUDE.md. Add new ones to `MIGRATIONS`
|
||||
* as the framework evolves — that is how "new things" reach existing apps.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
|
||||
/** The version of the CLI currently running (its own package.json). */
|
||||
function cliVersion(): string {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version;
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/** Query the registry for the latest published `@wrnexus/cli` version. */
|
||||
function latestPublished(): string | null {
|
||||
try {
|
||||
const out = spawnSync("npm", ["view", "@wrnexus/cli", "version"], { encoding: "utf8" });
|
||||
const v = (out.stdout ?? "").trim();
|
||||
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Numeric compare of `x.y.z` (pre-release/build tags ignored). */
|
||||
function cmp(a: string, b: string): number {
|
||||
const pa = a.split("-")[0]!.split(".").map(Number);
|
||||
const pb = b.split("-")[0]!.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (d) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface MigrationCtx {
|
||||
appRoot: string;
|
||||
from: string;
|
||||
to: string;
|
||||
dryRun: boolean;
|
||||
log: (msg: string) => void;
|
||||
}
|
||||
|
||||
interface Migration {
|
||||
/** Framework version that introduced this change. Runs when `from < version <= to`. */
|
||||
version: string;
|
||||
id: string;
|
||||
description: string;
|
||||
apply: (ctx: MigrationCtx) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run and MUST NOT
|
||||
* overwrite user code. Append new entries with the version that ships them.
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{
|
||||
version: "0.2.8",
|
||||
id: "gitignore-artifacts",
|
||||
description: "Ensure .gitignore covers framework build artifacts",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, ".gitignore");
|
||||
const want = ["node_modules/", "dist/", ".wrnexus/", "*.db"];
|
||||
const current = existsSync(file) ? readFileSync(file, "utf8") : "";
|
||||
const have = new Set(current.split(/\r?\n/).map((l) => l.trim()));
|
||||
const missing = want.filter((w) => !have.has(w));
|
||||
if (missing.length === 0) return;
|
||||
ctx.log(`+ .gitignore: ${missing.join(", ")}`);
|
||||
if (ctx.dryRun) return;
|
||||
const next = current.replace(/\s*$/, "") + "\n" + missing.join("\n") + "\n";
|
||||
writeFileSync(file, next.replace(/^\n+/, ""), "utf8");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Bump every `@wrnexus/*` range to `^target`. Returns the human-readable changes. */
|
||||
function bumpDeps(pkg: Record<string, unknown>, target: string): string[] {
|
||||
const changed: string[] = [];
|
||||
const next = `^${target}`;
|
||||
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const deps = pkg[field] as Record<string, string> | undefined;
|
||||
if (!deps) continue;
|
||||
for (const name of Object.keys(deps)) {
|
||||
if (name.startsWith("@wrnexus/") && deps[name] !== next) {
|
||||
changed.push(`${name} ${deps[name]} → ${next}`);
|
||||
deps[name] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** The framework version an app was last updated to (or its installed CLI version). */
|
||||
function appVersion(appRoot: string, pkg: Record<string, unknown>): string {
|
||||
const marker = (pkg.wrnexus as { version?: string } | undefined)?.version;
|
||||
if (marker) return marker;
|
||||
try {
|
||||
const p = join(appRoot, "node_modules", "@wrnexus", "cli", "package.json");
|
||||
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")).version;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
/** Refresh pure framework-owned reference files. Never touches user-edited CLAUDE.md. */
|
||||
function refreshFrameworkFiles(appRoot: string, dryRun: boolean, log: (m: string) => void): void {
|
||||
// llms.txt is a generated reference — always safe to overwrite.
|
||||
const llms = join(appRoot, "llms.txt");
|
||||
if (!existsSync(llms) || readFileSync(llms, "utf8") !== AI_GUIDE) {
|
||||
log("~ llms.txt refreshed");
|
||||
if (!dryRun) writeFileSync(llms, AI_GUIDE, "utf8");
|
||||
}
|
||||
// CLAUDE.md is often user-edited — only create it when absent.
|
||||
const claude = join(appRoot, "CLAUDE.md");
|
||||
if (!existsSync(claude)) {
|
||||
log("+ CLAUDE.md created");
|
||||
if (!dryRun) writeFileSync(claude, CLAUDE_MD, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a single app dir (bump its package.json, refresh files, run migrations). */
|
||||
function updateApp(appRoot: string, target: string, dryRun: boolean): boolean {
|
||||
const pkgPath = join(appRoot, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
console.log(` ⚠ ${appRoot}: no package.json — skipped`);
|
||||
return false;
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
||||
const from = appVersion(appRoot, pkg);
|
||||
const log = (m: string) => console.log(` ${m}`);
|
||||
console.log(` ▸ ${pkg.name ?? appRoot} (${from} → ${target})`);
|
||||
|
||||
const changes = bumpDeps(pkg, target);
|
||||
changes.forEach((c) => log(c));
|
||||
if (!changes.length) log("dependencies already current");
|
||||
|
||||
refreshFrameworkFiles(appRoot, dryRun, log);
|
||||
|
||||
for (const m of MIGRATIONS) {
|
||||
if (cmp(m.version, from) > 0 && cmp(m.version, target) <= 0) {
|
||||
m.apply({ appRoot, from, to: target, dryRun, log });
|
||||
}
|
||||
}
|
||||
|
||||
// Record the applied version so the next update knows where it started.
|
||||
pkg.wrnexus = { ...(pkg.wrnexus as object), version: target };
|
||||
if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Load a `wrnexus.workspace.ts`, returning its app dirs (or null if not a workspace). */
|
||||
async function workspaceApps(root: string): Promise<string[] | null> {
|
||||
for (const f of ["wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs"]) {
|
||||
const path = join(root, f);
|
||||
if (!existsSync(path)) continue;
|
||||
const mod = (await import(pathToFileURL(path).href)) as {
|
||||
default?: { apps?: { dir: string }[] };
|
||||
};
|
||||
return (mod.default?.apps ?? []).map((a) => resolve(root, a.dir));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runUpdate(dir: string, args: string[]): Promise<void> {
|
||||
const root = resolve(dir);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const versionArg = args.find((a) => a.startsWith("--version="))?.split("=")[1];
|
||||
const target =
|
||||
versionArg ?? (args.includes("--latest") ? latestPublished() : null) ?? cliVersion();
|
||||
|
||||
console.log(`\n ⚡ wrnexus update → ${target}${dryRun ? " (dry run)" : ""}\n`);
|
||||
|
||||
// Workspace → update the root manifest + every app; else just this app.
|
||||
const apps = await workspaceApps(root);
|
||||
const targets = apps ? [root, ...apps] : [root];
|
||||
let updated = 0;
|
||||
for (const t of targets) if (updateApp(t, target, dryRun)) updated++;
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n Dry run — no files written. Re-run without --dry-run to apply.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// One install at the top (Bun workspaces hoist), using the bun that's running us.
|
||||
console.log(`\n Installing…`);
|
||||
const res = spawnSync(process.execPath, ["install"], { cwd: root, stdio: "inherit" });
|
||||
if (res.status !== 0) {
|
||||
console.error(`\n ⚠ bun install exited with ${res.status}. Fix the error and re-run.`);
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
console.log(`\n ✓ Updated ${updated} package(s) to ${target}.`);
|
||||
console.log(` Review the changes, then rebuild/redeploy (wrnexus build).\n`);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Monorepo support:
|
||||
* - `wrnexus workspace <name>` scaffolds a multi-app workspace (apps/* + shared packages/*)
|
||||
* - `wrnexus gateway [--port]` serves every app behind one port, routed by domain
|
||||
*
|
||||
* A workspace holds several WrNexus apps under `apps/*` and shared libraries under
|
||||
* `packages/*`. Apps share code by importing a workspace package (e.g. `@app/shared`),
|
||||
* share databases, and talk at runtime via @wrnexus/pubsub (Redis driver for
|
||||
* cross-process). `wrnexus.workspace.ts` maps each app to the domains it serves.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { scaffoldApp } from "./create.ts";
|
||||
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
|
||||
|
||||
export interface WorkspaceApp {
|
||||
name: string;
|
||||
dir: string;
|
||||
domains: string[];
|
||||
port?: number;
|
||||
/** Per-app access control enforced at the gateway (basic auth, IP allowlist, forward-auth). */
|
||||
auth?: GatewayAuth;
|
||||
}
|
||||
export interface WorkspaceConfig {
|
||||
apps: WorkspaceApp[];
|
||||
/** Gateway-wide security (trusted hosts, rate limit, headers, access log). */
|
||||
security?: GatewaySecurity;
|
||||
}
|
||||
|
||||
const files = (name: string): Record<string, string> => ({
|
||||
"package.json": `{
|
||||
"name": "${name}",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["apps/*", "packages/*"],
|
||||
"scripts": {
|
||||
"dev": "wrnexus gateway",
|
||||
"gateway": "wrnexus gateway"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "^0.2.0"
|
||||
}
|
||||
}
|
||||
`,
|
||||
"wrnexus.workspace.ts": `import type { WorkspaceConfig } from "@wrnexus/cli/workspace";
|
||||
|
||||
// Map each app to the domains it serves. \`wrnexus gateway\` runs them all behind
|
||||
// one port and routes by Host header (add these hosts to your /etc/hosts).
|
||||
const config: WorkspaceConfig = {
|
||||
// Gateway-wide security (all optional):
|
||||
security: {
|
||||
trustedHostsOnly: true, // reject requests for unknown domains
|
||||
rateLimit: { max: 300, windowMs: 60_000 }, // per client IP
|
||||
headers: true, // baseline security headers at the edge
|
||||
accessLog: true, // log host → app, method, path, status
|
||||
},
|
||||
apps: [
|
||||
{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] },
|
||||
{
|
||||
name: "admin",
|
||||
dir: "apps/admin",
|
||||
domains: ["admin.localhost"],
|
||||
// Lock the admin app down at the edge (pick one):
|
||||
auth: { basic: { user: "admin", pass: "change-me" } },
|
||||
// auth: { allowIps: ["127.0.0.1", "::1"] },
|
||||
// auth: { forward: { url: "http://localhost:4001/api/verify" } }, // SSO
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
`,
|
||||
".gitignore": `node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
*.log
|
||||
*.db
|
||||
`,
|
||||
"packages/shared/package.json": `{
|
||||
"name": "@app/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"dependencies": {
|
||||
"@wrnexus/pubsub": "^0.2.0"
|
||||
}
|
||||
}
|
||||
`,
|
||||
"packages/shared/src/index.ts": `/**
|
||||
* Shared code for every app in this workspace. Import it anywhere: \`@app/shared\`.
|
||||
* The cross-app event bus uses Redis so messages reach every app process/domain.
|
||||
*/
|
||||
import { createPubSub } from "@wrnexus/pubsub";
|
||||
import { redisDriver } from "@wrnexus/pubsub/redis";
|
||||
|
||||
// One bus per process, backed by Redis (set REDIS_URL, defaults to localhost:6379).
|
||||
export const bus = createPubSub(redisDriver(process.env.REDIS_URL));
|
||||
|
||||
// Shared domain types can live here and be imported by every app.
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
`,
|
||||
"README.md": `# ${name}
|
||||
|
||||
A WrNexus **workspace** — multiple apps, one gateway, interconnected.
|
||||
|
||||
\`\`\`
|
||||
${name}/
|
||||
wrnexus.workspace.ts # apps ↔ domains map (used by \`wrnexus gateway\`)
|
||||
apps/
|
||||
web/ # a WrNexus app → localhost, web.localhost
|
||||
admin/ # a WrNexus app → admin.localhost
|
||||
packages/
|
||||
shared/ # @app/shared — shared code + cross-app pubsub bus
|
||||
\`\`\`
|
||||
|
||||
## Run everything (one port, routed by domain)
|
||||
|
||||
\`\`\`bash
|
||||
bun install
|
||||
bun run dev # = wrnexus gateway → http://localhost:3000
|
||||
\`\`\`
|
||||
|
||||
Add the hosts to your machine (e.g. /etc/hosts):
|
||||
|
||||
\`\`\`
|
||||
127.0.0.1 web.localhost admin.localhost
|
||||
\`\`\`
|
||||
|
||||
## Interconnect
|
||||
|
||||
- **Shared code:** import \`@app/shared\` in any app.
|
||||
- **Runtime messaging:** \`import { bus } from "@app/shared"\` then
|
||||
\`bus.publish("tenant:created", {...})\` in one app and
|
||||
\`bus.subscribe("tenant:*", fn)\` in another (needs Redis).
|
||||
- **Databases:** point apps at the same \`db\`/\`databases\` in their config.
|
||||
|
||||
## Add another app
|
||||
|
||||
\`\`\`bash
|
||||
wrnexus create apps/reports
|
||||
# then add it to wrnexus.workspace.ts with its domains
|
||||
\`\`\`
|
||||
`,
|
||||
});
|
||||
|
||||
/** Scaffold a monorepo workspace with two starter apps + a shared package. */
|
||||
export function createWorkspace(name: string): void {
|
||||
if (!name) {
|
||||
console.error("Usage: wrnexus workspace <name>");
|
||||
process.exit(1);
|
||||
}
|
||||
const root = resolve(process.cwd(), name);
|
||||
if (existsSync(root)) {
|
||||
console.error(`Refusing to overwrite existing directory: ${root}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const [rel, contents] of Object.entries(files(name))) {
|
||||
const target = join(root, rel);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, contents, "utf8");
|
||||
}
|
||||
// Two starter apps under apps/.
|
||||
scaffoldApp(join(root, "apps", "web"), "web");
|
||||
scaffoldApp(join(root, "apps", "admin"), "admin");
|
||||
|
||||
console.log(`✓ Created workspace ${name}`);
|
||||
console.log(`\nNext steps:`);
|
||||
console.log(` cd ${name}`);
|
||||
console.log(` bun install`);
|
||||
console.log(` bun run dev # wrnexus gateway (web + admin, routed by domain)`);
|
||||
}
|
||||
|
||||
/** Load `wrnexus.workspace.ts` from a directory. */
|
||||
export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig> {
|
||||
for (const file of ["wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs"]) {
|
||||
const path = join(root, file);
|
||||
if (existsSync(path)) {
|
||||
const mod = (await import(pathToFileURL(path).href)) as { default?: WorkspaceConfig };
|
||||
if (!mod.default?.apps?.length)
|
||||
throw new Error(`${file} must default-export { apps: [...] }`);
|
||||
return mod.default;
|
||||
}
|
||||
}
|
||||
throw new Error("No wrnexus.workspace.ts found. Run `wrnexus workspace <name>` to scaffold one.");
|
||||
}
|
||||
|
||||
/** Run the multi-app gateway from `wrnexus.workspace.ts`. */
|
||||
export async function runGateway(root: string, args: string[]): Promise<void> {
|
||||
const config = await loadWorkspaceConfig(resolve(root));
|
||||
const portArg = args.find((a) => a.startsWith("--port="));
|
||||
const hostArg = args.find((a) => a.startsWith("--host="));
|
||||
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
|
||||
const hostname = hostArg?.split("=")[1] || "::";
|
||||
const mode = args.includes("--prod") ? "production" : "development";
|
||||
|
||||
const { startGateway } = await import("@wrnexus/dev-server");
|
||||
await startGateway({
|
||||
port,
|
||||
hostname,
|
||||
mode,
|
||||
security: config.security,
|
||||
apps: config.apps.map((a) => ({
|
||||
name: a.name,
|
||||
dir: resolve(root, a.dir),
|
||||
domains: a.domains,
|
||||
port: a.port,
|
||||
auth: a.auth,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtempSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generateDocker } from "../src/docker.ts";
|
||||
|
||||
test("generateDocker scaffolds Dockerfile, .dockerignore, compose", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-docker-"));
|
||||
generateDocker(dir);
|
||||
expect(existsSync(join(dir, "Dockerfile"))).toBe(true);
|
||||
expect(existsSync(join(dir, ".dockerignore"))).toBe(true);
|
||||
expect(existsSync(join(dir, "docker-compose.yml"))).toBe(true);
|
||||
|
||||
const dockerfile = readFileSync(join(dir, "Dockerfile"), "utf8");
|
||||
expect(dockerfile).toContain("FROM oven/bun:1 AS build");
|
||||
expect(dockerfile).toContain("bun run build");
|
||||
expect(dockerfile).toContain("/healthz"); // health check uses the app endpoint
|
||||
expect(dockerfile).toContain('CMD ["bun", "dist/server.js"]');
|
||||
|
||||
expect(readFileSync(join(dir, "docker-compose.yml"), "utf8")).toContain("postgres:16-alpine");
|
||||
});
|
||||
|
||||
test("generateDocker does not overwrite existing files", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-docker-"));
|
||||
generateDocker(dir);
|
||||
const before = readFileSync(join(dir, "Dockerfile"), "utf8");
|
||||
// second run must leave the file untouched
|
||||
generateDocker(dir);
|
||||
expect(readFileSync(join(dir, "Dockerfile"), "utf8")).toBe(before);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { inspectProject } from "../src/doctor.ts";
|
||||
|
||||
test("doctor reports a healthy minimal project", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-doctor-"));
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" }));
|
||||
writeFileSync(join(root, "wrnexus.config.ts"), "export default {};");
|
||||
const checks = inspectProject(root);
|
||||
expect(checks.every((check) => check.ok)).toBe(true);
|
||||
});
|
||||
|
||||
test("doctor returns actionable missing-project checks", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-doctor-"));
|
||||
const checks = inspectProject(root);
|
||||
expect(checks.find((check) => check.name === "package.json")?.ok).toBe(false);
|
||||
expect(checks.find((check) => check.name === "app/pages")?.detail).toBe("Create app/pages");
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { scaffold } from "../src/generate.ts";
|
||||
|
||||
test("scaffold page: kebab file, PascalCase page name", () => {
|
||||
const f = scaffold("page", "about-us");
|
||||
expect(f.path).toBe("pages/about-us.wrn");
|
||||
expect(f.content).toContain("page AboutUs {");
|
||||
expect(f.content).toContain('layout = "public"');
|
||||
});
|
||||
|
||||
test("scaffold component: props + class", () => {
|
||||
const f = scaffold("component", "user-card");
|
||||
expect(f.path).toBe("components/user-card.wrn");
|
||||
expect(f.content).toContain("component UserCard {");
|
||||
expect(f.content).toContain('class="wire-user-card"');
|
||||
});
|
||||
|
||||
test("scaffold api: nested path keeps directories, exports GET", () => {
|
||||
const f = scaffold("api", "users/list");
|
||||
expect(f.path).toBe("api/users/list.ts");
|
||||
expect(f.content).toContain("export async function GET");
|
||||
});
|
||||
|
||||
test("scaffold schema: validation object", () => {
|
||||
const f = scaffold("schema", "signup");
|
||||
expect(f.path).toBe("schemas/signup.ts");
|
||||
expect(f.content).toContain('import { v } from "@wrnexus/validation"');
|
||||
expect(f.content).toContain("export default v.object(");
|
||||
});
|
||||
|
||||
test("scaffold strips extensions from the given name", () => {
|
||||
expect(scaffold("page", "home.wrn").path).toBe("pages/home.wrn");
|
||||
expect(scaffold("api", "ping.ts").path).toBe("api/ping.ts");
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generateMobile, mobileOptions } from "../src/mobile.ts";
|
||||
import { runMobileCommand } from "../src/mobile-command.ts";
|
||||
import { nativeCatalog, runNativeCommand } from "../src/native-command.ts";
|
||||
|
||||
test("generateMobile scaffolds a configurable Capacitor project", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-mobile-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@acme/store-front" }));
|
||||
|
||||
await generateMobile(dir, {
|
||||
appId: "com.acme.store",
|
||||
appName: "Acme Store",
|
||||
serverUrl: "https://store.example.com",
|
||||
});
|
||||
|
||||
const config = readFileSync(join(dir, "mobile", "capacitor.config.ts"), "utf8");
|
||||
expect(config).toContain('appId: mobile.appId ?? "com.acme.store"');
|
||||
expect(config).toContain('appName: mobile.appName ?? "Acme Store"');
|
||||
expect(config).toContain("...(mobile.capacitor ?? {})");
|
||||
expect(config).not.toContain("errorPath:");
|
||||
expect(config).toContain('"https://store.example.com"');
|
||||
expect(existsSync(join(dir, "mobile", "web", "index.html"))).toBe(true);
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "mobile", "package.json"), "utf8"));
|
||||
expect(pkg.dependencies["@capacitor/android"]).toBe("^8.0.0");
|
||||
expect(pkg.scripts["open:ios"]).toBe("cap open ios");
|
||||
});
|
||||
|
||||
test("generateMobile derives defaults and does not overwrite files", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-mobile-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "demo-app" }));
|
||||
await generateMobile(dir);
|
||||
const path = join(dir, "mobile", "capacitor.config.ts");
|
||||
const before = readFileSync(path, "utf8");
|
||||
expect(before).toContain('appId: mobile.appId ?? "com.example.demoapp"');
|
||||
expect(before).toContain('"http://localhost:3000"');
|
||||
await generateMobile(dir, { appName: "Changed" });
|
||||
expect(readFileSync(path, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
test("mobileOptions parses generator flags", () => {
|
||||
expect(
|
||||
mobileOptions(["--app-id=com.acme.app", "--app-name=Acme", "--url=https://app.test"]),
|
||||
).toEqual({
|
||||
appId: "com.acme.app",
|
||||
appName: "Acme",
|
||||
serverUrl: "https://app.test",
|
||||
mode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("generateMobile scaffolds a WebView-free native project from config", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-native-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "native-demo" }));
|
||||
writeFileSync(
|
||||
join(dir, "wrnexus.config.mjs"),
|
||||
`export default { mobile: { mode: "native", appId: "com.acme.native", apiUrl: "https://api.example.com" } }`,
|
||||
);
|
||||
await generateMobile(dir);
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "mobile", "package.json"), "utf8"));
|
||||
expect(pkg.wrnexus.mode).toBe("native");
|
||||
expect(pkg.dependencies.expo).toBeDefined();
|
||||
expect(existsSync(join(dir, "mobile", "app", "index.tsx"))).toBe(true);
|
||||
expect(readFileSync(join(dir, "mobile", "src", "wrnexus.ts"), "utf8")).toContain(
|
||||
"https://api.example.com",
|
||||
);
|
||||
expect(existsSync(join(dir, "mobile", "capacitor.config.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
test("mobile compile turns wrn pages into Expo routes", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-native-compile-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "native-compile" }));
|
||||
writeFileSync(join(dir, "wrnexus.config.mjs"), `export default { mobile: { mode: "native" } }`);
|
||||
await generateMobile(dir);
|
||||
const pages = join(dir, "app", "pages");
|
||||
const nested = join(pages, "account");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(
|
||||
join(nested, "profile.wrn"),
|
||||
"page Profile { view { <main><h1>Profile</h1></main> } }",
|
||||
);
|
||||
await runMobileCommand(dir, "compile");
|
||||
const output = readFileSync(join(dir, "mobile", "app", "account", "profile.tsx"), "utf8");
|
||||
expect(output).toContain("function Profile()");
|
||||
expect(output).toContain("<View><Text>Profile</Text></View>");
|
||||
});
|
||||
|
||||
test("native catalog uses built-in React Native sharing without installing expo-sharing", async () => {
|
||||
expect(nativeCatalog.share?.expo).toBeUndefined();
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-native-share-"));
|
||||
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "native-share" }));
|
||||
writeFileSync(join(dir, "wrnexus.config.mjs"), `export default { mobile: { mode: "native" } }`);
|
||||
await generateMobile(dir);
|
||||
await runNativeCommand(dir, "add", ["share"]);
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "mobile", "package.json"), "utf8"));
|
||||
expect(pkg.dependencies["expo-sharing"]).toBeUndefined();
|
||||
});
|
||||
Reference in New Issue
Block a user