Files
WRNexusJS/packages/cli/src/doctor.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

355 lines
13 KiB
TypeScript

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<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 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[] = [];
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<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.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<boolean> {
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<string, unknown>);
await runner.configResolved(config as Readonly<Record<string, unknown>>);
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");
}