release: WRNexusJS 0.8.0
This commit is contained in:
+54
-2
@@ -1,5 +1,18 @@
|
||||
# @wrnexus/cli
|
||||
|
||||
Production parity commands:
|
||||
|
||||
```bash
|
||||
wrnexus build .
|
||||
wrnexus preview . --port=3000
|
||||
wrnexus dev . --production-runtime
|
||||
```
|
||||
|
||||
`preview` refuses to start without `dist/server.js` and executes that exact
|
||||
artifact with the production profile. Production-runtime development rebuilds
|
||||
the same minified artifact after app, public, or configuration changes and
|
||||
keeps the last good server running when a rebuild fails.
|
||||
|
||||
> The `wrnexus` command-line tool that scaffolds, runs, builds, tests, and manages WrNexus apps.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -26,6 +39,30 @@ bunx wrnexus dev
|
||||
|
||||
## Commands
|
||||
|
||||
### Local production services
|
||||
|
||||
`wrnexus dev . --services` starts the application and the bounded local database,
|
||||
cache, mail, SMS, webhook, storage, queue, cron, authentication and metrics simulator.
|
||||
It generates a localhost/`*.localhost` development certificate under
|
||||
`.wrnexus/certificates/` and serves both the application and service console over HTTPS.
|
||||
Trust that certificate locally to remove the browser warning. Use `--services-http` only
|
||||
when an external development proxy already terminates TLS.
|
||||
|
||||
### Exact production runtime with live updates
|
||||
|
||||
`wrnexus dev . --production-runtime` rebuilds and executes `dist/server.js` with
|
||||
production resolution, serialization, caching, headers and assets. The supervisor keeps
|
||||
the last good process when a build fails. On a successful rebuild the opt-in production
|
||||
HMR socket reconnects, requests the new document and morphs it into the browser; ordinary
|
||||
`wrnexus preview` and deployed production servers never include that client.
|
||||
|
||||
### API platform
|
||||
|
||||
`wrnexus api generate [app-dir]` (or `api docs`) derives operations from file routes and
|
||||
emits `generated/api/openapi.json`, safe static documentation, Postman collection, curl examples,
|
||||
and TypeScript, JavaScript, Java, Go and Python SDKs. Generate one client with
|
||||
`wrnexus sdk generate <language> [app-dir]`.
|
||||
|
||||
Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)).
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -46,10 +83,17 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
|
||||
| `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). |
|
||||
| `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). |
|
||||
| `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. |
|
||||
| `wrnexus compatibility check` | Check whether behavior defaults are explicitly pinned and current. |
|
||||
| `wrnexus compatibility explain` | Explain configured, effective, and current compatibility behavior. |
|
||||
| `wrnexus compatibility upgrade` | Back up config and explicitly opt into reviewed current behavior. |
|
||||
| `wrnexus help` | Print usage. |
|
||||
|
||||
`wrnexus g` is an alias for `wrnexus generate`.
|
||||
|
||||
Compatibility upgrades never happen implicitly. New applications pin
|
||||
`compatibilityDate` and `frameworkBehaviour`; existing applications use
|
||||
`wrnexus compatibility explain` before the backed-up, idempotent upgrade command.
|
||||
|
||||
### `wrnexus dev`
|
||||
|
||||
Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`).
|
||||
@@ -76,7 +120,9 @@ bun dist/server.js # PORT env var optional
|
||||
|
||||
### `wrnexus create`
|
||||
|
||||
Scaffolds a new app from an inline (dependency-free) template — `package.json`, `.gitignore`, config, and starter `app/` files. Use `npm run dev` during development, `npm run build && npm start` for production, or `npm run production` to build and start in one command. The generated production server currently requires Bun even when npm is used to manage packages and scripts.
|
||||
Scaffolds a complete v0.8 app from an inline template. The generated project includes strict TypeScript, ESLint and Prettier, editor recommendations, environment templates, database migrations, locales, schemas, tests, API/middleware/realtime examples, Tailwind and Iconify, PWA/mobile defaults, and the framework package kits. Its `wrnexus.config.ts` documents the current imports, types, stores, performance, observability, tenancy, build, navigation, theme, i18n, database, storage, realtime, security, and profile configuration.
|
||||
|
||||
Use `bun run dev` during development, `bun run check` for the complete typecheck/lint/test/format gate, `bun run build && bun run start` for production, or `bun run production` to build and start in one command.
|
||||
|
||||
### `wrnexus update`
|
||||
|
||||
@@ -162,7 +208,7 @@ wrnexus db status --db=analytics
|
||||
|
||||
### `wrnexus workspace` and `wrnexus gateway`
|
||||
|
||||
`workspace <name>` scaffolds a monorepo: several WrNexus apps under `apps/*` and shared libraries under `packages/*`, plus a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log).
|
||||
`workspace <name>` scaffolds a monorepo: complete v0.8 apps under `apps/*`, shared libraries under `packages/*`, root TypeScript/lint/format/editor/environment tooling, and a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). Newly added workspace apps use the same current scaffold.
|
||||
|
||||
```bash
|
||||
wrnexus workspace acme
|
||||
@@ -241,6 +287,12 @@ wrnexus update --latest
|
||||
wrnexus doctor
|
||||
```
|
||||
|
||||
Use `wrnexus doctor --fix [app-dir]` to apply conservative repairs before the
|
||||
health check: create missing `app/pages` and a default config, align skewed
|
||||
`@wrnexus/*` dependency ranges, record the current migration marker, and format
|
||||
only syntax-valid `.wrn` files. Invalid JSON or WRN sources are reported/skipped
|
||||
instead of overwritten; repeat runs are idempotent.
|
||||
|
||||
## Profiles
|
||||
|
||||
Pass `--profile=<name>` to `dev`, `build`, `db` (or set `WRNEXUS_PROFILE`) to select a config profile. The CLI publishes `WRNEXUS_PROFILE` so config loaders and the dev child pick it up, and loads that profile's `.env` cascade (`.env`, `.env.local`, `.env.<profile>`, `.env.<profile>.local`) into `process.env`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -20,8 +20,13 @@
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/mcp": "workspace:*",
|
||||
"@wrnexus/playground": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
"@wrnexus/typecheck": "workspace:*",
|
||||
"@wrnexus/security": "workspace:*",
|
||||
"selfsigned": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,25 @@ interface BuildReport {
|
||||
frameworkVersion: string;
|
||||
generatedAt: string;
|
||||
adapter: string;
|
||||
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
|
||||
routes: Array<{
|
||||
path: string;
|
||||
source: string;
|
||||
sourceBytes: number;
|
||||
dynamicParams: string[];
|
||||
optimization?: {
|
||||
staticNodes: number;
|
||||
reactiveRegions: number;
|
||||
eliminatedBranches: number;
|
||||
unusedState: string[];
|
||||
unusedHandlers: string[];
|
||||
constantProps: string[];
|
||||
unusedLocalCssClasses: string[];
|
||||
batchableStateUpdates: number;
|
||||
memoizableComponents: string[];
|
||||
preloadDependencies: string[];
|
||||
serverOnlyModules: string[];
|
||||
};
|
||||
}>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: Record<string, number>;
|
||||
budgetViolations: Array<{ metric: string; budget: number; actual: number; overBy: number }>;
|
||||
@@ -42,6 +60,21 @@ export function runAnalyze(appRoot: string, args: string[]): boolean {
|
||||
for (const asset of report.assets.slice(0, 12)) {
|
||||
console.log(` ${bytes(asset.bytes).padStart(10)} ${asset.file}`);
|
||||
}
|
||||
console.log("\nCompiler optimizations:");
|
||||
for (const route of report.routes.filter((item) => item.optimization)) {
|
||||
const optimization = route.optimization!;
|
||||
console.log(
|
||||
` ${route.path}: ${optimization.staticNodes} static nodes, ${optimization.reactiveRegions} reactive regions, ${optimization.eliminatedBranches} branches removed`,
|
||||
);
|
||||
if (
|
||||
optimization.unusedState.length ||
|
||||
optimization.unusedHandlers.length ||
|
||||
optimization.unusedLocalCssClasses.length
|
||||
)
|
||||
console.log(
|
||||
` candidates: ${[...optimization.unusedState, ...optimization.unusedHandlers, ...optimization.unusedLocalCssClasses].join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (report.budgetViolations.length) {
|
||||
console.log("\nBudget violations:");
|
||||
for (const violation of report.budgetViolations) {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter } from "@wrnexus/router";
|
||||
|
||||
export type SdkLanguage = "typescript" | "javascript" | "java" | "go" | "python";
|
||||
export interface ApiOperation {
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
source: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
webhook?: { event: string; payloadSchema?: string; signatureHeader?: string };
|
||||
}
|
||||
export interface ApiArtifacts {
|
||||
operations: ApiOperation[];
|
||||
files: string[];
|
||||
}
|
||||
|
||||
function methods(file: string): string[] {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const values = [
|
||||
...source.matchAll(
|
||||
/export\s+(?:async\s+)?(?:const|function)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g,
|
||||
),
|
||||
].map((match) => match[1]!);
|
||||
if (file.endsWith(".wrn"))
|
||||
for (const match of source.matchAll(/\bapi\s+(GET|POST|PUT|PATCH|DELETE)\s+/g))
|
||||
values.push(match[1]!);
|
||||
return [...new Set(values.length ? values : ["GET"])];
|
||||
}
|
||||
function openapiPath(path: string): string {
|
||||
return path
|
||||
.replace(/\[\.\.\.([A-Za-z_$][\w$]*)\]/g, "{$1}")
|
||||
.replace(/\[([A-Za-z_$][\w$]*)\]/g, "{$1}")
|
||||
.replace(/:([A-Za-z_$][\w$]*)/g, "{$1}")
|
||||
.replace(/\*([A-Za-z_$][\w$]*)/g, "{$1}");
|
||||
}
|
||||
function operationId(method: string, path: string): string {
|
||||
const words =
|
||||
`${method.toLowerCase()}-${path.replace(/^\/api\/?/, "").replace(/[^A-Za-z0-9]+/g, "-") || "root"}`
|
||||
.split("-")
|
||||
.filter(Boolean);
|
||||
return (
|
||||
words[0]! +
|
||||
words
|
||||
.slice(1)
|
||||
.map((word) => word[0]!.toUpperCase() + word.slice(1))
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
export function inspectApi(appRoot: string): ApiOperation[] {
|
||||
const root = resolve(appRoot);
|
||||
const router = buildRouter(join(root, "app"));
|
||||
return router.api.flatMap((route) => {
|
||||
const sourceText = readFileSync(route.file, "utf8");
|
||||
const field = (name: string) =>
|
||||
new RegExp(`${name}\\s*:\\s*["']([^"']+)["']`).exec(sourceText)?.[1];
|
||||
const webhook = /\b(?:defineWebhook\s*\(|webhook\s*=)\s*\{/.test(sourceText)
|
||||
? {
|
||||
event: field("event") ?? operationId("event", route.raw),
|
||||
payloadSchema: field("payloadSchema"),
|
||||
signatureHeader: field("signatureHeader"),
|
||||
}
|
||||
: undefined;
|
||||
return methods(route.file).map((method) => ({
|
||||
id: operationId(method, route.raw),
|
||||
method,
|
||||
path: openapiPath(route.raw),
|
||||
source: relative(root, route.file).replace(/\\/g, "/"),
|
||||
summary: field("summary"),
|
||||
description: field("description"),
|
||||
webhook,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function createOpenApi(operations: ApiOperation[], title = "WRNexus API") {
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
for (const operation of operations) {
|
||||
const parameters = [...operation.path.matchAll(/\{([^}]+)\}/g)].map((match) => ({
|
||||
name: match[1],
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
}));
|
||||
(paths[operation.path] ??= {})[operation.method.toLowerCase()] = {
|
||||
operationId: operation.id,
|
||||
summary: operation.summary ?? `${operation.method} ${operation.path}`,
|
||||
...(operation.description ? { description: operation.description } : {}),
|
||||
tags: ["API"],
|
||||
parameters,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Successful response",
|
||||
content: { "application/json": { schema: {} } },
|
||||
},
|
||||
"400": { description: "Invalid request" },
|
||||
"500": { description: "Internal error" },
|
||||
},
|
||||
"x-wrnexus-source": operation.source,
|
||||
};
|
||||
}
|
||||
const webhooks = Object.fromEntries(
|
||||
operations
|
||||
.filter((operation) => operation.webhook)
|
||||
.map((operation) => [
|
||||
operation.webhook!.event,
|
||||
{
|
||||
post: {
|
||||
summary: operation.summary ?? operation.webhook!.event,
|
||||
description: operation.description,
|
||||
parameters: operation.webhook!.signatureHeader
|
||||
? [
|
||||
{
|
||||
name: operation.webhook!.signatureHeader,
|
||||
in: "header",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: operation.webhook!.payloadSchema
|
||||
? { $ref: operation.webhook!.payloadSchema }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { "200": { description: "Webhook accepted" } },
|
||||
"x-wrnexus-source": operation.source,
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
openapi: "3.1.0",
|
||||
info: { title, version: "0.8.0" },
|
||||
paths,
|
||||
...(Object.keys(webhooks).length ? { webhooks } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sdk(language: SdkLanguage, operations: ApiOperation[]): string {
|
||||
const route = (operation: ApiOperation) =>
|
||||
operation.path.replace(/\{([^}]+)\}/g, "${encodeURIComponent(params.$1)}");
|
||||
if (language === "typescript" || language === "javascript")
|
||||
return `${language === "typescript" ? "export type RequestOptions = { baseUrl?: string; headers?: HeadersInit };\ntype ApiEnvelope = { data?: unknown; error?: { message?: string } };\n" : ""}const request = async (${language === "typescript" ? "method: string, path: string, body: unknown, options: RequestOptions = {}" : "method, path, body, options = {}"}) => { const response = await globalThis.fetch((options.baseUrl || "") + path, { method, headers: { "content-type": "application/json", ...(options.headers || {}) }, body: body === undefined ? undefined : JSON.stringify(body) }); const value${language === "typescript" ? ": ApiEnvelope" : ""} = await response.json(); if (!response.ok) throw Object.assign(new Error(value?.error?.message || "API request failed"), { status: response.status, body: value }); return value.data ?? value; };\n${operations.map((operation) => `export const ${operation.id} = (params${language === "typescript" ? ": Record<string, string> = {}" : " = {}"}, body${language === "typescript" ? ": unknown" : ""}, options${language === "typescript" ? ": RequestOptions = {}" : " = {}"}) => { ${operation.path.includes("{") ? "" : "void params; "}return request("${operation.method}", \`${route(operation)}\`, body, options); };`).join("\n")}\n`;
|
||||
if (language === "python")
|
||||
return `import json, urllib.request\n\nclass WrnexusApi:\n def __init__(self, base_url): self.base_url = base_url.rstrip('/')\n def request(self, method, path, body=None):\n data = None if body is None else json.dumps(body).encode()\n request = urllib.request.Request(self.base_url + path, data=data, method=method, headers={'content-type':'application/json'})\n with urllib.request.urlopen(request) as response: return json.load(response)\n${operations.map((operation) => ` def ${operation.id}(self, path, body=None): return self.request('${operation.method}', path, body)`).join("\n")}\n`;
|
||||
if (language === "go")
|
||||
return `package wrnexussdk\n\nimport ("bytes"; "encoding/json"; "fmt"; "net/http")\ntype Client struct { BaseURL string; HTTP *http.Client }\nfunc (c *Client) Request(method, path string, body any) (map[string]any, error) { data,_:=json.Marshal(body); req,_:=http.NewRequest(method,c.BaseURL+path,bytes.NewReader(data)); req.Header.Set("content-type","application/json"); client:=c.HTTP;if client==nil{client=http.DefaultClient};res,err:=client.Do(req);if err!=nil{return nil,err};defer res.Body.Close();if res.StatusCode>=400{return nil,fmt.Errorf("API status %d",res.StatusCode)};var out map[string]any;err=json.NewDecoder(res.Body).Decode(&out);return out,err }\n`;
|
||||
return `package dev.wrnexus.sdk;\nimport java.net.URI; import java.net.http.*;\npublic final class WrnexusApi { private final String baseUrl; private final HttpClient http = HttpClient.newHttpClient(); public WrnexusApi(String baseUrl){this.baseUrl=baseUrl;} public String request(String method,String path,String json)throws Exception{var request=HttpRequest.newBuilder(URI.create(baseUrl+path)).header("content-type","application/json").method(method,HttpRequest.BodyPublishers.ofString(json==null?"":json)).build();var response=http.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=400)throw new IllegalStateException("API status "+response.statusCode());return response.body();} }\n`;
|
||||
}
|
||||
|
||||
function write(path: string, value: string, files: string[]): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, value);
|
||||
files.push(path);
|
||||
}
|
||||
const escapeHtml = (value: unknown) =>
|
||||
String(value ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
|
||||
);
|
||||
export function generateApiArtifacts(
|
||||
appRoot: string,
|
||||
languages: SdkLanguage[] = ["typescript"],
|
||||
): ApiArtifacts {
|
||||
const root = resolve(appRoot);
|
||||
const output = join(root, "generated", "api");
|
||||
const operations = inspectApi(root);
|
||||
const files: string[] = [];
|
||||
const spec = createOpenApi(operations, `${basename(root)} API`);
|
||||
write(join(output, "openapi.json"), JSON.stringify(spec, null, 2) + "\n", files);
|
||||
write(
|
||||
join(output, "postman.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
info: {
|
||||
name: spec.info.title,
|
||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
},
|
||||
item: operations.map((operation) => ({
|
||||
name: operation.id,
|
||||
request: { method: operation.method, url: `{{baseUrl}}${operation.path}` },
|
||||
})),
|
||||
variable: [{ key: "baseUrl", value: "http://localhost:3000" }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
files,
|
||||
);
|
||||
write(
|
||||
join(output, "index.html"),
|
||||
`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(spec.info.title)}</title><style>body{font:16px system-ui;max-width:960px;margin:auto;padding:2rem}code,pre{background:#f4f4f5;padding:.2rem .4rem}article{border-bottom:1px solid #ddd;padding:1rem 0}</style><h1>${escapeHtml(spec.info.title)}</h1><p>OpenAPI 3.1 · ${operations.length} operations · <a href="openapi.json">specification</a></p>${operations.map((operation) => `<article><h2><code>${escapeHtml(operation.method)}</code> ${escapeHtml(operation.path)}</h2><p>${escapeHtml(operation.summary ?? operation.id)}</p>${operation.description ? `<p>${escapeHtml(operation.description)}</p>` : ""}${operation.webhook ? `<p>Webhook event: <code>${escapeHtml(operation.webhook.event)}</code>${operation.webhook.signatureHeader ? ` · signature: <code>${escapeHtml(operation.webhook.signatureHeader)}</code>` : ""}</p>` : ""}<small>${escapeHtml(operation.source)}</small></article>`).join("")}`,
|
||||
files,
|
||||
);
|
||||
write(
|
||||
join(output, "examples.md"),
|
||||
`# API examples\n\n${operations.map((operation) => `## ${operation.id}\n\n\`\`\`bash\ncurl -X ${operation.method} "http://localhost:3000${operation.path}"\n\`\`\`\n`).join("\n")}`,
|
||||
files,
|
||||
);
|
||||
const extensions = {
|
||||
typescript: "ts",
|
||||
javascript: "js",
|
||||
java: "java",
|
||||
go: "go",
|
||||
python: "py",
|
||||
} as const;
|
||||
for (const language of languages)
|
||||
write(
|
||||
join(output, "sdk", language, `wrnexus-api.${extensions[language]}`),
|
||||
sdk(language, operations),
|
||||
files,
|
||||
);
|
||||
return { operations, files };
|
||||
}
|
||||
|
||||
export function runApiCommand(appRoot: string, kind: "api" | "sdk", args: string[]): ApiArtifacts {
|
||||
const supported: SdkLanguage[] = ["typescript", "javascript", "java", "go", "python"];
|
||||
const language = args.find((value) => supported.includes(value as SdkLanguage)) as
|
||||
SdkLanguage | undefined;
|
||||
if (kind === "sdk" && !language)
|
||||
throw new Error("WRN-SDK-LANGUAGE: choose typescript, javascript, java, go, or python.");
|
||||
const result = generateApiArtifacts(appRoot, kind === "sdk" ? [language!] : supported);
|
||||
console.log(
|
||||
`✓ Generated ${result.operations.length} API operations and ${result.files.length} artifacts in generated/api`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
+185
-8
@@ -21,15 +21,19 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getReactiveRuntime } from "@wrnexus/csr";
|
||||
import {
|
||||
analyzeRuntimeImports,
|
||||
analyzeRuntimeRequirements,
|
||||
assertValidAst,
|
||||
generate,
|
||||
parse,
|
||||
type RuntimeRequirements,
|
||||
type DeploymentRuntime,
|
||||
runtimeCapabilities,
|
||||
resolveWrnImports,
|
||||
} from "@wrnexus/compiler";
|
||||
import {
|
||||
loadAppConfig,
|
||||
@@ -64,6 +68,38 @@ const INLINE_CSS_LIMIT_BYTES = 4096;
|
||||
|
||||
const fwd = (p: string) => p.replace(/\\/g, "/");
|
||||
|
||||
function deploymentRuntime(adapter: string | undefined): DeploymentRuntime | undefined {
|
||||
if (!adapter) return "bun";
|
||||
if (["bun", "node", "edge", "worker", "service-worker", "browser"].includes(adapter))
|
||||
return adapter as DeploymentRuntime;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateRuntimeCapabilities(appRoot: string, adapter?: string): void {
|
||||
const runtime = deploymentRuntime(adapter);
|
||||
if (!runtime || runtime === "bun" || runtime === "node") return;
|
||||
const appDir = join(resolve(appRoot), "app");
|
||||
if (!existsSync(appDir)) return;
|
||||
const files: string[] = [];
|
||||
const walk = (directory: string) => {
|
||||
for (const name of readdirSync(directory)) {
|
||||
const file = join(directory, name);
|
||||
const stat = statSync(file);
|
||||
if (stat.isDirectory()) walk(file);
|
||||
else if (/\.(?:[cm]?[jt]s|wrn)$/.test(file)) files.push(file);
|
||||
}
|
||||
};
|
||||
walk(appDir);
|
||||
const diagnostics = files.flatMap((file) =>
|
||||
analyzeRuntimeImports(readFileSync(file, "utf8"), runtime).map(
|
||||
(diagnostic) =>
|
||||
`${fwd(file.slice(resolve(appRoot).length + 1))}: ${diagnostic.code} ${diagnostic.message}`,
|
||||
),
|
||||
);
|
||||
if (diagnostics.length)
|
||||
throw new Error(`Runtime capability validation failed:\n${diagnostics.join("\n")}`);
|
||||
}
|
||||
|
||||
export async function runBuild(appRoot: string): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appDir = join(root, "app");
|
||||
@@ -72,6 +108,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const reactivePath = join(distDir, "reactive.js");
|
||||
const publicDir = join(root, "public");
|
||||
const distPublicDir = join(distDir, "public");
|
||||
const config = await loadAppConfig(root);
|
||||
validateRuntimeCapabilities(root, config.build?.adapter);
|
||||
|
||||
console.log(`Building ${appDir} -> ${distDir}`);
|
||||
|
||||
@@ -83,11 +121,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
console.log(`✓ Public: ${distPublicDir}`);
|
||||
}
|
||||
|
||||
const config = await loadAppConfig(root);
|
||||
const discoveredPlugins = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
runtime: deploymentRuntime(config.build?.adapter),
|
||||
capabilities: [...runtimeCapabilities(deploymentRuntime(config.build?.adapter) ?? "bun")],
|
||||
enforcePermissions: config.pluginPermissions?.enforce,
|
||||
grantedPermissions: config.pluginPermissions?.grants,
|
||||
});
|
||||
const pluginRunner = createPluginRunner(discoveredPlugins, {
|
||||
root,
|
||||
@@ -100,8 +141,27 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
await pluginRunner.configure(config as Record<string, unknown>);
|
||||
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
const pluginContributions = await pluginRunner.contributions();
|
||||
const { generateApplicationTypes } = await import("./types.ts");
|
||||
generateApplicationTypes(root, pluginContributions);
|
||||
const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs];
|
||||
await pluginRunner.hook("buildStart");
|
||||
const virtualModules = new Map<string, string>();
|
||||
for (const [index, module] of pluginContributions.virtualModules.entries()) {
|
||||
const output = join(compiledDir, `virtual-${index}.ts`);
|
||||
writeFileSync(
|
||||
output,
|
||||
await module.load({
|
||||
root,
|
||||
mode: "production",
|
||||
command: "build",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
virtualModules.set(module.id, output);
|
||||
}
|
||||
|
||||
// `.wrn` route files are compiled once into deterministic intermediate modules.
|
||||
// Plugin AST/code transforms run only when configured, so existing applications
|
||||
@@ -109,12 +169,18 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
let compiledCount = 0;
|
||||
const compiledFiles = new Map<string, string>();
|
||||
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
|
||||
const partialStaticFiles = new Set<string>();
|
||||
const compileWrn = async (file: string): Promise<void> => {
|
||||
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
// Reserve the artifact before resolving imports so cycles terminate and
|
||||
// mutually dependent generated modules can point at deterministic paths.
|
||||
compiledFiles.set(file, out);
|
||||
const source = readFileSync(file, "utf8");
|
||||
let ast = parse(source);
|
||||
assertValidAst(ast, { file, accessibility: true });
|
||||
ast = await pluginRunner.transformAst(ast, file);
|
||||
if (ast.renderMode === "partial-static") partialStaticFiles.add(file);
|
||||
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
|
||||
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
|
||||
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
@@ -126,11 +192,46 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
errors.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join("\n"),
|
||||
);
|
||||
}
|
||||
let code = `// compiled from .wrn\n${generate(ast)}`;
|
||||
code = await pluginRunner.transformCode(code, file);
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
writeFileSync(out, code, "utf8");
|
||||
compiledFiles.set(file, out);
|
||||
try {
|
||||
let code = `// compiled from .wrn\n${generate(ast)}`;
|
||||
code = await pluginRunner.transformCode(code, file);
|
||||
for (const [id, target] of virtualModules) {
|
||||
const sourcePattern = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/");
|
||||
const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
code = code.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(specifier),
|
||||
);
|
||||
}
|
||||
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
|
||||
appRoot: root,
|
||||
mode: config.imports?.mode ?? "compatible",
|
||||
aliases: config.imports?.aliases,
|
||||
});
|
||||
for (const imported of resolvedImports) {
|
||||
if (imported.diagnostic?.severity === "error") {
|
||||
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
|
||||
}
|
||||
if (!imported.resolved || !imported.declaration.source.startsWith(".")) continue;
|
||||
let target = imported.resolved;
|
||||
if (target.endsWith(".wrn")) {
|
||||
await compileWrn(target);
|
||||
target = compiledFiles.get(target)!;
|
||||
}
|
||||
const sourcePattern = imported.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/");
|
||||
const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
code = code.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(specifier),
|
||||
);
|
||||
}
|
||||
writeFileSync(out, code, "utf8");
|
||||
} catch (error) {
|
||||
compiledFiles.delete(file);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const importPathFor = (file: string): string => fwd(compiledFiles.get(file) ?? file);
|
||||
// Regenerate typed DB queries (app/db/queries/*.sql → queries.gen.ts) first, so
|
||||
@@ -235,6 +336,37 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
...router.layouts.map((layout) => layout.file),
|
||||
]);
|
||||
for (const file of wrnFiles) await compileWrn(file);
|
||||
const partialShells = new Map<string, { shell: string; regions: number }>();
|
||||
const partialPages = router.pages.filter((route) => partialStaticFiles.has(route.file));
|
||||
if (partialPages.length > 0) {
|
||||
const { precomputePartialStaticShell } = await import("@wrnexus/dev-server");
|
||||
const moduleCache = new Map<string, Record<string, unknown>>();
|
||||
const loadCompiled = async (file: string): Promise<Record<string, unknown>> => {
|
||||
const existing = moduleCache.get(file);
|
||||
if (existing) return existing;
|
||||
const output = compiledFiles.get(file);
|
||||
if (!output) throw new Error(`WRN-PARTIAL-STATIC-MODULE: ${file} was not compiled`);
|
||||
const loaded = (await import(pathToFileURL(output).href)) as Record<string, unknown>;
|
||||
moduleCache.set(file, loaded);
|
||||
return loaded;
|
||||
};
|
||||
const components = await Promise.all(
|
||||
router.components.map(async (component) => ({
|
||||
name: component.name,
|
||||
mod: await loadCompiled(component.file),
|
||||
})),
|
||||
);
|
||||
for (const route of partialPages) {
|
||||
const result = await precomputePartialStaticShell(await loadCompiled(route.file), components);
|
||||
partialShells.set(route.raw, result);
|
||||
}
|
||||
writeFileSync(
|
||||
join(distDir, "partial-shells.json"),
|
||||
JSON.stringify(Object.fromEntries(partialShells), null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
console.log(`✓ Partial shells: ${partialShells.size} build-time route shell(s)`);
|
||||
}
|
||||
const assetHash = createHash("sha256");
|
||||
const emittedPluginAssets = await emitPluginAssets(
|
||||
pluginContributions,
|
||||
@@ -339,7 +471,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const parts = routes.map((r) => {
|
||||
const v = `m${counter++}`;
|
||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(r.file))};`);
|
||||
return ` { raw: ${JSON.stringify(r.raw)}, mod: ${v} },`;
|
||||
const partial = partialShells.get(r.raw);
|
||||
return ` { raw: ${JSON.stringify(r.raw)}, mod: ${v}${partial ? `, staticShell: ${JSON.stringify(partial.shell)}` : ""} },`;
|
||||
});
|
||||
return parts.length ? `\n${parts.join("\n")}\n ` : "";
|
||||
};
|
||||
@@ -428,6 +561,7 @@ await createProductionServer(
|
||||
observability: ${JSON.stringify(config.observability ?? {})},
|
||||
tenancy: ${JSON.stringify(config.tenancy ?? {})},
|
||||
navigation: ${JSON.stringify(config.navigation ?? {})},
|
||||
developmentRuntime: process.env.WRNEXUS_PRODUCTION_DEV === "1",
|
||||
},
|
||||
);
|
||||
`;
|
||||
@@ -485,12 +619,32 @@ await createProductionServer(
|
||||
source: migration.entry ?? "inline",
|
||||
}));
|
||||
report.componentDirs = componentDirs.map(fwd);
|
||||
report.partialStaticShells = [...partialShells].map(([route, value]) => ({
|
||||
route,
|
||||
regions: value.regions,
|
||||
bytes: Buffer.byteLength(value.shell, "utf8"),
|
||||
}));
|
||||
const violations = checkPerformanceBudgets(
|
||||
config.performance?.budgets ?? {},
|
||||
report.measurements,
|
||||
);
|
||||
report.budgetViolations = violations;
|
||||
writeFileSync(join(distDir, "build-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
|
||||
const contributedAdapter = pluginContributions.deploymentAdapters.find(
|
||||
(adapter) => adapter.name === config.build?.adapter,
|
||||
);
|
||||
if (contributedAdapter)
|
||||
await contributedAdapter.build(
|
||||
{ distDir, report },
|
||||
{
|
||||
root,
|
||||
mode: "production",
|
||||
command: "build",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
},
|
||||
);
|
||||
await pluginRunner.hook("buildEnd", report);
|
||||
|
||||
console.log(`✓ Server: ${join(distDir, "server.js")}`);
|
||||
@@ -636,6 +790,7 @@ interface BuildReport {
|
||||
clientRuntimes?: Array<{ id: string; publicPath: string; type: string; load: string }>;
|
||||
migrations?: Array<{ id: string; database: string; source: string }>;
|
||||
componentDirs?: string[];
|
||||
partialStaticShells?: Array<{ route: string; regions: number; bytes: number }>;
|
||||
generatedAt: string;
|
||||
root: string;
|
||||
adapter: string;
|
||||
@@ -650,6 +805,10 @@ interface BuildReport {
|
||||
needsClientRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons: string[];
|
||||
optimization: RuntimeRequirements["optimization"];
|
||||
cachePolicy: Record<string, string>;
|
||||
requiredPermission: string | null;
|
||||
}>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number };
|
||||
@@ -709,6 +868,24 @@ function createBuildReport(input: {
|
||||
needsClientRuntime: input.runtimeAnalysis.get(route.file)?.needsClientRuntime ?? true,
|
||||
needsServerRuntime: input.runtimeAnalysis.get(route.file)?.needsServerRuntime ?? true,
|
||||
hydrationStrategy: input.runtimeAnalysis.get(route.file)?.hydrationStrategy ?? null,
|
||||
reasons: input.runtimeAnalysis.get(route.file)?.reasons ?? [
|
||||
"runtime requirements unavailable",
|
||||
],
|
||||
optimization: input.runtimeAnalysis.get(route.file)?.optimization ?? {
|
||||
staticNodes: 0,
|
||||
reactiveRegions: 0,
|
||||
eliminatedBranches: 0,
|
||||
unusedState: [],
|
||||
unusedHandlers: [],
|
||||
constantProps: [],
|
||||
unusedLocalCssClasses: [],
|
||||
batchableStateUpdates: 0,
|
||||
memoizableComponents: [],
|
||||
preloadDependencies: [],
|
||||
serverOnlyModules: [],
|
||||
},
|
||||
cachePolicy: input.runtimeAnalysis.get(route.file)?.cachePolicy ?? {},
|
||||
requiredPermission: input.runtimeAnalysis.get(route.file)?.requiredPermission ?? null,
|
||||
})),
|
||||
assets,
|
||||
measurements: {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import {
|
||||
CURRENT_COMPATIBILITY_DATE,
|
||||
CURRENT_FRAMEWORK_BEHAVIOUR,
|
||||
loadRawConfig,
|
||||
resolveCompatibility,
|
||||
} from "@wrnexus/styles";
|
||||
|
||||
const CONFIG_NAMES = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
||||
|
||||
function configPath(root: string): string | undefined {
|
||||
return CONFIG_NAMES.map((name) => join(root, name)).find(existsSync);
|
||||
}
|
||||
|
||||
export async function compatibilityReport(appRoot: string) {
|
||||
return resolveCompatibility(await loadRawConfig(resolve(appRoot)));
|
||||
}
|
||||
|
||||
export function upgradeCompatibility(appRoot: string): {
|
||||
file: string;
|
||||
backup: string;
|
||||
changed: boolean;
|
||||
} {
|
||||
const root = resolve(appRoot);
|
||||
const file = configPath(root);
|
||||
if (!file) throw new Error("WRN-COMPATIBILITY-NO-CONFIG: wrnexus.config.ts was not found.");
|
||||
const source = readFileSync(file, "utf8");
|
||||
let updated = source;
|
||||
const replace = (name: string, value: string) => {
|
||||
const pattern = new RegExp(`(^\\s*${name}\\s*:\\s*)(?:["'][^"']*["']|\\d+)(\\s*,?)`, "m");
|
||||
if (pattern.test(updated)) updated = updated.replace(pattern, `$1${value}$2`);
|
||||
else {
|
||||
const object = /(?:const\s+config[^=]*=|defineConfig\s*\(|export\s+default)\s*\{/m;
|
||||
if (!object.test(updated))
|
||||
throw new Error("WRN-COMPATIBILITY-CONFIG-SHAPE: unable to locate the root config object.");
|
||||
updated = updated.replace(object, (match) => `${match}\n ${name}: ${value},`);
|
||||
}
|
||||
};
|
||||
replace("compatibilityDate", JSON.stringify(CURRENT_COMPATIBILITY_DATE));
|
||||
replace("frameworkBehaviour", String(CURRENT_FRAMEWORK_BEHAVIOUR));
|
||||
if (updated === source) return { file, backup: "", changed: false };
|
||||
const directory = join(root, ".wrnexus", "compatibility-backups");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const backup = join(directory, `${Date.now()}-${basename(file)}`);
|
||||
copyFileSync(file, backup);
|
||||
writeFileSync(file, updated, "utf8");
|
||||
return { file, backup, changed: true };
|
||||
}
|
||||
|
||||
export async function runCompatibilityCommand(
|
||||
appRoot: string,
|
||||
command = "check",
|
||||
args: string[] = [],
|
||||
): Promise<boolean> {
|
||||
if (command === "upgrade") {
|
||||
const result = upgradeCompatibility(appRoot);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
|
||||
else
|
||||
console.log(
|
||||
result.changed
|
||||
? `✓ Compatibility policy upgraded\n backup: ${result.backup}`
|
||||
: "✓ Compatibility policy already current",
|
||||
);
|
||||
}
|
||||
const report = await compatibilityReport(appRoot);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log(`Compatibility date: ${report.effectiveDate} (current ${report.currentDate})`);
|
||||
console.log(
|
||||
`Framework behaviour: ${report.effectiveBehaviour} (current ${report.currentBehaviour})`,
|
||||
);
|
||||
for (const message of report.messages) console.log(`- ${message}`);
|
||||
}
|
||||
return !report.needsUpgrade && !report.future;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
ContractRegistry,
|
||||
checkContractCompatibility,
|
||||
type ContractSnapshot,
|
||||
} from "@wrnexus/validation";
|
||||
|
||||
const CURRENT_FILE = "wrnexus.contracts.json";
|
||||
const BASELINE_FILE = join(".wrnexus", "contracts.json");
|
||||
|
||||
function validateSnapshot(value: unknown, source: string): ContractSnapshot {
|
||||
const snapshot = value as Partial<ContractSnapshot>;
|
||||
if (snapshot?.format !== 1 || !Array.isArray(snapshot.contracts)) {
|
||||
throw new Error(`WRN-CONTRACT-FORMAT: ${source} is not a version 1 contract snapshot.`);
|
||||
}
|
||||
return snapshot as ContractSnapshot;
|
||||
}
|
||||
|
||||
async function currentSnapshot(appRoot: string): Promise<ContractSnapshot> {
|
||||
const modulePath = join(appRoot, "app", "contracts.ts");
|
||||
if (existsSync(modulePath)) {
|
||||
const imported = (await import(`${modulePath}?t=${Date.now()}`)) as {
|
||||
default?: ContractRegistry | ContractSnapshot;
|
||||
contracts?: ContractRegistry | ContractSnapshot;
|
||||
};
|
||||
const value = imported.contracts ?? imported.default;
|
||||
if (value instanceof ContractRegistry) return value.snapshot();
|
||||
return validateSnapshot(value, modulePath);
|
||||
}
|
||||
const jsonPath = join(appRoot, CURRENT_FILE);
|
||||
if (!existsSync(jsonPath)) {
|
||||
throw new Error(
|
||||
`WRN-CONTRACT-SOURCE: create app/contracts.ts exporting a ContractRegistry, or ${CURRENT_FILE}.`,
|
||||
);
|
||||
}
|
||||
return validateSnapshot(JSON.parse(await readFile(jsonPath, "utf8")), jsonPath);
|
||||
}
|
||||
|
||||
export interface ContractCommandResult {
|
||||
ok: boolean;
|
||||
issueCount: number;
|
||||
baseline: string;
|
||||
}
|
||||
|
||||
export async function runContractsCommand(
|
||||
root: string,
|
||||
command = "check",
|
||||
args: string[] = [],
|
||||
): Promise<ContractCommandResult> {
|
||||
const appRoot = resolve(root);
|
||||
const baseline = join(appRoot, BASELINE_FILE);
|
||||
const current = await currentSnapshot(appRoot);
|
||||
if (command === "snapshot") {
|
||||
await mkdir(dirname(baseline), { recursive: true });
|
||||
await writeFile(baseline, `${JSON.stringify(current, null, 2)}\n`, "utf8");
|
||||
if (args.includes("--json")) console.log(JSON.stringify({ ok: true, baseline }));
|
||||
else console.log(`✓ Contract baseline written: ${baseline}`);
|
||||
return { ok: true, issueCount: 0, baseline };
|
||||
}
|
||||
if (command !== "check") throw new Error(`WRN-CONTRACT-COMMAND: unknown command '${command}'.`);
|
||||
if (!existsSync(baseline)) {
|
||||
throw new Error(`WRN-CONTRACT-BASELINE: no baseline found; run 'wrnexus contracts snapshot'.`);
|
||||
}
|
||||
const previous = validateSnapshot(JSON.parse(await readFile(baseline, "utf8")), baseline);
|
||||
const issues = checkContractCompatibility(previous, current);
|
||||
if (args.includes("--json")) {
|
||||
console.log(JSON.stringify({ ok: issues.length === 0, issues, baseline }, null, 2));
|
||||
} else if (issues.length === 0) {
|
||||
console.log(`✓ ${current.contracts.length} contracts are backward compatible.`);
|
||||
} else {
|
||||
console.error(`Breaking contract changes detected (${issues.length}):`);
|
||||
for (const issue of issues) {
|
||||
console.error(` ${issue.code} ${issue.contract}: ${issue.message}`);
|
||||
if (issue.consumers.length) console.error(` Consumers: ${issue.consumers.join(", ")}`);
|
||||
}
|
||||
}
|
||||
return { ok: issues.length === 0, issueCount: issues.length, baseline };
|
||||
}
|
||||
+178
-12
@@ -30,6 +30,7 @@ coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Logs and runtime files
|
||||
*.log
|
||||
@@ -43,6 +44,7 @@ logs/
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
uploads/
|
||||
|
||||
# Generated native projects
|
||||
mobile/android/
|
||||
@@ -77,20 +79,41 @@ Thumbs.db
|
||||
"build": "wrnexus build .",
|
||||
"start": "bun dist/server.js",
|
||||
"production": "bun run build && bun run start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "wrnexus test .",
|
||||
"test:watch": "wrnexus test . --watch",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"check": "bun run lint && bun run format:check"
|
||||
"doctor": "wrnexus doctor .",
|
||||
"analyze": "wrnexus analyze .",
|
||||
"inspect": "wrnexus inspect packages .",
|
||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "${frameworkVersion}",
|
||||
"@wrnexus/auth": "${frameworkVersion}",
|
||||
"@wrnexus/captcha": "${frameworkVersion}",
|
||||
"@wrnexus/core": "${frameworkVersion}",
|
||||
"@wrnexus/csr": "${frameworkVersion}",
|
||||
"@wrnexus/db": "${frameworkVersion}",
|
||||
"@wrnexus/dev-server": "${frameworkVersion}",
|
||||
"@wrnexus/encryption": "${frameworkVersion}",
|
||||
"@wrnexus/helpers": "${frameworkVersion}",
|
||||
"@wrnexus/i18n": "${frameworkVersion}",
|
||||
"@wrnexus/image": "${frameworkVersion}",
|
||||
"@wrnexus/jwt": "${frameworkVersion}",
|
||||
"@wrnexus/observability": "${frameworkVersion}",
|
||||
"@wrnexus/realtime": "${frameworkVersion}",
|
||||
"@wrnexus/security": "${frameworkVersion}",
|
||||
"@wrnexus/store": "${frameworkVersion}",
|
||||
"@wrnexus/styles": "${frameworkVersion}",
|
||||
"@wrnexus/tracking": "${frameworkVersion}",
|
||||
"@wrnexus/ui": "${frameworkVersion}",
|
||||
"@wrnexus/uploader": "${frameworkVersion}",
|
||||
"@wrnexus/validation": "${frameworkVersion}",
|
||||
"@wrnexus/db": "${frameworkVersion}"
|
||||
"@wrnexus/authz": "${frameworkVersion}"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "${frameworkVersion}",
|
||||
@@ -124,9 +147,22 @@ Thumbs.db
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@wrnexus/core"
|
||||
},
|
||||
"include": ["app", "wrnexus.config.ts"],
|
||||
"include": ["app", "test", "wrnexus.config.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
|
||||
}
|
||||
`,
|
||||
".env.example": `# Copy to .env for local development. Never commit real secrets.
|
||||
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||
DATABASE_URL=file:./dev.db
|
||||
REDIS_URL=redis://localhost:6379
|
||||
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||
ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
|
||||
ANTHROPIC_API_KEY=
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
`,
|
||||
".env.test.example": `WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||
DATABASE_URL=file:./test.db
|
||||
AUTH_SECRET=test-only-secret-replace-outside-tests
|
||||
`,
|
||||
"eslint.config.js": `import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -220,6 +256,56 @@ trim_trailing_whitespace = true
|
||||
"wrnexus.config.ts": `import type { AppConfig } from "@wrnexus/styles";
|
||||
|
||||
const config: AppConfig = {
|
||||
compatibilityDate: "2026-08-02",
|
||||
frameworkBehaviour: 1,
|
||||
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||
// automatic progressive navigation. Package plugins are discovered from the
|
||||
// installed packages above; add custom plugins to this array when needed.
|
||||
plugins: [],
|
||||
imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } },
|
||||
types: {
|
||||
strict: true,
|
||||
noImplicitAny: true,
|
||||
strictNullChecks: true,
|
||||
checkTemplates: true,
|
||||
checkComponentProps: true,
|
||||
generateDeclarations: true,
|
||||
},
|
||||
functions: { legacyDefaultRuntime: "current" },
|
||||
stores: { strictMutations: true, persistence: true },
|
||||
compatibility: {
|
||||
legacyEmit: false,
|
||||
legacyEventProps: false,
|
||||
legacyComponentDiscovery: false,
|
||||
stringLayouts: false,
|
||||
},
|
||||
experimental: {},
|
||||
|
||||
performance: {
|
||||
enforcement: "warn",
|
||||
analyze: true,
|
||||
budgets: {
|
||||
routeJsBytes: 50 * 1024,
|
||||
routeCssBytes: 25 * 1024,
|
||||
lcpMs: 2_500,
|
||||
inpMs: 200,
|
||||
cls: 0.1,
|
||||
},
|
||||
},
|
||||
observability: {
|
||||
enabled: true,
|
||||
serviceName: "APP_SLUG",
|
||||
serverTiming: true,
|
||||
sampleRate: 1,
|
||||
exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none",
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
webVitals: true,
|
||||
},
|
||||
tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] },
|
||||
build: { cache: true, sourceMaps: true, report: true, adapter: "bun" },
|
||||
navigation: { mode: "auto" },
|
||||
devToolbar: { enabled: true, position: "bottom-center", openEditor: true },
|
||||
|
||||
mobile: {
|
||||
enabled: true,
|
||||
appId: "com.example.APP_SLUG",
|
||||
@@ -272,18 +358,77 @@ const config: AppConfig = {
|
||||
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
|
||||
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
|
||||
|
||||
// security: {
|
||||
// cors: {
|
||||
// enabled: true,
|
||||
// origin: ["http://localhost:5173"],
|
||||
// },
|
||||
// },
|
||||
theme: { palette: "violet", default: "light" },
|
||||
i18n: { default: "en", locales: ["en"] },
|
||||
db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" },
|
||||
databases: {},
|
||||
storage: {
|
||||
default: "public",
|
||||
stores: {
|
||||
public: {
|
||||
driver: "local",
|
||||
access: "public",
|
||||
dir: "uploads/public",
|
||||
maxBytes: 10_000_000,
|
||||
accept: ["image/*", "application/pdf"],
|
||||
},
|
||||
private: {
|
||||
driver: "local",
|
||||
access: "private",
|
||||
dir: "uploads/private",
|
||||
maxBytes: 10_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL },
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
security: {
|
||||
cors: { enabled: false },
|
||||
},
|
||||
profiles: {
|
||||
development: {},
|
||||
test: {
|
||||
db: { driver: "sqlite", url: "file:./test.db" },
|
||||
observability: { exporter: "none", sampleRate: 0 },
|
||||
},
|
||||
staging: {
|
||||
seo: { robots: "noindex,nofollow" },
|
||||
performance: { enforcement: "error" },
|
||||
build: { sourceMaps: true, report: true },
|
||||
},
|
||||
production: {
|
||||
seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN },
|
||||
performance: { enforcement: "error" },
|
||||
build: { sourceMaps: false, report: true },
|
||||
devToolbar: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
`,
|
||||
"public/robots.txt": `User-agent: *
|
||||
Allow: /
|
||||
`,
|
||||
"app/locales/en.json": `{
|
||||
"common": {
|
||||
"appName": "APP_NAME",
|
||||
"welcome": "Welcome to APP_NAME"
|
||||
}
|
||||
}
|
||||
`,
|
||||
"app/db/migrations/0001_init.sql": `-- Create application tables here.
|
||||
-- Run with: bunx wrnexus db migrate
|
||||
`,
|
||||
"app/db/seed.ts": `// Add deterministic development seed data here.
|
||||
export async function seed(): Promise<void> {}
|
||||
`,
|
||||
"app/schemas/contact.ts": `import { v } from "@wrnexus/validation";
|
||||
|
||||
export const contactSchema = v.object({
|
||||
email: v.string().email(),
|
||||
message: v.string().min(10).max(2_000),
|
||||
});
|
||||
`,
|
||||
"app/styles/global.css": `/*
|
||||
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
|
||||
@@ -441,10 +586,11 @@ component Counter {
|
||||
"app/api/ai.ts": `// POST /api/ai { "prompt": "..." } → Claude's reply.
|
||||
// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this.
|
||||
import { createAI } from "@wrnexus/ai";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
|
||||
|
||||
export const POST = async (ctx) => {
|
||||
export const POST = async (ctx: Context) => {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
|
||||
}
|
||||
@@ -455,10 +601,14 @@ export const POST = async (ctx) => {
|
||||
return ai.streamResponse(prompt);
|
||||
};
|
||||
`,
|
||||
"app/middleware/logger.ts": `export default async function logger(ctx, next) {
|
||||
"app/middleware/logger.ts": `import type { Middleware } from "@wrnexus/core";
|
||||
|
||||
const logger: Middleware = async (ctx, next) => {
|
||||
console.log(ctx.req.method, ctx.url.pathname);
|
||||
return next();
|
||||
}
|
||||
};
|
||||
|
||||
export default logger;
|
||||
`,
|
||||
"app/realtime/chat.ts": `// ws://<host>/realtime/chat — a simple broadcast room.
|
||||
//
|
||||
@@ -480,6 +630,22 @@ export default defineRoom({
|
||||
client.room.broadcast({ type: "message", data: msg });
|
||||
},
|
||||
});
|
||||
`,
|
||||
"test/smoke.test.ts": `import { expect, test } from "bun:test";
|
||||
import { parseOrThrow } from "@wrnexus/validation";
|
||||
import { contactSchema } from "../app/schemas/contact.ts";
|
||||
|
||||
test("starter validation schema accepts a contact request", () => {
|
||||
expect(
|
||||
parseOrThrow(contactSchema, {
|
||||
email: "hello@example.com",
|
||||
message: "Hello from the generated application.",
|
||||
}),
|
||||
).toEqual({
|
||||
email: "hello@example.com",
|
||||
message: "Hello from the generated application.",
|
||||
});
|
||||
});
|
||||
`,
|
||||
};
|
||||
|
||||
|
||||
+30
-1
@@ -20,6 +20,8 @@ import { pathToFileURL } from "node:url";
|
||||
import { loadAppConfig, type AppConfig } from "@wrnexus/styles";
|
||||
import {
|
||||
generateQueriesFile,
|
||||
analyzeMigrations,
|
||||
loadMigrations,
|
||||
migrate,
|
||||
parseQueries,
|
||||
rollback,
|
||||
@@ -125,6 +127,18 @@ export async function runDbCommand(
|
||||
const dbBase = dbBaseOf(appDir, dbName);
|
||||
const migrationsDir = join(dbBase, "migrations");
|
||||
const label = dbName ? ` (db: ${dbName})` : "";
|
||||
const safetyIssues = analyzeMigrations(loadMigrations(migrationsDir));
|
||||
|
||||
if (sub === "check") {
|
||||
if (!safetyIssues.length) console.log(`✓ Migration rollout safety check passed${label}.`);
|
||||
for (const issue of safetyIssues) {
|
||||
console[issue.severity === "error" ? "error" : "warn"](
|
||||
`${issue.code} ${issue.migration}: ${issue.statement}\n ${issue.recommendation}`,
|
||||
);
|
||||
}
|
||||
if (safetyIssues.some((issue) => issue.severity === "error")) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbName && !config.databases?.[dbName]) {
|
||||
console.error(
|
||||
@@ -213,6 +227,21 @@ export async function runDbCommand(
|
||||
break;
|
||||
}
|
||||
case "migrate": {
|
||||
const pending = new Set(
|
||||
(await status(db, migrationsDir))
|
||||
.filter((migration) => !migration.applied)
|
||||
.map((migration) => migration.name),
|
||||
);
|
||||
const pendingIssues = safetyIssues.filter((issue) => pending.has(issue.migration));
|
||||
const blockers = pendingIssues.filter((issue) => issue.severity === "error");
|
||||
for (const issue of pendingIssues.filter((item) => item.severity === "warning")) {
|
||||
console.warn(`${issue.code} ${issue.migration}: ${issue.recommendation}`);
|
||||
}
|
||||
if (blockers.length && !args.includes("--allow-breaking")) {
|
||||
throw new Error(
|
||||
`WRN-DB-UNSAFE-MIGRATION: ${blockers.length} breaking rollout operation(s) found. Run 'wrnexus db check' and use an expand/contract migration; --allow-breaking explicitly overrides this gate.`,
|
||||
);
|
||||
}
|
||||
const applied = await migrate(db, migrationsDir);
|
||||
console.log(
|
||||
applied.length
|
||||
@@ -234,7 +263,7 @@ export async function runDbCommand(
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
"Usage: wrnexus db <migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
|
||||
"Usage: wrnexus db <check|migrate|rollback|status|generate|seed|studio [table]|new [name] [--from-models]> [--db=<name>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { generateDocker } from "./docker.ts";
|
||||
|
||||
export const DEPLOY_TARGETS = [
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"systemd",
|
||||
"railway",
|
||||
"render",
|
||||
"fly",
|
||||
] as const;
|
||||
export type DeployTarget = (typeof DEPLOY_TARGETS)[number];
|
||||
|
||||
const ENVIRONMENT = `# Copy to .env.production and replace every required value.
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/DB
|
||||
SESSION_SECRET=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
|
||||
`;
|
||||
|
||||
const OPERATIONS = `# WRNexus deployment operations
|
||||
|
||||
- Liveness: \`GET /healthz\`
|
||||
- Readiness: \`GET /readyz\` (includes registered dependency checks)
|
||||
- Migrations: run \`bunx wrnexus db migrate --profile=production\` once per release before scaling.
|
||||
- Shutdown: the Bun production server drains on SIGTERM/SIGINT.
|
||||
- Assets: \`dist/public\` files are content-addressed and may be cached immutably by a CDN.
|
||||
- Secrets: provide \`DATABASE_URL\` and \`SESSION_SECRET\` through the platform secret store; never commit production env files.
|
||||
- Logs: stdout/stderr are structured for platform collection. Configure OTLP for centralized telemetry.
|
||||
- Scaling: start with 250m CPU/256Mi memory, use readiness probes, and scale horizontally from request latency and CPU.
|
||||
`;
|
||||
|
||||
const KUBERNETES = `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: wrnexus
|
||||
spec:
|
||||
selector: { app: wrnexus }
|
||||
ports: [{ name: http, port: 80, targetPort: 3000 }]
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: wrnexus
|
||||
spec:
|
||||
replicas: 2
|
||||
selector: { matchLabels: { app: wrnexus } }
|
||||
template:
|
||||
metadata: { labels: { app: wrnexus } }
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: ghcr.io/OWNER/APP:latest
|
||||
ports: [{ containerPort: 3000 }]
|
||||
envFrom: [{ secretRef: { name: wrnexus-secrets } }]
|
||||
livenessProbe: { httpGet: { path: /healthz, port: 3000 }, initialDelaySeconds: 5 }
|
||||
readinessProbe: { httpGet: { path: /readyz, port: 3000 }, initialDelaySeconds: 5 }
|
||||
resources:
|
||||
requests: { cpu: 250m, memory: 256Mi }
|
||||
limits: { cpu: "1", memory: 512Mi }
|
||||
lifecycle: { preStop: { exec: { command: ["sh", "-c", "sleep 5"] } } }
|
||||
terminationGracePeriodSeconds: 30
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: wrnexus-migrate
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: migrate
|
||||
image: ghcr.io/OWNER/APP:latest
|
||||
command: ["bunx", "wrnexus", "db", "migrate", "--profile=production"]
|
||||
envFrom: [{ secretRef: { name: wrnexus-secrets } }]
|
||||
`;
|
||||
|
||||
const SYSTEMD = `[Unit]
|
||||
Description=WRNexus application
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/srv/wrnexus
|
||||
EnvironmentFile=/etc/wrnexus/wrnexus.env
|
||||
ExecStartPre=/usr/bin/bunx wrnexus db migrate --profile=production
|
||||
ExecStart=/usr/bin/bun dist/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
User=wrnexus
|
||||
Group=wrnexus
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/srv/wrnexus
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`;
|
||||
|
||||
const NGINX = `server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
location /assets/ { root /srv/wrnexus/dist/public; expires 1y; add_header Cache-Control "public, immutable"; }
|
||||
location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-ID $request_id; }
|
||||
}
|
||||
`;
|
||||
|
||||
const RAILWAY = `[build]
|
||||
builder = "DOCKERFILE"
|
||||
|
||||
[deploy]
|
||||
startCommand = "bun dist/server.js"
|
||||
healthcheckPath = "/readyz"
|
||||
restartPolicyType = "ON_FAILURE"
|
||||
preDeployCommand = ["bunx wrnexus db migrate --profile=production"]
|
||||
`;
|
||||
|
||||
const RENDER = `services:
|
||||
- type: web
|
||||
name: wrnexus
|
||||
runtime: docker
|
||||
healthCheckPath: /readyz
|
||||
preDeployCommand: bunx wrnexus db migrate --profile=production
|
||||
envVars:
|
||||
- key: DATABASE_URL
|
||||
sync: false
|
||||
- key: SESSION_SECRET
|
||||
sync: false
|
||||
`;
|
||||
|
||||
const FLY = `app = "wrnexus-app"
|
||||
primary_region = "bom"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
[env]
|
||||
PORT = "3000"
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = "stop"
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
[[http_service.checks]]
|
||||
path = "/readyz"
|
||||
interval = "15s"
|
||||
timeout = "2s"
|
||||
[deploy]
|
||||
release_command = "bunx wrnexus db migrate --profile=production"
|
||||
`;
|
||||
|
||||
function write(root: string, relative: string, content: string, files: string[]): void {
|
||||
const path = join(root, relative);
|
||||
if (existsSync(path)) return;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content, "utf8");
|
||||
files.push(relative);
|
||||
}
|
||||
|
||||
export function generateDeployment(appRoot: string, target: string): string[] {
|
||||
if (!DEPLOY_TARGETS.includes(target as DeployTarget))
|
||||
throw new Error(`WRN-DEPLOY-TARGET: use ${DEPLOY_TARGETS.join(" | ")}.`);
|
||||
const root = resolve(appRoot);
|
||||
const files: string[] = [];
|
||||
if (target === "docker" || ["kubernetes", "railway", "render", "fly"].includes(target))
|
||||
generateDocker(root);
|
||||
write(root, ".env.production.example", ENVIRONMENT, files);
|
||||
write(root, "deploy/README.md", OPERATIONS, files);
|
||||
if (target === "kubernetes") write(root, "deploy/kubernetes.yaml", KUBERNETES, files);
|
||||
if (target === "systemd") {
|
||||
write(root, "deploy/wrnexus.service", SYSTEMD, files);
|
||||
write(root, "deploy/nginx.conf", NGINX, files);
|
||||
}
|
||||
if (target === "railway") write(root, "railway.toml", RAILWAY, files);
|
||||
if (target === "render") write(root, "render.yaml", RENDER, files);
|
||||
if (target === "fly") write(root, "fly.toml", FLY, files);
|
||||
return files;
|
||||
}
|
||||
+81
-2
@@ -13,6 +13,7 @@
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { resolve, join } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { RESTART_EXIT_CODE } from "@wrnexus/dev-server";
|
||||
|
||||
@@ -20,7 +21,12 @@ import { RESTART_EXIT_CODE } from "@wrnexus/dev-server";
|
||||
// it works whether @wrnexus/dev-server is a workspace or an installed dependency.
|
||||
const SERVE_ENTRY = fileURLToPath(import.meta.resolve("@wrnexus/dev-server/serve-entry"));
|
||||
|
||||
export function runDev(appRoot: string, port: number, hostname = "::"): void {
|
||||
export function runDev(
|
||||
appRoot: string,
|
||||
port: number,
|
||||
hostname = "::",
|
||||
tls?: { certFile: string; keyFile: string },
|
||||
): void {
|
||||
const appDir = join(resolve(appRoot), "app");
|
||||
let child: ChildProcess | null = null;
|
||||
let shuttingDown = false;
|
||||
@@ -28,7 +34,15 @@ export function runDev(appRoot: string, port: number, hostname = "::"): void {
|
||||
const spawnChild = (): void => {
|
||||
child = spawn(
|
||||
process.execPath, // the Bun binary
|
||||
[SERVE_ENTRY, appDir, String(port), "development", hostname],
|
||||
[
|
||||
SERVE_ENTRY,
|
||||
appDir,
|
||||
String(port),
|
||||
"development",
|
||||
hostname,
|
||||
"true",
|
||||
...(tls ? [tls.certFile, tls.keyFile] : []),
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
@@ -81,3 +95,68 @@ export function runDev(appRoot: string, port: number, hostname = "::"): void {
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
export async function runProductionDev(
|
||||
appRoot: string,
|
||||
port: number,
|
||||
hostname = "::",
|
||||
): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
let child: ChildProcess | undefined;
|
||||
let building = false;
|
||||
let pending = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const { runBuild } = await import("./build.ts");
|
||||
|
||||
const rebuild = async (): Promise<void> => {
|
||||
if (building) {
|
||||
pending = true;
|
||||
return;
|
||||
}
|
||||
building = true;
|
||||
try {
|
||||
await runBuild(root);
|
||||
child?.kill();
|
||||
const { runPreview } = await import("./preview.ts");
|
||||
child = runPreview(root, { port, hostname, developmentRuntime: true });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] production-runtime rebuild failed; keeping the last good server.",
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
building = false;
|
||||
if (pending) {
|
||||
pending = false;
|
||||
void rebuild();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`\n ⚡ WrNexus dev (exact production runtime) — ${root}`);
|
||||
await rebuild();
|
||||
const watchers: FSWatcher[] = [];
|
||||
for (const name of [
|
||||
"app",
|
||||
"public",
|
||||
"wrnexus.config.ts",
|
||||
"wrnexus.config.js",
|
||||
"wrnexus.config.mjs",
|
||||
]) {
|
||||
const target = join(root, name);
|
||||
if (!existsSync(target)) continue;
|
||||
watchers.push(
|
||||
watch(target, { recursive: true }, () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => void rebuild(), 120);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const shutdown = (): void => {
|
||||
watchers.forEach((watcher) => watcher.close());
|
||||
child?.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { diagnose } from "@wrnexus/syntax";
|
||||
import { diagnose, formatWrn } from "@wrnexus/syntax";
|
||||
import { buildRouter, findRouteConflicts } from "@wrnexus/router";
|
||||
import { loadAppConfig, validateAppConfig } from "@wrnexus/styles";
|
||||
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
|
||||
@@ -12,6 +12,12 @@ export interface DoctorCheck {
|
||||
level?: "error" | "warning";
|
||||
}
|
||||
|
||||
export interface DoctorRepair {
|
||||
name: string;
|
||||
changed: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
function parseVersion(value: string): [number, number, number] {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.replace(/^[^\d]*/, ""));
|
||||
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0];
|
||||
@@ -53,6 +59,81 @@ function frameworkRanges(pkg: Record<string, unknown>): Map<string, string[]> {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function repairProject(appRoot: string): DoctorRepair[] {
|
||||
const root = resolve(appRoot);
|
||||
const repairs: DoctorRepair[] = [];
|
||||
const pages = join(root, "app", "pages");
|
||||
if (!existsSync(pages)) {
|
||||
mkdirSync(pages, { recursive: true });
|
||||
repairs.push({ name: "app/pages", changed: true, detail: "created app/pages" });
|
||||
}
|
||||
|
||||
const configNames = ["wrnexus.config.ts", "wrnexus.config.mjs", "wrnexus.config.js"];
|
||||
if (!configNames.some((name) => existsSync(join(root, name)))) {
|
||||
writeFileSync(join(root, "wrnexus.config.ts"), "export default {};\n", "utf8");
|
||||
repairs.push({ name: "configuration", changed: true, detail: "created wrnexus.config.ts" });
|
||||
}
|
||||
|
||||
const pkgPath = join(root, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const source = readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(source) as Record<string, unknown>;
|
||||
const ranges = frameworkRanges(pkg);
|
||||
const preferred = [...ranges.keys()].sort((left, right) => {
|
||||
const a = parseVersion(left);
|
||||
const b = parseVersion(right);
|
||||
return b[0] - a[0] || b[1] - a[1] || b[2] - a[2];
|
||||
})[0];
|
||||
let changed = false;
|
||||
if (preferred && ranges.size > 1) {
|
||||
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const dependencies = pkg[field] as Record<string, string> | undefined;
|
||||
for (const name of Object.keys(dependencies ?? {})) {
|
||||
if (name.startsWith("@wrnexus/") && dependencies![name] !== preferred) {
|
||||
dependencies![name] = preferred;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const marker = (pkg.wrnexus as Record<string, unknown> | undefined) ?? {};
|
||||
if (!marker.version || !versionAtLeast(String(marker.version), "0.8.0")) {
|
||||
marker.version = "0.8.0";
|
||||
pkg.wrnexus = marker;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||
repairs.push({
|
||||
name: "package.json",
|
||||
changed: true,
|
||||
detail: preferred
|
||||
? `aligned framework packages to ${preferred}`
|
||||
: "recorded 0.8.0 marker",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
repairs.push({ name: "package.json", changed: false, detail: "skipped invalid JSON" });
|
||||
}
|
||||
}
|
||||
|
||||
let formatted = 0;
|
||||
for (const file of walk(join(root, "app"), ".wrn")) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (diagnose(source).some((item) => item.severity === "error")) continue;
|
||||
const output = formatWrn(source, { tabSize: 2, printWidth: 100, multilineAttributes: true });
|
||||
if (output !== source) {
|
||||
writeFileSync(file, output, "utf8");
|
||||
formatted++;
|
||||
}
|
||||
}
|
||||
if (formatted) {
|
||||
repairs.push({ name: "WRN formatting", changed: true, detail: `formatted ${formatted} files` });
|
||||
}
|
||||
return repairs;
|
||||
}
|
||||
|
||||
export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
const root = resolve(appRoot);
|
||||
const checks: DoctorCheck[] = [];
|
||||
@@ -174,8 +255,12 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
return checks;
|
||||
}
|
||||
|
||||
export async function runDoctor(appRoot: string): Promise<boolean> {
|
||||
export async function runDoctor(
|
||||
appRoot: string,
|
||||
options: { fix?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
const repairs = options.fix ? repairProject(root) : [];
|
||||
const checks = inspectProject(root);
|
||||
try {
|
||||
const config = await loadAppConfig(root);
|
||||
@@ -257,6 +342,8 @@ export async function runDoctor(appRoot: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
console.log("WRNexus doctor\n");
|
||||
for (const repair of repairs) console.log(` ↻ ${repair.name}: ${repair.detail}`);
|
||||
if (repairs.length) console.log("");
|
||||
for (const check of checks) {
|
||||
const optional = check.level === "warning";
|
||||
console.log(` ${check.ok ? "✓" : optional ? "⚠" : "✗"} ${check.name}: ${check.detail}`);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
interface ExplainRoute {
|
||||
kind: "page" | "api" | "realtime";
|
||||
path: string;
|
||||
source: string;
|
||||
execution: string;
|
||||
canPrerender: boolean;
|
||||
needsClientRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons?: string[];
|
||||
cachePolicy?: Record<string, string>;
|
||||
requiredPermission?: string | null;
|
||||
}
|
||||
|
||||
interface ExplainReport {
|
||||
frameworkVersion: string;
|
||||
adapter: string;
|
||||
routes: ExplainRoute[];
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: Record<string, number>;
|
||||
budgetViolations: Array<{ metric: string; budget: number; actual: number }>;
|
||||
}
|
||||
|
||||
export interface Explanation {
|
||||
target: string;
|
||||
subject: string;
|
||||
summary: string;
|
||||
reasons: string[];
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function loadReport(root: string): ExplainReport {
|
||||
const path = join(resolve(root), "dist", "build-report.json");
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(
|
||||
"WRN-EXPLAIN-NO-BUILD: run `wrnexus build` before requesting build explanations.",
|
||||
);
|
||||
}
|
||||
return JSON.parse(readFileSync(path, "utf8")) as ExplainReport;
|
||||
}
|
||||
|
||||
function routeMatch(routes: ExplainRoute[], subject: string): ExplainRoute | undefined {
|
||||
const normalized = subject.startsWith("/") ? subject : `/${subject}`;
|
||||
return routes.find((route) => route.path === normalized || route.source.includes(subject));
|
||||
}
|
||||
|
||||
export function explainBuildDecision(root: string, target: string, subject = ""): Explanation {
|
||||
const report = loadReport(root);
|
||||
if (
|
||||
target === "route" ||
|
||||
target === "hydration" ||
|
||||
target === "cache" ||
|
||||
target === "permission"
|
||||
) {
|
||||
const route =
|
||||
target === "permission"
|
||||
? report.routes.find((item) => item.requiredPermission === subject)
|
||||
: routeMatch(report.routes, subject);
|
||||
if (!route) throw new Error(`WRN-EXPLAIN-NOT-FOUND: no route or source matches '${subject}'.`);
|
||||
if (target === "cache") {
|
||||
const policy = route.cachePolicy ?? {};
|
||||
const entries = Object.entries(policy);
|
||||
return {
|
||||
target,
|
||||
subject: route.path,
|
||||
summary: entries.length
|
||||
? `Route cache uses '${policy.strategy ?? "framework-default"}' strategy.`
|
||||
: "Route has no explicit cache policy and uses safe framework defaults.",
|
||||
reasons: entries.length
|
||||
? entries.map(([name, value]) => `${name} = ${value}`)
|
||||
: ["responses remain private/revalidated unless an explicit safe policy enables reuse"],
|
||||
evidence: { source: route.source, cachePolicy: policy, execution: route.execution },
|
||||
};
|
||||
}
|
||||
if (target === "permission") {
|
||||
const requested = subject.startsWith("/") ? undefined : subject;
|
||||
const matches = report.routes.filter((item) =>
|
||||
requested ? item.requiredPermission === requested : item.path === route.path,
|
||||
);
|
||||
return {
|
||||
target,
|
||||
subject: requested ?? route.path,
|
||||
summary: matches.length
|
||||
? `${matches.length} route(s) require this permission.`
|
||||
: "No built route declares this permission.",
|
||||
reasons: matches.length
|
||||
? matches.map((item) => `${item.path} declares security.permission in ${item.source}`)
|
||||
: ["authorization may still be enforced programmatically; inspect authz policies"],
|
||||
evidence: { routes: matches },
|
||||
};
|
||||
}
|
||||
const reasons = route.reasons?.length ? route.reasons : ["no dynamic requirement was detected"];
|
||||
return {
|
||||
target,
|
||||
subject: route.path,
|
||||
summary:
|
||||
target === "hydration"
|
||||
? route.needsClientRuntime
|
||||
? `Hydration uses '${route.hydrationStrategy ?? "load"}' because client runtime is required.`
|
||||
: "Hydration is omitted because no client runtime is required."
|
||||
: `Route execution is '${route.execution}'${route.canPrerender ? " and can prerender" : " and cannot prerender"}.`,
|
||||
reasons,
|
||||
evidence: { ...route },
|
||||
};
|
||||
}
|
||||
if (target === "bundle") {
|
||||
const assets = [...report.assets].sort((a, b) => b.bytes - a.bytes);
|
||||
return {
|
||||
target,
|
||||
subject: subject || "production bundle",
|
||||
summary: `${assets.length} emitted assets; largest is ${assets[0]?.file ?? "none"}.`,
|
||||
reasons: assets.slice(0, 10).map((asset) => `${asset.file}: ${asset.bytes} bytes`),
|
||||
evidence: { measurements: report.measurements, largestAssets: assets.slice(0, 10) },
|
||||
};
|
||||
}
|
||||
if (target === "build") {
|
||||
return {
|
||||
target,
|
||||
subject: "production build",
|
||||
summary: `${report.routes.length} routes target the ${report.adapter} adapter.`,
|
||||
reasons: report.budgetViolations.length
|
||||
? report.budgetViolations.map(
|
||||
(item) => `${item.metric} exceeds ${item.budget} with ${item.actual}`,
|
||||
)
|
||||
: ["all configured performance budgets pass"],
|
||||
evidence: {
|
||||
frameworkVersion: report.frameworkVersion,
|
||||
adapter: report.adapter,
|
||||
measurements: report.measurements,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`WRN-EXPLAIN-TARGET: unsupported target '${target}'.`);
|
||||
}
|
||||
|
||||
export function runExplain(root: string, target: string, subject: string, args: string[]): void {
|
||||
const explanation = explainBuildDecision(root, target, subject);
|
||||
if (args.includes("--json")) {
|
||||
console.log(JSON.stringify(explanation, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(explanation.summary);
|
||||
explanation.reasons.forEach((reason, index) => console.log(` ${index + 1}. ${reason}`));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import {
|
||||
auditLocaleKeys,
|
||||
extractTranslationKeysFromFiles,
|
||||
flattenMessageKeys,
|
||||
loadLocales,
|
||||
} from "@wrnexus/i18n";
|
||||
|
||||
function sourceFiles(root: string): string[] {
|
||||
const output: string[] = [];
|
||||
const visit = (directory: string) => {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist")
|
||||
continue;
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(path);
|
||||
else if ([".wrn", ".ts", ".tsx", ".js", ".jsx"].includes(extname(entry.name)))
|
||||
output.push(path);
|
||||
}
|
||||
};
|
||||
visit(join(root, "app"));
|
||||
return output.sort();
|
||||
}
|
||||
export function runI18nCommand(appRoot: string, command: string): boolean {
|
||||
const root = resolve(appRoot);
|
||||
const extracted = extractTranslationKeysFromFiles(sourceFiles(root));
|
||||
const keys = [...new Set(extracted.map((entry) => entry.key))].sort();
|
||||
const messages = loadLocales(join(root, "app", "locales"), { strict: true });
|
||||
const locales = Object.keys(messages).sort();
|
||||
const reference = locales[0] ?? "en";
|
||||
const audit = auditLocaleKeys(messages, reference);
|
||||
const referenceKeys = new Set(flattenMessageKeys(messages[reference] ?? {}));
|
||||
const unused = [...referenceKeys].filter((key) => !keys.includes(key)).sort();
|
||||
const missingFromReference = keys.filter((key) => !referenceKeys.has(key));
|
||||
const report = { reference, locales, extracted: keys, missingFromReference, unused, audit };
|
||||
if (command === "extract") {
|
||||
const directory = join(root, ".wrnexus");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const file = join(directory, "i18n-keys.json");
|
||||
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
console.log(`✓ Extracted ${keys.length} translation keys to ${file}`);
|
||||
return true;
|
||||
}
|
||||
if (command !== "validate")
|
||||
throw new Error("WRN-I18N-COMMAND: use i18n extract or i18n validate.");
|
||||
for (const locale of locales) {
|
||||
const missing = [
|
||||
...new Set([...(audit[locale]?.missing ?? []), ...missingFromReference]),
|
||||
].sort();
|
||||
if (missing.length) {
|
||||
console.log(`Missing in ${locale}:`);
|
||||
missing.forEach((key) => console.log(`- ${key}`));
|
||||
}
|
||||
}
|
||||
if (unused.length) {
|
||||
console.log("Unused keys:");
|
||||
unused.forEach((key) => console.log(`- ${key}`));
|
||||
}
|
||||
const healthy =
|
||||
missingFromReference.length === 0 &&
|
||||
Object.values(audit).every((value) => value.missing.length === 0);
|
||||
if (healthy) console.log(`✓ ${locales.length} locales contain every extracted key.`);
|
||||
return healthy;
|
||||
}
|
||||
+190
-7
@@ -40,7 +40,12 @@ function help(): void {
|
||||
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]
|
||||
@@ -51,6 +56,9 @@ Usage:
|
||||
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
|
||||
@@ -58,12 +66,35 @@ Usage:
|
||||
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 [app-dir] [--watch] Run the app's tests (bun test, 'test' profile)
|
||||
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] Check project structure, versions, syntax, routes, and config
|
||||
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
|
||||
@@ -91,7 +122,31 @@ async function main(): Promise<void> {
|
||||
const port = portArg ? Number(portArg.split("=")[1]) : 3000;
|
||||
const host = hostArg?.split("=")[1] || "::";
|
||||
bootstrapProfile(appRoot, "development", rest);
|
||||
runDev(appRoot, port, host);
|
||||
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": {
|
||||
@@ -101,6 +156,15 @@ async function main(): Promise<void> {
|
||||
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;
|
||||
@@ -129,10 +193,18 @@ async function main(): Promise<void> {
|
||||
case "g": {
|
||||
if (rest[0] === "routes") {
|
||||
const { regenerateRoutes } = await import("./routes.ts");
|
||||
const n = regenerateRoutes(join(process.cwd(), "app"));
|
||||
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());
|
||||
@@ -153,6 +225,20 @@ async function main(): Promise<void> {
|
||||
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("--"));
|
||||
@@ -191,10 +277,87 @@ async function main(): Promise<void> {
|
||||
}
|
||||
case "doctor": {
|
||||
const { runDoctor } = await import("./doctor.ts");
|
||||
const healthy = await runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||
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);
|
||||
@@ -202,6 +365,12 @@ async function main(): Promise<void> {
|
||||
}
|
||||
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);
|
||||
@@ -213,13 +382,25 @@ async function main(): Promise<void> {
|
||||
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 appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
|
||||
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);
|
||||
const { runTests } = await import("./test.ts");
|
||||
runTests(appRoot, rest);
|
||||
break;
|
||||
}
|
||||
@@ -259,6 +440,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
} 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);
|
||||
|
||||
@@ -4,10 +4,41 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
||||
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { uiComponentsDir } from "@wrnexus/ui";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
export type InspectTarget =
|
||||
"packages" | "plugins" | "routes" | "assets" | "runtimes" | "styles" | "migrations" | "bundle";
|
||||
|
||||
export function inspectComponent(appRoot: string, requestedName: string): unknown {
|
||||
const root = resolve(appRoot);
|
||||
const router = buildRouter(join(root, "app"), { componentDirs: [uiComponentsDir()] });
|
||||
const component = router.components.find(
|
||||
(candidate) => candidate.name.toLowerCase() === requestedName.toLowerCase(),
|
||||
);
|
||||
if (!component) throw new Error(`Component not found: ${requestedName}`);
|
||||
const ast = parse(readFileSync(component.file, "utf8"));
|
||||
return {
|
||||
name: ast.name,
|
||||
file: relative(root, component.file).replace(/\\/g, "/"),
|
||||
props: ast.props.map(({ name, valueType, required, default: defaultValue }) => ({
|
||||
name,
|
||||
type: valueType ?? "unknown",
|
||||
required,
|
||||
...(defaultValue === undefined || defaultValue === "undefined"
|
||||
? {}
|
||||
: { default: defaultValue }),
|
||||
})),
|
||||
outputs: ast.outputs.map(({ name, payload }) => ({ name, payload: payload ?? null })),
|
||||
functions: ast.runtimeFunctions.map(({ name, runtime, async, parameters, returnType }) => ({
|
||||
name,
|
||||
runtime,
|
||||
async,
|
||||
parameters,
|
||||
returnType: returnType ?? "unknown",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function json(path: string): Record<string, any> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, any>;
|
||||
@@ -158,3 +189,12 @@ export async function runInspect(
|
||||
);
|
||||
else console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export function runInspectComponent(appRoot: string, name: string, args: string[] = []): void {
|
||||
const value = inspectComponent(appRoot, name);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(value, null, 2));
|
||||
else {
|
||||
console.log(`WRNexus component ${name}\n`);
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { resolve } from "node:path";
|
||||
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
|
||||
export async function runPluginCliCommand(
|
||||
appRoot: string,
|
||||
commandName: string,
|
||||
args: string[],
|
||||
): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadAppConfig(root);
|
||||
const discovered = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
});
|
||||
const runner = createPluginRunner(discovered, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "cli",
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
await runner.configure(config as Record<string, unknown>);
|
||||
await runner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
const command = (await runner.contributions()).cliCommands.find(
|
||||
(candidate) => candidate.name === commandName,
|
||||
);
|
||||
if (!command) return false;
|
||||
await command.run(args, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "cli",
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export interface PreviewOptions {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
stdio?: "inherit" | "pipe";
|
||||
/** Enable the production artifact's reconnecting DOM-morph client. */
|
||||
developmentRuntime?: boolean;
|
||||
}
|
||||
|
||||
export function productionEntry(appRoot: string): string {
|
||||
const entry = join(resolve(appRoot), "dist", "server.js");
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error("WRN-PREVIEW-NO-BUILD: run `wrnexus build` before `wrnexus preview`.");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function runPreview(appRoot: string, options: PreviewOptions = {}): ChildProcess {
|
||||
const entry = productionEntry(appRoot);
|
||||
const port = options.port ?? 3000;
|
||||
const hostname = options.hostname ?? "::";
|
||||
console.log(
|
||||
`\n ▶ WrNexus production preview — http://${hostname === "::" ? "localhost" : hostname}:${port}`,
|
||||
);
|
||||
return spawn(process.execPath, [entry], {
|
||||
cwd: resolve(appRoot),
|
||||
stdio: options.stdio ?? "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
WRNEXUS_PROFILE: process.env.WRNEXUS_PROFILE ?? "production",
|
||||
PORT: String(port),
|
||||
HOST: hostname,
|
||||
...(options.developmentRuntime ? { WRNEXUS_PRODUCTION_DEV: "1" } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, relative, resolve } from "node:path";
|
||||
import { diagnose } from "@wrnexus/compiler";
|
||||
import { currentCliVersion } from "./update-notifier.ts";
|
||||
|
||||
const SENSITIVE_KEY =
|
||||
/(?:secret|token|password|passwd|credential|api[-_]?key|private[-_]?key|cookie|authorization|session|dsn|database[-_]?url)/i;
|
||||
function sanitizeText(value: string): string {
|
||||
return value
|
||||
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]")
|
||||
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[REDACTED_IP]")
|
||||
.replace(/https?:\/\/[^\s"'`/]+/gi, (origin) =>
|
||||
/localhost|127\.0\.0\.1|example\.(?:com|test|org)/i.test(origin)
|
||||
? origin
|
||||
: "https://[REDACTED_DOMAIN]",
|
||||
)
|
||||
.replace(/\b(?:sk|pk|wrn|ghp|xox[baprs])[_-][A-Za-z0-9_-]{12,}\b/g, "[REDACTED_TOKEN]")
|
||||
.replace(
|
||||
/((?:secret|token|password|apiKey|authorization|cookie)\s*[:=]\s*)(["'`])[^"'`]*\2/gi,
|
||||
"$1$2[REDACTED]$2",
|
||||
)
|
||||
.slice(0, 512 * 1024);
|
||||
}
|
||||
function sanitizeValue(value: unknown, key = ""): unknown {
|
||||
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
||||
if (typeof value === "string") return sanitizeText(value);
|
||||
if (Array.isArray(value)) return value.slice(0, 200).map((item) => sanitizeValue(item));
|
||||
if (value && typeof value === "object")
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.slice(0, 500)
|
||||
.map(([name, item]) => [name, sanitizeValue(item, name)]),
|
||||
);
|
||||
return value;
|
||||
}
|
||||
function packageVersions(root: string) {
|
||||
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }).sort(
|
||||
([left], [right]) => left.localeCompare(right),
|
||||
),
|
||||
);
|
||||
}
|
||||
export interface ReproductionReportOptions {
|
||||
file?: string;
|
||||
error?: string;
|
||||
output?: string;
|
||||
command?: string[];
|
||||
}
|
||||
export function generateReproductionReport(
|
||||
appRoot: string,
|
||||
options: ReproductionReportOptions = {},
|
||||
) {
|
||||
const root = resolve(appRoot);
|
||||
const selected = options.file ? resolve(root, options.file) : undefined;
|
||||
if (
|
||||
selected &&
|
||||
(!relative(root, selected) ||
|
||||
relative(root, selected).startsWith("..") ||
|
||||
!existsSync(selected))
|
||||
)
|
||||
throw new Error("WRN-REPORT-FILE: selected source must exist inside the application.");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const output = resolve(root, options.output ?? join(".wrnexus", "reports", stamp));
|
||||
if (!relative(root, output) || relative(root, output).startsWith(".."))
|
||||
throw new Error("WRN-REPORT-OUTPUT: output must stay inside the application.");
|
||||
mkdirSync(output, { recursive: true });
|
||||
let source: string | undefined;
|
||||
let diagnostics: unknown[] = [];
|
||||
if (selected) {
|
||||
source = readFileSync(selected, "utf8");
|
||||
diagnostics = selected.endsWith(".wrn")
|
||||
? diagnose(source, { file: basename(selected), accessibility: true })
|
||||
: [];
|
||||
writeFileSync(
|
||||
join(output, `sanitized-${basename(selected)}`),
|
||||
`${sanitizeText(source)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
const configNames = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
||||
const configFile = configNames.map((name) => join(root, name)).find(existsSync);
|
||||
const commands = options.command?.length
|
||||
? options.command
|
||||
: [
|
||||
"bun install --frozen-lockfile",
|
||||
selected ? "bunx wrnexus typecheck ." : "bunx wrnexus doctor .",
|
||||
"bunx wrnexus build .",
|
||||
];
|
||||
const report = sanitizeValue({
|
||||
schemaVersion: 1,
|
||||
frameworkVersion: currentCliVersion(),
|
||||
runtime: { bun: Bun.version, platform: process.platform, architecture: process.arch },
|
||||
source: selected ? relative(root, selected).replace(/\\/g, "/") : undefined,
|
||||
diagnostics,
|
||||
dependencies: packageVersions(root),
|
||||
config: configFile ? sanitizeText(readFileSync(configFile, "utf8")) : undefined,
|
||||
error: options.error,
|
||||
commands,
|
||||
});
|
||||
const reportFile = join(output, "report.json");
|
||||
writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
writeFileSync(
|
||||
join(output, "README.md"),
|
||||
`# Sanitized WRNexus reproduction\n\nGenerated by WRNexus ${currentCliVersion()}. Review the bundle before sharing. Values matching secrets, credentials, emails, IPs and non-public domains are redacted.\n\n## Reproduce\n\n${commands.map((command) => `- \`${command}\``).join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
directory: output,
|
||||
reportFile,
|
||||
sourceFile: selected ? join(output, `sanitized-${basename(selected)}`) : undefined,
|
||||
};
|
||||
}
|
||||
export function runReport(appRoot: string, args: string[]) {
|
||||
const option = (name: string) =>
|
||||
args.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const result = generateReproductionReport(appRoot, {
|
||||
file: option("file"),
|
||||
error: option("error"),
|
||||
output: option("output"),
|
||||
});
|
||||
console.log(`✓ Sanitized reproduction bundle: ${result.directory}`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { withSecurityHeaders } from "@wrnexus/core";
|
||||
import { isSafeUrl, secureCookieOptions } from "@wrnexus/security";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
|
||||
export interface SecurityAuditCheck {
|
||||
id: string;
|
||||
asvs: string[];
|
||||
passed: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SecurityAuditReport {
|
||||
version: "ASVS 5.0.0";
|
||||
root: string;
|
||||
checks: SecurityAuditCheck[];
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export async function securityHeaders(appRoot: string): Promise<Record<string, string>> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadAppConfig(root);
|
||||
const response = withSecurityHeaders(
|
||||
new Request("https://security-audit.invalid/", {
|
||||
headers: { origin: "https://untrusted.invalid" },
|
||||
}),
|
||||
new Response("audit"),
|
||||
"production",
|
||||
config.security,
|
||||
"audit-nonce",
|
||||
);
|
||||
return Object.fromEntries(
|
||||
[...response.headers.entries()].sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function securityAudit(appRoot: string): Promise<SecurityAuditReport> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadAppConfig(root);
|
||||
const headers = await securityHeaders(root);
|
||||
const cors = typeof config.security?.cors === "object" ? config.security.cors : undefined;
|
||||
const checks: SecurityAuditCheck[] = [
|
||||
{
|
||||
id: "SEC-HEADERS",
|
||||
asvs: ["v5.0.0-3.4.1", "v5.0.0-3.4.6"],
|
||||
passed: config.security?.headers !== false && headers["x-content-type-options"] === "nosniff",
|
||||
message: "Browser security headers are enabled.",
|
||||
},
|
||||
{
|
||||
id: "SEC-CSP",
|
||||
asvs: ["v5.0.0-3.4.6"],
|
||||
passed:
|
||||
config.security?.contentSecurityPolicy !== false &&
|
||||
Boolean(headers["content-security-policy"]),
|
||||
message: "Nonce-capable Content Security Policy is enabled.",
|
||||
},
|
||||
{
|
||||
id: "SEC-CORS",
|
||||
asvs: ["v5.0.0-3.4.2"],
|
||||
passed:
|
||||
!(cors?.credentials && cors.origin === "*") &&
|
||||
!(cors?.credentials && Array.isArray(cors.origin) && cors.origin.includes("*")),
|
||||
message: "Credentialed CORS does not use a wildcard origin.",
|
||||
},
|
||||
{
|
||||
id: "SEC-CSRF",
|
||||
asvs: ["v5.0.0-3.5.1"],
|
||||
passed: true,
|
||||
message: "The shared runtime verifies double-submit CSRF tokens on unsafe requests.",
|
||||
},
|
||||
{
|
||||
id: "SEC-OUTPUT-ENCODING",
|
||||
asvs: ["v5.0.0-1.1.2", "v5.0.0-1.2.1", "v5.0.0-1.2.3"],
|
||||
passed: true,
|
||||
message: "Compiler HTML/attribute/JSON boundaries use contextual escaping.",
|
||||
},
|
||||
{
|
||||
id: "SEC-SSRF-REDIRECT",
|
||||
asvs: ["v5.0.0-1.3.6", "v5.0.0-3.7.2"],
|
||||
passed: !isSafeUrl("javascript:alert(1)"),
|
||||
message:
|
||||
"Unsafe URL protocols are rejected and outbound fetch uses allowlist/private-address controls.",
|
||||
},
|
||||
];
|
||||
return { version: "ASVS 5.0.0", root, checks, passed: checks.every((check) => check.passed) };
|
||||
}
|
||||
|
||||
function securityTests(root: string): string[] {
|
||||
const output: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (["node_modules", "dist", ".wrnexus"].includes(entry.name)) continue;
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(path);
|
||||
else if (/(?:security|abuse).*\.test\.[cm]?[jt]s$/i.test(entry.name)) output.push(path);
|
||||
}
|
||||
};
|
||||
walk(join(root, "app"));
|
||||
walk(join(root, "test"));
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function runSecurityCommand(
|
||||
appRoot: string,
|
||||
subcommand = "audit",
|
||||
args: string[] = [],
|
||||
): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
if (subcommand === "headers") {
|
||||
const headers = await securityHeaders(root);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(headers, null, 2));
|
||||
else for (const [name, value] of Object.entries(headers)) console.log(`${name}: ${value}`);
|
||||
return true;
|
||||
}
|
||||
if (subcommand === "test") {
|
||||
const report = await securityAudit(root);
|
||||
secureCookieOptions({ url: new URL("https://security-audit.invalid/") });
|
||||
const tests = securityTests(root);
|
||||
if (!report.passed) return false;
|
||||
if (!tests.length) {
|
||||
console.log("✓ Built-in security probes passed; no application security test files found.");
|
||||
return true;
|
||||
}
|
||||
const result = Bun.spawnSync(["bun", "test", ...tests], {
|
||||
cwd: root,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
if (subcommand !== "audit")
|
||||
throw new Error(`WRN-SECURITY-COMMAND: unknown command '${subcommand}'.`);
|
||||
const report = await securityAudit(root);
|
||||
if (args.includes("--json")) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log(`${report.version} application security audit\n`);
|
||||
for (const check of report.checks)
|
||||
console.log(
|
||||
`${check.passed ? "✓" : "✗"} ${check.id} [${check.asvs.join(", ")}] — ${check.message}`,
|
||||
);
|
||||
}
|
||||
return report.passed;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
export interface LocalServiceRecord {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface LocalServicesState {
|
||||
database: Map<string, unknown>;
|
||||
cache: Map<string, { value: unknown; expiresAt?: number }>;
|
||||
mail: LocalServiceRecord[];
|
||||
sms: LocalServiceRecord[];
|
||||
webhooks: LocalServiceRecord[];
|
||||
storage: Map<string, Uint8Array>;
|
||||
queue: LocalServiceRecord[];
|
||||
cron: LocalServiceRecord[];
|
||||
auth: Map<string, LocalServiceRecord>;
|
||||
metrics: LocalServiceRecord[];
|
||||
}
|
||||
export function createLocalServicesState(): LocalServicesState {
|
||||
return {
|
||||
database: new Map(),
|
||||
cache: new Map(),
|
||||
mail: [],
|
||||
sms: [],
|
||||
webhooks: [],
|
||||
storage: new Map(),
|
||||
queue: [],
|
||||
cron: [],
|
||||
auth: new Map(),
|
||||
metrics: [],
|
||||
};
|
||||
}
|
||||
const json = (value: unknown, status = 200, origin = "https://localhost:3000") =>
|
||||
Response.json(value, {
|
||||
status,
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
"access-control-allow-origin": origin,
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
async function boundedJson(
|
||||
request: Request,
|
||||
maximum = 256 * 1024,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const text = await request.text();
|
||||
if (new TextEncoder().encode(text).byteLength > maximum)
|
||||
throw new RangeError("payload-too-large");
|
||||
const value = JSON.parse(text) as unknown;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw new TypeError("object-required");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function record(value: Record<string, unknown>): LocalServiceRecord {
|
||||
return { id: crypto.randomUUID(), createdAt: new Date().toISOString(), ...value };
|
||||
}
|
||||
export function createLocalServicesHandler(
|
||||
state = createLocalServicesState(),
|
||||
options: { origin?: string } = {},
|
||||
) {
|
||||
const origin = options.origin ?? "https://localhost:3000";
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
if (request.method === "OPTIONS")
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"access-control-allow-origin": origin,
|
||||
"access-control-allow-methods": "GET,POST,PUT,DELETE",
|
||||
"access-control-allow-headers": "content-type",
|
||||
},
|
||||
});
|
||||
if (path === "/healthz" || path === "/readyz")
|
||||
return json({ status: "up", service: "wrnexus-local-services" }, 200, origin);
|
||||
if (path === "/" || path === "/__services")
|
||||
return new Response(
|
||||
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>WRNexus Local Services</title></head><body><h1>WRNexus Local Services</h1><nav>${["database", "cache", "mail", "sms", "webhooks", "storage", "queue", "cron", "auth", "metrics"].map((name) => `<a href="/${name}">${name}</a> `).join("")}</nav><p>Use the JSON endpoints to inspect or inject local development events.</p></body></html>`,
|
||||
{
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
|
||||
},
|
||||
},
|
||||
);
|
||||
try {
|
||||
if (path === "/database") {
|
||||
if (request.method === "GET") return json(Object.fromEntries(state.database), 200, origin);
|
||||
const body = await boundedJson(request);
|
||||
if (typeof body.key !== "string") return json({ error: "key is required" }, 400);
|
||||
state.database.set(body.key, body.value);
|
||||
return json({ key: body.key, value: body.value }, 201, origin);
|
||||
}
|
||||
if (path === "/cache") {
|
||||
if (request.method === "GET") {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of state.cache)
|
||||
if (value.expiresAt && value.expiresAt <= now) state.cache.delete(key);
|
||||
return json(Object.fromEntries(state.cache), 200, origin);
|
||||
}
|
||||
const body = await boundedJson(request);
|
||||
if (typeof body.key !== "string") return json({ error: "key is required" }, 400);
|
||||
const ttlMs = typeof body.ttlMs === "number" ? Math.max(0, body.ttlMs) : undefined;
|
||||
state.cache.set(body.key, {
|
||||
value: body.value,
|
||||
...(ttlMs ? { expiresAt: Date.now() + ttlMs } : {}),
|
||||
});
|
||||
return json({ stored: true }, 201, origin);
|
||||
}
|
||||
for (const [name, values] of [
|
||||
["mail", state.mail],
|
||||
["sms", state.sms],
|
||||
["webhooks", state.webhooks],
|
||||
["queue", state.queue],
|
||||
["cron", state.cron],
|
||||
["metrics", state.metrics],
|
||||
] as const)
|
||||
if (path === `/${name}`) {
|
||||
if (request.method === "GET") return json(values, 200, origin);
|
||||
const value = record(await boundedJson(request));
|
||||
values.unshift(value);
|
||||
if (values.length > 500) values.length = 500;
|
||||
return json(value, 202, origin);
|
||||
}
|
||||
if (path === "/storage") {
|
||||
if (request.method === "GET")
|
||||
return json(
|
||||
[...state.storage.entries()].map(([key, value]) => ({ key, bytes: value.byteLength })),
|
||||
200,
|
||||
origin,
|
||||
);
|
||||
const key = url.searchParams.get("key");
|
||||
if (!key || key.includes("..") || key.length > 256)
|
||||
return json({ error: "safe key is required" }, 400);
|
||||
const bytes = new Uint8Array(await request.arrayBuffer());
|
||||
if (bytes.byteLength > 10 * 1024 * 1024) return json({ error: "object too large" }, 413);
|
||||
state.storage.set(key, bytes);
|
||||
return json({ key, bytes: bytes.byteLength }, 201, origin);
|
||||
}
|
||||
if (path.startsWith("/storage/") && request.method === "GET") {
|
||||
const key = decodeURIComponent(path.slice(9));
|
||||
const value = state.storage.get(key);
|
||||
return value
|
||||
? new Response(Uint8Array.from(value), {
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-disposition": "attachment",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
})
|
||||
: json({ error: "not found" }, 404);
|
||||
}
|
||||
if (path === "/auth") {
|
||||
if (request.method === "GET") return json([...state.auth.values()], 200, origin);
|
||||
const value = record(await boundedJson(request));
|
||||
if (typeof value.email !== "string") return json({ error: "email is required" }, 400);
|
||||
state.auth.set(value.id, value);
|
||||
return json({ user: value, accessToken: `local_${value.id}` }, 201, origin);
|
||||
}
|
||||
return json({ error: "not found" }, 404);
|
||||
} catch (error) {
|
||||
if (error instanceof RangeError) return json({ error: error.message }, 413);
|
||||
return json({ error: error instanceof Error ? error.message : "invalid request" }, 400);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalCertificate {
|
||||
cert: string;
|
||||
key: string;
|
||||
certFile: string;
|
||||
keyFile: string;
|
||||
reused: boolean;
|
||||
}
|
||||
|
||||
/** Generate and cache a localhost-only development certificate without requiring OpenSSL. */
|
||||
export async function ensureLocalCertificate(appRoot: string): Promise<LocalCertificate> {
|
||||
const directory = join(resolve(appRoot), ".wrnexus", "certificates");
|
||||
const certFile = join(directory, "localhost.pem");
|
||||
const keyFile = join(directory, "localhost-key.pem");
|
||||
if (existsSync(certFile) && existsSync(keyFile)) {
|
||||
try {
|
||||
const cert = readFileSync(certFile, "utf8");
|
||||
const key = readFileSync(keyFile, "utf8");
|
||||
const certificate = new X509Certificate(cert);
|
||||
if (Date.parse(certificate.validTo) > Date.now() + 7 * 24 * 60 * 60 * 1000) {
|
||||
return { cert, key, certFile, keyFile, reused: true };
|
||||
}
|
||||
} catch {
|
||||
// Replace invalid or expired development material below.
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
const generated = await generate([{ name: "commonName", value: "localhost" }], {
|
||||
algorithm: "sha256",
|
||||
keyType: "ec",
|
||||
curve: "P-256",
|
||||
notBeforeDate: new Date(now.getTime() - 60_000),
|
||||
notAfterDate: expires,
|
||||
extensions: [
|
||||
{ name: "basicConstraints", cA: false, critical: true },
|
||||
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true, critical: true },
|
||||
{ name: "extKeyUsage", serverAuth: true },
|
||||
{
|
||||
name: "subjectAltName",
|
||||
altNames: [
|
||||
{ type: 2, value: "localhost" },
|
||||
{ type: 2, value: "*.localhost" },
|
||||
{ type: 7, ip: "127.0.0.1" },
|
||||
{ type: 7, ip: "::1" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(certFile, generated.cert, { encoding: "utf8", mode: 0o600 });
|
||||
writeFileSync(keyFile, generated.private, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(certFile, 0o600);
|
||||
chmodSync(keyFile, 0o600);
|
||||
} catch {
|
||||
// Windows ACLs are inherited from the private workspace directory.
|
||||
}
|
||||
return {
|
||||
cert: generated.cert,
|
||||
key: generated.private,
|
||||
certFile,
|
||||
keyFile,
|
||||
reused: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startLocalServices(
|
||||
options: {
|
||||
appRoot?: string;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
https?: boolean;
|
||||
origin?: string;
|
||||
certificate?: LocalCertificate;
|
||||
} = {},
|
||||
) {
|
||||
const port = options.port ?? 3099;
|
||||
const hostname = options.hostname ?? "127.0.0.1";
|
||||
const secure = options.https !== false;
|
||||
const tls = secure
|
||||
? (options.certificate ?? (await ensureLocalCertificate(options.appRoot ?? ".")))
|
||||
: undefined;
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname,
|
||||
fetch: createLocalServicesHandler(undefined, { origin: options.origin }),
|
||||
...(tls ? { tls: { cert: tls.cert, key: tls.key } } : {}),
|
||||
});
|
||||
console.log(
|
||||
` ▸ Local services: ${secure ? "https" : "http"}://${hostname}:${server.port}/__services`,
|
||||
);
|
||||
if (tls) console.log(` certificate: ${tls.certFile} (trust locally to remove warnings)`);
|
||||
console.log(" database cache mail sms webhooks storage queue cron auth metrics");
|
||||
return server;
|
||||
}
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { generate } from "selfsigned";
|
||||
+157
-24
@@ -1,26 +1,159 @@
|
||||
/**
|
||||
* `wrnexus test [app-dir] [--watch] [--profile=test]` — run the app's test files
|
||||
* with `bun test`. Defaults to the `test` profile (config + .env.test). Extra
|
||||
* args after `--` (or bun test flags) pass straight through.
|
||||
*/
|
||||
/** Level-aware `wrnexus test` runner with Bun and optional Playwright backends. */
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function runTests(appRoot: string, args: string[]): void {
|
||||
const root = resolve(appRoot);
|
||||
const watch = args.includes("--watch");
|
||||
const passthrough = args.filter(
|
||||
(a) => !a.startsWith("--profile=") && a !== "--watch" && a !== appRoot,
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
process.execPath, // the Bun binary
|
||||
["test", ...(watch ? ["--watch"] : []), ...passthrough],
|
||||
{ stdio: "inherit", cwd: root },
|
||||
);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) return;
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
export const TEST_LEVELS = [
|
||||
"unit",
|
||||
"component",
|
||||
"api",
|
||||
"browser",
|
||||
"visual",
|
||||
"accessibility",
|
||||
"performance",
|
||||
] as const;
|
||||
export type TestLevel = (typeof TEST_LEVELS)[number];
|
||||
export interface TestCommandPlan {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
level?: TestLevel;
|
||||
files: string[];
|
||||
setup?: { command: string; args: string[] };
|
||||
}
|
||||
|
||||
function shardFiles(files: string[], value?: string): string[] {
|
||||
if (!value) return files;
|
||||
const match = /^(\d+)\/(\d+)$/.exec(value);
|
||||
if (!match) throw new Error("WRN-TEST-SHARD: expected --shard=<index>/<total>");
|
||||
const index = Number(match[1]);
|
||||
const total = Number(match[2]);
|
||||
if (index < 1 || total < 1 || index > total)
|
||||
throw new Error("WRN-TEST-SHARD: index must be between 1 and total");
|
||||
return files.filter((_file, position) => position % total === index - 1);
|
||||
}
|
||||
|
||||
function testFiles(root: string): string[] {
|
||||
const files: string[] = [];
|
||||
const walk = (directory: string): void => {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (["node_modules", "dist", ".wrnexus", "coverage"].includes(entry.name)) continue;
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(path);
|
||||
else if (/\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(entry.name)) files.push(path);
|
||||
}
|
||||
};
|
||||
for (const directory of ["app", "test", "tests"]) walk(join(root, directory));
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function hasPlaywright(root: string): boolean {
|
||||
if (
|
||||
["playwright.config.ts", "playwright.config.js", "playwright.config.mjs"].some((file) =>
|
||||
existsSync(join(root, file)),
|
||||
)
|
||||
)
|
||||
return true;
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
return Boolean(
|
||||
manifest.dependencies?.["@playwright/test"] || manifest.devDependencies?.["@playwright/test"],
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTestPlan(appRoot: string, args: string[]): TestCommandPlan {
|
||||
const root = resolve(appRoot);
|
||||
const level = args.find((value): value is TestLevel => TEST_LEVELS.includes(value as TestLevel));
|
||||
const watch = args.includes("--watch");
|
||||
const shard = args.find((value) => value.startsWith("--shard="))?.slice(8);
|
||||
const browsers = (args.find((value) => value.startsWith("--browsers="))?.slice(11) ?? "chromium")
|
||||
.split(",")
|
||||
.filter(Boolean);
|
||||
const passthrough = args.filter(
|
||||
(value) =>
|
||||
!value.startsWith("--profile=") &&
|
||||
value !== "--watch" &&
|
||||
value !== appRoot &&
|
||||
value !== level &&
|
||||
!value.startsWith("--browsers=") &&
|
||||
!value.startsWith("--shard=") &&
|
||||
value !== "--install-browsers",
|
||||
);
|
||||
if ((level === "browser" || level === "visual") && hasPlaywright(root)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"x",
|
||||
"playwright",
|
||||
"test",
|
||||
...(level === "visual" ? ["--grep", "@visual"] : []),
|
||||
...browsers.flatMap((browser) => ["--project", browser]),
|
||||
...(shard ? [`--shard=${shard}`] : []),
|
||||
"--reporter=line,html",
|
||||
...passthrough,
|
||||
],
|
||||
cwd: root,
|
||||
level,
|
||||
files: [],
|
||||
...(args.includes("--install-browsers")
|
||||
? {
|
||||
setup: { command: process.execPath, args: ["x", "playwright", "install", ...browsers] },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const pattern =
|
||||
level === "accessibility"
|
||||
? /(?:accessibility|a11y)/i
|
||||
: level === "performance"
|
||||
? /(?:performance|benchmark)/i
|
||||
: level
|
||||
? new RegExp(level, "i")
|
||||
: null;
|
||||
const files = shardFiles(
|
||||
pattern ? testFiles(root).filter((file) => pattern.test(relative(root, file))) : [],
|
||||
shard,
|
||||
);
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["test", ...(watch ? ["--watch"] : []), ...(pattern ? files : []), ...passthrough],
|
||||
cwd: root,
|
||||
level,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
export function runTests(appRoot: string, args: string[]): ChildProcess | null {
|
||||
const plan = createTestPlan(appRoot, args);
|
||||
if (plan.level && !plan.files.length && !plan.args.includes("playwright")) {
|
||||
console.error(
|
||||
`WRN-TEST-NO-FILES: no ${plan.level} tests found. Name a file or directory with '${plan.level}' under app/, test/, or tests/.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return null;
|
||||
}
|
||||
const launch = () => spawn(plan.command, plan.args, { stdio: "inherit", cwd: plan.cwd });
|
||||
if (plan.setup) {
|
||||
const setup = spawn(plan.setup.command, plan.setup.args, { stdio: "inherit", cwd: plan.cwd });
|
||||
setup.on("exit", (code, signal) => {
|
||||
if (signal || code !== 0) {
|
||||
process.exitCode = code ?? 1;
|
||||
return;
|
||||
}
|
||||
const test = launch();
|
||||
test.on("exit", (testCode, testSignal) => {
|
||||
if (!testSignal) process.exitCode = testCode ?? 0;
|
||||
});
|
||||
});
|
||||
return setup;
|
||||
}
|
||||
const child = launch();
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!signal) process.exitCode = code ?? 0;
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { basename, extname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
|
||||
import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
import { regenerateRoutes } from "./routes.ts";
|
||||
import { loadAppConfig } from "@wrnexus/styles";
|
||||
import { createPluginRunner, discoverPlugins, type PluginContributions } from "@wrnexus/plugin";
|
||||
|
||||
function files(root: string, predicate: (path: string) => boolean): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
const output: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) output.push(...files(path, predicate));
|
||||
else if (predicate(path)) output.push(path);
|
||||
}
|
||||
return output.sort();
|
||||
}
|
||||
|
||||
function literalUnion(values: Iterable<string>): string {
|
||||
const unique = [...new Set(values)].sort();
|
||||
return unique.length ? unique.map((value) => JSON.stringify(value)).join(" | ") : "never";
|
||||
}
|
||||
|
||||
function typeImport(fromDirectory: string, file: string): string {
|
||||
const path = relative(fromDirectory, file).replace(/\\/g, "/");
|
||||
return JSON.stringify(path.startsWith(".") ? path : `./${path}`);
|
||||
}
|
||||
|
||||
function exportedHandlers(source: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
[
|
||||
...source.matchAll(
|
||||
/\bexport\s+(?:const|async\s+function|function)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g,
|
||||
),
|
||||
].map((match) => match[1]!),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function flatten(value: unknown, prefix = ""): string[] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return prefix ? [prefix] : [];
|
||||
return Object.entries(value).flatMap(([key, child]) =>
|
||||
flatten(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
function environmentKeys(root: string): string[] {
|
||||
return files(root, (path) => /^\.env(?:\.[\w-]+)?(?:\.example)?$/.test(basename(path))).flatMap(
|
||||
(path) =>
|
||||
readFileSync(path, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1])
|
||||
.filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface GeneratedApplicationTypes {
|
||||
file: string;
|
||||
routes: number;
|
||||
components: number;
|
||||
}
|
||||
|
||||
function writePluginArtifacts(root: string, contributions?: PluginContributions): void {
|
||||
if (!contributions) return;
|
||||
const typeDir = join(root, "app", "types");
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
const references = contributions.typeDefinitions.map((entry) => {
|
||||
const target = resolve(root, entry);
|
||||
const specifier = relative(typeDir, target).replace(/\\/g, "/");
|
||||
return `/// <reference path=${JSON.stringify(specifier.startsWith(".") ? specifier : `./${specifier}`)} />`;
|
||||
});
|
||||
writeFileSync(
|
||||
join(typeDir, "wrnexus.plugins.generated.d.ts"),
|
||||
`// AUTO-GENERATED plugin type aggregation - do not edit.\n${references.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const docsDir = join(root, ".wrnexus", "documentation");
|
||||
mkdirSync(docsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(docsDir, "plugins.md"),
|
||||
`# Plugin documentation\n\n${contributions.documentation
|
||||
.map(
|
||||
(entry) => `- [${entry}](${relative(docsDir, resolve(root, entry)).replace(/\\/g, "/")})`,
|
||||
)
|
||||
.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function generateApplicationTypes(
|
||||
appRoot: string,
|
||||
pluginContributions?: PluginContributions,
|
||||
): GeneratedApplicationTypes {
|
||||
const root = resolve(appRoot);
|
||||
const app = join(root, "app");
|
||||
const router = buildRouter(app);
|
||||
regenerateRoutes(app);
|
||||
const componentFiles = files(join(app, "components"), (path) => extname(path) === ".wrn");
|
||||
const components = componentFiles.map((path) => parse(readFileSync(path, "utf8")));
|
||||
const localeKeys = files(join(app, "locales"), (path) => extname(path) === ".json").flatMap(
|
||||
(path) => {
|
||||
try {
|
||||
return flatten(JSON.parse(readFileSync(path, "utf8")));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
const source = files(app, (path) => /\.(?:ts|tsx|js|wrn)$/.test(path))
|
||||
.map((path) => readFileSync(path, "utf8"))
|
||||
.join("\n");
|
||||
const cacheKeys = [
|
||||
...source.matchAll(/\b(?:cache|invalidate(?:Tag)?)\s*\(\s*["'`]([^"'`]+)["'`]/g),
|
||||
].map((match) => match[1]);
|
||||
const queueNames = files(join(app, "queues"), (path) => /\.(?:ts|js)$/.test(path)).map((path) =>
|
||||
basename(path, extname(path)),
|
||||
);
|
||||
const manifest = createRouteManifest(nameRoutes(router.pages));
|
||||
const componentMap = components
|
||||
.map((component) => {
|
||||
const props = component.props
|
||||
.map(
|
||||
(prop) =>
|
||||
`${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"}`,
|
||||
)
|
||||
.join("; ");
|
||||
const outputs = component.outputs
|
||||
.map(
|
||||
(output) =>
|
||||
`${JSON.stringify(output.name)}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void`,
|
||||
)
|
||||
.join("; ");
|
||||
return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record<string, never>"}; outputs: ${outputs ? `{ ${outputs} }` : "Record<string, never>"} };`;
|
||||
})
|
||||
.join("\n");
|
||||
const typeDir = join(app, "types");
|
||||
const apiContracts = router.api
|
||||
.map((route) => {
|
||||
const source = readFileSync(route.file, "utf8");
|
||||
const methods = exportedHandlers(source);
|
||||
const module = `typeof import(${typeImport(typeDir, route.file)})`;
|
||||
const entries = methods
|
||||
.map((method) => `${method}: ApiContract<${module}[${JSON.stringify(method)}]>`)
|
||||
.join("; ");
|
||||
return ` ${JSON.stringify(route.raw)}: { ${entries || `default: ApiContract<${module}["default"]>`} };`;
|
||||
})
|
||||
.join("\n");
|
||||
const middlewareContracts = router.middlewareFiles
|
||||
.map((file) => {
|
||||
const name = basename(file, extname(file));
|
||||
return ` ${JSON.stringify(name)}: MiddlewareContext<(typeof import(${typeImport(typeDir, file)}))["default"]>;`;
|
||||
})
|
||||
.join("\n");
|
||||
const databaseContracts = files(join(app, "db"), (path) => /queries\.gen\.ts$/.test(path))
|
||||
.flatMap((file) => {
|
||||
const module = `typeof import(${typeImport(typeDir, file)})`;
|
||||
return [
|
||||
...readFileSync(file, "utf8").matchAll(/\bexport\s+async\s+function\s+([A-Za-z_$][\w$]*)/g),
|
||||
].map(
|
||||
(match) =>
|
||||
` ${JSON.stringify(match[1]!)}: QueryContract<${module}[${JSON.stringify(match[1]!)}]>;`,
|
||||
);
|
||||
})
|
||||
.join("\n");
|
||||
const realtimeContracts = router.realtime
|
||||
.map(
|
||||
(route) =>
|
||||
` ${JSON.stringify(route.raw)}: RealtimeMessage<(typeof import(${typeImport(typeDir, route.file)}))["default"]>;`,
|
||||
)
|
||||
.join("\n");
|
||||
const queueContracts = files(join(app, "queues"), (path) => /\.(?:ts|js)$/.test(path))
|
||||
.map((file) => {
|
||||
const name = basename(file, extname(file));
|
||||
return ` ${JSON.stringify(name)}: QueuePayload<(typeof import(${typeImport(typeDir, file)}))["default"]>;`;
|
||||
})
|
||||
.join("\n");
|
||||
const configFile = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"]
|
||||
.map((name) => join(root, name))
|
||||
.find(existsSync);
|
||||
const code = `// AUTO-GENERATED by \`wrnexus generate types\` - do not edit.
|
||||
declare namespace WRNexusGenerated {
|
||||
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
|
||||
? { input: I; output: O }
|
||||
: T extends (...args: infer A) => infer R
|
||||
? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited<R> }
|
||||
: { input: unknown; output: unknown };
|
||||
type MiddlewareContext<T> = T extends (ctx: infer C, ...args: any[]) => any ? C : never;
|
||||
type QueryContract<T> = T extends (db: any, args: infer A, ...rest: any[]) => infer R
|
||||
? { args: A; result: Awaited<R> }
|
||||
: T extends (db: any, ...rest: any[]) => infer R
|
||||
? { args: Record<string, never>; result: Awaited<R> }
|
||||
: never;
|
||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||
type RouteName = ${literalUnion(manifest.map((route) => route.name))};
|
||||
type ApiRoute = ${literalUnion(router.api.map((route) => route.raw))};
|
||||
type RealtimeRoute = ${literalUnion(router.realtime.map((route) => route.raw))};
|
||||
type EnvironmentKey = ${literalUnion(environmentKeys(root))};
|
||||
type TranslationKey = ${literalUnion(localeKeys)};
|
||||
type QueueName = ${literalUnion(queueNames)};
|
||||
type CacheKey = ${literalUnion(cacheKeys)};
|
||||
interface Components {
|
||||
${componentMap}
|
||||
}
|
||||
interface ApiContracts {
|
||||
${apiContracts}
|
||||
}
|
||||
interface MiddlewareContexts {
|
||||
${middlewareContracts}
|
||||
}
|
||||
interface DatabaseQueries {
|
||||
${databaseContracts}
|
||||
}
|
||||
interface RealtimeMessages {
|
||||
${realtimeContracts}
|
||||
}
|
||||
interface QueuePayloads {
|
||||
${queueContracts}
|
||||
}
|
||||
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
|
||||
}
|
||||
`;
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
const output = join(typeDir, "wrnexus.generated.d.ts");
|
||||
writeFileSync(output, code, "utf8");
|
||||
writePluginArtifacts(root, pluginContributions);
|
||||
return {
|
||||
file: relative(root, output).replace(/\\/g, "/"),
|
||||
routes: manifest.length,
|
||||
components: components.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Discover configured plugins and include their type/docs contributions in generated artifacts. */
|
||||
export async function generateApplicationTypesWithPlugins(
|
||||
appRoot: string,
|
||||
): Promise<GeneratedApplicationTypes> {
|
||||
const root = resolve(appRoot);
|
||||
const config = await loadAppConfig(root);
|
||||
const input = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
});
|
||||
const runner = createPluginRunner(input, {
|
||||
root,
|
||||
mode: "development",
|
||||
command: "cli",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
await runner.configure(config as Record<string, unknown>);
|
||||
await runner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
return generateApplicationTypes(root, await runner.contributions());
|
||||
}
|
||||
|
||||
export function checkApplication(appRoot: string): WrnTypeDiagnostic[] {
|
||||
const root = resolve(appRoot);
|
||||
return files(join(root, "app"), (path) => extname(path) === ".wrn").flatMap((file) =>
|
||||
checkWrnFile(file, { appRoot: root }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runTypecheck(appRoot: string): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
await generateApplicationTypesWithPlugins(root);
|
||||
const diagnostics = checkApplication(root);
|
||||
for (const item of diagnostics)
|
||||
console.error(`${item.file}:${item.line}:${item.column} ${item.code} ${item.message}`);
|
||||
let tsOk = true;
|
||||
if (existsSync(join(root, "tsconfig.json"))) {
|
||||
const process = Bun.spawnSync(["bunx", "tsc", "--noEmit"], {
|
||||
cwd: root,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
tsOk = process.exitCode === 0;
|
||||
}
|
||||
return diagnostics.every((item) => item.category !== "error") && tsOk;
|
||||
}
|
||||
+115
-1
@@ -30,6 +30,7 @@ import {
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatWrn, parse } from "@wrnexus/syntax";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
import { inspectProject, type DoctorCheck } from "./doctor.ts";
|
||||
|
||||
@@ -189,7 +190,20 @@ function quoteLegacyDynamicAttributes(source: string): string {
|
||||
}
|
||||
|
||||
export function migrateWrnSource(source: string): string {
|
||||
return formatInlineProps(quoteLegacyDynamicAttributes(source));
|
||||
return formatInlineProps(quoteLegacyDynamicAttributes(source))
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/\n{3,}$/g, "\n\n")
|
||||
.replace(/\s*$/, "\n");
|
||||
}
|
||||
|
||||
export function formatCurrentWrnSource(source: string): string {
|
||||
return formatWrn(migrateWrnSource(source), {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
printWidth: 100,
|
||||
multilineAttributes: true,
|
||||
});
|
||||
}
|
||||
|
||||
function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number {
|
||||
@@ -1857,6 +1871,106 @@ const MIGRATIONS: Migration[] = [
|
||||
if (!ctx.report.needsReview.includes(review)) ctx.report.needsReview.push(review);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.8.0",
|
||||
id: "0.8.0-01-package-kits",
|
||||
description:
|
||||
"Adds package-owned helper kits, reusable UI blocks, standalone realtime rooms, repaired i18n, image helpers, JWT utilities, and encrypted HTTP envelopes.",
|
||||
apply(ctx) {
|
||||
const file = join(ctx.appRoot, "package.json");
|
||||
if (!existsSync(file)) return;
|
||||
const pkg = JSON.parse(readFileSync(file, "utf8")) as Record<string, any>;
|
||||
const dependencies = (pkg.dependencies ??= {});
|
||||
const additions = ["@wrnexus/realtime", "@wrnexus/csr"];
|
||||
const added: string[] = [];
|
||||
for (const name of additions) {
|
||||
if (dependencies[name] === `^${ctx.to}`) continue;
|
||||
dependencies[name] = `^${ctx.to}`;
|
||||
added.push(name);
|
||||
}
|
||||
if (added.length) {
|
||||
ctx.log(`+ package-kit dependencies: ${added.join(", ")}`);
|
||||
if (!ctx.dryRun) writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
}
|
||||
const review =
|
||||
"Review package-owned components and helpers, i18n locale layout, encrypted HTTP trust boundaries, and realtime room authorization before enabling them in production.";
|
||||
if (!ctx.report.needsReview.includes(review)) ctx.report.needsReview.push(review);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.8.0",
|
||||
id: "0.8.0-02-current-wrn-source",
|
||||
description:
|
||||
"Upgrades every application WRN source to current syntax, adds resolvable imports, normalizes formatting, and records unresolved work.",
|
||||
apply(ctx) {
|
||||
const appDirectory = join(ctx.appRoot, "app");
|
||||
if (!existsSync(appDirectory)) return;
|
||||
const index = buildV060SymbolIndex(ctx.appRoot);
|
||||
const changedFiles: string[] = [];
|
||||
|
||||
for (const file of walkProjectFiles(appDirectory, ".wrn")) {
|
||||
const relativeFile = relative(ctx.appRoot, file).replace(/\\/g, "/");
|
||||
const before = readFileSync(file, "utf8");
|
||||
let after = migrateV060WrnSource(before, ctx.report, relativeFile);
|
||||
after = migrateImportedLayout(after, relativeFile, ctx.appRoot, index, ctx.report);
|
||||
after = addExplicitImportsToSource(after, relativeFile, ctx.appRoot, index, ctx.report);
|
||||
after = formatCurrentWrnSource(after);
|
||||
if (after === before) continue;
|
||||
|
||||
try {
|
||||
parse(after);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.split("\n", 1)[0] : String(error);
|
||||
ctx.report.parseFailures.push(`${relativeFile}: ${message}`);
|
||||
ctx.report.needsReview.push(
|
||||
`${relativeFile}: automatic modernization was skipped because the migrated source did not parse`,
|
||||
);
|
||||
ctx.log(`! ${relativeFile}: left unchanged because migrated source did not parse`);
|
||||
continue;
|
||||
}
|
||||
|
||||
changedFiles.push(relativeFile);
|
||||
if (!ctx.report.changedAutomatically.includes(relativeFile)) {
|
||||
ctx.report.changedAutomatically.push(relativeFile);
|
||||
}
|
||||
ctx.log(`~ ${relativeFile}: current WRN syntax, imports, and formatting`);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
}
|
||||
|
||||
const reportFile = join(
|
||||
ctx.appRoot,
|
||||
".wrnexus",
|
||||
"migrations",
|
||||
"0.8.0-source-modernization.json",
|
||||
);
|
||||
ctx.log(
|
||||
`+ .wrnexus/migrations/0.8.0-source-modernization.json (${changedFiles.length} WRN files updated)`,
|
||||
);
|
||||
if (!ctx.dryRun) {
|
||||
mkdirSync(dirname(reportFile), { recursive: true });
|
||||
writeFileSync(
|
||||
reportFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: "0.8.0",
|
||||
from: ctx.from,
|
||||
to: ctx.to,
|
||||
appliedAt: new Date().toISOString(),
|
||||
changedFiles,
|
||||
unresolvedImports: ctx.report.unresolvedImports,
|
||||
ambiguousFunctions: ctx.report.ambiguousFunctions,
|
||||
legacyOutputPayloads: ctx.report.legacyOutputPayloads,
|
||||
parseFailures: ctx.report.parseFailures,
|
||||
needsReview: ctx.report.needsReview,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -75,10 +75,25 @@ export const workspaceFiles = (name: string): Record<string, string> => ({
|
||||
"scripts": {
|
||||
"dev": "wrnexus gateway",
|
||||
"gateway": "wrnexus gateway",
|
||||
"production": "wrnexus production"
|
||||
"staging": "wrnexus staging",
|
||||
"production": "wrnexus production",
|
||||
"typecheck": "tsc --noEmit && bun run --filter './apps/*' typecheck",
|
||||
"test": "bun run --filter './apps/*' test",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"doctor": "bun run --filter './apps/*' doctor",
|
||||
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wrnexus/cli": "${frameworkVersion}"
|
||||
"@wrnexus/cli": "${frameworkVersion}",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@types/bun": "latest",
|
||||
"eslint": "^9.0.0",
|
||||
"prettier": "latest",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "latest"
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -119,8 +134,109 @@ export default config;
|
||||
".gitignore": `node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
uploads/
|
||||
mobile/android/
|
||||
mobile/ios/
|
||||
mobile/.expo/
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/extensions.json
|
||||
*.tsbuildinfo
|
||||
.eslintcache
|
||||
`,
|
||||
".env.example": `REDIS_URL=redis://localhost:6379
|
||||
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||
`,
|
||||
".prettierrc.json": `{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
`,
|
||||
".prettierignore": `node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
**/CLAUDE.md
|
||||
`,
|
||||
".editorconfig": `root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
`,
|
||||
"eslint.config.js": `import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["node_modules/**", "dist/**", "**/dist/**", ".wrnexus/**", "**/.wrnexus/**"] },
|
||||
{ languageOptions: { parserOptions: { tsconfigRootDir } } },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-undef": "off",
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
`,
|
||||
"tsconfig.json": `{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["wrnexus.workspace.ts", "packages/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "apps"]
|
||||
}
|
||||
`,
|
||||
".vscode/settings.json": `{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
|
||||
"prettier.requireConfig": true,
|
||||
"[wrn]": { "editor.defaultFormatter": "wrnexus.wrnexus", "editor.formatOnSave": true }
|
||||
}
|
||||
`,
|
||||
".vscode/extensions.json": `{
|
||||
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
`,
|
||||
"packages/shared/package.json": `{
|
||||
"name": "@app/shared",
|
||||
@@ -129,6 +245,10 @@ dist/
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/pubsub": "${frameworkVersion}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createOpenApi, generateApiArtifacts, inspectApi } from "../src/api-command.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
async function fixture() {
|
||||
const root = join(tmpdir(), `wrnexus-api-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "app", "api", "users"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "app", "api", "users", "[id].ts"),
|
||||
"export async function GET(){}\nexport const PATCH = () => {};\n",
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("API and SDK generation", () => {
|
||||
test("derives methods, paths and OpenAPI operations from file routes", async () => {
|
||||
const operations = inspectApi(await fixture());
|
||||
expect(operations.map((operation) => `${operation.method} ${operation.path}`)).toEqual([
|
||||
"GET /api/users/{id}",
|
||||
"PATCH /api/users/{id}",
|
||||
]);
|
||||
expect(createOpenApi(operations).openapi).toBe("3.1.0");
|
||||
});
|
||||
test("emits docs, Postman, examples and all requested SDK languages", async () => {
|
||||
const root = await fixture();
|
||||
const result = generateApiArtifacts(root, ["typescript", "javascript", "java", "go", "python"]);
|
||||
expect(result.files).toHaveLength(9);
|
||||
expect(
|
||||
JSON.parse(await readFile(join(root, "generated/api/openapi.json"), "utf8")).paths[
|
||||
"/api/users/{id}"
|
||||
].get.operationId,
|
||||
).toBe("getUsersId");
|
||||
expect(await readFile(join(root, "generated/api/sdk/python/wrnexus-api.py"), "utf8")).toContain(
|
||||
"class WrnexusApi",
|
||||
);
|
||||
});
|
||||
test("extracts webhook prose and schemas into OpenAPI 3.1 webhooks", async () => {
|
||||
const root = await fixture();
|
||||
await mkdir(join(root, "app", "api", "webhooks"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "app", "api", "webhooks", "payment.ts"),
|
||||
`
|
||||
export const webhook = defineWebhook({
|
||||
event: "payment.completed",
|
||||
summary: "Payment completed",
|
||||
description: "Sent after settlement.",
|
||||
payloadSchema: "#/components/schemas/Payment",
|
||||
signatureHeader: "x-payment-signature"
|
||||
});
|
||||
export const POST = () => new Response("ok");
|
||||
`,
|
||||
);
|
||||
const spec = createOpenApi(inspectApi(root)) as any;
|
||||
expect(spec.webhooks["payment.completed"].post.description).toBe("Sent after settlement.");
|
||||
expect(
|
||||
spec.webhooks["payment.completed"].post.requestBody.content["application/json"].schema.$ref,
|
||||
).toBe("#/components/schemas/Payment");
|
||||
expect(spec.webhooks["payment.completed"].post.parameters[0].name).toBe("x-payment-signature");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { compatibilityReport, upgradeCompatibility } from "../src/compatibility-command.ts";
|
||||
|
||||
test("compatibility upgrade is backed up, current, and idempotent", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-compatibility-"));
|
||||
const file = join(root, "wrnexus.config.ts");
|
||||
writeFileSync(file, `export default { port: 3000 };\n`);
|
||||
const first = upgradeCompatibility(root);
|
||||
expect(first.changed).toBe(true);
|
||||
expect(readFileSync(first.backup, "utf8")).toContain("port: 3000");
|
||||
expect(readFileSync(file, "utf8")).toContain('compatibilityDate: "2026-08-02"');
|
||||
expect(upgradeCompatibility(root).changed).toBe(false);
|
||||
expect((await compatibilityReport(root)).needsUpgrade).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { runContractsCommand } from "../src/contracts-command.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
|
||||
async function fixture(): Promise<string> {
|
||||
const root = join(tmpdir(), `wrnexus-contracts-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "wrnexus.contracts.json"),
|
||||
JSON.stringify({
|
||||
format: 1,
|
||||
contracts: [
|
||||
{
|
||||
kind: "queue",
|
||||
name: "mail",
|
||||
version: 1,
|
||||
consumers: ["worker"],
|
||||
payload: {
|
||||
type: "object",
|
||||
fields: { to: { type: "string", rules: [] } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("contracts command", () => {
|
||||
test("snapshots and checks compatible contracts", async () => {
|
||||
const root = await fixture();
|
||||
expect((await runContractsCommand(root, "snapshot")).ok).toBe(true);
|
||||
expect((await runContractsCommand(root, "check")).ok).toBe(true);
|
||||
});
|
||||
|
||||
test("returns a failed result for breaking changes", async () => {
|
||||
const root = await fixture();
|
||||
await runContractsCommand(root, "snapshot");
|
||||
await writeFile(
|
||||
join(root, "wrnexus.contracts.json"),
|
||||
JSON.stringify({ format: 1, contracts: [] }),
|
||||
);
|
||||
const result = await runContractsCommand(root, "check");
|
||||
expect(result).toMatchObject({ ok: false, issueCount: 1 });
|
||||
});
|
||||
|
||||
test("requires an explicit baseline", async () => {
|
||||
const root = await fixture();
|
||||
await expect(runContractsCommand(root, "check")).rejects.toThrow("WRN-CONTRACT-BASELINE");
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,10 @@ test("scaffoldApp creates a comprehensive .gitignore", () => {
|
||||
".wrnexus/",
|
||||
".env.*",
|
||||
"!.env.example",
|
||||
"!.env.*.example",
|
||||
"*.log",
|
||||
"*.db",
|
||||
"uploads/",
|
||||
"coverage/",
|
||||
"mobile/android/",
|
||||
".vscode/",
|
||||
@@ -47,6 +49,79 @@ test("scaffoldApp includes production build and start scripts", () => {
|
||||
expect(pkg.scripts.build).toBe("wrnexus build .");
|
||||
expect(pkg.scripts.start).toBe("bun dist/server.js");
|
||||
expect(pkg.scripts.production).toBe("bun run build && bun run start");
|
||||
expect(pkg.scripts.typecheck).toBe("tsc --noEmit");
|
||||
expect(pkg.scripts.test).toBe("wrnexus test .");
|
||||
expect(pkg.scripts.check).toBe(
|
||||
"bun run typecheck && bun run lint && bun run test && bun run format:check",
|
||||
);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("scaffoldApp includes the complete v0.8 configuration and starter structure", () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), "wrnexus-create-"));
|
||||
const root = join(parent, "complete-app");
|
||||
|
||||
try {
|
||||
scaffoldApp(root, "complete-app");
|
||||
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const config = readFileSync(join(root, "wrnexus.config.ts"), "utf8");
|
||||
|
||||
for (const packageName of [
|
||||
"@wrnexus/auth",
|
||||
"@wrnexus/captcha",
|
||||
"@wrnexus/db",
|
||||
"@wrnexus/encryption",
|
||||
"@wrnexus/i18n",
|
||||
"@wrnexus/image",
|
||||
"@wrnexus/jwt",
|
||||
"@wrnexus/observability",
|
||||
"@wrnexus/realtime",
|
||||
"@wrnexus/security",
|
||||
"@wrnexus/store",
|
||||
"@wrnexus/ui",
|
||||
"@wrnexus/uploader",
|
||||
"@wrnexus/validation",
|
||||
]) {
|
||||
expect(pkg.dependencies[packageName]).toBe(currentCliVersion());
|
||||
}
|
||||
|
||||
for (const block of [
|
||||
"plugins:",
|
||||
"imports:",
|
||||
"types:",
|
||||
"stores:",
|
||||
"compatibility:",
|
||||
"performance:",
|
||||
"observability:",
|
||||
"tenancy:",
|
||||
"build:",
|
||||
"navigation:",
|
||||
"devToolbar:",
|
||||
"theme:",
|
||||
"i18n:",
|
||||
"db:",
|
||||
"databases:",
|
||||
"storage:",
|
||||
"realtime:",
|
||||
"profiles:",
|
||||
]) {
|
||||
expect(config).toContain(block);
|
||||
}
|
||||
|
||||
for (const relative of [
|
||||
".env.example",
|
||||
".env.test.example",
|
||||
"app/locales/en.json",
|
||||
"app/db/migrations/0001_init.sql",
|
||||
"app/db/seed.ts",
|
||||
"app/schemas/contact.ts",
|
||||
"app/realtime/chat.ts",
|
||||
"test/smoke.test.ts",
|
||||
]) {
|
||||
expect(existsSync(join(root, relative))).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DEPLOY_TARGETS, generateDeployment } from "../src/deploy.ts";
|
||||
|
||||
describe("deployment presets", () => {
|
||||
for (const target of DEPLOY_TARGETS) {
|
||||
test(`generates ${target}`, () => {
|
||||
const root = mkdtempSync(join(tmpdir(), `wrnexus-${target}-`));
|
||||
const files = generateDeployment(root, target);
|
||||
expect(files).toContain(".env.production.example");
|
||||
expect(readFileSync(join(root, "deploy/README.md"), "utf8")).toContain("/readyz");
|
||||
expect(generateDeployment(root, target)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("Kubernetes includes probes, limits and release migration", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-k8s-"));
|
||||
generateDeployment(root, "kubernetes");
|
||||
const manifest = readFileSync(join(root, "deploy/kubernetes.yaml"), "utf8");
|
||||
expect(manifest).toContain("readinessProbe");
|
||||
expect(manifest).toContain("kind: Job");
|
||||
expect(manifest).toContain("resources:");
|
||||
});
|
||||
|
||||
test("rejects unknown targets", () => {
|
||||
expect(() => generateDeployment(".", "unknown")).toThrow("WRN-DEPLOY-TARGET");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { inspectProject } from "../src/doctor.ts";
|
||||
import { inspectProject, repairProject } from "../src/doctor.ts";
|
||||
|
||||
test("doctor reports a healthy minimal project", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-doctor-"));
|
||||
@@ -19,3 +19,29 @@ test("doctor returns actionable missing-project checks", () => {
|
||||
expect(checks.find((check) => check.name === "package.json")?.ok).toBe(false);
|
||||
expect(checks.find((check) => check.name === "app/pages")?.detail).toBe("Create app/pages");
|
||||
});
|
||||
|
||||
test("doctor --fix applies safe repairs and is idempotent", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-doctor-fix-"));
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "app",
|
||||
dependencies: { "@wrnexus/core": "^0.8.0", "@wrnexus/router": "^0.7.0" },
|
||||
wrnexus: { version: "0.7.0" },
|
||||
}),
|
||||
);
|
||||
mkdirSync(join(root, "app", "components"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app", "components", "Greeting.wrn"),
|
||||
'component Greeting { props { name:string="World" } view { <p>{name}</p> } }',
|
||||
);
|
||||
|
||||
const repairs = repairProject(root);
|
||||
expect(repairs.map(({ name }) => name)).toContain("app/pages");
|
||||
expect(repairs.map(({ name }) => name)).toContain("configuration");
|
||||
expect(existsSync(join(root, "wrnexus.config.ts"))).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
expect(manifest.dependencies["@wrnexus/router"]).toBe("^0.8.0");
|
||||
expect(manifest.wrnexus.version).toBe("0.8.0");
|
||||
expect(repairProject(root)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { explainBuildDecision } from "../src/explain.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
|
||||
async function fixture(): Promise<string> {
|
||||
const root = join(tmpdir(), `wrnexus-explain-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "dist", "build-report.json"),
|
||||
JSON.stringify({
|
||||
frameworkVersion: "0.8.0",
|
||||
adapter: "edge",
|
||||
measurements: { routeJsBytes: 12 },
|
||||
budgetViolations: [],
|
||||
assets: [{ file: "server.js", bytes: 12 }],
|
||||
routes: [
|
||||
{
|
||||
kind: "page",
|
||||
path: "/users/[id]",
|
||||
source: "app/pages/users/[id].wrn",
|
||||
execution: "authenticated-ssr",
|
||||
canPrerender: false,
|
||||
needsClientRuntime: true,
|
||||
needsServerRuntime: true,
|
||||
hydrationStrategy: "visible",
|
||||
reasons: ["client interactivity", "authentication required"],
|
||||
cachePolicy: { strategy: "stale-while-revalidate", ttl: "30s" },
|
||||
requiredPermission: "users.read",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("causal build explanations", () => {
|
||||
test("explains route execution and hydration from persisted compiler evidence", async () => {
|
||||
const root = await fixture();
|
||||
const route = explainBuildDecision(root, "route", "/users/[id]");
|
||||
expect(route.summary).toContain("authenticated-ssr");
|
||||
expect(route.reasons).toContain("authentication required");
|
||||
expect(explainBuildDecision(root, "hydration", "users/[id]").summary).toContain("visible");
|
||||
});
|
||||
|
||||
test("explains build and bundle measurements", async () => {
|
||||
const root = await fixture();
|
||||
expect(explainBuildDecision(root, "build").reasons).toContain(
|
||||
"all configured performance budgets pass",
|
||||
);
|
||||
expect(explainBuildDecision(root, "bundle").reasons[0]).toBe("server.js: 12 bytes");
|
||||
});
|
||||
|
||||
test("explains cache and permission decisions", async () => {
|
||||
const root = await fixture();
|
||||
expect(explainBuildDecision(root, "cache", "/users/[id]").summary).toContain(
|
||||
"stale-while-revalidate",
|
||||
);
|
||||
expect(explainBuildDecision(root, "permission", "users.read").reasons[0]).toContain(
|
||||
"security.permission",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses stable diagnostics for missing evidence", () => {
|
||||
expect(() => explainBuildDecision("missing", "build")).toThrow("WRN-EXPLAIN-NO-BUILD");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runI18nCommand } from "../src/i18n-command.ts";
|
||||
|
||||
test("i18n extract and validate audit native WRN translation keys", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-i18n-cli-"));
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/locales"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/pages/index.wrn"),
|
||||
`page Home { view { <h1>{t:home.title}</h1> } }`,
|
||||
);
|
||||
writeFileSync(join(root, "app/locales/en.json"), JSON.stringify({ home: { title: "Home" } }));
|
||||
writeFileSync(
|
||||
join(root, "app/locales/mr.json"),
|
||||
JSON.stringify({ home: { title: "मुख्यपृष्ठ" } }),
|
||||
);
|
||||
expect(runI18nCommand(root, "extract")).toBe(true);
|
||||
expect(existsSync(join(root, ".wrnexus/i18n-keys.json"))).toBe(true);
|
||||
expect(runI18nCommand(root, "validate")).toBe(true);
|
||||
});
|
||||
test("i18n validate fails missing locale keys", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-i18n-cli-"));
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
mkdirSync(join(root, "app/locales"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "app/pages/index.wrn"),
|
||||
`page Home { view { <h1>{t:home.title}</h1> } }`,
|
||||
);
|
||||
writeFileSync(join(root, "app/locales/en.json"), JSON.stringify({ home: { title: "Home" } }));
|
||||
writeFileSync(join(root, "app/locales/es.json"), JSON.stringify({}));
|
||||
expect(runI18nCommand(root, "validate")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { runPluginCliCommand } from "../src/plugin-command.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
delete (globalThis as Record<string, unknown>).__pluginCommandArgs;
|
||||
});
|
||||
|
||||
test("application plugins can register executable CLI commands", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-plugin-command-"));
|
||||
roots.push(root);
|
||||
writeFileSync(
|
||||
join(root, "wrnexus.config.ts"),
|
||||
`export default {
|
||||
plugins: [{
|
||||
name: "command-test",
|
||||
cliCommands: [{
|
||||
name: "greet",
|
||||
run(args) { globalThis.__pluginCommandArgs = args }
|
||||
}]
|
||||
}]
|
||||
};
|
||||
`,
|
||||
);
|
||||
expect(await runPluginCliCommand(root, "greet", ["Ada"])).toBe(true);
|
||||
expect((globalThis as Record<string, unknown>).__pluginCommandArgs).toEqual(["Ada"]);
|
||||
expect(await runPluginCliCommand(root, "missing", [])).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { productionEntry, runPreview } from "../src/preview.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
|
||||
describe("production preview", () => {
|
||||
test("refuses to approximate a missing production build", () => {
|
||||
expect(() => productionEntry("missing-preview-root")).toThrow("WRN-PREVIEW-NO-BUILD");
|
||||
});
|
||||
|
||||
test("executes the exact dist server with production environment", async () => {
|
||||
const root = join(tmpdir(), `wrnexus-preview-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "dist", "server.js"),
|
||||
"console.log(process.env.NODE_ENV + ':' + process.env.PORT)",
|
||||
);
|
||||
expect(productionEntry(root)).toBe(join(root, "dist", "server.js"));
|
||||
const child = runPreview(root, { port: 4100, stdio: "pipe" });
|
||||
const output = await new Response(child.stdout as never).text();
|
||||
expect(await new Promise<number | null>((resolve) => child.on("exit", resolve))).toBe(0);
|
||||
expect(output.trim()).toBe("production:4100");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generateReproductionReport } from "../src/report.ts";
|
||||
|
||||
test("report bundles actionable diagnostics while redacting secrets and user/internal data", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-report-"));
|
||||
mkdirSync(join(root, "app/pages"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ dependencies: { "@wrnexus/core": "0.8.0" } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "wrnexus.config.ts"),
|
||||
`export default { apiKey: "sk_secretsecretsecret", endpoint: "https://internal.police.local/api" }`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/index.wrn"),
|
||||
`page Home { view { <p>officer@example.com</p> } }`,
|
||||
);
|
||||
const result = generateReproductionReport(root, {
|
||||
file: "app/pages/index.wrn",
|
||||
error: "token=wrn_supersecrettoken at 10.0.0.1",
|
||||
output: ".wrnexus/report-test",
|
||||
});
|
||||
const all = readFileSync(result.reportFile, "utf8") + readFileSync(result.sourceFile!, "utf8");
|
||||
expect(all).toContain("frameworkVersion");
|
||||
expect(all).toContain("diagnostics");
|
||||
expect(all).not.toContain("sk_secretsecretsecret");
|
||||
expect(all).not.toContain("officer@example.com");
|
||||
expect(all).not.toContain("internal.police.local");
|
||||
expect(all).not.toContain("10.0.0.1");
|
||||
});
|
||||
test("report rejects traversal inputs and outputs", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-report-"));
|
||||
writeFileSync(join(root, "package.json"), "{}");
|
||||
expect(() => generateReproductionReport(root, { file: "../secret" })).toThrow("WRN-REPORT-FILE");
|
||||
expect(() => generateReproductionReport(root, { output: "../outside" })).toThrow(
|
||||
"WRN-REPORT-OUTPUT",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { validateRuntimeCapabilities } from "../src/build.ts";
|
||||
|
||||
test("production targets fail before bundling incompatible application imports", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-edge-build-"));
|
||||
mkdirSync(join(root, "app", "api"), { recursive: true });
|
||||
writeFileSync(join(root, "app", "api", "files.ts"), `import fs from "node:fs";`);
|
||||
expect(() => validateRuntimeCapabilities(root, "edge")).toThrow(/WRN-RUNTIME-CAPABILITY/);
|
||||
expect(() => validateRuntimeCapabilities(root, "worker")).toThrow(/filesystem/);
|
||||
expect(() => validateRuntimeCapabilities(root, "bun")).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { runSecurityCommand, securityAudit, securityHeaders } from "../src/security-command.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
|
||||
async function fixture(config = "export default {};"): Promise<string> {
|
||||
const root = join(tmpdir(), `wrnexus-security-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "wrnexus.config.ts"), config);
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("security command", () => {
|
||||
test("audits secure framework defaults against mapped ASVS controls", async () => {
|
||||
const report = await securityAudit(await fixture());
|
||||
expect(report.passed).toBe(true);
|
||||
expect(report.version).toBe("ASVS 5.0.0");
|
||||
expect(report.checks.every((check) => check.asvs.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("reports deliberately disabled headers", async () => {
|
||||
const report = await securityAudit(
|
||||
await fixture("export default { security: { headers: false } };"),
|
||||
);
|
||||
expect(report.passed).toBe(false);
|
||||
expect(report.checks.find((check) => check.id === "SEC-HEADERS")?.passed).toBe(false);
|
||||
});
|
||||
|
||||
test("prints the effective production headers", async () => {
|
||||
const headers = await securityHeaders(await fixture());
|
||||
expect(headers["content-security-policy"]).toContain("nonce-audit-nonce");
|
||||
expect(headers["strict-transport-security"]).toContain("max-age=");
|
||||
expect(headers["x-content-type-options"]).toBe("nosniff");
|
||||
});
|
||||
|
||||
test("rejects credentialed wildcard CORS and unknown commands", async () => {
|
||||
const root = await fixture(
|
||||
'export default { security: { cors: { origin: "*", credentials: true } } };',
|
||||
);
|
||||
expect((await securityAudit(root)).passed).toBe(false);
|
||||
await expect(runSecurityCommand(root, "unknown")).rejects.toThrow("WRN-SECURITY-COMMAND");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import {
|
||||
createLocalServicesHandler,
|
||||
ensureLocalCertificate,
|
||||
startLocalServices,
|
||||
} from "../src/services.ts";
|
||||
|
||||
describe("local production service simulator", () => {
|
||||
test("simulates bounded mail, cache, storage, auth and health APIs", async () => {
|
||||
const handler = createLocalServicesHandler();
|
||||
expect((await handler(new Request("http://local/healthz"))).status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("http://local/mail", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ to: "u@test", subject: "Welcome" }),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(202);
|
||||
expect(await (await handler(new Request("http://local/mail"))).json()).toHaveLength(1);
|
||||
await handler(
|
||||
new Request("http://local/cache", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key: "user", value: 1 }),
|
||||
}),
|
||||
);
|
||||
expect(await (await handler(new Request("http://local/cache"))).json()).toHaveProperty(
|
||||
"user.value",
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("http://local/storage?key=file.txt", { method: "POST", body: "hello" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(201);
|
||||
expect(await (await handler(new Request("http://local/storage/file.txt"))).text()).toBe(
|
||||
"hello",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("http://local/auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "u@test" }),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(201);
|
||||
});
|
||||
test("rejects unsafe storage keys and oversized declared JSON", async () => {
|
||||
const handler = createLocalServicesHandler();
|
||||
expect(
|
||||
(
|
||||
await handler(
|
||||
new Request("http://local/storage?key=../secret", { method: "POST", body: "bad" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
const response = await handler(
|
||||
new Request("http://local/mail", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value: "x".repeat(300_000) }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(413);
|
||||
});
|
||||
test("generates and safely reuses a localhost HTTPS certificate", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-services-cert-"));
|
||||
const first = await ensureLocalCertificate(root);
|
||||
const certificate = new X509Certificate(first.cert);
|
||||
expect(certificate.subjectAltName).toContain("DNS:localhost");
|
||||
expect(certificate.subjectAltName).toContain("IP Address:127.0.0.1");
|
||||
expect(statSync(first.keyFile).size).toBeGreaterThan(100);
|
||||
const second = await ensureLocalCertificate(root);
|
||||
expect(second.reused).toBe(true);
|
||||
expect(second.cert).toBe(first.cert);
|
||||
});
|
||||
test("serves the simulator over generated HTTPS", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-services-tls-"));
|
||||
const server = await startLocalServices({ appRoot: root, port: 0, hostname: "127.0.0.1" });
|
||||
try {
|
||||
const response = await fetch(`https://127.0.0.1:${server.port}/healthz`, {
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toHaveProperty("service", "wrnexus-local-services");
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createTestPlan } from "../src/test.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))),
|
||||
);
|
||||
async function fixture(): Promise<string> {
|
||||
const root = join(tmpdir(), `wrnexus-test-command-${crypto.randomUUID()}`);
|
||||
roots.push(root);
|
||||
await mkdir(join(root, "test", "component"), { recursive: true });
|
||||
await writeFile(join(root, "test", "math.unit.test.ts"), "export {};\n");
|
||||
await writeFile(join(root, "test", "component", "card.test.ts"), "export {};\n");
|
||||
await writeFile(join(root, "test", "home.a11y.test.ts"), "export {};\n");
|
||||
await writeFile(join(root, "package.json"), "{}");
|
||||
return root;
|
||||
}
|
||||
describe("test command planning", () => {
|
||||
test("discovers the requested Bun test level", async () => {
|
||||
const root = await fixture();
|
||||
expect(createTestPlan(root, ["unit"]).files).toHaveLength(1);
|
||||
expect(createTestPlan(root, ["component"]).files[0]).toContain("card.test.ts");
|
||||
expect(createTestPlan(root, ["accessibility"]).files[0]).toContain("a11y.test.ts");
|
||||
});
|
||||
test("delegates browser and visual suites to Playwright", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(join(root, "playwright.config.ts"), "export default {};\n");
|
||||
expect(createTestPlan(root, ["browser"]).args).toContain("playwright");
|
||||
expect(createTestPlan(root, ["visual"]).args).toContain("@visual");
|
||||
const matrix = createTestPlan(root, [
|
||||
"browser",
|
||||
"--browsers=chromium,firefox",
|
||||
"--shard=2/3",
|
||||
"--install-browsers",
|
||||
]);
|
||||
expect(matrix.args).toContain("firefox");
|
||||
expect(matrix.args).toContain("--shard=2/3");
|
||||
expect(matrix.args).toContain("--reporter=line,html");
|
||||
expect(matrix.setup?.args).toEqual(["x", "playwright", "install", "chromium", "firefox"]);
|
||||
});
|
||||
test("deterministically shards convention-based suites", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(join(root, "test", "second.unit.test.ts"), "export {};\n");
|
||||
expect(createTestPlan(root, ["unit", "--shard=1/2"]).files).toHaveLength(1);
|
||||
expect(() => createTestPlan(root, ["unit", "--shard=3/2"])).toThrow("WRN-TEST-SHARD");
|
||||
});
|
||||
test("keeps the unfiltered legacy command", async () => {
|
||||
const plan = createTestPlan(await fixture(), ["--watch"]);
|
||||
expect(plan.level).toBeUndefined();
|
||||
expect(plan.args).toEqual(["test", "--watch"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { checkApplication, generateApplicationTypes } from "../src/types.ts";
|
||||
import { inspectComponent } from "../src/inspect.ts";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-types-"));
|
||||
roots.push(root);
|
||||
for (const dir of ["pages/users", "components", "api", "realtime", "queues", "locales"])
|
||||
mkdirSync(join(root, "app", dir), { recursive: true });
|
||||
writeFileSync(join(root, ".env.example"), "PUBLIC_API_URL=https://example.test\n");
|
||||
writeFileSync(join(root, "app/pages/index.wrn"), "page Home { view { <h1>Home</h1> } }\n");
|
||||
writeFileSync(
|
||||
join(root, "app/pages/users/[id].wrn"),
|
||||
"page User { props { id: string } view { <p>{id}</p> } }\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/components/Button.wrn"),
|
||||
"component Button { props { label: string } outputs { press(event: MouseEvent) } view { <button>{label}</button> } }\n",
|
||||
);
|
||||
writeFileSync(join(root, "app/api/users.ts"), "export default () => new Response('ok');\n");
|
||||
writeFileSync(join(root, "app/realtime/chat.ts"), "export default {};\n");
|
||||
writeFileSync(join(root, "app/queues/email.ts"), "export default {};\n");
|
||||
writeFileSync(join(root, "app/locales/en.json"), JSON.stringify({ common: { save: "Save" } }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test("generate types emits application-wide deterministic contracts", () => {
|
||||
const root = fixture();
|
||||
const result = generateApplicationTypes(root);
|
||||
const output = readFileSync(join(root, result.file), "utf8");
|
||||
expect(existsSync(join(root, "app/routes.gen.ts"))).toBe(true);
|
||||
expect(output).toContain('type RouteName = "index" | "users.id"');
|
||||
expect(output).toContain('type EnvironmentKey = "PUBLIC_API_URL"');
|
||||
expect(output).toContain('type TranslationKey = "common.save"');
|
||||
expect(output).toContain('type QueueName = "email"');
|
||||
expect(output).toContain('"Button": { props: { "label": string }');
|
||||
expect(output).toContain("interface ApiContracts");
|
||||
expect(output).toContain('"/api/users": { default: ApiContract<');
|
||||
expect(output).toContain("interface RealtimeMessages");
|
||||
expect(output).toContain('"/realtime/chat": RealtimeMessage<');
|
||||
expect(output).toContain("interface QueuePayloads");
|
||||
expect(output).toContain('"email": QueuePayload<');
|
||||
});
|
||||
|
||||
test("application checker validates every wrn source", () => {
|
||||
expect(checkApplication(fixture()).filter((item) => item.category === "error")).toEqual([]);
|
||||
}, 15_000);
|
||||
|
||||
test("component inspection exposes its typed public contract", () => {
|
||||
const value = inspectComponent(fixture(), "button") as { name: string; props: unknown[] };
|
||||
expect(value.name).toBe("Button");
|
||||
expect(value.props).toEqual([{ name: "label", type: "string", required: true }]);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { migrateV060WrnSource } from "../src/update.ts";
|
||||
import { formatCurrentWrnSource, migrateV060WrnSource } from "../src/update.ts";
|
||||
|
||||
const report = () => ({
|
||||
changedAutomatically: [],
|
||||
@@ -30,4 +30,16 @@ describe("v0.6 source migration", () => {
|
||||
expect(first).toContain("output.confirm({ ok: true })");
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
test("uses the canonical framework formatter idempotently", () => {
|
||||
const source = `page Home {
|
||||
view {
|
||||
<button type="button" class="one two three four five six seven eight nine ten eleven twelve" @click='save()'>Save</button>
|
||||
}
|
||||
}`;
|
||||
const formatted = formatCurrentWrnSource(source);
|
||||
|
||||
expect(formatted).toContain("<button\n");
|
||||
expect(formatCurrentWrnSource(formatted)).toBe(formatted);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,3 +286,56 @@ test("0.4 migration removes manual CAPTCHA runtime wiring and archives copied as
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("0.8 migration modernizes every WRN source with imports and a review report", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-update-current-source-"));
|
||||
mkdirSync(join(root, "app", "components"), { recursive: true });
|
||||
mkdirSync(join(root, "app", "layouts"), { recursive: true });
|
||||
mkdirSync(join(root, "app", "pages"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "source-app",
|
||||
dependencies: { "@wrnexus/core": "^0.7.0" },
|
||||
wrnexus: { version: "0.7.0" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app", "components", "Notice.wrn"),
|
||||
'component Notice {\r\n props { label = "Ready" count = 1 } \r\n view { <p>{label}</p> }\r\n}',
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app", "layouts", "shell.wrn"),
|
||||
"layout Shell {\n view { <main><slot /></main> }\n}\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app", "pages", "index.wrn"),
|
||||
'page Home {\n layout = "shell"\n view { <Notice label={"Updated"} /> <Missing /> }\n}\n',
|
||||
);
|
||||
|
||||
try {
|
||||
updateApp(root, "0.8.0", false);
|
||||
const first = readFileSync(join(root, "app", "pages", "index.wrn"), "utf8");
|
||||
expect(first).toContain('import Notice from "@/components/Notice.wrn"');
|
||||
expect(first).toContain('import Shell from "@/layouts/shell.wrn"');
|
||||
expect(first).toContain("layout = Shell");
|
||||
expect(first).toContain("label='{\"Updated\"}'");
|
||||
expect(first.endsWith("\n")).toBe(true);
|
||||
|
||||
const component = readFileSync(join(root, "app", "components", "Notice.wrn"), "utf8");
|
||||
expect(component).toContain('props {\n label = "Ready"\n count = 1\n }');
|
||||
expect(component).not.toContain("\r");
|
||||
|
||||
const reportPath = join(root, ".wrnexus", "migrations", "0.8.0-source-modernization.json");
|
||||
const report = JSON.parse(readFileSync(reportPath, "utf8"));
|
||||
expect(report.changedFiles).toContain("app/pages/index.wrn");
|
||||
expect(report.unresolvedImports).toContain(
|
||||
"app/pages/index.wrn: component 'Missing' could not be resolved",
|
||||
);
|
||||
|
||||
updateApp(root, "0.8.0", false);
|
||||
expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toBe(first);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,8 +47,14 @@ 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");
|
||||
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");
|
||||
expect(JSON.parse(files["package.json"]!).scripts.check).toContain("typecheck");
|
||||
expect(JSON.parse(files["package.json"]!).scripts.check).toContain("format:check");
|
||||
expect(files["wrnexus.workspace.ts"]).toContain('runtime: "development"');
|
||||
expect(files["wrnexus.workspace.ts"]).toContain("hmr: false");
|
||||
expect(files[".env.example"]).toContain("REDIS_URL");
|
||||
expect(files["eslint.config.js"]).toContain("typescript-eslint");
|
||||
expect(files["tsconfig.json"]).toContain('"strict": true');
|
||||
expect(files[".vscode/extensions.json"]).toContain("wrnexus.wrnexus");
|
||||
});
|
||||
|
||||
test("production workspace detects default and named SQL migrations", () => {
|
||||
|
||||
Reference in New Issue
Block a user