release: WRNexusJS 0.3.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
@@ -20,6 +20,8 @@
|
||||
"@wrnexus/ui": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*",
|
||||
"@wrnexus/i18n": "workspace:*",
|
||||
"@wrnexus/db": "workspace:*"
|
||||
"@wrnexus/db": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
interface BuildReport {
|
||||
frameworkVersion: string;
|
||||
generatedAt: string;
|
||||
adapter: string;
|
||||
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: Record<string, number>;
|
||||
budgetViolations: Array<{ metric: string; budget: number; actual: number; overBy: number }>;
|
||||
}
|
||||
|
||||
function bytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function runAnalyze(appRoot: string, args: string[]): boolean {
|
||||
const root = resolve(appRoot);
|
||||
const path = join(root, "dist", "build-report.json");
|
||||
if (!existsSync(path)) {
|
||||
console.error("WRN-BUILD-REPORT-MISSING: run `wrnexus build` before `wrnexus analyze`.");
|
||||
return false;
|
||||
}
|
||||
const report = JSON.parse(readFileSync(path, "utf8")) as BuildReport;
|
||||
if (args.includes("--json")) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
return report.budgetViolations.length === 0;
|
||||
}
|
||||
|
||||
console.log(`WRNexus build analysis (${report.frameworkVersion})`);
|
||||
console.log(` Generated: ${report.generatedAt}`);
|
||||
console.log(` Adapter: ${report.adapter}`);
|
||||
console.log(` Routes: ${report.routes.length}`);
|
||||
console.log("\nMeasurements:");
|
||||
for (const [metric, value] of Object.entries(report.measurements)) {
|
||||
console.log(` ${metric.padEnd(18)} ${bytes(value)}`);
|
||||
}
|
||||
console.log("\nLargest assets:");
|
||||
for (const asset of report.assets.slice(0, 12)) {
|
||||
console.log(` ${bytes(asset.bytes).padStart(10)} ${asset.file}`);
|
||||
}
|
||||
if (report.budgetViolations.length) {
|
||||
console.log("\nBudget violations:");
|
||||
for (const violation of report.budgetViolations) {
|
||||
console.log(
|
||||
` ✗ ${violation.metric}: ${bytes(violation.actual)} > ${bytes(violation.budget)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("\n ✓ No configured performance budget violations.");
|
||||
}
|
||||
return report.budgetViolations.length === 0;
|
||||
}
|
||||
+162
-18
@@ -11,11 +11,20 @@
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getReactiveRuntime } from "@wrnexus/csr";
|
||||
import { compileWireFile } from "@wrnexus/compiler";
|
||||
import { assertValidAst, generate, parse } from "@wrnexus/compiler";
|
||||
import {
|
||||
loadAppConfig,
|
||||
headToString,
|
||||
@@ -30,6 +39,8 @@ import { uiComponentsDir, uiCss } from "@wrnexus/ui";
|
||||
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
|
||||
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { checkPerformanceBudgets } from "@wrnexus/core";
|
||||
import { createPluginRunner } from "@wrnexus/plugin";
|
||||
|
||||
// Import the production server from the package specifier (not a source path) so
|
||||
// the generated entry resolves whether @wrnexus/dev-server is a workspace or an
|
||||
@@ -58,23 +69,47 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
console.log(`✓ Public: ${distPublicDir}`);
|
||||
}
|
||||
|
||||
// `.wrn` route files are compiled to `.ts` so Bun.build can bundle them.
|
||||
let compiledCount = 0;
|
||||
const importPathFor = (file: string): string => {
|
||||
if (!file.endsWith(".wrn")) {
|
||||
return fwd(file);
|
||||
}
|
||||
|
||||
const ts = compileWireFile(readFileSync(file, "utf8"), file);
|
||||
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
|
||||
writeFileSync(out, ts, "utf8");
|
||||
|
||||
return fwd(out);
|
||||
};
|
||||
|
||||
const config = await loadAppConfig(root);
|
||||
const pluginRunner = createPluginRunner(config.plugins, {
|
||||
root,
|
||||
mode: "production",
|
||||
command: "build",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
});
|
||||
await pluginRunner.configure(config as Record<string, unknown>);
|
||||
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
await pluginRunner.hook("buildStart");
|
||||
|
||||
// `.wrn` route files are compiled once into deterministic intermediate modules.
|
||||
// Plugin AST/code transforms run only when configured, so existing applications
|
||||
// keep the exact compiler path and output contract by default.
|
||||
let compiledCount = 0;
|
||||
const compiledFiles = new Map<string, string>();
|
||||
const compileWrn = async (file: string): Promise<void> => {
|
||||
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
||||
const source = readFileSync(file, "utf8");
|
||||
let ast = parse(source);
|
||||
assertValidAst(ast, { file, accessibility: true });
|
||||
ast = await pluginRunner.transformAst(ast, file);
|
||||
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
|
||||
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
|
||||
console.warn(`[${diagnostic.code}] ${file}: ${diagnostic.message}`);
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new Error(
|
||||
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);
|
||||
};
|
||||
const importPathFor = (file: string): string => fwd(compiledFiles.get(file) ?? file);
|
||||
// Regenerate typed DB queries (app/db/queries/*.sql → queries.gen.ts) first, so
|
||||
// any page/API importing them is built against the current SQL.
|
||||
const { regenerateQueries } = await import("./db.ts");
|
||||
@@ -105,6 +140,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
|
||||
const wrnFiles = new Set([
|
||||
...router.pages.map((route) => route.file),
|
||||
...router.api.map((route) => route.file),
|
||||
...router.realtime.map((route) => route.file),
|
||||
...router.components.map((component) => component.file),
|
||||
...router.layouts.map((layout) => layout.file),
|
||||
]);
|
||||
for (const file of wrnFiles) await compileWrn(file);
|
||||
const assetHash = createHash("sha256");
|
||||
|
||||
// 1) Components are `.wrn` modules rendered server-side — no browser chunks.
|
||||
@@ -271,6 +314,8 @@ await createProductionServer(
|
||||
mobile: ${JSON.stringify(config.mobile ?? {})},
|
||||
pwa: ${JSON.stringify(config.pwa ?? {})},
|
||||
security: ${JSON.stringify(config.security ?? {})},
|
||||
observability: ${JSON.stringify(config.observability ?? {})},
|
||||
tenancy: ${JSON.stringify(config.tenancy ?? {})},
|
||||
},
|
||||
);
|
||||
`;
|
||||
@@ -285,19 +330,118 @@ await createProductionServer(
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
minify: true,
|
||||
sourcemap: config.build?.sourceMaps ? "inline" : "none",
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("Server build failed:\n" + result.logs.map(String).join("\n"));
|
||||
}
|
||||
writeFileSync(join(distDir, "server.js"), await result.outputs[0]!.text(), "utf8");
|
||||
|
||||
const report = createBuildReport({
|
||||
root,
|
||||
distDir,
|
||||
publicDir: distPublicDir,
|
||||
adapter: config.build?.adapter ?? "bun",
|
||||
routes: router.pages,
|
||||
runtimeFile: reactivePath,
|
||||
cssFile: hasStyles ? join(distDir, "styles.css") : join(distDir, "framework.css"),
|
||||
});
|
||||
const violations = checkPerformanceBudgets(
|
||||
config.performance?.budgets ?? {},
|
||||
report.measurements,
|
||||
);
|
||||
report.budgetViolations = violations;
|
||||
writeFileSync(join(distDir, "build-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
|
||||
await pluginRunner.hook("buildEnd", report);
|
||||
|
||||
console.log(`✓ Server: ${join(distDir, "server.js")}`);
|
||||
console.log(
|
||||
`✓ Routes: ${router.pages.length} pages, ${router.api.length} api, ${router.realtime.length} realtime, ${mwVars.length} middleware`,
|
||||
);
|
||||
console.log(`✓ Report: ${join(distDir, "build-report.json")}`);
|
||||
if (violations.length) {
|
||||
for (const violation of violations) {
|
||||
console.warn(
|
||||
`⚠ Budget ${violation.metric}: ${violation.actual} > ${violation.budget} (+${violation.overBy})`,
|
||||
);
|
||||
}
|
||||
if (config.performance?.enforcement === "error") {
|
||||
throw new Error(
|
||||
`WRN-PERFORMANCE-BUDGET: ${violations.length} production budget(s) exceeded.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
|
||||
}
|
||||
|
||||
interface BuildReport {
|
||||
frameworkVersion: string;
|
||||
generatedAt: string;
|
||||
root: string;
|
||||
adapter: string;
|
||||
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number };
|
||||
budgetViolations: ReturnType<typeof checkPerformanceBudgets>;
|
||||
}
|
||||
|
||||
function fileBytes(file: string): number {
|
||||
return existsSync(file) && statSync(file).isFile() ? statSync(file).size : 0;
|
||||
}
|
||||
|
||||
function walkFiles(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) files.push(...walkFiles(path));
|
||||
else if (stat.isFile()) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function createBuildReport(input: {
|
||||
root: string;
|
||||
distDir: string;
|
||||
publicDir: string;
|
||||
adapter: string;
|
||||
routes: Route[];
|
||||
runtimeFile: string;
|
||||
cssFile: string;
|
||||
}): BuildReport {
|
||||
const assets = walkFiles(input.distDir)
|
||||
.filter((file) => !file.endsWith("build-report.json") && !fwd(file).includes("/compiled/"))
|
||||
.map((file) => ({ file: fwd(file.slice(input.distDir.length + 1)), bytes: fileBytes(file) }))
|
||||
.sort((a, b) => b.bytes - a.bytes);
|
||||
const imageExtensions = /\.(?:avif|gif|jpe?g|png|svg|webp)$/i;
|
||||
const imageBytes = Math.max(
|
||||
0,
|
||||
...walkFiles(input.publicDir)
|
||||
.filter((file) => imageExtensions.test(file))
|
||||
.map(fileBytes),
|
||||
);
|
||||
return {
|
||||
frameworkVersion: "0.3.0",
|
||||
generatedAt: new Date().toISOString(),
|
||||
root: input.root,
|
||||
adapter: input.adapter,
|
||||
routes: input.routes.map((route) => ({
|
||||
path: route.raw,
|
||||
source: fwd(route.file.replace(input.root, "").replace(/^\//, "")),
|
||||
sourceBytes: fileBytes(route.file),
|
||||
dynamicParams: route.paramNames,
|
||||
})),
|
||||
assets,
|
||||
measurements: {
|
||||
routeJsBytes: fileBytes(input.runtimeFile),
|
||||
routeCssBytes: fileBytes(input.cssFile),
|
||||
imageBytes,
|
||||
},
|
||||
budgetViolations: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function buildBrowserRuntime(
|
||||
source: string,
|
||||
outFile: string,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { resolve } from "node:path";
|
||||
import { explainAppConfig } from "@wrnexus/styles";
|
||||
|
||||
function jsonReplacer(_key: string, value: unknown): unknown {
|
||||
if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function runConfigCommand(appRoot: string, args: string[]): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const profile = args.find((arg) => arg.startsWith("--profile="))?.split("=")[1];
|
||||
const explained = await explainAppConfig(root, profile);
|
||||
if (args.includes("--json")) {
|
||||
console.log(JSON.stringify(explained, jsonReplacer, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`WRNexus resolved configuration\n`);
|
||||
console.log(` Root: ${root}`);
|
||||
console.log(` Profile: ${explained.profile}`);
|
||||
console.log(` Sources: ${explained.sources.join(", ") || "framework defaults"}`);
|
||||
if (explained.issues.length) {
|
||||
console.log("\nIssues:");
|
||||
for (const issue of explained.issues) {
|
||||
console.log(` ${issue.severity === "error" ? "✗" : "⚠"} ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
}
|
||||
console.log("\nResolved value:\n");
|
||||
console.log(JSON.stringify(explained.config, jsonReplacer, 2));
|
||||
}
|
||||
+147
-14
@@ -1,26 +1,99 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { diagnose } from "@wrnexus/syntax";
|
||||
import { buildRouter, findRouteConflicts } from "@wrnexus/router";
|
||||
import { loadAppConfig, validateAppConfig } from "@wrnexus/styles";
|
||||
|
||||
export interface DoctorCheck {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
level?: "error" | "warning";
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
function versionAtLeast(value: string, minimum: string): boolean {
|
||||
const left = parseVersion(value);
|
||||
const right = parseVersion(minimum);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (left[i] !== right[i]) return left[i]! > right[i]!;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function walk(dir: string, extension: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (["node_modules", "dist", ".git", ".wrnexus"].includes(entry)) continue;
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) files.push(...walk(path, extension));
|
||||
else if (stat.isFile() && extname(path) === extension) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function frameworkRanges(pkg: Record<string, unknown>): Map<string, string[]> {
|
||||
const ranges = new Map<string, string[]>();
|
||||
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const deps = pkg[field] as Record<string, string> | undefined;
|
||||
for (const [name, range] of Object.entries(deps ?? {})) {
|
||||
if (!name.startsWith("@wrnexus/")) continue;
|
||||
const values = ranges.get(range) ?? [];
|
||||
values.push(name);
|
||||
ranges.set(range, values);
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
const root = resolve(appRoot);
|
||||
const checks: DoctorCheck[] = [];
|
||||
const pkgPath = join(root, "package.json");
|
||||
const bunVersion = typeof Bun !== "undefined" ? String(Bun.version) : "";
|
||||
checks.push({
|
||||
name: "Bun runtime",
|
||||
ok: typeof Bun !== "undefined",
|
||||
detail: typeof Bun !== "undefined" ? `v${Bun.version}` : "Bun is required",
|
||||
ok: !!bunVersion && versionAtLeast(bunVersion, "1.3.0"),
|
||||
detail: bunVersion ? `v${bunVersion} (minimum 1.3.0)` : "Bun 1.3.0 or newer is required",
|
||||
});
|
||||
checks.push({
|
||||
name: "package.json",
|
||||
ok: existsSync(pkgPath),
|
||||
detail: existsSync(pkgPath) ? pkgPath : "Run this command from a WrNexus project root",
|
||||
detail: existsSync(pkgPath) ? pkgPath : "Run this command from a WRNexus project root",
|
||||
});
|
||||
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
||||
const ranges = frameworkRanges(pkg);
|
||||
checks.push({
|
||||
name: "framework package versions",
|
||||
ok: ranges.size <= 1,
|
||||
detail:
|
||||
ranges.size <= 1
|
||||
? ([...ranges.keys()][0] ?? "No @wrnexus packages declared")
|
||||
: `version skew: ${[...ranges.entries()]
|
||||
.map(([range, names]) => `${range} (${names.join(", ")})`)
|
||||
.join("; ")}`,
|
||||
});
|
||||
const marker = (pkg.wrnexus as { version?: string } | undefined)?.version;
|
||||
checks.push({
|
||||
name: "update marker",
|
||||
ok: !marker || versionAtLeast(marker, "0.3.0"),
|
||||
detail: marker ? `project last migrated to ${marker}` : "missing; run `wrnexus update`",
|
||||
level: "warning",
|
||||
});
|
||||
} catch {
|
||||
checks.push({ name: "package JSON", ok: false, detail: "package.json is invalid JSON" });
|
||||
}
|
||||
}
|
||||
|
||||
const app = join(root, "app");
|
||||
checks.push({
|
||||
name: "app/pages",
|
||||
@@ -34,13 +107,51 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
name: "configuration",
|
||||
ok: !!config,
|
||||
detail: config ?? "No wrnexus.config file; framework defaults will be used",
|
||||
level: "warning",
|
||||
});
|
||||
|
||||
const wrnFiles = walk(app, ".wrn");
|
||||
let syntaxErrors = 0;
|
||||
let syntaxWarnings = 0;
|
||||
for (const file of wrnFiles) {
|
||||
const diagnostics = diagnose(readFileSync(file, "utf8"), { file, accessibility: true });
|
||||
syntaxErrors += diagnostics.filter((item) => item.severity === "error").length;
|
||||
syntaxWarnings += diagnostics.filter((item) => item.severity !== "error").length;
|
||||
}
|
||||
checks.push({
|
||||
name: "WRN language",
|
||||
ok: syntaxErrors === 0,
|
||||
detail: `${wrnFiles.length} files, ${syntaxErrors} errors, ${syntaxWarnings} warnings`,
|
||||
});
|
||||
|
||||
if (existsSync(join(app, "pages"))) {
|
||||
try {
|
||||
const router = buildRouter(app);
|
||||
const conflicts = [
|
||||
...findRouteConflicts(router.pages),
|
||||
...findRouteConflicts(router.api),
|
||||
...findRouteConflicts(router.realtime),
|
||||
];
|
||||
checks.push({
|
||||
name: "route manifest",
|
||||
ok: conflicts.length === 0,
|
||||
detail: conflicts.length
|
||||
? conflicts.map((conflict) => conflict.raw).join(", ")
|
||||
: `${router.pages.length} pages, ${router.api.length} API, ${router.realtime.length} realtime`,
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: "route manifest",
|
||||
ok: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mobilePkg = join(root, "mobile", "package.json");
|
||||
if (existsSync(mobilePkg)) {
|
||||
try {
|
||||
const mobile = JSON.parse(readFileSync(mobilePkg, "utf8")) as {
|
||||
wrnexus?: { mode?: string };
|
||||
};
|
||||
const mobile = JSON.parse(readFileSync(mobilePkg, "utf8")) as { wrnexus?: { mode?: string } };
|
||||
checks.push({
|
||||
name: "mobile project",
|
||||
ok: mobile.wrnexus?.mode === "webview" || mobile.wrnexus?.mode === "native",
|
||||
@@ -57,12 +168,34 @@ export function inspectProject(appRoot: string): DoctorCheck[] {
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function runDoctor(appRoot: string): boolean {
|
||||
const checks = inspectProject(appRoot);
|
||||
console.log("WrNexus doctor\n");
|
||||
for (const check of checks)
|
||||
console.log(` ${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`);
|
||||
export async function runDoctor(appRoot: string): Promise<boolean> {
|
||||
const root = resolve(appRoot);
|
||||
const checks = inspectProject(root);
|
||||
try {
|
||||
const config = await loadAppConfig(root);
|
||||
const issues = validateAppConfig(config);
|
||||
checks.push({
|
||||
name: "resolved configuration",
|
||||
ok: issues.every((issue) => issue.severity !== "error"),
|
||||
detail: issues.length
|
||||
? issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")
|
||||
: "valid",
|
||||
level: issues.some((issue) => issue.severity === "error") ? "error" : "warning",
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
name: "resolved configuration",
|
||||
ok: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
console.log("WRNexus doctor\n");
|
||||
for (const check of checks) {
|
||||
const optional = check.level === "warning";
|
||||
console.log(` ${check.ok ? "✓" : optional ? "⚠" : "✗"} ${check.name}: ${check.detail}`);
|
||||
}
|
||||
console.log("\n Security dependencies: run `bun audit`");
|
||||
console.log(" Complete verification: run `bun run check`");
|
||||
return checks.every((check) => check.ok || check.name === "configuration");
|
||||
return checks.every((check) => check.ok || check.level === "warning");
|
||||
}
|
||||
|
||||
@@ -60,7 +60,9 @@ Usage:
|
||||
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 profiles [app-dir] List config profiles (dev/prod/uat/…) and their env files
|
||||
wrnexus doctor [app-dir] Check project structure, runtime, mobile config, and next fixes
|
||||
wrnexus doctor [app-dir] Check project structure, versions, syntax, routes, and config
|
||||
wrnexus config [app-dir] --explain Print the fully resolved profile configuration
|
||||
wrnexus analyze [app-dir] Inspect dist/build-report.json and performance budgets
|
||||
|
||||
Profiles: pass --profile=<name> to dev/build/db (or set WRNEXUS_PROFILE) to load
|
||||
that profile's .env cascade and config overrides. e.g. wrnexus dev --profile=uat
|
||||
@@ -181,7 +183,18 @@ async function main(): Promise<void> {
|
||||
}
|
||||
case "doctor": {
|
||||
const { runDoctor } = await import("./doctor.ts");
|
||||
const healthy = runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||
const healthy = await runDoctor(rest.find((a) => !a.startsWith("--")) ?? ".");
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
case "config": {
|
||||
const { runConfigCommand } = await import("./config-command.ts");
|
||||
await runConfigCommand(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
|
||||
break;
|
||||
}
|
||||
case "analyze": {
|
||||
const { runAnalyze } = await import("./analyze.ts");
|
||||
const healthy = runAnalyze(rest.find((a) => !a.startsWith("--")) ?? ".", rest);
|
||||
if (!healthy) process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
+191
-21
@@ -12,12 +12,20 @@
|
||||
* the running CLI's own version (the default — pair with `bunx @wrnexus/cli@latest
|
||||
* update` to jump to the newest release with no network guesswork).
|
||||
*
|
||||
* Migrations are CONSERVATIVE: they only add/refresh framework-owned things and
|
||||
* never clobber your own code or edited CLAUDE.md. Add new ones to `MIGRATIONS`
|
||||
* Migrations are CONSERVATIVE: source rewrites are syntax-aware, idempotent, and
|
||||
* protected by a complete pre-update backup. User-owned files are never replaced wholesale. Add new ones to `MIGRATIONS`
|
||||
* as the framework evolves — that is how "new things" reach existing apps.
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -86,9 +94,95 @@ const LEGACY_VSCODE_EXTENSIONS =
|
||||
2,
|
||||
) + "\n";
|
||||
|
||||
function walkProjectFiles(dir: string, extension: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (["node_modules", "dist", ".git", ".wrnexus"].includes(entry)) continue;
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) out.push(...walkProjectFiles(path, extension));
|
||||
else if (stat.isFile() && path.endsWith(extension)) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatInlineProps(source: string): string {
|
||||
return source.replace(
|
||||
/(^[ \t]*)props\s*\{([^{}\n]*)\}/gm,
|
||||
(whole, indent: string, body: string) => {
|
||||
const declaration =
|
||||
/([A-Za-z_$][\w$]*)(\s*:\s*[^=]+?)?\s*=\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\[\]|\{\}|true|false|null|undefined|-?\d+(?:\.\d+)?)/gy;
|
||||
const values: string[] = [];
|
||||
let offset = 0;
|
||||
while (offset < body.length) {
|
||||
while (/\s/.test(body[offset] ?? "")) offset++;
|
||||
if (offset >= body.length) break;
|
||||
declaration.lastIndex = offset;
|
||||
const match = declaration.exec(body);
|
||||
if (!match || match.index !== offset) return whole;
|
||||
values.push(`${match[1]}${match[2] ?? ""} = ${match[3]}`);
|
||||
offset = declaration.lastIndex;
|
||||
}
|
||||
if (values.length < 2) return whole;
|
||||
return `${indent}props {\n${values.map((value) => `${indent} ${value}`).join("\n")}\n${indent}}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function quoteLegacyDynamicAttributes(source: string): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
const start = source.indexOf("<", index);
|
||||
if (start < 0) return output + source.slice(index);
|
||||
output += source.slice(index, start);
|
||||
if (source.startsWith("<!--", start)) {
|
||||
const end = source.indexOf("-->", start + 4);
|
||||
if (end < 0) return output + source.slice(start);
|
||||
output += source.slice(start, end + 3);
|
||||
index = end + 3;
|
||||
continue;
|
||||
}
|
||||
let end = start + 1;
|
||||
let quote = "";
|
||||
let braceDepth = 0;
|
||||
for (; end < source.length; end++) {
|
||||
const char = source[end]!;
|
||||
if (quote) {
|
||||
if (char === "\\") end++;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") quote = char;
|
||||
else if (char === "{") braceDepth++;
|
||||
else if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
|
||||
else if (char === ">" && braceDepth === 0) break;
|
||||
}
|
||||
if (end >= source.length) return output + source.slice(start);
|
||||
let tag = source.slice(start, end + 1);
|
||||
if (!/^<\/?[A-Za-z]/.test(tag) || /^<\//.test(tag)) {
|
||||
output += tag;
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
tag = tag.replace(
|
||||
/(\s[@:#A-Za-z_$][\w$:.-]*)\s*=\s*\{([^{}']+)\}/g,
|
||||
(_all, name: string, expr: string) => `${name}='{${expr.trim()}}'`,
|
||||
);
|
||||
output += tag;
|
||||
index = end + 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function migrateWrnSource(source: string): string {
|
||||
return formatInlineProps(quoteLegacyDynamicAttributes(source));
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run and MUST NOT
|
||||
* overwrite user code. Append new entries with the version that ships them.
|
||||
* Versioned, idempotent upgrade steps. Each MUST be safe to re-run. Source
|
||||
* migrations must preserve semantics and are protected by update backups.
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{
|
||||
@@ -359,7 +453,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.32",
|
||||
id: "fix-runtime-bug",
|
||||
id: "fix-runtime-bug-0-2-32",
|
||||
description:
|
||||
"Fixes safe encoding and browser parsing of multiline component behavior metadata.",
|
||||
apply() {
|
||||
@@ -368,7 +462,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.33",
|
||||
id: "fix-runtime-bug",
|
||||
id: "fix-runtime-bug-0-2-33",
|
||||
description:
|
||||
"Fixes safe encoding and browser parsing of multiline component behavior metadata.",
|
||||
apply() {
|
||||
@@ -431,7 +525,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.40",
|
||||
id: "conditions-bug",
|
||||
id: "conditions-bug-0-2-40",
|
||||
description:
|
||||
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
|
||||
apply() {
|
||||
@@ -440,7 +534,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.41",
|
||||
id: "conditions-bug",
|
||||
id: "conditions-bug-0-2-41",
|
||||
description:
|
||||
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
|
||||
apply() {
|
||||
@@ -449,7 +543,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.42",
|
||||
id: "conditions-bug",
|
||||
id: "conditions-bug-0-2-42",
|
||||
description:
|
||||
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
|
||||
apply() {
|
||||
@@ -458,7 +552,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.43",
|
||||
id: "conditions-bug",
|
||||
id: "conditions-bug-0-2-43",
|
||||
description:
|
||||
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
|
||||
apply() {
|
||||
@@ -467,7 +561,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.44",
|
||||
id: "conditions-bug",
|
||||
id: "conditions-bug-0-2-44",
|
||||
description:
|
||||
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
|
||||
apply() {
|
||||
@@ -476,7 +570,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.45",
|
||||
id: "conditions-bug",
|
||||
id: "conditions-bug-0-2-45",
|
||||
description:
|
||||
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
|
||||
apply() {
|
||||
@@ -485,7 +579,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.46",
|
||||
id: "conditions-bug",
|
||||
id: "conditions-bug-0-2-46",
|
||||
description:
|
||||
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
|
||||
apply() {
|
||||
@@ -494,7 +588,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.47",
|
||||
id: "conditions-bug",
|
||||
id: "conditions-bug-0-2-47",
|
||||
description:
|
||||
"Adds server-rendered component each/if blocks and fixes dynamic component rendering.",
|
||||
apply() {
|
||||
@@ -503,7 +597,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.48",
|
||||
id: "components-update",
|
||||
id: "components-update-0-2-48",
|
||||
description: "Adds reactive hydration support for server-rendered each loop locals.",
|
||||
apply() {
|
||||
// No generated application files require automatic migration.
|
||||
@@ -511,7 +605,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.49",
|
||||
id: "components-update",
|
||||
id: "components-update-0-2-49",
|
||||
description: "Adds reactive hydration support for server-rendered each loop locals.",
|
||||
apply() {
|
||||
// No generated application files require automatic migration.
|
||||
@@ -519,7 +613,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.50",
|
||||
id: "components-update",
|
||||
id: "components-update-0-2-50",
|
||||
description: "Adds reactive hydration support for server-rendered each loop locals.",
|
||||
apply() {
|
||||
// No generated application files require automatic migration.
|
||||
@@ -769,7 +863,7 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.78",
|
||||
id: "ui-components-fix",
|
||||
id: "ui-components-fix-2",
|
||||
description: "Dev toolbar for optimization and developer.",
|
||||
apply() {
|
||||
// Language and shared UI behavior improve automatically after updating.
|
||||
@@ -777,12 +871,87 @@ const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
{
|
||||
version: "0.2.79",
|
||||
id: "ui-components-fix",
|
||||
id: "ui-components-fix-3",
|
||||
description: "Dev toolbar for optimization and developer.",
|
||||
apply() {
|
||||
// Language and shared UI behavior improve automatically after updating.
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
id: "language-foundation-and-compatible-platform-upgrade",
|
||||
description:
|
||||
"Adds the shared syntax/AST package, stable diagnostics, plugins, partial hydration, advanced routing, build analysis, tracing, typed data APIs, and safe WRN source normalization.",
|
||||
apply(ctx) {
|
||||
const runnable = existsSync(join(ctx.appRoot, "app", "pages"));
|
||||
const packageFile = join(ctx.appRoot, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(packageFile, "utf8")) as Record<string, any>;
|
||||
const scripts = (pkg.scripts ??= {});
|
||||
const dependencies = (pkg.dependencies ??= {});
|
||||
const addedScripts: string[] = [];
|
||||
const addedDependencies: string[] = [];
|
||||
if (runnable) {
|
||||
for (const [name, command] of Object.entries({
|
||||
doctor: "wrnexus doctor .",
|
||||
"config:explain": "wrnexus config . --explain",
|
||||
analyze: "wrnexus analyze .",
|
||||
"update:preview": "wrnexus update . --dry-run",
|
||||
})) {
|
||||
if (!scripts[name]) {
|
||||
scripts[name] = command;
|
||||
addedScripts.push(name);
|
||||
}
|
||||
}
|
||||
for (const name of ["@wrnexus/syntax", "@wrnexus/plugin"]) {
|
||||
if (!dependencies[name]) {
|
||||
dependencies[name] = `^${ctx.to}`;
|
||||
addedDependencies.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addedScripts.length) ctx.log(`+ package scripts: ${addedScripts.join(", ")}`);
|
||||
if (addedDependencies.length) ctx.log(`+ dependencies: ${addedDependencies.join(", ")}`);
|
||||
if (!ctx.dryRun && (addedScripts.length || addedDependencies.length)) {
|
||||
writeFileSync(packageFile, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
const changedFiles: string[] = [];
|
||||
for (const file of walkProjectFiles(join(ctx.appRoot, "app"), ".wrn")) {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = migrateWrnSource(before);
|
||||
if (after === before) continue;
|
||||
changedFiles.push(file.slice(ctx.appRoot.length + 1).replace(/\\/g, "/"));
|
||||
ctx.log(`~ normalized ${changedFiles[changedFiles.length - 1]}`);
|
||||
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
|
||||
}
|
||||
|
||||
const reportFile = join(ctx.appRoot, ".wrnexus", "migrations", "0.3.0.json");
|
||||
ctx.log(`+ .wrnexus/migrations/0.3.0.json (${changedFiles.length} source files normalized)`);
|
||||
if (!ctx.dryRun) {
|
||||
mkdirSync(dirname(reportFile), { recursive: true });
|
||||
writeFileSync(
|
||||
reportFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: "0.3.0",
|
||||
from: ctx.from,
|
||||
appliedAt: new Date().toISOString(),
|
||||
changedFiles,
|
||||
compatibility: {
|
||||
legacyDataFor: true,
|
||||
legacyEachBlocks: true,
|
||||
legacyComponentMounts: true,
|
||||
quotedDynamicAttributes: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
@@ -862,12 +1031,13 @@ function backupProjectFiles(appRoot: string, from: string, target: string): stri
|
||||
".vscode/extensions.json",
|
||||
"CLAUDE.md",
|
||||
"public/llms.txt",
|
||||
"app",
|
||||
]) {
|
||||
const source = join(appRoot, name);
|
||||
if (existsSync(source)) {
|
||||
const destination = join(backup, name);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
cpSync(source, destination);
|
||||
cpSync(source, destination, { recursive: statSync(source).isDirectory() });
|
||||
}
|
||||
}
|
||||
return backup;
|
||||
|
||||
@@ -168,3 +168,23 @@ test("update verification formats before checking and building", () => {
|
||||
test("update verification skips unavailable scripts", () => {
|
||||
expect(verificationCommands({ check: "bun run lint" })).toEqual([["run", "check"]]);
|
||||
});
|
||||
|
||||
test("0.3 WRN source normalization is conservative and idempotent", async () => {
|
||||
const { migrateWrnSource } = await import("../src/update.ts");
|
||||
const source = `component Card {
|
||||
props { eyebrow = "" title = "" description = "" align = "start" class = "" }
|
||||
view {
|
||||
<FeatureList items={items} emptyLabel="No items" />
|
||||
<button @click={save()} data-config={{ nested: true }}>Save</button>
|
||||
}
|
||||
}`;
|
||||
|
||||
const migrated = migrateWrnSource(source);
|
||||
expect(migrated).toContain("props {\n");
|
||||
expect(migrated).toContain(' eyebrow = ""');
|
||||
expect(migrated).toContain("items='{items}'");
|
||||
expect(migrated).toContain("@click='{save()}'");
|
||||
// Nested brace expressions are deliberately left untouched for manual review.
|
||||
expect(migrated).toContain("data-config={{ nested: true }}");
|
||||
expect(migrateWrnSource(migrated)).toBe(migrated);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user