All files / src diagnostics.ts

100% Statements 58/58
100% Branches 40/40
100% Functions 13/13
100% Lines 50/50

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                4x 4x 4x 4x 4x       18x 18x 18x 2x         24x 24x 18x 18x   4x           18x 18x 18x 12x   7x     1x           20x 18x 6x   18x 18x 72x 72x   18x         41x         21x                                       136x             21x         20x 20x 18x   20x         20x 20x 20x         6x 6x 38x     6x 6x         10x 10x 9x 9x 9x         3x    
import { zipSync } from "fflate";
import { node } from "./binary-manager";
import { formatJournalEntry } from "./error-journal";
import { MESSAGES } from "./locales/en";
import { redactSecrets, redactSettings } from "./redact";
import { LOG_FILE, LOGS_DIR } 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) };
    }
}
 
/** 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)"}`,
        `- 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")}`),
        "",
        "## Recent plugin errors",
        "```",
        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")));
    }
    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 = process.env.HOME ?? process.env.USERPROFILE;
    if (home) {
        const downloads = node.join(home, "Downloads");
        try {
            if (node.existsSync(downloads)) return downloads;
        } catch {
            // fall through to fallbackDir
        }
    }
    return fallbackDir;
}