All files / src diagnostics.ts

100% Statements 68/68
100% Branches 48/48
100% Functions 14/14
100% Lines 59/59

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157                6x 6x 6x 6x 6x       19x 19x 19x 2x         30x 30x 19x 19x   4x           24x 24x 24x 15x 15x 15x   14x           24x 24x 24x 13x   7x     1x           26x 24x 6x   24x 24x 120x 120x   24x         53x         27x                                           226x             27x         26x 26x 24x   26x 24x   26x         26x 26x 26x         6x 6x 49x     6x 6x         9x 9x 8x 8x 8x         3x    
import { zipSync } from "fflate";
import { node } from "./node";
import { formatJournalEntry } from "./error-journal";
import { MESSAGES } from "./locales/en";
import { redactConfigKeys, redactSecrets, redactSettings } from "./redact";
import { LOG_FILE, LOGS_DIR, SHARED_PATH } from "./types";
import type { CollectedFile, DiagnosticsBundle, DiagnosticsContext } from "./types";
 
export const LOG_TAIL_MAX_BYTES = 1_048_576;
const textEncoder = new TextEncoder();
const NOTE_NOT_FOUND = "not found";
const NOTE_TRUNCATED = "truncated to last 1 MiB";
const EXPECTED_LOGS = Object.values(LOG_FILE);
 
/** Reads a file, keeping only the last LOG_TAIL_MAX_BYTES when oversized. */
function readTailCapped(path: string): { text: string; note: string | null } {
    const size = node.statSync(path).size;
    const text = node.readFileSync(path, "utf-8");
    if (size <= LOG_TAIL_MAX_BYTES) return { text, note: null };
    return { text: text.slice(-LOG_TAIL_MAX_BYTES), note: NOTE_TRUNCATED };
}
 
/** Collects one file as redacted bytes, or a miss with the reason noted. */
function collectFile(zipName: string, path: string): CollectedFile {
    try {
        if (!node.existsSync(path)) return { name: zipName, data: null, note: NOTE_NOT_FOUND };
        const { text, note } = readTailCapped(path);
        return { name: zipName, data: textEncoder.encode(redactSecrets(text)), note };
    } catch (e) {
        return { name: zipName, data: null, note: e instanceof Error ? e.message : String(e) };
    }
}
 
/** Collects the shared root's config.json, blanking its credential fields by key. */
function collectSharedConfig(sharedRoot: string): CollectedFile {
    const path = node.join(sharedRoot, SHARED_PATH.CONFIG);
    try {
        if (!node.existsSync(path)) return { name: SHARED_PATH.CONFIG, data: null, note: NOTE_NOT_FOUND };
        const parsed = JSON.parse(node.readFileSync(path, "utf-8")) as Record<string, unknown>;
        const json = JSON.stringify(redactConfigKeys(parsed), null, 2);
        return { name: SHARED_PATH.CONFIG, data: textEncoder.encode(json), note: null };
    } catch (e) {
        return { name: SHARED_PATH.CONFIG, data: null, note: e instanceof Error ? e.message : String(e) };
    }
}
 
/** Lists .log file names under <dataDir>/logs, or [] when unreadable. */
function listLogFiles(dataDir: string): string[] {
    try {
        const dir = node.join(dataDir, LOGS_DIR);
        if (!node.existsSync(dir)) return [];
        return node
            .readdirSync(dir)
            .filter((f) => String(f).endsWith(".log"))
            .map(String);
    } catch {
        return [];
    }
}
 
/** Collects every log under the data dir, recording misses for expected names. */
function collectLogFiles(dataDir: string | null): CollectedFile[] {
    if (dataDir === null) return [];
    const found = listLogFiles(dataDir).map((name) =>
        collectFile(`${LOGS_DIR}/${name}`, node.join(dataDir, LOGS_DIR, name)),
    );
    const haveNames = new Set(found.map((f) => f.name));
    for (const name of EXPECTED_LOGS) {
        const zipName = `${LOGS_DIR}/${name}`;
        if (!haveNames.has(zipName)) found.push({ name: zipName, data: null, note: NOTE_NOT_FOUND });
    }
    return found;
}
 
/** Renders the journal entries as plain log lines. */
function journalText(ctx: DiagnosticsContext): string {
    return ctx.journalEntries.map(formatJournalEntry).join("\n");
}
 
/** Renders the human-readable summary.md for the bundle. */
export function renderSummary(ctx: DiagnosticsContext, files: CollectedFile[]): string {
    const lines: string[] = [
        MESSAGES.DIAG_REVIEW_WARNING,
        "",
        "# lilbee diagnostics",
        "",
        "## Environment",
        `- Plugin version: ${ctx.pluginVersion}`,
        `- Server version: ${ctx.serverVersion || "(unknown)"}`,
        `- Server build: ${ctx.serverVariant ? MESSAGES.LABEL_SERVER_BUILD(ctx.serverVariant) : "(unknown)"}`,
        `- GPU detection: ${ctx.gpuDetection ? MESSAGES.DESC_GPU_DETECTION(ctx.gpuDetection) : MESSAGES.DESC_GPU_DETECTION_NONE}`,
        `- Platform: ${process.platform} ${process.arch}`,
        `- Server state: ${ctx.serverState}`,
        `- Server URL: ${ctx.serverUrl || "(none)"}`,
        `- Data dir: ${ctx.dataDir ?? `(not local) ${MESSAGES.DIAG_REMOTE_SERVER_NOTE}`}`,
        `- Shared root: ${ctx.sharedRoot ?? "(none)"}`,
        "",
        "## Last server output",
        "```",
        redactSecrets(ctx.lastOutput) || "(empty)",
        "```",
        "",
        "## Collected files",
        ...files.map((f) => `- ${f.name}: ${f.data === null ? (f.note ?? "missing") : (f.note ?? "ok")}`),
        "",
        "## Plugin journal (errors and lifecycle events)",
        "```",
        redactSecrets(journalText(ctx)) || "(none)",
        "```",
    ];
    return lines.join("\n");
}
 
/** Gathers logs, config, settings, and the journal into a redacted bundle. */
export function collectDiagnostics(ctx: DiagnosticsContext): DiagnosticsBundle {
    const files: CollectedFile[] = collectLogFiles(ctx.dataDir);
    if (ctx.dataDir !== null) {
        files.push(collectFile("config.toml", node.join(ctx.dataDir, "config.toml")));
    }
    if (ctx.sharedRoot !== null) {
        files.push(collectSharedConfig(ctx.sharedRoot));
    }
    files.push({
        name: "settings.json",
        data: textEncoder.encode(JSON.stringify(redactSettings(ctx.settings), null, 2)),
        note: null,
    });
    files.push({ name: "journal.log", data: textEncoder.encode(redactSecrets(journalText(ctx))), note: null });
    const summaryMarkdown = renderSummary(ctx, files);
    return { files, summaryMarkdown };
}
 
/** Zips the summary plus every collected file, skipping misses. */
export function buildZip(bundle: DiagnosticsBundle): Uint8Array {
    const entries: Record<string, Uint8Array> = { "summary.md": textEncoder.encode(bundle.summaryMarkdown) };
    for (const file of bundle.files) {
        if (file.data !== null) entries[file.name] = file.data;
    }
    // The store scanner lints without fflate's types; unknown keeps both linters satisfied.
    const zipped: unknown = zipSync(entries);
    return zipped as Uint8Array;
}
 
/** Returns ~/Downloads when present, otherwise the given fallback dir. */
export function resolveOutputDir(fallbackDir: string): string {
    const home = node.homedir();
    if (home) {
        const downloads = node.join(home, "Downloads");
        try {
            if (node.existsSync(downloads)) return downloads;
        } catch {
            // fall through to fallbackDir
        }
    }
    return fallbackDir;
}