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