import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { extname, join, resolve } from "node:path"; import { diagnose, formatWrn } from "@wrnexus/syntax"; import { buildRouter, findRouteConflicts } from "@wrnexus/router"; import { loadAppConfig, validateAppConfig } from "@wrnexus/styles"; import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin"; export interface DoctorCheck { name: string; ok: boolean; detail: string; 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]; } 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): Map { const ranges = new Map(); for (const field of ["dependencies", "devDependencies", "peerDependencies"]) { const deps = pkg[field] as Record | 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 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; 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 | 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 | 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[] = []; const pkgPath = join(root, "package.json"); const bunVersion = typeof Bun !== "undefined" ? String(Bun.version) : ""; checks.push({ name: "Bun runtime", 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", }); if (existsSync(pkgPath)) { try { const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record; 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.4.0"), detail: marker && versionAtLeast(marker, "0.4.0") ? `project last migrated to ${marker}` : marker ? `project is on ${marker}; run \`wrnexus update 0.4.0\`` : "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", ok: existsSync(join(app, "pages")), detail: existsSync(join(app, "pages")) ? "page directory found" : "Create app/pages", }); const config = ["wrnexus.config.ts", "wrnexus.config.mjs", "wrnexus.config.js"].find((name) => existsSync(join(root, name)), ); checks.push({ 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 } }; checks.push({ name: "mobile project", ok: mobile.wrnexus?.mode === "webview" || mobile.wrnexus?.mode === "native", detail: `mode: ${mobile.wrnexus?.mode ?? "missing"}`, }); } catch { checks.push({ name: "mobile project", ok: false, detail: "mobile/package.json is invalid JSON", }); } } return checks; } export async function runDoctor( appRoot: string, options: { fix?: boolean } = {}, ): Promise { const root = resolve(appRoot); const repairs = options.fix ? repairProject(root) : []; 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", }); const discovered = await discoverPlugins(root, config.plugins, { includeDevDependencies: true, strict: true, warn: (message) => checks.push({ name: "package plugin discovery", ok: false, detail: message }), }); const runner = createPluginRunner(discovered, { root, mode: "development", command: "dev", metadata: new Map(), warn: () => {}, }); await runner.configure(config as Record); await runner.configResolved(config as Readonly>); const contributions = await runner.contributions(); checks.push({ name: "package plugins", ok: true, detail: `${runner.plugins.length} plugins, ${contributions.clientRuntimes.length} runtimes, ${contributions.assets.length} assets, ${contributions.routes.length} routes, ${contributions.migrations.length} migrations`, }); const missingContributions = [ ...contributions.componentDirs.map((path) => ({ kind: "component directory", path })), ...contributions.clientRuntimes .filter((runtime) => runtime.entry) .map((runtime) => ({ kind: `runtime ${runtime.id}`, path: runtime.entry! })), ...contributions.assets .filter((asset) => asset.entry) .map((asset) => ({ kind: `asset ${asset.id}`, path: asset.entry! })), ...contributions.routes.map((route) => ({ kind: `${route.kind} route ${route.path}`, path: route.entry, })), ...contributions.middleware.map((path) => ({ kind: "middleware", path })), ...contributions.migrations .filter((migration) => migration.entry) .map((migration) => ({ kind: `migration ${migration.id}`, path: migration.entry! })), ].filter((entry) => !existsSync(entry.path)); checks.push({ name: "package contribution files", ok: missingContributions.length === 0, detail: missingContributions.length ? missingContributions.map((entry) => `${entry.kind}: ${entry.path}`).join("; ") : "all discovered contribution files exist", }); const legacyCaptchaAssets = [ "public/assets/wrnexus/captcha.js", "public/__wrnexus/captcha.js", ].filter((path) => existsSync(join(root, path))); checks.push({ name: "legacy CAPTCHA runtime copies", ok: legacyCaptchaAssets.length === 0, detail: legacyCaptchaAssets.length ? `remove with wrnexus update 0.4.0: ${legacyCaptchaAssets.join(", ")}` : "none; CAPTCHA runtime is package-managed", level: "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 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}`); } console.log("\n Security dependencies: run `bun audit`"); console.log(" Complete verification: run `bun run check`"); return checks.every((check) => check.ok || check.level === "warning"); }