355 lines
13 KiB
TypeScript
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");
|
|
}
|