feat: add helpers and improve workspace auth flows

This commit is contained in:
2026-07-13 13:53:36 +05:30
parent 88e907783a
commit b4e5fade19
74 changed files with 853 additions and 131 deletions
+8
View File
@@ -34,6 +34,7 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
| `wrnexus build [app-dir]` | Build a self-contained production server bundle + assets into `dist/`. |
| `wrnexus create <app-name>` | Scaffold a new single app from an inline template. |
| `wrnexus workspace <name>` | Scaffold a monorepo (`apps/*` + shared `packages/*`). |
| `wrnexus workspace add <name>` | Add and register an app in the current workspace. |
| `wrnexus gateway [--port=3000]` | Serve every workspace app behind one port, routed by domain. |
| `wrnexus generate <type> <name>` | Scaffold a `page` \| `component` \| `api` \| `schema`. |
| `wrnexus generate routes` | Regenerate the typed routes file (`app/routes.gen.ts`). |
@@ -167,6 +168,13 @@ wrnexus workspace acme
wrnexus gateway --port=3000
```
From a workspace root, add and register another app in one command:
```bash
wrnexus workspace add reports --domain=reports.localhost
bun install
```
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+15
View File
@@ -167,6 +167,21 @@ export const POST = async (ctx) => {
\`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\`.
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
\`@wrnexus/helpers\` instead of constructing it from untrusted headers:
\`\`\`ts
import { redirectToLogin } from "@wrnexus/helpers";
return redirectToLogin(ctx, "/login", {
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
});
\`\`\`
The package also exports \`getOriginalRequestUrl\`, \`getOriginalRequestOrigin\`,
\`getOriginalRequestPath\`, and \`getOriginalRequestMethod\`. Always pass \`allowedHosts\` when
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
## Middleware & realtime
\`\`\`ts
+2
View File
@@ -84,6 +84,7 @@ Thumbs.db
"dependencies": {
"@wrnexus/ai": "${frameworkVersion}",
"@wrnexus/core": "${frameworkVersion}",
"@wrnexus/helpers": "${frameworkVersion}",
"@wrnexus/styles": "${frameworkVersion}",
"@wrnexus/validation": "${frameworkVersion}",
"@wrnexus/db": "${frameworkVersion}"
@@ -175,6 +176,7 @@ dist/
.wrnexus/
**/.wrnexus/
*.log
CLAUDE.md
`,
".editorconfig": `root = true
+8 -2
View File
@@ -43,6 +43,8 @@ Usage:
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 workspace add <name> [--domain=name.localhost]
Add an app to the current workspace
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
@@ -92,8 +94,12 @@ async function main(): Promise<void> {
createApp(rest[0] ?? "");
break;
case "workspace": {
const { createWorkspace } = await import("./workspace.ts");
createWorkspace(rest.find((a) => !a.startsWith("--")) ?? "");
const { addWorkspaceApp, createWorkspace } = await import("./workspace.ts");
if (rest[0] === "add") {
await addWorkspaceApp(".", rest[1] ?? "", rest.slice(2));
} else {
createWorkspace(rest.find((a) => !a.startsWith("--")) ?? "");
}
break;
}
case "gateway": {
+137 -10
View File
@@ -1,6 +1,7 @@
/**
* Monorepo support:
* - `wrnexus workspace <name>` scaffolds a multi-app workspace (apps/* + shared packages/*)
* - `wrnexus workspace add <name> --domain=<host>` adds an app to the current workspace
* - `wrnexus gateway [--port]` serves every app behind one port, routed by domain
*
* A workspace holds several WrNexus apps under `apps/*` and shared libraries under
@@ -9,7 +10,7 @@
* cross-process). `wrnexus.workspace.ts` maps each app to the domains it serves.
*/
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { scaffoldApp } from "./create.ts";
@@ -151,12 +152,139 @@ apps are internal gateway targets, not public workspace URLs.
## Add another app
\`\`\`bash
wrnexus create apps/reports
# then add it to wrnexus.workspace.ts with its domains
wrnexus workspace add reports --domain=reports.localhost
\`\`\`
`,
});
const workspaceConfigNames = [
"wrnexus.workspace.ts",
"wrnexus.workspace.js",
"wrnexus.workspace.mjs",
] as const;
function workspaceConfigPath(root: string): string | null {
for (const file of workspaceConfigNames) {
const path = join(root, file);
if (existsSync(path)) return path;
}
return null;
}
/** Insert an app entry into the top-level `apps: [...]` array. */
export function insertWorkspaceApp(source: string, app: WorkspaceApp): string {
const declaration = /\bapps\s*:\s*\[/.exec(source);
if (!declaration) throw new Error("Workspace config has no `apps: [...]` array.");
const start = source.indexOf("[", declaration.index);
let depth = 0;
let quote = "";
let escaped = false;
let lineComment = false;
let blockComment = false;
let end = -1;
for (let i = start; i < source.length; i++) {
const char = source[i]!;
const next = source[i + 1] ?? "";
if (lineComment) {
if (char === "\n") lineComment = false;
continue;
}
if (blockComment) {
if (char === "*" && next === "/") {
blockComment = false;
i++;
}
continue;
}
if (quote) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === quote) quote = "";
continue;
}
if (char === "/" && next === "/") {
lineComment = true;
i++;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
i++;
continue;
}
if (char === '"' || char === "'" || char === "`") {
quote = char;
continue;
}
if (char === "[") depth++;
if (char === "]" && --depth === 0) {
end = i;
break;
}
}
if (end < 0) throw new Error("Workspace `apps` array is not closed.");
const serialized = `{ name: ${JSON.stringify(app.name)}, dir: ${JSON.stringify(app.dir)}, domains: ${JSON.stringify(app.domains)}${app.port ? `, port: ${app.port}` : ""} }`;
if (!source.slice(start, end).includes("\n")) {
const contents = source.slice(start + 1, end).trimEnd();
const separator = contents && !contents.endsWith(",") ? "," : "";
return source.slice(0, end) + `${separator} ${serialized}` + source.slice(end);
}
const lineStart = source.lastIndexOf("\n", end - 1) + 1;
const closeIndent = /^\s*/.exec(source.slice(lineStart, end))?.[0] ?? "";
const entry = `${closeIndent} ${serialized},\n`;
return source.slice(0, lineStart) + entry + source.slice(lineStart);
}
/** Add a scaffolded app and register its domain in the current workspace. */
export async function addWorkspaceApp(root: string, name: string, args: string[]): Promise<void> {
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
throw new Error(
"App name must start with a letter and contain only lowercase letters, digits, and hyphens.",
);
}
const resolvedRoot = resolve(root);
const configPath = workspaceConfigPath(resolvedRoot);
if (!configPath) throw new Error("No wrnexus.workspace.ts found in the current directory.");
const domain =
args.find((arg) => arg.startsWith("--domain="))?.split("=")[1] || `${name}.localhost`;
if (!/^[a-z0-9.-]+$/.test(domain)) throw new Error(`Invalid workspace domain: ${domain}`);
const portValue = args.find((arg) => arg.startsWith("--port="))?.split("=")[1];
const port = portValue ? Number(portValue) : undefined;
if (portValue && (!Number.isInteger(port) || port! < 1 || port! > 65535)) {
throw new Error(`Invalid app port: ${portValue}`);
}
const config = await loadWorkspaceConfig(resolvedRoot);
const dir = `apps/${name}`;
if (config.apps.some((app) => app.name === name || app.dir.replace(/\\/g, "/") === dir)) {
throw new Error(`Workspace app '${name}' already exists.`);
}
if (config.apps.some((app) => app.domains.includes(domain))) {
throw new Error(`Workspace domain '${domain}' is already assigned.`);
}
const appRoot = join(resolvedRoot, "apps", name);
if (existsSync(appRoot)) throw new Error(`Refusing to overwrite existing directory: ${appRoot}`);
const original = readFileSync(configPath, "utf8");
const next = insertWorkspaceApp(original, { name, dir, domains: [domain], port });
scaffoldApp(appRoot, name);
try {
writeFileSync(configPath, next, "utf8");
} catch (error) {
rmSync(appRoot, { recursive: true, force: true });
throw error;
}
console.log(`✓ Added app ${name}`);
console.log(` directory: ${dir}`);
console.log(` domain: http://${domain}:3000`);
console.log("\nRun `bun install`, then `bun run dev`.");
}
/** Scaffold a monorepo workspace with two starter apps + a shared package. */
export function createWorkspace(name: string): void {
if (!name) {
@@ -187,14 +315,13 @@ export function createWorkspace(name: string): void {
/** 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;
const path = workspaceConfigPath(root);
if (path) {
const mod = (await import(pathToFileURL(path).href)) as { default?: WorkspaceConfig };
if (!mod.default?.apps?.length) {
throw new Error(`${path.split(/[\\/]/).at(-1)} must default-export { apps: [...] }`);
}
return mod.default;
}
throw new Error("No wrnexus.workspace.ts found. Run `wrnexus workspace <name>` to scaffold one.");
}
+2
View File
@@ -61,12 +61,14 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", ()
const version = currentCliVersion();
expect(pkg.wrnexus.version).toBe(version);
expect(pkg.dependencies["@wrnexus/helpers"]).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, ".prettierignore"), "utf8")).toContain("CLAUDE.md");
expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toContain(
'href="/api/hello"',
);
+49 -1
View File
@@ -1,6 +1,9 @@
import { expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { currentCliVersion } from "../src/update-notifier.ts";
import { workspaceFiles } from "../src/workspace.ts";
import { addWorkspaceApp, insertWorkspaceApp, workspaceFiles } from "../src/workspace.ts";
test("workspace templates pin the running framework release", () => {
const files = workspaceFiles("acme");
@@ -13,3 +16,48 @@ test("workspace templates pin the running framework release", () => {
expect(files["README.md"]).toContain("http://127.0.0.1:3000");
expect(files["README.md"]).toContain("internal gateway targets");
});
test("insertWorkspaceApp handles nested arrays and comments", () => {
const source = `const config = {
apps: [
{ name: "web", dir: "apps/web", domains: ["localhost"] },
{ name: "admin", dir: "apps/admin", domains: ["admin.localhost"], auth: { allowIps: ["::1"] } }, // ]
],
};
export default config;
`;
const result = insertWorkspaceApp(source, {
name: "reports",
dir: "apps/reports",
domains: ["reports.localhost"],
});
expect(result).toContain(
'{ name: "reports", dir: "apps/reports", domains: ["reports.localhost"] },',
);
expect(result.indexOf('name: "reports"')).toBeLessThan(result.indexOf("\n ],"));
});
test("workspace add scaffolds and registers an app", async () => {
const root = mkdtempSync(join(tmpdir(), "wrnexus-workspace-add-"));
writeFileSync(
join(root, "wrnexus.workspace.ts"),
'export default { apps: [{ name: "web", dir: "apps/web", domains: ["localhost"] }] };\n',
);
try {
await addWorkspaceApp(root, "reports", ["--domain=reports.localhost"]);
const manifest = JSON.parse(
readFileSync(join(root, "apps", "reports", "package.json"), "utf8"),
);
const config = readFileSync(join(root, "wrnexus.workspace.ts"), "utf8");
expect(manifest.name).toBe("reports");
expect(manifest.wrnexus.version).toBe(currentCliVersion());
expect(config).toContain('name: "reports"');
expect(config).toContain('domains: ["reports.localhost"]');
expect(existsSync(join(root, "apps", "reports", "public", "llms.txt"))).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});