#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, "..");
const DEFAULT_OUTPUT_DIR = join(REPO_ROOT, "docs", "assets");
const SESSION_ID = "019ef4c0-0000-7000-8000-000000000001";
const DEMO_FILE = "docs/assets/pi-web-dev-screenshot.png";
const DEFAULT_SITE_URL = "https://pi-web.dev/";
const VIEWPORTS = {
  desktop: { width: 1440, height: 900, mobile: false },
  tablet: { width: 1024, height: 768, mobile: false },
  mobile: { width: 390, height: 844, mobile: true },
};

const args = parseArgs(process.argv.slice(2));
const outputDir = resolve(args.outputDir ?? DEFAULT_OUTPUT_DIR);
const keepTemp = args.keepTemp === true;
const siteUrl = args.siteUrl ?? DEFAULT_SITE_URL;
const chromeBin = args.chromeBin ?? process.env["CHROME_BIN"] ?? findChrome();

if (chromeBin === undefined) {
  fail("Chromium was not found. Install chromium-browser/chromium or set CHROME_BIN=/path/to/chrome.");
}

const tempRoot = await mkdtemp(join(tmpdir(), "pi-web-screenshots-"));
const children = new Set();
let cleanedUp = false;

process.once("SIGINT", () => {
  void cleanup().finally(() => process.exit(130));
});
process.once("SIGTERM", () => {
  void cleanup().finally(() => process.exit(143));
});

async function main() {
  const logsDir = join(tempRoot, "logs");
  const dataDir = join(tempRoot, "pi-web-data");
  const configPath = join(tempRoot, "config.json");
  const sessionDir = join(tempRoot, "sessions");
  const agentDir = join(tempRoot, "pi-agent");
  const demoProject = join(tempRoot, "pi-web");
  const projectsFile = join(dataDir, "projects.json");
  const socketPath = join(dataDir, "sessiond.sock");
  await Promise.all([
    mkdir(logsDir, { recursive: true }),
    mkdir(sessionDir, { recursive: true }),
    mkdir(agentDir, { recursive: true }),
    mkdir(dataDir, { recursive: true }),
    mkdir(outputDir, { recursive: true }),
  ]);

  console.log(`Temporary workspace: ${tempRoot}`);
  await cloneDemoProject(demoProject);
  await removeLegacyDemoMedia(demoProject);

  const projectId = "pi-web-demo";
  const workspaceId = createWorkspaceId(projectId, demoProject);
  await writeJson(projectsFile, {
    projects: [{ id: projectId, name: "pi-web", path: demoProject, createdAt: new Date().toISOString() }],
  });
  await writeJson(configPath, { host: "127.0.0.1", allowedHosts: true });
  await writeDemoSession(sessionDir, demoProject);

  const apiPort = await getFreePort();
  const clientPort = await getFreePort();
  const debugPort = await getFreePort();
  const env = {
    ...process.env,
    PI_WEB_DATA_DIR: dataDir,
    PI_WEB_CONFIG: configPath,
    PI_WEB_PROJECTS_FILE: projectsFile,
    PI_WEB_SESSIOND_SOCKET: socketPath,
    PI_WEB_HOST: "127.0.0.1",
    PI_WEB_PORT: String(apiPort),
    PI_WEB_ALLOWED_HOSTS: "true",
    PI_CODING_AGENT_DIR: agentDir,
    PI_CODING_AGENT_SESSION_DIR: sessionDir,
    PI_OFFLINE: "1",
    NO_COLOR: "1",
  };

  const tsxBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
  const viteBin = join(REPO_ROOT, "node_modules", ".bin", process.platform === "win32" ? "vite.cmd" : "vite");
  assertExecutable(tsxBin, "Run npm install before capturing screenshots.");
  assertExecutable(viteBin, "Run npm install before capturing screenshots.");

  console.log("Starting isolated PI WEB session daemon, API server, and Vite client…");
  startChild("sessiond", tsxBin, ["src/server/sessiond.ts"], { env, cwd: REPO_ROOT, logsDir });
  await waitForFile(socketPath, 10_000);
  startChild("api", tsxBin, ["src/server/index.ts"], { env, cwd: REPO_ROOT, logsDir });
  await waitForHttp(`http://127.0.0.1:${apiPort}/api/projects`, 15_000);
  startChild("vite", viteBin, ["--host", "127.0.0.1", "--port", String(clientPort), "--strictPort", "true"], { env, cwd: REPO_ROOT, logsDir });
  await waitForHttp(`http://127.0.0.1:${clientPort}/`, 30_000);

  console.log("Starting Chromium and capturing screenshots…");
  const chrome = startChild("chromium", chromeBin, chromeArgs(debugPort, join(tempRoot, "chrome-profile")), { env, cwd: REPO_ROOT, logsDir });
  await waitForHttp(`http://127.0.0.1:${debugPort}/json/version`, 15_000);
  const cdp = await openPage(debugPort);
  try {
    await cdp.send("Page.enable");
    await cdp.send("Runtime.enable");

    await captureWebsiteScreenshot(cdp, join(demoProject, DEMO_FILE), siteUrl);

    const appUrl = new URL(`http://127.0.0.1:${clientPort}/`);
    appUrl.searchParams.set("project", projectId);
    appUrl.searchParams.set("workspace", workspaceId);
    appUrl.searchParams.set("session", SESSION_ID);
    appUrl.searchParams.set("view", "chat");

    await captureDesktop(cdp, appUrl, join(outputDir, "pi-web-desktop.png"));
    await captureDefaultApp(cdp, appUrl, VIEWPORTS.tablet, join(outputDir, "pi-web-tablet.png"));
    await captureDefaultApp(cdp, appUrl, VIEWPORTS.mobile, join(outputDir, "pi-web-mobile.png"));
  } finally {
    cdp.close();
    chrome.kill("SIGTERM");
  }

  console.log(`Wrote ${join(outputDir, "pi-web-desktop.png")}`);
  console.log(`Wrote ${join(outputDir, "pi-web-tablet.png")}`);
  console.log(`Wrote ${join(outputDir, "pi-web-mobile.png")}`);
  if (keepTemp) console.log(`Kept temporary workspace: ${tempRoot}`);
}

async function captureWebsiteScreenshot(cdp, outputPath, url) {
  await mkdir(dirname(outputPath), { recursive: true });
  await setViewport(cdp, { width: 1280, height: 720, mobile: false });
  try {
    await navigate(cdp, url, 20_000);
    await waitForDocumentFonts(cdp);
    await sleep(3500);
    await capturePng(cdp, outputPath);
  } catch (error) {
    console.warn(`Unable to capture ${url}; using a local fallback image. ${error instanceof Error ? error.message : String(error)}`);
    const fallback = `data:text/html,${encodeURIComponent(fallbackWebsiteHtml(url))}`;
    await navigate(cdp, fallback, 10_000);
    await sleep(300);
    await capturePng(cdp, outputPath);
  }
}

async function captureDesktop(cdp, appUrl, outputPath) {
  await setViewport(cdp, VIEWPORTS.desktop);
  await navigate(cdp, appUrl.href, 15_000);
  await waitForApp(cdp);
  await selectPreviewImage(cdp);
  await sleep(500);
  await capturePng(cdp, outputPath);
}

async function captureDefaultApp(cdp, appUrl, viewport, outputPath) {
  await setViewport(cdp, viewport);
  await navigate(cdp, appUrl.href, 15_000);
  await waitForApp(cdp);
  await sleep(700);
  await capturePng(cdp, outputPath);
}

async function selectPreviewImage(cdp) {
  await evaluate(cdp, `(async () => {
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const app = document.querySelector("pi-web-app");
    if (!app) throw new Error("pi-web-app not found");
    if (typeof app.openWorkspaceTool !== "function" || app.files === undefined) {
      throw new Error("PI WEB app internals needed for deterministic screenshot setup were not available");
    }
    app.openWorkspaceTool("core:workspace.files");
    await app.updateComplete;
    await sleep(900);
    await app.files.refreshFiles();
    await app.updateComplete;
    if (app.state?.expandedDirs?.["docs"] === undefined) await app.files.expandDir("docs");
    await app.updateComplete;
    if (app.state?.expandedDirs?.["docs/assets"] === undefined) await app.files.expandDir("docs/assets");
    await app.updateComplete;
    await app.files.selectFile(${JSON.stringify(DEMO_FILE)});
    await app.updateComplete;

    const panel = app.shadowRoot?.querySelector("workspace-panel");
    const root = panel?.shadowRoot;
    await panel?.updateComplete;
    const start = Date.now();
    while (Date.now() - start < 8000) {
      const image = root?.querySelector(".image-preview img");
      if (root?.textContent.includes(${JSON.stringify(DEMO_FILE)}) && image instanceof HTMLImageElement && image.complete) return true;
      await sleep(100);
    }
    throw new Error("Timed out waiting for image preview");
  })()`);
}

async function waitForApp(cdp) {
  await evaluate(cdp, `new Promise((resolve, reject) => {
    const start = Date.now();
    const visibleText = () => {
      const app = document.querySelector("pi-web-app");
      const appRoot = app?.shadowRoot;
      const chatRoot = appRoot?.querySelector("chat-view")?.shadowRoot;
      return [appRoot?.textContent ?? "", chatRoot?.textContent ?? ""].join("\\n");
    };
    const check = () => {
      const text = visibleText();
      if (text.includes("Showing messages") && text.includes("assistant")) {
        resolve(true);
        return;
      }
      if (Date.now() - start > 15000) {
        reject(new Error("PI WEB app did not restore the seeded session in time. Visible text: " + visibleText()));
        return;
      }
      setTimeout(check, 100);
    };
    check();
  })`);
}

async function waitForDocumentFonts(cdp) {
  try {
    await evaluate(cdp, `document.fonts?.ready?.then(() => true) ?? true`);
  } catch {
    // Font loading is best-effort; screenshots still work with fallback fonts.
  }
}

async function navigate(cdp, url, timeoutMs) {
  const loaded = cdp.waitForEvent("Page.loadEventFired", timeoutMs).catch(() => undefined);
  await cdp.send("Page.navigate", { url });
  await loaded;
}

async function setViewport(cdp, viewport) {
  await cdp.send("Emulation.setDeviceMetricsOverride", {
    width: viewport.width,
    height: viewport.height,
    deviceScaleFactor: 1,
    mobile: viewport.mobile,
  });
}

async function capturePng(cdp, outputPath) {
  await mkdir(dirname(outputPath), { recursive: true });
  const { data } = await cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false });
  await writeFile(outputPath, Buffer.from(data, "base64"));
}

async function cloneDemoProject(target) {
  const result = spawnSync("git", ["clone", "--quiet", "--local", "--no-hardlinks", REPO_ROOT, target], {
    cwd: REPO_ROOT,
    encoding: "utf8",
  });
  if (result.status !== 0) throw new Error(`git clone failed:\n${result.stderr || result.stdout}`);
}

async function removeLegacyDemoMedia(projectRoot) {
  await Promise.all([
    rm(join(projectRoot, "docs", "assets", "pi-web-demo.gif"), { force: true }),
    rm(join(projectRoot, "docs", "assets", "pi-web-demo.webm"), { force: true }),
    rm(join(projectRoot, "docs", "assets", "pi-web-demo-flow.gif"), { force: true }),
  ]);
}

async function writeDemoSession(sessionDir, cwd) {
  const now = new Date();
  const timestamp = now.toISOString();
  const file = join(sessionDir, `${timestamp.replaceAll(":", "-")}_${SESSION_ID}.jsonl`);
  const ms = now.getTime();
  const entries = [
    { type: "session", version: 3, id: SESSION_ID, timestamp, cwd },
    { type: "model_change", id: "10000001", parentId: null, timestamp: iso(ms + 100), provider: "openai-codex", modelId: "gpt-5.5" },
    { type: "thinking_level_change", id: "10000002", parentId: "10000001", timestamp: iso(ms + 200), thinkingLevel: "off" },
    {
      type: "message",
      id: "10000003",
      parentId: "10000002",
      timestamp: iso(ms + 1000),
      message: {
        role: "user",
        content: [{ type: "text", text: "Take a screenshot of https://pi-web.dev, save it under docs/assets, and tell me where I can preview it." }],
        timestamp: ms + 1000,
      },
    },
    { type: "session_info", id: "10000004", parentId: "10000003", timestamp: iso(ms + 1100), name: "Screenshot pi-web.dev" },
    {
      type: "message",
      id: "10000005",
      parentId: "10000004",
      timestamp: iso(ms + 2000),
      message: {
        role: "assistant",
        content: [{
          type: "toolCall",
          id: "call_demo_screenshot",
          name: "bash",
          arguments: { command: `capture-browser-screenshot https://pi-web.dev ${DEMO_FILE}` },
        }],
        api: "openai-codex-responses",
        provider: "openai-codex",
        model: "gpt-5.5",
        usage: zeroUsage(),
        stopReason: "toolUse",
        timestamp: ms + 2000,
      },
    },
    {
      type: "message",
      id: "10000006",
      parentId: "10000005",
      timestamp: iso(ms + 3000),
      message: {
        role: "toolResult",
        toolCallId: "call_demo_screenshot",
        toolName: "bash",
        content: [{ type: "text", text: `Saved screenshot to ${DEMO_FILE}` }],
        isError: false,
        timestamp: ms + 3000,
      },
    },
    {
      type: "message",
      id: "10000007",
      parentId: "10000006",
      timestamp: iso(ms + 4000),
      message: {
        role: "assistant",
        content: [{ type: "text", text: `Done — I saved the screenshot at \`${DEMO_FILE}\`. Open the Files panel to preview it.` }],
        api: "openai-codex-responses",
        provider: "openai-codex",
        model: "gpt-5.5",
        usage: zeroUsage(),
        stopReason: "stop",
        timestamp: ms + 4000,
      },
    },
  ];
  await writeFile(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
}

function fallbackWebsiteHtml(url) {
  return `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
    body{margin:0;min-height:100vh;display:grid;place-items:center;background:linear-gradient(135deg,#07121f,#2b174c);color:#f8fafc;font:24px system-ui,sans-serif}
    main{width:min(900px,calc(100vw - 80px));padding:56px;border:1px solid rgba(255,255,255,.22);border-radius:28px;background:rgba(10,16,32,.72);box-shadow:0 24px 80px rgba(0,0,0,.35)}
    h1{margin:0 0 14px;font-size:64px;letter-spacing:-.06em}.eyebrow{color:#c084fc;text-transform:uppercase;letter-spacing:.16em;font-size:14px;font-weight:700}p{line-height:1.5;color:#dbeafe}
  </style></head><body><main><div class="eyebrow">PI WEB</div><h1>pi-web.dev</h1><p>Fallback screenshot for ${escapeHtml(url)}.</p></main></body></html>`;
}

function chromeArgs(debugPort, userDataDir) {
  return [
    "--headless=new",
    `--remote-debugging-port=${debugPort}`,
    "--remote-debugging-address=127.0.0.1",
    "--remote-allow-origins=*",
    `--user-data-dir=${userDataDir}`,
    "--window-size=1440,900",
    "--force-device-scale-factor=1",
    "--hide-scrollbars",
    "--disable-background-networking",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    "--disable-extensions",
    "--disable-features=Translate,MediaRouter,OptimizationHints",
    "--no-default-browser-check",
    "--no-first-run",
    "--no-sandbox",
    "about:blank",
  ];
}

function startChild(name, command, childArgs, { env, cwd, logsDir }) {
  const logPath = join(logsDir, `${name}.log`);
  const child = spawn(command, childArgs, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
  children.add(child);
  const chunks = [];
  const collect = (chunk) => {
    chunks.push(Buffer.from(chunk));
    if (chunks.length > 120) chunks.shift();
  };
  child.stdout.on("data", collect);
  child.stderr.on("data", collect);
  child.stdout.on("data", (chunk) => appendLog(logPath, chunk));
  child.stderr.on("data", (chunk) => appendLog(logPath, chunk));
  child.once("exit", (code, signal) => {
    children.delete(child);
    if (!cleanedUp && code !== 0 && signal === null) {
      const recent = Buffer.concat(chunks).toString("utf8").trim();
      console.error(`${name} exited with code ${code}. Recent log:\n${recent}`);
    }
  });
  return child;
}

function appendLog(path, chunk) {
  void mkdir(dirname(path), { recursive: true })
    .then(() => writeFile(path, chunk, { flag: "a" }))
    .catch(() => undefined);
}

async function cleanup() {
  if (cleanedUp) return;
  cleanedUp = true;
  await Promise.all([...children].map((child) => terminate(child)));
  if (!keepTemp) await rm(tempRoot, { recursive: true, force: true });
}

async function terminate(child) {
  if (child.exitCode !== null || child.signalCode !== null) return;
  child.kill("SIGTERM");
  await Promise.race([
    new Promise((resolve) => child.once("exit", resolve)),
    sleep(2500).then(() => {
      if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
    }),
  ]);
}

async function waitForHttp(url, timeoutMs) {
  const start = Date.now();
  let lastError;
  while (Date.now() - start < timeoutMs) {
    try {
      const response = await fetch(url);
      if (response.ok) return;
      lastError = new Error(`${response.status} ${response.statusText}`);
    } catch (error) {
      lastError = error;
    }
    await sleep(150);
  }
  throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}

async function waitForFile(path, timeoutMs) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    if (existsSync(path)) return;
    await sleep(100);
  }
  throw new Error(`Timed out waiting for ${path}`);
}

async function getFreePort() {
  return new Promise((resolve, reject) => {
    const server = createServer();
    server.listen(0, "127.0.0.1", () => {
      const address = server.address();
      const port = typeof address === "object" && address !== null ? address.port : undefined;
      server.close(() => {
        if (port === undefined) reject(new Error("Unable to allocate a port"));
        else resolve(port);
      });
    });
    server.on("error", reject);
  });
}

async function openPage(debugPort) {
  const response = await fetch(`http://127.0.0.1:${debugPort}/json/new?about:blank`, { method: "PUT" });
  if (!response.ok) throw new Error(`Unable to create Chromium tab: ${response.status} ${response.statusText}`);
  const info = await response.json();
  return CDP.connect(info.webSocketDebuggerUrl);
}

async function evaluate(cdp, expression) {
  const response = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
  if (response.exceptionDetails !== undefined) throw new Error(`Browser evaluation failed: ${JSON.stringify(response.exceptionDetails)}`);
  return response.result?.value;
}

class CDP {
  static connect(url) {
    return new Promise((resolve, reject) => {
      const ws = new WebSocket(url);
      const cdp = new CDP(ws);
      ws.addEventListener("open", () => resolve(cdp), { once: true });
      ws.addEventListener("error", (event) => reject(event.error ?? new Error("CDP websocket error")), { once: true });
    });
  }

  constructor(ws) {
    this.ws = ws;
    this.nextId = 1;
    this.pending = new Map();
    this.listeners = new Map();
    ws.addEventListener("message", (event) => this.onMessage(event));
    ws.addEventListener("close", () => {
      for (const { reject } of this.pending.values()) reject(new Error("CDP websocket closed"));
      this.pending.clear();
    });
  }

  send(method, params = {}) {
    const id = this.nextId++;
    this.ws.send(JSON.stringify({ id, method, params }));
    return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
  }

  waitForEvent(method, timeoutMs) {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        cleanupListener();
        reject(new Error(`Timed out waiting for ${method}`));
      }, timeoutMs);
      const listener = (params) => {
        cleanupListener();
        resolve(params);
      };
      const cleanupListener = () => {
        clearTimeout(timer);
        const listeners = this.listeners.get(method) ?? [];
        this.listeners.set(method, listeners.filter((candidate) => candidate !== listener));
      };
      this.listeners.set(method, [...this.listeners.get(method) ?? [], listener]);
    });
  }

  close() {
    this.ws.close();
  }

  onMessage(event) {
    const message = JSON.parse(String(event.data));
    if (message.id !== undefined) {
      const pending = this.pending.get(message.id);
      if (pending === undefined) return;
      this.pending.delete(message.id);
      if (message.error !== undefined) pending.reject(new Error(JSON.stringify(message.error)));
      else pending.resolve(message.result ?? {});
      return;
    }
    if (message.method !== undefined) {
      for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {});
    }
  }
}

function createWorkspaceId(projectId, path) {
  return createHash("sha1").update(`${projectId}:${path}`).digest("hex").slice(0, 12);
}

async function writeJson(path, value) {
  await mkdir(dirname(path), { recursive: true });
  await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}

function iso(ms) {
  return new Date(ms).toISOString();
}

function zeroUsage() {
  return {
    input: 0,
    output: 0,
    cacheRead: 0,
    cacheWrite: 0,
    totalTokens: 0,
    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
  };
}

function parseArgs(argv) {
  const parsed = {};
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg === "--help" || arg === "-h") {
      console.log(`Usage: node scripts/capture-screenshots.mjs [--output-dir docs/assets] [--site-url https://pi-web.dev/] [--keep-temp] [--chrome-bin /path/to/chrome]\n\nCaptures desktop, tablet, and mobile PI WEB screenshots from an isolated temporary instance.`);
      process.exit(0);
    }
    if (arg === "--keep-temp") {
      parsed.keepTemp = true;
      continue;
    }
    if (arg === "--output-dir") {
      parsed.outputDir = requireValue(argv, ++i, arg);
      continue;
    }
    if (arg.startsWith("--output-dir=")) {
      parsed.outputDir = arg.slice("--output-dir=".length);
      continue;
    }
    if (arg === "--site-url") {
      parsed.siteUrl = requireValue(argv, ++i, arg);
      continue;
    }
    if (arg.startsWith("--site-url=")) {
      parsed.siteUrl = arg.slice("--site-url=".length);
      continue;
    }
    if (arg === "--chrome-bin") {
      parsed.chromeBin = requireValue(argv, ++i, arg);
      continue;
    }
    if (arg.startsWith("--chrome-bin=")) {
      parsed.chromeBin = arg.slice("--chrome-bin=".length);
      continue;
    }
    fail(`Unknown argument: ${arg}`);
  }
  return parsed;
}

function requireValue(argv, index, flag) {
  const value = argv[index];
  if (value === undefined || value.startsWith("--")) fail(`${flag} requires a value`);
  return value;
}

function findChrome() {
  return findExecutable(["chromium-browser", "chromium", "google-chrome", "google-chrome-stable"]);
}

function findExecutable(candidates) {
  for (const candidate of candidates) {
    const result = spawnSync("sh", ["-lc", `command -v ${shellQuote(candidate)}`], { encoding: "utf8" });
    if (result.status === 0) return result.stdout.trim().split("\n")[0];
  }
  return undefined;
}

function shellQuote(value) {
  return `'${value.replaceAll("'", "'\\''")}'`;
}

function assertExecutable(path, message) {
  if (!existsSync(path)) fail(`${path} was not found. ${message}`);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function escapeHtml(value) {
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}

function fail(message) {
  console.error(message);
  process.exit(1);
}

try {
  await main();
} finally {
  await cleanup();
}
