first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+275
View File
@@ -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;
+306
View File
@@ -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;
}
+391
View File
@@ -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`);
}
+244
View File
@@ -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();
}
}
+83
View File
@@ -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);
}
+93
View File
@@ -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");
}
+68
View File
@@ -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");
}
+37
View File
@@ -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`);
}
}
+127
View File
@@ -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}`);
}
+190
View File
@@ -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);
});
+209
View File
@@ -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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
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>");
}
+267
View File
@@ -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");
}
+101
View File
@@ -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...>>");
}
+31
View File
@@ -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}`);
}
+17
View File
@@ -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;
}
+26
View File
@@ -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);
});
}
+216
View File
@@ -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`);
}
+218
View File
@@ -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,
})),
});
}