457 lines
21 KiB
TypeScript
457 lines
21 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* @wrnexus/cli — the `wrnexus` command line.
|
|
*
|
|
* wrnexus dev [app-dir] [--port=3000] start the dev server (live reload)
|
|
* wrnexus build [app-dir] build a production server + assets
|
|
* wrnexus create <app-name> scaffold a new app
|
|
* wrnexus eject <name...> copy a Wire UI component into your app
|
|
* wrnexus db <migrate|rollback|status|new> database migrations
|
|
*/
|
|
|
|
import { join, resolve } from "node:path";
|
|
import { resolveProfile, loadEnv } from "@wrnexus/styles";
|
|
import { runDev } from "./dev.ts";
|
|
import { createApp } from "./create.ts";
|
|
import { currentCliVersion, notifyIfUpdateAvailable } from "./update-notifier.ts";
|
|
|
|
/**
|
|
* Resolve the active profile from `--profile=<name>` (or WRNEXUS_PROFILE / mode),
|
|
* publish it as WRNEXUS_PROFILE (so config loaders + the dev child pick it up),
|
|
* and load its `.env` cascade into process.env. Returns the profile name.
|
|
*/
|
|
function bootstrapProfile(
|
|
appRoot: string,
|
|
mode: "development" | "production",
|
|
args: string[],
|
|
): string {
|
|
const flag = args.find((a) => a.startsWith("--profile="));
|
|
const profile = resolveProfile({ explicit: flag?.split("=")[1], mode });
|
|
process.env.WRNEXUS_PROFILE = profile;
|
|
const loaded = loadEnv(resolve(appRoot), profile);
|
|
const count = Object.keys(loaded).length;
|
|
console.log(` ▸ profile: ${profile}${count ? ` (${count} env vars loaded)` : ""}`);
|
|
return profile;
|
|
}
|
|
|
|
function help(): void {
|
|
console.log(`wrnexus — WrNexus CLI
|
|
|
|
Usage:
|
|
wrnexus dev [app-dir] [--port=3000] [--host=::]
|
|
Start the development server (live reload)
|
|
wrnexus dev [app-dir] --services Start local production-service simulators with the app
|
|
wrnexus dev [app-dir] --production-runtime
|
|
wrnexus dev [app-dir] --services [--services-port=3099] [--services-http]
|
|
Rebuild and reload the exact production artifact
|
|
wrnexus build [app-dir] Build a production server bundle + assets
|
|
wrnexus preview [app-dir] Serve the existing exact production output
|
|
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 production [workspace-dir] Build, migrate, and serve the complete production workspace
|
|
wrnexus <environment> [workspace-dir] Run a configured workspace environment (development/staging/custom)
|
|
wrnexus generate <type> <name> Scaffold a page | component | api | schema
|
|
wrnexus generate routes | docker | mobile
|
|
Generate routes or scaffold deployment targets
|
|
wrnexus generate types [app-dir] Generate application-wide route/component/key types
|
|
wrnexus routes [app-dir] Generate typed named routes
|
|
wrnexus typecheck [app-dir] Generate types and check TypeScript plus every .wrn file
|
|
wrnexus mobile add <package...> Install Capacitor or Expo native packages
|
|
wrnexus mobile compile Compile .wrn pages into native Expo routes
|
|
wrnexus native list List cross-platform native capabilities
|
|
wrnexus native add <capability...> Install capability packages for the configured mobile mode
|
|
wrnexus eject <name...> Copy a Wire UI component into app/components
|
|
wrnexus update [dir] [--latest] Upgrade deps, migrate project files, and verify the app
|
|
wrnexus db <cmd> Migrations: migrate | rollback | status | seed | generate | new
|
|
wrnexus test [level] [app-dir] [--watch]
|
|
Run unit | component | api | browser | visual | accessibility | performance
|
|
wrnexus profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
|
wrnexus doctor [app-dir] [--fix] Check project health; optionally apply safe repairs
|
|
wrnexus compatibility <check|explain|upgrade> [app-dir]
|
|
Inspect or explicitly upgrade behavior defaults
|
|
wrnexus contracts <check|snapshot> [app-dir]
|
|
Detect breaking boundary contract changes
|
|
wrnexus security <audit|headers|test> [app-dir]
|
|
Audit ASVS controls, inspect headers, or run abuse tests
|
|
wrnexus api <generate|docs> [app-dir] Generate OpenAPI, docs, Postman, examples and SDKs
|
|
wrnexus sdk generate <language> [app-dir]
|
|
Generate TypeScript, JavaScript, Java, Go, or Python SDK
|
|
wrnexus deploy <target> [app-dir] Generate docker | kubernetes | systemd | railway | render | fly
|
|
wrnexus mcp [app-dir] Start the WRNexus MCP server over stdio
|
|
wrnexus i18n <extract|validate> [app-dir]
|
|
Extract and audit translation keys
|
|
wrnexus report [app-dir] [--file=app/pages/page.wrn]
|
|
Create a sanitized reproduction bundle
|
|
wrnexus playground [--port=4173] Start the shareable WRN compiler playground
|
|
wrnexus config [app-dir] --explain Print the fully resolved profile configuration
|
|
wrnexus analyze [app-dir] Inspect dist/build-report.json and performance budgets
|
|
wrnexus explain <route|build|hydration|bundle> [subject] [app-dir]
|
|
Explain compiler and production build decisions
|
|
wrnexus explain <cache|permission> <subject> [app-dir]
|
|
Explain route caching or permission enforcement
|
|
wrnexus inspect <target> [app-dir] Inspect packages | plugins | routes | assets | runtimes | styles | migrations | bundle
|
|
wrnexus inspect component <name> [app-dir]
|
|
Inspect a component's typed public contract
|
|
wrnexus generate system <name> Scaffold a complete framework-native package
|
|
|
|
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
|
|
that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat
|
|
|
|
Workspace environments: configure protocol, rootDomain, port, hostname, runtime
|
|
(development|production), hmr, build, migrate, and profile in wrnexus.workspace.ts.
|
|
CLI overrides: --port, --host, --build/--no-build, --migrate/--no-migrate,
|
|
--profile, and --prepare-only.
|
|
|
|
Update options: --dry-run previews changes; --no-verify skips post-update check/build.
|
|
`);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const [command, ...rest] = process.argv.slice(2);
|
|
|
|
notifyIfUpdateAvailable(currentCliVersion(), command);
|
|
|
|
switch (command) {
|
|
case "dev": {
|
|
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
|
const portArg = rest.find((a) => a.startsWith("--port="));
|
|
const hostArg = rest.find((a) => a.startsWith("--host="));
|
|
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
|
|
const host = hostArg?.split("=")[1] || "::";
|
|
bootstrapProfile(appRoot, "development", rest);
|
|
let developmentCertificate:
|
|
| { certFile: string; keyFile: string; cert: string; key: string; reused: boolean }
|
|
| undefined;
|
|
if (rest.includes("--services") && !rest.includes("--services-http")) {
|
|
const { ensureLocalCertificate } = await import("./services.ts");
|
|
developmentCertificate = await ensureLocalCertificate(appRoot);
|
|
}
|
|
if (rest.includes("--services")) {
|
|
const { startLocalServices } = await import("./services.ts");
|
|
await startLocalServices({
|
|
appRoot,
|
|
port: Number(
|
|
rest.find((value) => value.startsWith("--services-port="))?.split("=")[1] ?? 3099,
|
|
),
|
|
https: !rest.includes("--services-http"),
|
|
origin: `${rest.includes("--services-http") ? "http" : "https"}://localhost:${port}`,
|
|
certificate: developmentCertificate,
|
|
});
|
|
}
|
|
if (rest.includes("--production-runtime")) {
|
|
const { runProductionDev } = await import("./dev.ts");
|
|
await runProductionDev(appRoot, port, host);
|
|
} else {
|
|
runDev(appRoot, port, host, developmentCertificate);
|
|
}
|
|
break;
|
|
}
|
|
case "build": {
|
|
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
|
bootstrapProfile(appRoot, "production", rest);
|
|
const { runBuild } = await import("./build.ts");
|
|
await runBuild(appRoot);
|
|
break;
|
|
}
|
|
case "preview": {
|
|
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
|
const port = Number(rest.find((a) => a.startsWith("--port="))?.split("=")[1] ?? 3000);
|
|
const hostname = rest.find((a) => a.startsWith("--host="))?.split("=")[1] || "::";
|
|
bootstrapProfile(appRoot, "production", rest);
|
|
const { runPreview } = await import("./preview.ts");
|
|
runPreview(appRoot, { port, hostname });
|
|
break;
|
|
}
|
|
case "create":
|
|
createApp(rest[0] ?? "");
|
|
break;
|
|
case "workspace": {
|
|
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": {
|
|
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
|
const { runGateway } = await import("./workspace.ts");
|
|
await runGateway(appRoot, rest);
|
|
break;
|
|
}
|
|
case "production": {
|
|
const workspaceRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
|
const { runProduction } = await import("./workspace.ts");
|
|
await runProduction(workspaceRoot, rest);
|
|
break;
|
|
}
|
|
case "generate":
|
|
case "g": {
|
|
if (rest[0] === "routes") {
|
|
const { regenerateRoutes } = await import("./routes.ts");
|
|
const n = regenerateRoutes(join(resolve(rest[1] ?? "."), "app"));
|
|
console.log(`✓ Generated app/routes.gen.ts (${n} routes)`);
|
|
break;
|
|
}
|
|
if (rest[0] === "types") {
|
|
const { generateApplicationTypesWithPlugins } = await import("./types.ts");
|
|
const result = await generateApplicationTypesWithPlugins(rest[1] ?? ".");
|
|
console.log(
|
|
`✓ Generated ${result.file} (${result.routes} routes, ${result.components} components)`,
|
|
);
|
|
break;
|
|
}
|
|
if (rest[0] === "docker") {
|
|
const { generateDocker } = await import("./docker.ts");
|
|
generateDocker(process.cwd());
|
|
break;
|
|
}
|
|
if (rest[0] === "system") {
|
|
const { generateSystem } = await import("./system.ts");
|
|
const files = generateSystem(process.cwd(), rest[1] ?? "");
|
|
console.log(`✓ Created @wrnexus/${rest[1]} (${files.length} files)`);
|
|
break;
|
|
}
|
|
if (rest[0] === "mobile") {
|
|
const { generateMobile, mobileOptions } = await import("./mobile.ts");
|
|
await generateMobile(process.cwd(), mobileOptions(rest.slice(1)));
|
|
break;
|
|
}
|
|
const { runGenerate } = await import("./generate.ts");
|
|
runGenerate(".", rest[0], rest[1]);
|
|
break;
|
|
}
|
|
case "routes": {
|
|
const appRoot = resolve(rest.find((arg) => !arg.startsWith("--")) ?? ".");
|
|
const { regenerateRoutes } = await import("./routes.ts");
|
|
const count = regenerateRoutes(join(appRoot, "app"));
|
|
console.log(`✓ Generated app/routes.gen.ts (${count} routes)`);
|
|
break;
|
|
}
|
|
case "typecheck": {
|
|
const { runTypecheck } = await import("./types.ts");
|
|
const healthy = await runTypecheck(rest.find((arg) => !arg.startsWith("--")) ?? ".");
|
|
if (!healthy) process.exitCode = 1;
|
|
else console.log("✓ Application types are valid");
|
|
break;
|
|
}
|
|
case "eject": {
|
|
const { runEject } = await import("./eject.ts");
|
|
const args = rest.filter((a) => !a.startsWith("--"));
|
|
// First arg may be an app dir; treat known component names as names.
|
|
runEject(".", args);
|
|
break;
|
|
}
|
|
case "mobile": {
|
|
const { runMobileCommand } = await import("./mobile-command.ts");
|
|
await runMobileCommand(".", rest[0], rest.slice(1));
|
|
break;
|
|
}
|
|
case "native": {
|
|
const { runNativeCommand } = await import("./native-command.ts");
|
|
await runNativeCommand(".", rest[0], rest.slice(1));
|
|
break;
|
|
}
|
|
case "update":
|
|
case "upgrade": {
|
|
const dir = rest.find((a) => !a.startsWith("--")) ?? ".";
|
|
const { runUpdate } = await import("./update.ts");
|
|
await runUpdate(dir, rest);
|
|
break;
|
|
}
|
|
case "db": {
|
|
bootstrapProfile(".", "development", rest);
|
|
const { runDbCommand } = await import("./db.ts");
|
|
const [sub, ...dbArgs] = rest.filter((a) => !a.startsWith("--profile="));
|
|
await runDbCommand(".", sub, dbArgs);
|
|
break;
|
|
}
|
|
case "profiles": {
|
|
const { listProfiles } = await import("./profiles.ts");
|
|
await listProfiles(rest.find((a) => !a.startsWith("--")) ?? ".");
|
|
break;
|
|
}
|
|
case "doctor": {
|
|
const { runDoctor } = await import("./doctor.ts");
|
|
const healthy = await runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".", {
|
|
fix: rest.includes("--fix"),
|
|
});
|
|
if (!healthy) process.exitCode = 1;
|
|
break;
|
|
}
|
|
case "compatibility": {
|
|
const { runCompatibilityCommand } = await import("./compatibility-command.ts");
|
|
const subcommand = rest.find((value) => !value.startsWith("--")) ?? "check";
|
|
const appRoot = rest.filter((value) => !value.startsWith("--"))[1] ?? ".";
|
|
if (!["check", "explain", "upgrade"].includes(subcommand))
|
|
throw new Error(`WRN-COMPATIBILITY-COMMAND: unknown command '${subcommand}'.`);
|
|
const current = await runCompatibilityCommand(appRoot, subcommand, rest);
|
|
if (!current && subcommand !== "explain") process.exitCode = 1;
|
|
break;
|
|
}
|
|
case "contracts": {
|
|
const { runContractsCommand } = await import("./contracts-command.ts");
|
|
const subcommand = rest.find((value) => !value.startsWith("--")) ?? "check";
|
|
const appRoot = rest.filter((value) => !value.startsWith("--"))[1] ?? ".";
|
|
const result = await runContractsCommand(appRoot, subcommand, rest);
|
|
if (!result.ok) process.exitCode = 1;
|
|
break;
|
|
}
|
|
case "security": {
|
|
const values = rest.filter((value) => !value.startsWith("--"));
|
|
const { runSecurityCommand } = await import("./security-command.ts");
|
|
const healthy = await runSecurityCommand(values[1] ?? ".", values[0] ?? "audit", rest);
|
|
if (!healthy) process.exitCode = 1;
|
|
break;
|
|
}
|
|
case "api": {
|
|
const values = rest.filter((value) => !value.startsWith("--"));
|
|
if (!["generate", "docs"].includes(values[0] ?? ""))
|
|
throw new Error("WRN-API-COMMAND: use api generate or api docs.");
|
|
const { runApiCommand } = await import("./api-command.ts");
|
|
runApiCommand(values[1] ?? ".", "api", rest);
|
|
break;
|
|
}
|
|
case "sdk": {
|
|
const values = rest.filter((value) => !value.startsWith("--"));
|
|
if (values[0] !== "generate")
|
|
throw new Error("WRN-SDK-COMMAND: use sdk generate <language>.");
|
|
const { runApiCommand } = await import("./api-command.ts");
|
|
runApiCommand(values[2] ?? ".", "sdk", rest);
|
|
break;
|
|
}
|
|
case "deploy": {
|
|
const values = rest.filter((value) => !value.startsWith("--"));
|
|
const { generateDeployment } = await import("./deploy.ts");
|
|
const files = generateDeployment(values[1] ?? ".", values[0] ?? "");
|
|
console.log(`✓ Deployment preset '${values[0]}' ready (${files.length} new files)`);
|
|
break;
|
|
}
|
|
case "mcp": {
|
|
const { runMcpStdio } = await import("@wrnexus/mcp/stdio");
|
|
await runMcpStdio(resolve(rest.find((value) => !value.startsWith("--")) ?? "."));
|
|
break;
|
|
}
|
|
case "i18n": {
|
|
const values = rest.filter((value) => !value.startsWith("--"));
|
|
const { runI18nCommand } = await import("./i18n-command.ts");
|
|
if (!runI18nCommand(values[1] ?? ".", values[0] ?? "validate")) process.exitCode = 1;
|
|
break;
|
|
}
|
|
case "report": {
|
|
const { runReport } = await import("./report.ts");
|
|
runReport(rest.find((value) => !value.startsWith("--")) ?? ".", rest);
|
|
break;
|
|
}
|
|
case "playground": {
|
|
const { createPlaygroundHandler } = await import("@wrnexus/playground");
|
|
const port = Number(rest.find((value) => value.startsWith("--port="))?.split("=")[1] ?? 4173);
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname: "127.0.0.1",
|
|
fetch: createPlaygroundHandler(),
|
|
});
|
|
console.log(`▶ WRNexus playground: http://localhost:${server.port}`);
|
|
break;
|
|
}
|
|
case "config": {
|
|
const { runConfigCommand } = await import("./config-command.ts");
|
|
await runConfigCommand(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
|
|
break;
|
|
}
|
|
case "inspect": {
|
|
const target = rest.find((arg) => !arg.startsWith("--"));
|
|
if (target === "component") {
|
|
const values = rest.filter((arg) => !arg.startsWith("--"));
|
|
const { runInspectComponent } = await import("./inspect.ts");
|
|
runInspectComponent(values[2] ?? ".", values[1] ?? "", rest);
|
|
break;
|
|
}
|
|
const appRoot = rest.filter((arg) => !arg.startsWith("--"))[1] ?? ".";
|
|
const { runInspect } = await import("./inspect.ts");
|
|
await runInspect(appRoot, target, rest);
|
|
break;
|
|
}
|
|
case "analyze": {
|
|
const { runAnalyze } = await import("./analyze.ts");
|
|
const healthy = runAnalyze(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
|
|
if (!healthy) process.exitCode = 1;
|
|
break;
|
|
}
|
|
case "explain": {
|
|
const values = rest.filter((value) => !value.startsWith("--"));
|
|
const target = values[0] ?? "build";
|
|
const subject = target === "build" || target === "bundle" ? "" : (values[1] ?? "");
|
|
const appRoot =
|
|
target === "build" || target === "bundle" ? (values[1] ?? ".") : (values[2] ?? ".");
|
|
const { runExplain } = await import("./explain.ts");
|
|
runExplain(appRoot, target, subject, rest);
|
|
break;
|
|
}
|
|
case "test": {
|
|
const { TEST_LEVELS, runTests } = await import("./test.ts");
|
|
const values = rest.filter((value) => !value.startsWith("--"));
|
|
const hasLevel = TEST_LEVELS.includes(values[0] as (typeof TEST_LEVELS)[number]);
|
|
const appRoot = (hasLevel ? values[1] : values[0]) ?? ".";
|
|
const flag = rest.find((a) => a.startsWith("--profile="));
|
|
// Tests default to the `test` profile (config + .env.test), unless overridden.
|
|
process.env.WRNEXUS_PROFILE = resolveProfile({ explicit: flag?.split("=")[1] ?? "test" });
|
|
loadEnv(resolve(appRoot), process.env.WRNEXUS_PROFILE);
|
|
runTests(appRoot, rest);
|
|
break;
|
|
}
|
|
case undefined:
|
|
case "help":
|
|
case "--help":
|
|
case "-h":
|
|
help();
|
|
break;
|
|
default: {
|
|
const { loadWorkspaceConfig, runGateway, runProduction } = await import("./workspace.ts");
|
|
const workspaceRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
|
try {
|
|
const workspace = await loadWorkspaceConfig(resolve(workspaceRoot));
|
|
const knownEnvironments = new Set([
|
|
"development",
|
|
"production",
|
|
workspace.defaultEnvironment,
|
|
...Object.keys(workspace.environments ?? {}),
|
|
]);
|
|
if (!knownEnvironments.has(command)) throw new Error("unknown-environment-command");
|
|
const environmentArgs = rest.filter((arg) => arg !== workspaceRoot);
|
|
environmentArgs.push(`--environment=${command}`);
|
|
const environmentConfig = workspace.environments?.[command];
|
|
const runtime =
|
|
environmentConfig?.runtime ?? (command === "development" ? "development" : "production");
|
|
if (runtime === "development") {
|
|
if (environmentArgs.includes("--prepare-only")) {
|
|
console.log(
|
|
`\n ✓ Workspace environment "${command}" uses the development runtime (HMR ${environmentConfig?.hmr === false ? "disabled" : "enabled"}).\n`,
|
|
);
|
|
break;
|
|
}
|
|
await runGateway(workspaceRoot, environmentArgs);
|
|
} else {
|
|
await runProduction(workspaceRoot, environmentArgs);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message !== "unknown-environment-command") throw error;
|
|
const { runPluginCliCommand } = await import("./plugin-command.ts");
|
|
if (await runPluginCliCommand(workspaceRoot, command, rest)) break;
|
|
console.error(`Unknown command or workspace environment: ${command}\n`);
|
|
help();
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|