fix: stabilize workspace gateway and scaffolding
This commit is contained in:
@@ -167,6 +167,11 @@ wrnexus workspace acme
|
||||
wrnexus gateway --port=3000
|
||||
```
|
||||
|
||||
Development gateways bind to `127.0.0.1` by default for reliable access on Windows,
|
||||
macOS, and Linux. Open the configured app domain on the gateway port (for example
|
||||
`http://localhost:3000` or `http://admin.localhost:3000`), not the internal child ports
|
||||
printed while apps start. Pass `--host=0.0.0.0` to accept connections from other devices.
|
||||
|
||||
### `wrnexus test`
|
||||
|
||||
Runs the app's tests with `bun test`. Defaults to the `test` profile (config + `.env.test`). Pass `--watch` to re-run on change; extra flags pass straight through to `bun test`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.15",
|
||||
"version": "0.2.16",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Framework conventions shipped into scaffolded apps as CLAUDE.md and llms.txt so
|
||||
* Framework conventions shipped into scaffolded apps as CLAUDE.md and public/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). */
|
||||
/** The canonical WrNexus conventions reference (served as /llms.txt). */
|
||||
export const AI_GUIDE = `# WrNexus
|
||||
|
||||
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
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 frameworkVersion = currentCliVersion();
|
||||
|
||||
/** 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,
|
||||
"public/llms.txt": AI_GUIDE,
|
||||
".gitignore": `# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -64,6 +67,9 @@ Thumbs.db
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"wrnexus": {
|
||||
"version": "${frameworkVersion}"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "wrnexus dev .",
|
||||
"build": "wrnexus build .",
|
||||
@@ -76,14 +82,14 @@ Thumbs.db
|
||||
"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"
|
||||
"@wrnexus/ai": "${frameworkVersion}",
|
||||
"@wrnexus/core": "${frameworkVersion}",
|
||||
"@wrnexus/styles": "${frameworkVersion}",
|
||||
"@wrnexus/validation": "${frameworkVersion}",
|
||||
"@wrnexus/db": "${frameworkVersion}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "^0.2.0",
|
||||
"@wrnexus/cli": "${frameworkVersion}",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@tailwindcss/cli": "^4.0.0",
|
||||
"@types/bun": "latest",
|
||||
@@ -306,7 +312,7 @@ page Home {
|
||||
|
||||
<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>
|
||||
<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">
|
||||
@@ -332,6 +338,27 @@ page Home {
|
||||
</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>.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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
|
||||
* 3. Refresh framework-owned reference files (public/llms.txt) and apply any
|
||||
* versioned, idempotent migrations that newer releases introduce.
|
||||
* 4. Record the applied version in package.json (`"wrnexus": { version }`).
|
||||
*
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
import { inspectProject } from "./doctor.ts";
|
||||
@@ -190,11 +190,14 @@ function appVersion(appRoot: string, pkg: Record<string, unknown>): string {
|
||||
|
||||
/** 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");
|
||||
// Public llms.txt is generated and web-visible, so it is safe to refresh.
|
||||
const llms = join(appRoot, "public", "llms.txt");
|
||||
if (!existsSync(llms) || readFileSync(llms, "utf8") !== AI_GUIDE) {
|
||||
log("~ llms.txt refreshed");
|
||||
if (!dryRun) writeFileSync(llms, AI_GUIDE, "utf8");
|
||||
log("~ public/llms.txt refreshed");
|
||||
if (!dryRun) {
|
||||
mkdirSync(join(appRoot, "public"), { recursive: true });
|
||||
writeFileSync(llms, AI_GUIDE, "utf8");
|
||||
}
|
||||
}
|
||||
// CLAUDE.md is often user-edited — only create it when absent.
|
||||
const claude = join(appRoot, "CLAUDE.md");
|
||||
@@ -223,10 +226,14 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri
|
||||
"tsconfig.json",
|
||||
".gitignore",
|
||||
"CLAUDE.md",
|
||||
"llms.txt",
|
||||
"public/llms.txt",
|
||||
]) {
|
||||
const source = join(appRoot, name);
|
||||
if (existsSync(source)) cpSync(source, join(backup, name));
|
||||
if (existsSync(source)) {
|
||||
const destination = join(backup, name);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
cpSync(source, destination);
|
||||
}
|
||||
}
|
||||
return backup;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ 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 { currentCliVersion } from "./update-notifier.ts";
|
||||
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
|
||||
|
||||
const frameworkVersion = currentCliVersion();
|
||||
|
||||
export interface WorkspaceApp {
|
||||
name: string;
|
||||
dir: string;
|
||||
@@ -29,7 +32,7 @@ export interface WorkspaceConfig {
|
||||
security?: GatewaySecurity;
|
||||
}
|
||||
|
||||
const files = (name: string): Record<string, string> => ({
|
||||
export const workspaceFiles = (name: string): Record<string, string> => ({
|
||||
"package.json": `{
|
||||
"name": "${name}",
|
||||
"private": true,
|
||||
@@ -40,7 +43,7 @@ const files = (name: string): Record<string, string> => ({
|
||||
"gateway": "wrnexus gateway"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "^0.2.0"
|
||||
"@wrnexus/cli": "${frameworkVersion}"
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -86,7 +89,7 @@ dist/
|
||||
"main": "src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"dependencies": {
|
||||
"@wrnexus/pubsub": "^0.2.0"
|
||||
"@wrnexus/pubsub": "${frameworkVersion}"
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -124,7 +127,7 @@ ${name}/
|
||||
|
||||
\`\`\`bash
|
||||
bun install
|
||||
bun run dev # = wrnexus gateway → http://localhost:3000
|
||||
bun run dev # = wrnexus gateway → http://127.0.0.1:3000
|
||||
\`\`\`
|
||||
|
||||
Add the hosts to your machine (e.g. /etc/hosts):
|
||||
@@ -133,6 +136,10 @@ Add the hosts to your machine (e.g. /etc/hosts):
|
||||
127.0.0.1 web.localhost admin.localhost
|
||||
\`\`\`
|
||||
|
||||
Open \`http://localhost:3000\` for the web app or
|
||||
\`http://admin.localhost:3000\` for the admin app. The ports printed for individual
|
||||
apps are internal gateway targets, not public workspace URLs.
|
||||
|
||||
## Interconnect
|
||||
|
||||
- **Shared code:** import \`@app/shared\` in any app.
|
||||
@@ -162,7 +169,7 @@ export function createWorkspace(name: string): void {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const [rel, contents] of Object.entries(files(name))) {
|
||||
for (const [rel, contents] of Object.entries(workspaceFiles(name))) {
|
||||
const target = join(root, rel);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, contents, "utf8");
|
||||
@@ -198,7 +205,7 @@ export async function runGateway(root: string, args: string[]): Promise<void> {
|
||||
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 hostname = hostArg?.split("=")[1];
|
||||
const mode = args.includes("--prod") ? "production" : "development";
|
||||
|
||||
const { startGateway } = await import("@wrnexus/dev-server");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { scaffoldApp } from "../src/create.ts";
|
||||
import { currentCliVersion } from "../src/update-notifier.ts";
|
||||
|
||||
test("scaffoldApp creates a comprehensive .gitignore", () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), "wrnexus-create-"));
|
||||
@@ -49,3 +50,28 @@ test("scaffoldApp includes production build and start scripts", () => {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("scaffoldApp pins the current framework and exposes llms.txt publicly", () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), "wrnexus-create-"));
|
||||
const root = join(parent, "current-app");
|
||||
|
||||
try {
|
||||
scaffoldApp(root, "current-app");
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const version = currentCliVersion();
|
||||
|
||||
expect(pkg.wrnexus.version).toBe(version);
|
||||
for (const dependency of Object.values(pkg.dependencies) as string[]) {
|
||||
expect(dependency).toBe(version);
|
||||
}
|
||||
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
|
||||
expect(existsSync(join(root, "public", "llms.txt"))).toBe(true);
|
||||
expect(existsSync(join(root, "llms.txt"))).toBe(false);
|
||||
expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toContain(
|
||||
'href="/api/hello"',
|
||||
);
|
||||
expect(existsSync(join(root, "app", "pages", "about.wrn"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ test("updateApp migrates project files without marking an unverified update comp
|
||||
const gitignore = readFileSync(join(root, ".gitignore"), "utf8");
|
||||
expect(gitignore).toContain("!.env.example");
|
||||
expect(gitignore).toContain("mobile/android/");
|
||||
expect(readFileSync(join(root, "public", "llms.txt"), "utf8")).toContain("# WrNexus");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { currentCliVersion } from "../src/update-notifier.ts";
|
||||
import { workspaceFiles } from "../src/workspace.ts";
|
||||
|
||||
test("workspace templates pin the running framework release", () => {
|
||||
const files = workspaceFiles("acme");
|
||||
const rootPackage = JSON.parse(files["package.json"]!);
|
||||
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
|
||||
const version = currentCliVersion();
|
||||
|
||||
expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version);
|
||||
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(version);
|
||||
expect(files["README.md"]).toContain("http://127.0.0.1:3000");
|
||||
expect(files["README.md"]).toContain("internal gateway targets");
|
||||
});
|
||||
Reference in New Issue
Block a user