feat: add helpers and improve workspace auth flows
This commit is contained in:
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user