mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* fix(doctor): resolve two false-positive WARNs (#6162) The `omniroute doctor` command reported two warnings on healthy installs even though the underlying checks actually passed. Both came from the doctor probing state that already worked; they looked like bugs but users couldn't tell without manual digging. Issue 1 — Server liveness HTTP 401 /api/health and /api/health/degradation both require the management token. Doctor called them without auth → 401 → WARN, even when the Next.js server was clearly alive and listening. Fix: probe the configured health endpoint first; on 401/403, fall back to a publicly served static asset (/favicon.ico) to confirm the server is alive. WARN now only fires when both probes fail. Issue 2 — CLI Tools '@/shared' import tool-detector.ts (and 3 other cli-helper files) import @/shared/... aliases that resolve via tsconfig.json paths. The CLI ships raw TS source (no compile step) and runs through tsx, but tsx does not honor tsconfig paths at runtime, and tsconfig-paths only hooks CJS Module._resolveFilename while doctor uses ESM `import()`. Fix: replace @/shared/... with relative imports in the 4 cli-helper files. This is the same pattern these files already use for ./config- generator/* imports. No new dependency, no architectural change, and the fix doesn't regress Next.js itself which keeps using @/shared. Verified on v3.8.43 (Node v24.17, Windows 11): Before: 7 ok, 2 warning(s), 0 failure(s) After: 8 ok, N warning(s), 0 failure(s) where N accurately reflects which CLI tools are installed and configured for OmniRoute (e.g. Hermes Agent installed but not pointed at 20128 → 2 real warnings, not 1 false-positive). Refs #6162 * fix(doctor): derive fallback URL from primary URL via new URL() Per Gemini code-assist review feedback: the previous fallback constructed the /favicon.ico URL from defaults (127.0.0.1:PORT) which ignored custom host/port/protocol configurations supplied via: - OMNIROUTE_DOCTOR_LIVENESS_URL - OMNIROUTE_DOCTOR_HOST - --liveness-url / --host CLI flags Parse the primary URL with new URL() to preserve protocol, host, port, and subpaths. The previous default-based fallback remains as a catch-all for invalid primary URLs. * test(doctor): add regression tests for #6162 fixes Two new test files lock the fix and satisfy the PR Test Policy gate ("production code change without tests"): - tests/unit/cli-helper-tool-detector-paths-6162.test.ts Locks the @/shared → relative imports fix across all 4 cli-helper files. Asserts (a) no @/shared alias remains in the cli-helper sources, and (b) each file is importable at runtime via tsx/ESM, which would have thrown "Cannot find package '@/shared'" before the fix. - tests/unit/cli-doctor-liveness-fallback-6162.test.ts Locks the /favicon.ico fallback in doctor.mjs. Asserts the fallback probe exists, derives its URL from the primary URL via new URL() (per Gemini review feedback), and that the buggy 'Server responded with HTTP 401' WARN path is gone. Both tests use only node:test + node:assert/strict so they slot into the existing 'test' and 'test:unit' scripts with no extra config. * test(doctor): fix primary.ok regex in fallback test The earlier regex /primary\.ok\s*\?/ required a '?' immediately after, but the actual doctor.mjs code uses a multi-line if-block: if (primary.ok) { return ok(...); } Use /\bprimary\.ok\b/ instead so the assertion matches the existing branching. --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
528 lines
18 KiB
JavaScript
528 lines
18 KiB
JavaScript
import fs from "node:fs";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createDecipheriv, scryptSync } from "node:crypto";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
|
import { printHeading } from "../io.mjs";
|
|
import { t } from "../i18n.mjs";
|
|
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
|
|
|
const STATIC_SALT = "omniroute-field-encryption-v1";
|
|
const KEY_LENGTH = 32;
|
|
const CHECK_TIMEOUT_MS = 2000;
|
|
|
|
function ok(name, message, details = {}) {
|
|
return { name, status: "ok", message, details };
|
|
}
|
|
|
|
function warn(name, message, details = {}) {
|
|
return { name, status: "warn", message, details };
|
|
}
|
|
|
|
function fail(name, message, details = {}) {
|
|
return { name, status: "fail", message, details };
|
|
}
|
|
|
|
function parsePort(value, fallback) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
|
|
}
|
|
|
|
function parseConfiguredPort(value) {
|
|
if (value === undefined || value === null || value === "") return { valid: true, port: null };
|
|
const parsed = Number.parseInt(String(value), 10);
|
|
return {
|
|
valid: Number.isFinite(parsed) && parsed > 0 && parsed <= 65535,
|
|
port: parsed,
|
|
};
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
const gb = bytes / 1024 / 1024 / 1024;
|
|
return `${gb.toFixed(1)} GB`;
|
|
}
|
|
|
|
function findEnvFileCandidates(dataDir) {
|
|
const candidates = [];
|
|
if (process.env.DATA_DIR) candidates.push(path.join(process.env.DATA_DIR, ".env"));
|
|
candidates.push(path.join(dataDir, ".env"));
|
|
candidates.push(path.join(process.cwd(), ".env"));
|
|
return [...new Set(candidates)];
|
|
}
|
|
|
|
function checkConfig(dataDir) {
|
|
const envCandidates = findEnvFileCandidates(dataDir);
|
|
const envFile = envCandidates.find((candidate) => fs.existsSync(candidate));
|
|
const portChecks = [
|
|
["PORT", process.env.PORT],
|
|
["API_PORT", process.env.API_PORT],
|
|
["DASHBOARD_PORT", process.env.DASHBOARD_PORT],
|
|
].map(([name, value]) => ({ name, value, ...parseConfiguredPort(value) }));
|
|
const invalidPorts = portChecks.filter((item) => !item.valid);
|
|
|
|
if (invalidPorts.length > 0) {
|
|
return fail(
|
|
"Config",
|
|
`Invalid port setting: ${invalidPorts.map((item) => item.name).join(", ")}`,
|
|
{ envFile: envFile || null, invalidPorts }
|
|
);
|
|
}
|
|
|
|
if (!envFile) {
|
|
return warn("Config", ".env file not found; using defaults and process environment", {
|
|
checked: envCandidates,
|
|
});
|
|
}
|
|
|
|
return ok("Config", `.env found at ${envFile}`, { envFile });
|
|
}
|
|
|
|
function resolveMigrationsDir(rootDir) {
|
|
const configured = process.env.OMNIROUTE_MIGRATIONS_DIR;
|
|
const candidates = [
|
|
configured,
|
|
path.join(rootDir, "src", "lib", "db", "migrations"),
|
|
path.join(rootDir, "app", "src", "lib", "db", "migrations"),
|
|
path.join(process.cwd(), "src", "lib", "db", "migrations"),
|
|
].filter(Boolean);
|
|
|
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
}
|
|
|
|
function readMigrationFiles(migrationsDir) {
|
|
if (!migrationsDir) return [];
|
|
return fs
|
|
.readdirSync(migrationsDir)
|
|
.filter((file) => /^\d+_.+\.sql$/.test(file))
|
|
.sort()
|
|
.map((file) => {
|
|
const [, version, name] = file.match(/^(\d+)_(.+)\.sql$/) || [];
|
|
return { version, name, file };
|
|
});
|
|
}
|
|
|
|
async function checkDatabase(dbPath, rootDir) {
|
|
if (!fs.existsSync(dbPath)) {
|
|
return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath });
|
|
}
|
|
|
|
try {
|
|
const { quickCheckValue, hasMigrationTable, appliedMigrationVersions } =
|
|
await readDatabaseHealth(dbPath);
|
|
if (quickCheckValue !== "ok") {
|
|
return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath });
|
|
}
|
|
|
|
const migrationsDir = resolveMigrationsDir(rootDir);
|
|
const migrationFiles = readMigrationFiles(migrationsDir);
|
|
if (migrationFiles.length === 0) {
|
|
return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" });
|
|
}
|
|
|
|
if (!hasMigrationTable) {
|
|
return warn("Database", "SQLite is readable, but migration table is missing", { dbPath });
|
|
}
|
|
|
|
const applied = new Set(appliedMigrationVersions);
|
|
const pending = migrationFiles.filter((migration) => !applied.has(migration.version));
|
|
|
|
if (pending.length > 0) {
|
|
return warn("Database", `${pending.length} migration(s) appear pending`, {
|
|
dbPath,
|
|
pending: pending.map((migration) => migration.file),
|
|
});
|
|
}
|
|
|
|
return ok("Database", "SQLite quick_check passed and migrations look current", { dbPath });
|
|
} catch (error) {
|
|
return fail("Database", "SQLite database could not be read", {
|
|
dbPath,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
function deriveStorageKey() {
|
|
const secret = process.env.STORAGE_ENCRYPTION_KEY;
|
|
if (!secret) return null;
|
|
return scryptSync(secret, STATIC_SALT, KEY_LENGTH);
|
|
}
|
|
|
|
function decryptCredentialSample(value, key) {
|
|
const prefix = "enc:v1:";
|
|
const body = value.slice(prefix.length);
|
|
const [ivHex, encryptedHex, authTagHex] = body.split(":");
|
|
if (!ivHex || !encryptedHex || !authTagHex) throw new Error("Malformed encrypted value");
|
|
|
|
const authTagBuf = Buffer.from(authTagHex, "hex");
|
|
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"), {
|
|
authTagLength: authTagBuf.length,
|
|
});
|
|
decipher.setAuthTag(authTagBuf);
|
|
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
|
decrypted += decipher.final("utf8");
|
|
return decrypted;
|
|
}
|
|
|
|
async function checkStorageEncryption(dbPath) {
|
|
const secret = process.env.STORAGE_ENCRYPTION_KEY;
|
|
if (secret !== undefined && String(secret).trim() === "") {
|
|
return fail("Storage/encryption", "STORAGE_ENCRYPTION_KEY is set but empty");
|
|
}
|
|
|
|
if (!fs.existsSync(dbPath)) {
|
|
return secret
|
|
? ok("Storage/encryption", "Encryption key is configured; database not initialized yet")
|
|
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
|
|
}
|
|
|
|
try {
|
|
const { hasProviderTable, encryptedValues } = await readEncryptedCredentialSamples(dbPath);
|
|
if (!hasProviderTable) {
|
|
return secret
|
|
? ok("Storage/encryption", "Encryption key is configured; provider table not initialized")
|
|
: warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode");
|
|
}
|
|
|
|
if (encryptedValues.length === 0) {
|
|
return secret
|
|
? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found")
|
|
: warn(
|
|
"Storage/encryption",
|
|
"No STORAGE_ENCRYPTION_KEY configured; credentials are plaintext"
|
|
);
|
|
}
|
|
|
|
if (!secret) {
|
|
return fail(
|
|
"Storage/encryption",
|
|
"Encrypted credentials exist but STORAGE_ENCRYPTION_KEY is missing",
|
|
{ encryptedSamples: encryptedValues.length }
|
|
);
|
|
}
|
|
|
|
const key = deriveStorageKey();
|
|
for (const value of encryptedValues) {
|
|
decryptCredentialSample(value, key);
|
|
}
|
|
|
|
return ok("Storage/encryption", "Encrypted credential samples decrypt successfully", {
|
|
encryptedSamples: encryptedValues.length,
|
|
});
|
|
} catch (error) {
|
|
return fail("Storage/encryption", "Encrypted credential check failed", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
function checkPort(port, label) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
|
|
server.once("error", (error) => {
|
|
if (error.code === "EADDRINUSE") {
|
|
resolve(warn("Port availability", `${label} port ${port} is already in use`, { port }));
|
|
} else {
|
|
resolve(
|
|
warn("Port availability", `${label} port ${port} could not be checked`, {
|
|
port,
|
|
error: error.message,
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
server.once("listening", () => {
|
|
server.close(() => {
|
|
resolve(ok("Port availability", `${label} port ${port} is available`, { port }));
|
|
});
|
|
});
|
|
|
|
server.listen(port, "127.0.0.1");
|
|
});
|
|
}
|
|
|
|
async function checkPorts() {
|
|
const port = parsePort(process.env.PORT || "20128", 20128);
|
|
const apiPort = parsePort(process.env.API_PORT || String(port), port);
|
|
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
|
|
const checks = await Promise.all([
|
|
checkPort(dashboardPort, "Dashboard"),
|
|
apiPort === dashboardPort ? Promise.resolve(null) : checkPort(apiPort, "API"),
|
|
]);
|
|
const results = checks.filter(Boolean);
|
|
const failResult = results.find((result) => result.status === "fail");
|
|
if (failResult) return failResult;
|
|
const warnResults = results.filter((result) => result.status === "warn");
|
|
if (warnResults.length > 0) {
|
|
return warn("Port availability", warnResults.map((result) => result.message).join("; "), {
|
|
ports: { apiPort, dashboardPort },
|
|
});
|
|
}
|
|
return ok("Port availability", "Configured port(s) are available", {
|
|
ports: { apiPort, dashboardPort },
|
|
});
|
|
}
|
|
|
|
async function checkNodeRuntime(rootDir) {
|
|
try {
|
|
const { getNodeRuntimeSupport } = await import(
|
|
pathToFileURL(path.join(rootDir, "bin", "nodeRuntimeSupport.mjs")).href
|
|
);
|
|
const support = getNodeRuntimeSupport();
|
|
if (!support.nodeCompatible) {
|
|
return fail("Node runtime", `${support.nodeVersion} is outside supported policy`, support);
|
|
}
|
|
return ok("Node runtime", `${support.nodeVersion} is supported`, support);
|
|
} catch {
|
|
// nodeRuntimeSupport.mjs is only available in full source installs, not in Docker images
|
|
const version = process.version;
|
|
return warn(
|
|
"Node runtime",
|
|
`${version} (runtime support module unavailable in this environment)`,
|
|
{ nodeVersion: version }
|
|
);
|
|
}
|
|
}
|
|
|
|
async function checkNativeBinary(rootDir) {
|
|
const candidates = [
|
|
path.join(
|
|
rootDir,
|
|
"app",
|
|
"node_modules",
|
|
"better-sqlite3",
|
|
"build",
|
|
"Release",
|
|
"better_sqlite3.node"
|
|
),
|
|
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
|
|
];
|
|
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
|
|
if (!binaryPath) {
|
|
return warn("Native binary", "better-sqlite3 native binary was not found", { candidates });
|
|
}
|
|
|
|
try {
|
|
const { isNativeBinaryCompatible } = await import(
|
|
pathToFileURL(path.join(rootDir, "scripts", "build", "native-binary-compat.mjs")).href
|
|
);
|
|
const compatible = isNativeBinaryCompatible(binaryPath);
|
|
if (!compatible) {
|
|
return fail("Native binary", "better-sqlite3 native binary is incompatible", { binaryPath });
|
|
}
|
|
return ok("Native binary", "better-sqlite3 native binary is compatible", { binaryPath });
|
|
} catch {
|
|
// native-binary-compat.mjs is only available in full source installs, not in Docker images
|
|
return warn("Native binary", "Compatibility check unavailable in this environment", {
|
|
binaryPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
function checkMemory() {
|
|
const configured = process.env.OMNIROUTE_MEMORY_MB || "512";
|
|
const memoryMb = Number.parseInt(configured, 10);
|
|
if (!Number.isFinite(memoryMb) || memoryMb < 64 || memoryMb > 16384) {
|
|
return fail("Memory", `Invalid OMNIROUTE_MEMORY_MB: ${configured}`, { configured });
|
|
}
|
|
|
|
const total = os.totalmem();
|
|
const free = os.freemem();
|
|
const requestedBytes = memoryMb * 1024 * 1024;
|
|
if (requestedBytes > total) {
|
|
return warn(
|
|
"Memory",
|
|
`Requested memory ${memoryMb} MB exceeds total RAM ${formatBytes(total)}`,
|
|
{
|
|
memoryMb,
|
|
totalBytes: total,
|
|
freeBytes: free,
|
|
}
|
|
);
|
|
}
|
|
|
|
return ok("Memory", `${memoryMb} MB limit configured; ${formatBytes(free)} free`, {
|
|
memoryMb,
|
|
totalBytes: total,
|
|
freeBytes: free,
|
|
});
|
|
}
|
|
|
|
async function fetchWithTimeout(url) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
|
try {
|
|
return await fetch(url, { signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function formatHostForUrl(host) {
|
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
}
|
|
|
|
function resolveLivenessUrl(options = {}) {
|
|
const explicitUrl = options.livenessUrl || process.env.OMNIROUTE_DOCTOR_LIVENESS_URL;
|
|
if (explicitUrl) return explicitUrl;
|
|
|
|
const port = parsePort(process.env.PORT || "20128", 20128);
|
|
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
|
|
const host = String(options.livenessHost || process.env.OMNIROUTE_DOCTOR_HOST || "127.0.0.1")
|
|
.trim()
|
|
.replace(/^https?:\/\//, "")
|
|
.replace(/\/.*$/, "");
|
|
|
|
return `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/api/health/degradation`;
|
|
}
|
|
|
|
async function probeUrl(url) {
|
|
try {
|
|
const response = await fetchWithTimeout(url);
|
|
return { ok: response.ok, status: response.status };
|
|
} catch {
|
|
return { ok: false, status: 0 };
|
|
}
|
|
}
|
|
|
|
async function checkServerLiveness(options = {}) {
|
|
const url = resolveLivenessUrl(options);
|
|
|
|
// First attempt: configured health endpoint (may require auth token).
|
|
const primary = await probeUrl(url);
|
|
if (primary.ok) {
|
|
return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status });
|
|
}
|
|
|
|
// #6162: /api/health and /api/health/degradation require a management token.
|
|
// When unauthenticated, fall back to probing a publicly served static asset
|
|
// (favicon.ico) to confirm the Next.js server is alive and reachable.
|
|
// Derive the fallback URL from the primary URL (preserving protocol/host/port)
|
|
// so custom liveness URL configurations are honored. Fall back to defaults
|
|
// only if the primary URL can't be parsed.
|
|
let fallbackUrl;
|
|
try {
|
|
const parsed = new URL(url);
|
|
parsed.pathname = "/favicon.ico";
|
|
parsed.search = "";
|
|
parsed.hash = "";
|
|
fallbackUrl = parsed.toString();
|
|
} catch {
|
|
const port = parsePort(process.env.PORT || "20128", 20128);
|
|
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
|
|
const host = String(options.livenessHost || process.env.OMNIROUTE_DOCTOR_HOST || "127.0.0.1")
|
|
.trim()
|
|
.replace(/^https?:\/\//, "")
|
|
.replace(/\/.*$/, "");
|
|
fallbackUrl = `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/favicon.ico`;
|
|
}
|
|
const fallback = await probeUrl(fallbackUrl);
|
|
|
|
if (fallback.ok) {
|
|
return ok(
|
|
"Server liveness",
|
|
`Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`,
|
|
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
|
|
);
|
|
}
|
|
|
|
return warn(
|
|
"Server liveness",
|
|
`Server health endpoint returned HTTP ${primary.status || "no-response"} and fallback probe failed`,
|
|
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
|
|
);
|
|
}
|
|
|
|
export async function collectDoctorChecks(context = {}, options = {}) {
|
|
const rootDir =
|
|
context.rootDir ||
|
|
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
const dataDir = resolveDataDir();
|
|
const dbPath = resolveStoragePath(dataDir);
|
|
|
|
const checks = [];
|
|
checks.push(checkConfig(dataDir));
|
|
checks.push(await checkDatabase(dbPath, rootDir));
|
|
checks.push(await checkStorageEncryption(dbPath));
|
|
checks.push(await checkPorts());
|
|
checks.push(await checkNodeRuntime(rootDir));
|
|
checks.push(await checkNativeBinary(rootDir));
|
|
checks.push(checkMemory());
|
|
|
|
if (!options.skipLiveness) {
|
|
checks.push(await checkServerLiveness(options));
|
|
}
|
|
|
|
// CLI tool health checks
|
|
try {
|
|
const { collectCliToolChecks } = await import("../../../src/lib/cli-helper/doctor/checks.ts");
|
|
const cliChecks = await collectCliToolChecks();
|
|
checks.push(...cliChecks);
|
|
} catch (err) {
|
|
checks.push(warn("CLI Tools", `Could not run CLI tool checks: ${err.message}`));
|
|
}
|
|
|
|
return {
|
|
dataDir,
|
|
dbPath,
|
|
checks,
|
|
summary: {
|
|
ok: checks.filter((check) => check.status === "ok").length,
|
|
warn: checks.filter((check) => check.status === "warn").length,
|
|
fail: checks.filter((check) => check.status === "fail").length,
|
|
},
|
|
};
|
|
}
|
|
|
|
function printCheck(check) {
|
|
const label = check.status.toUpperCase().padEnd(4);
|
|
const color =
|
|
check.status === "ok" ? "\x1b[32m" : check.status === "warn" ? "\x1b[33m" : "\x1b[31m";
|
|
console.log(`${color}${label}\x1b[0m ${check.name}: ${check.message}`);
|
|
}
|
|
|
|
export function registerDoctor(program) {
|
|
program
|
|
.command("doctor")
|
|
.description(t("doctor.title"))
|
|
.option("--no-liveness", "Skip HTTP health endpoint probing")
|
|
.option("--host <host>", "Host for server liveness probing", "127.0.0.1")
|
|
.option("--liveness-url <url>", "Full health endpoint URL override")
|
|
.action(async (opts, cmd) => {
|
|
const globalOpts = cmd.optsWithGlobals();
|
|
const exitCode = await runDoctorCommand({ ...opts, output: globalOpts.output });
|
|
if (exitCode !== 0) process.exit(exitCode);
|
|
});
|
|
}
|
|
|
|
export async function runDoctorCommand(opts = {}, context = {}) {
|
|
const isJson = (opts.output ?? "table") === "json";
|
|
const skipLiveness = !(opts.liveness ?? true);
|
|
|
|
const result = await collectDoctorChecks(context, {
|
|
skipLiveness,
|
|
livenessHost: opts.host,
|
|
livenessUrl: opts.livenessUrl,
|
|
});
|
|
|
|
if (isJson) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else {
|
|
printHeading("OmniRoute Doctor");
|
|
console.log(`Data dir: ${result.dataDir}`);
|
|
console.log(`Database: ${result.dbPath}\n`);
|
|
for (const check of result.checks) {
|
|
printCheck(check);
|
|
}
|
|
console.log(
|
|
`\nSummary: ${result.summary.ok} ok, ${result.summary.warn} warning(s), ${result.summary.fail} failure(s)`
|
|
);
|
|
}
|
|
|
|
return result.summary.fail > 0 ? 1 : 0;
|
|
}
|