Files
WRNexusJS/packages/cli/src/create.ts
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

693 lines
22 KiB
TypeScript

/**
* `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";
import { currentCliVersion } from "./update-notifier.ts";
const cliVersion = currentCliVersion();
// Framework packages use independent patch versions. Keep scaffolds on the
// compatible 0.8 line instead of assuming every package shares the CLI patch.
export const scaffoldFrameworkRange = "^0.8.8";
/** 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,
"public/llms.txt": AI_GUIDE,
".gitignore": `# Dependencies
node_modules/
# WRNexusJS and production builds
dist/
.wrnexus/
**/.wrnexus/
coverage/
# Environment files and local secrets
.env
.env.*
!.env.example
!.env.*.example
# Logs and runtime files
*.log
logs/
*.pid
*.pid.lock
# Local databases
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
uploads/
# Generated native projects
mobile/android/
mobile/ios/
mobile/.expo/
# Editors and operating systems
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
*.swp
*.swo
.DS_Store
Thumbs.db
# TypeScript and test caches
*.tsbuildinfo
.eslintcache
.nyc_output/
`,
"package.json": `{
"name": "APP_NAME",
"version": "0.1.0",
"private": true,
"type": "module",
"wrnexus": {
"version": "${cliVersion}"
},
"scripts": {
"dev": "wrnexus dev .",
"build": "wrnexus build .",
"start": "bun dist/server.js",
"production": "bun run build && bun run start",
"typecheck": "tsc --noEmit",
"test": "wrnexus test .",
"test:watch": "wrnexus test . --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"doctor": "wrnexus doctor .",
"analyze": "wrnexus analyze .",
"inspect": "wrnexus inspect packages .",
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
},
"dependencies": {
"@wrnexus/ai": "${scaffoldFrameworkRange}",
"@wrnexus/auth": "${scaffoldFrameworkRange}",
"@wrnexus/captcha": "${scaffoldFrameworkRange}",
"@wrnexus/core": "${scaffoldFrameworkRange}",
"@wrnexus/csr": "${scaffoldFrameworkRange}",
"@wrnexus/db": "${scaffoldFrameworkRange}",
"@wrnexus/dev-server": "${scaffoldFrameworkRange}",
"@wrnexus/encryption": "${scaffoldFrameworkRange}",
"@wrnexus/helpers": "${scaffoldFrameworkRange}",
"@wrnexus/i18n": "${scaffoldFrameworkRange}",
"@wrnexus/image": "${scaffoldFrameworkRange}",
"@wrnexus/jwt": "${scaffoldFrameworkRange}",
"@wrnexus/observability": "${scaffoldFrameworkRange}",
"@wrnexus/realtime": "${scaffoldFrameworkRange}",
"@wrnexus/security": "${scaffoldFrameworkRange}",
"@wrnexus/store": "${scaffoldFrameworkRange}",
"@wrnexus/styles": "${scaffoldFrameworkRange}",
"@wrnexus/tracking": "${scaffoldFrameworkRange}",
"@wrnexus/ui": "${scaffoldFrameworkRange}",
"@wrnexus/uploader": "${scaffoldFrameworkRange}",
"@wrnexus/validation": "${scaffoldFrameworkRange}",
"@wrnexus/authz": "${scaffoldFrameworkRange}"
},
"devDependencies": {
"@wrnexus/cli": "${cliVersion}",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@iconify/tailwind4": "^1.2.3",
"@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", "test", "wrnexus.config.ts"],
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
}
`,
".env.example": `# Copy to .env for local development. Never commit real secrets.
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
DATABASE_URL=file:./dev.db
REDIS_URL=redis://localhost:6379
AUTH_SECRET=replace-with-at-least-32-random-characters
ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
ANTHROPIC_API_KEY=
OTEL_EXPORTER_OTLP_ENDPOINT=
`,
".env.test.example": `WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
DATABASE_URL=file:./test.db
AUTH_SECRET=test-only-secret-replace-outside-tests
`,
"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
CLAUDE.md
`,
".editorconfig": `root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
`,
".vscode/settings.json": `{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"prettier.requireConfig": true,
"[wrn]": {
"editor.defaultFormatter": "wrnexus.wrnexus",
"editor.formatOnSave": true
}
}
`,
".vscode/extensions.json": `{
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
`,
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
compatibilityDate: "2026-08-02",
frameworkBehaviour: 1,
// v0.8 defaults: explicit imports, strict template types, safe stores, and
// automatic progressive navigation. Package plugins are discovered from the
// installed packages above; add custom plugins to this array when needed.
plugins: [],
imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } },
types: {
strict: true,
noImplicitAny: true,
strictNullChecks: true,
checkTemplates: true,
checkComponentProps: true,
generateDeclarations: true,
},
functions: { legacyDefaultRuntime: "current" },
stores: { strictMutations: true, persistence: true },
compatibility: {
legacyEmit: false,
legacyEventProps: false,
legacyComponentDiscovery: false,
stringLayouts: false,
},
experimental: {},
performance: {
enforcement: "warn",
analyze: true,
budgets: {
routeJsBytes: 50 * 1024,
routeCssBytes: 25 * 1024,
lcpMs: 2_500,
inpMs: 200,
cls: 0.1,
},
},
observability: {
enabled: true,
serviceName: "APP_SLUG",
serverTiming: true,
sampleRate: 1,
exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
webVitals: true,
},
tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] },
build: { cache: true, sourceMaps: true, report: true, adapter: "bun" },
navigation: { mode: "auto" },
devToolbar: { enabled: true, position: "bottom-center", openEditor: true },
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",
// Set WRNEXUS_PUBLIC_ORIGIN in production when TLS terminates at a proxy.
canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN,
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, appRoot, mode }) => {
const args = ["@tailwindcss/cli", "-i", entryPath!];
if (mode === "production") args.push("--minify");
return await Bun.$.cwd(appRoot)\`bunx \${args}\`.text();
},
},
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
fonts: {
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
google: [{ family: "Plus Jakarta Sans", 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 }],
theme: { palette: "violet", default: "light" },
i18n: { default: "en", locales: ["en"] },
db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" },
databases: {},
storage: {
default: "public",
stores: {
public: {
driver: "local",
access: "public",
dir: "uploads/public",
maxBytes: 10_000_000,
accept: ["image/*", "application/pdf"],
},
private: {
driver: "local",
access: "private",
dir: "uploads/private",
maxBytes: 10_000_000,
},
},
},
realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL },
port: Number(process.env.PORT ?? 3000),
security: {
cors: { enabled: false },
},
profiles: {
development: {},
test: {
db: { driver: "sqlite", url: "file:./test.db" },
observability: { exporter: "none", sampleRate: 0 },
},
staging: {
seo: { robots: "noindex,nofollow" },
performance: { enforcement: "error" },
build: { sourceMaps: true, report: true },
},
production: {
seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN },
performance: { enforcement: "error" },
build: { sourceMaps: false, report: true },
devToolbar: false,
},
},
};
export default config;
`,
"public/robots.txt": `User-agent: *
Allow: /
`,
"app/locales/en.json": `{
"common": {
"appName": "APP_NAME",
"welcome": "Welcome to APP_NAME"
}
}
`,
"app/db/migrations/0001_init.sql": `-- Create application tables here.
-- Run with: bunx wrnexus db migrate
`,
"app/db/seed.ts": `// Add deterministic development seed data here.
export async function seed(): Promise<void> {}
`,
"app/schemas/contact.ts": `import { v } from "@wrnexus/validation";
export const contactSchema = v.object({
email: v.string().email(),
message: v.string().min(10).max(2_000),
});
`,
"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";
@plugin "@iconify/tailwind4";
@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-wrn-theme-toggle flips it. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
body {
font-family: var(--wrn-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
}
`,
"app/layouts/document.wrn": `// Global document layout. The framework renders this once around the selected
// page layout and merges SEO metadata, styles, and scripts into <head>/<body>.
// Resolved theme, language, URL, and pathname are available as safe SSR props,
// so document attributes do not need a client-side correction. Request cookies
// remain server-private and are never passed into document hydration.
layout Document {
runtime = "server"
props {
theme = "light"
language = "en"
url = ""
pathname = "/"
}
view {
<html>
<head></head>
<body>
<div id="app"><slot /></div>
</body>
</html>
}
}
`,
"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-wrn-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="/api/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 API</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/pages/about.wrn": `page About {
seo {
title = "About"
description = "Learn how APP_NAME is built with WrNexus."
}
view {
<main class="min-h-screen bg-white px-6 py-20 text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
<article class="mx-auto max-w-2xl">
<a href="/" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">← Home</a>
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-indigo-500">WrNexus application</p>
<h1 class="mt-4 text-4xl font-bold tracking-tight">About APP_NAME</h1>
<p class="mt-6 text-lg leading-8 text-slate-600 dark:text-slate-400">
This page is server-rendered from <code>app/pages/about.wrn</code>. Add state,
events, components, APIs, and data without switching to another UI framework.
</p>
</article>
</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";
import type { Context } from "@wrnexus/core";
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
export const POST = async (ctx: Context) => {
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": `import type { Middleware } from "@wrnexus/core";
const logger: Middleware = async (ctx, next) => {
console.log(ctx.req.method, ctx.url.pathname);
return next();
};
export default logger;
`,
"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 });
},
});
`,
"test/smoke.test.ts": `import { expect, test } from "bun:test";
import { parseOrThrow } from "@wrnexus/validation";
import { contactSchema } from "../app/schemas/contact.ts";
test("starter validation schema accepts a contact request", () => {
expect(
parseOrThrow(contactSchema, {
email: "hello@example.com",
message: "Hello from the generated application.",
}),
).toEqual({
email: "hello@example.com",
message: "Hello from the generated application.",
});
});
`,
};
/**
* 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`);
}