feat: add helpers and improve workspace auth flows
This commit is contained in:
+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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user