master
Colby Knox Publish 0.22.1 950bd14 19d ago
#!/usr/bin/env node
// hero-studio doctor: verify the server-side render pipeline actually works,
// BEFORE a human is put in front of the pose gate. Checks that puppeteer-core
// resolves and fits this Node, that a Chrome binary exists, that the binary
// RUNS (a file-exists test passes right past missing shared libraries), and
// that python3+PIL are present for the provenance stamp.
//
// Exit 0 all green, exit 1 with named failures and exact remediation.
// Light containers (Hydrogen Desktop) ship none of this on purpose; the agent
// installing hero-studio there runs the printed commands itself.
import { existsSync, readdirSync, readFileSync } from 'fs';
import { execFile } from 'child_process';
import { homedir } from 'os';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';

const DIR = path.dirname(fileURLToPath(import.meta.url));

// ---- shared discovery: renderer-paths.cjs is the one list (hero-studio#58);
// render-hero.mjs and server.js read the same file ----
import { createRequire } from 'module';
const RP = createRequire(import.meta.url)('./renderer-paths.cjs');
const PLAT = createRequire(import.meta.url)('./platform.cjs');
export const puppeteerCandidates = RP.puppeteerCandidates;
export const puppeteerInstallCommand = RP.puppeteerInstallCommand;
export const quickRendererStatus = RP.quickStatus;

// import() of a bare absolute path fails on Windows (ERR_UNSUPPORTED_ESM_URL_SCHEME:
// "C:" reads as a URL scheme), and the old empty catch turned that into
// "puppeteer-core not found" while the files were right there. Anything that
// is not already a URL goes through pathToFileURL.
export function importSpecifier(p) {
  return /^(file|data|node):/i.test(p) ? p : pathToFileURL(p).href;
}

export async function loadPuppeteer() {
  const errors = [];
  for (const c of puppeteerCandidates()) {
    try { return (await import(importSpecifier(c))).default; } catch (e) { errors.push(c + ': ' + (e && e.message || e)); }
  }
  if (errors.length && process.env.HERO_STUDIO_DEBUG) console.error('puppeteer-core candidates failed:\n  ' + errors.join('\n  '));
  if (!RP.ISOLATED) { try { return (await import('puppeteer-core')).default; } catch {} }
  throw new Error('puppeteer-core not found. Fix: ' + RP.puppeteerInstallCommand());
}

export function findChrome() {
  const c = RP.chromePath();
  if (c) return c;
  throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path, or: ' + RP.chromeInstallCommand());
}

// ---- checks ----

const run = (cmd, args, timeout = 15000) => new Promise((res) => {
  execFile(cmd, args, { timeout }, (err, stdout, stderr) =>
    res({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || '') }));
});

function puppeteerEngineFit() {
  // puppeteer-core 25.x imports fine on Node 18 and then fails at launch, so
  // the mismatch never surfaces where you would look. Check engines up front.
  for (const c of RP.puppeteerPackages()) {
    try {
      const pkg = JSON.parse(readFileSync(c, 'utf8'));
      const want = pkg.engines && pkg.engines.node;
      const m = want && want.match(/>=\s*(\d+)\.(\d+)/);
      if (!m) return { fit: true, version: pkg.version };
      const [maj, min] = process.versions.node.split('.').map(Number);
      const fit = maj > +m[1] || (maj === +m[1] && min >= +m[2]);
      return { fit, version: pkg.version, want };
    } catch {}
  }
  // resolved via bare import only; try its package.json through require paths
  return { fit: true, version: null };
}

export async function runChecks() {
  const checks = [];
  const fail = (name, detail, fix) => checks.push({ name, ok: false, detail, fix });
  const pass = (name, detail) => checks.push({ name, ok: true, detail });

  // 1. puppeteer-core resolves. An engines mismatch is a WARNING, not a
  // failure: the launch check below is the ground truth (25.x's engines say
  // node >=22.12 yet it launches fine on some Node 18 images), but when the
  // launch DOES fail on mismatched engines, the pin is the first fix to try.
  let pptr = null;
  let engineWarn = null;
  try {
    pptr = await loadPuppeteer();
    const eng = puppeteerEngineFit();
    if (!eng.fit) engineWarn = `engines want node ${eng.want}; this is ${process.versions.node}`;
    pass('puppeteer-core', 'resolves' + (eng.version ? ` (${eng.version})` : '') + (engineWarn ? `; warning: ${engineWarn}, trusting the launch check` : ''));
  } catch (e) {
    fail('puppeteer-core', e.message, RP.puppeteerInstallCommand());
  }

  // 2. a Chrome binary exists
  let chrome = null;
  try {
    chrome = findChrome();
    pass('chrome binary', chrome);
  } catch (e) {
    fail('chrome binary', e.message,
      'npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer\n' +
      '      # --path matters: findChrome() scans ~/.cache/puppeteer/chrome/' + (PLAT.IS_WIN || PLAT.IS_MAC ? '\n    or: ' + PLAT.fixFor('chrome') : ''));
  }

  // 3. the binary actually RUNS. This is the check that catches missing shared
  // libraries; a file-exists test lies on rootfs images never built for a browser.
  let chromeRuns = false;
  // Windows Chrome prints nothing for --version and macOS's is a GUI app, so
  // there the probe proves nothing and used to block the real check below.
  // The headless launch IS the check on those platforms.
  if (chrome && (PLAT.IS_WIN || PLAT.IS_MAC)) {
    chromeRuns = true;
    pass('chrome binary present', 'the headless launch below is the real check on ' + process.platform);
  } else if (chrome) {
    const v = await run(chrome, ['--version']);
    if (v.ok) {
      chromeRuns = true;
      pass('chrome runs', v.stdout.trim());
    } else {
      const ldd = await run('ldd', [chrome]);
      const missing = ldd.stdout.split('\n').filter((l) => l.includes('not found'))
        .map((l) => l.trim().split(' ')[0]);
      fail('chrome runs', 'chrome --version failed' + (missing.length ? '; missing libraries: ' + missing.join(', ') : ''),
        'sudo apt-get install -y libglib2.0-0 libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \\\n' +
        '      libdbus-1-3 libcups2 libxkbcommon0 libasound2t64 libgbm1 libpango-1.0-0 libcairo2 \\\n' +
        '      libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libatspi2.0-0 fonts-liberation\n' +
        '      # libasound2t64 is the Ubuntu 24.04 (noble) name; older releases use libasound2');
    }
  }

  // 4. the definitive check: a real headless launch through puppeteer, same
  // args as render-hero.mjs. If this passes, the local renderer works.
  let launchOk = false;
  if (pptr && chromeRuns) {
    try {
      const browser = await pptr.launch({
        executablePath: chrome, headless: 'new',
        args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
      });
      await browser.close();
      launchOk = true;
      pass('headless launch', 'puppeteer launched Chrome and closed it cleanly');
    } catch (e) {
      fail('headless launch', String(e && e.message || e).split('\n')[0],
        engineWarn
          ? RP.puppeteerInstallCommand() + '    # ' + engineWarn + '; the pin is the first fix to try'
          : 'read the launch error; check PUPPETEER_CORE/CHROME env overrides and OS libraries');
    }
  }
  const localOk = launchOk;

  // 5. the pup fallback: Generate can render through the HOST browser (Adom
  // Desktop) when the container itself cannot run Chrome (#7). Needs the
  // desktop relay answering plus a proxy URL the host can reach the studio on.
  let pupOk = false;
  const proxied = !!process.env.VSCODE_PROXY_URI;
  // On a desktop there is no container and no host to fall back to: the
  // local renderer already runs on this machine. The leg is reported as not
  // applicable rather than probed (and never as a failure).
  if (!PLAT.inContainer()) {
    checks.push({ name: 'pup fallback', ok: false, optional: true,
      detail: 'not used on a desktop: Generate renders with the local Chrome on this machine' });
  } else {
  // Post-rename boxes answer via adom-bridge-cli with pup_* verbs (#31); the
  // adapter resolves whichever generation this box has.
  const { makeBridge } = await import('./bridge.mjs');
  const bridge = await makeBridge(process.env.ADOM_TARGET || '');
  const ad = await bridge.call('status', {}, 12000);
  const adAnswered = ad.ok && !ad.out.includes('"error"') && !ad.out.includes('_adom_use_cli_instead');
  if (adAnswered && proxied) {
    pupOk = true;
    pass('pup fallback', bridge.generation + ' answers (' + bridge.verbs.status + '); the host browser can render');
  } else {
    fail('pup fallback', !adAnswered
      ? 'the desktop bridge did not answer (' + bridge.generation + ', ' + bridge.verbs.status + '); no host relay from ' + PLAT.where()
      : 'VSCODE_PROXY_URI is not set, so the host browser cannot reach the studio',
      'optional when the local renderer works; otherwise start the desktop bridge app on the host and retry');
    checks[checks.length - 1].optional = true;
  }
  }

  // 6. python3 + PIL stamp the provenance metadata after the render (and crop
  // the pup screenshot); required on BOTH render paths
  const py = PLAT.pythonWithPillow({ fresh: true });
  const pilOk = !!py;
  if (py) pass('python + PIL', 'provenance stamping ready (' + py.label + ')');
  else fail('python + PIL', 'the export downscale + provenance stamp needs Python with Pillow (tried ' + (PLAT.IS_WIN ? 'python, py -3, python3' : 'python3, python') + ')',
    PLAT.fixFor('pillow'));

  const renderer = localOk ? 'local' : (pupOk ? 'pup' : null);
  return { ok: pilOk && !!renderer, renderer, checks };
}

// ---- brand gate ----

/// Resolve an owner's brand guide and report it the way the renderer checks
/// report: named findings with an exact fix. Advisory by design: a starter or
/// missing guide is what the wizard's Brand step exists for, so it must never
/// lock the pose gate the way a dead renderer does.
export async function runBrandChecks(owner) {
  const checks = [];
  const fail = (name, detail, fix) => checks.push({ name, ok: false, detail, fix });
  const pass = (name, detail) => checks.push({ name, ok: true, detail });
  const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';
  const headers = {};
  Object.assign(headers, PLAT.authHeader());

  let guide = null;
  try {
    const r = await fetch(REG + '/api/owners/' + encodeURIComponent(owner) + '/brand', { headers });
    const body = await r.json().catch(() => ({}));
    if (r.status === 404) {
      guide = { owner, status: 'missing', hints: body.hints || [] };
    } else if (!r.ok) {
      fail('brand guide', 'the registry answered HTTP ' + r.status + ' for ' + owner,
        'check ADOM_WIKI_REGISTRY (' + REG + ') and the wiki identity (' + PLAT.wikiIdentity().source + '); until it answers, the hero holds rather than wearing Adom\'s look');
      return { ok: false, owner, status: 'unavailable', guide: null, checks };
    } else {
      guide = body;
    }
  } catch (e) {
    fail('brand guide', 'could not reach ' + REG + ': ' + String(e && e.message || e).split('\n')[0],
      'the guide is resolved per pose; a hero cannot be branded until the registry answers');
    return { ok: false, owner, status: 'unavailable', guide: null, checks };
  }

  const status = guide.status || 'missing';
  const who = (guide.editable_by && guide.editable_by.text) || null;
  // editable_by.text is the server's own sentence about who may edit this
  // guide. It is relayed verbatim, never paraphrased.
  const whoFix = who || ('this identity cannot edit ' + owner + '/brand');

  if (status === 'confirmed') {
    pass('brand guide', owner + '/brand is confirmed (v' + (guide.version || '?') + (guide.sha ? ' ' + String(guide.sha).slice(0, 7) : '') + ')');
  } else if (status === 'starter') {
    fail('brand guide', owner + '/brand is a STARTER guide: no human has confirmed it',
      'pose the page and confirm it at the wizard\'s Brand step; a human confirms, never a thread. ' + whoFix);
  } else if (status === 'missing') {
    fail('brand guide', owner + '/brand does not exist yet',
      'the wizard\'s Brand step writes a starter guide for a human to confirm: adom-wiki brand show --owner ' + owner + '. ' + whoFix);
  } else if (status === 'invalid') {
    const errs = (guide.errors || []).map((e) => (e && e.field ? e.field + ': ' : '') + (e && e.message || e));
    fail('brand guide', owner + '/brand fails brand.json v1 validation' + (errs.length ? ': ' + errs.join('; ') : ''),
      'fix the named fields, then confirm at the Brand step. ' + whoFix);
  } else if (status === 'private') {
    fail('brand guide', owner + '/brand is not readable with this identity',
      'the hero cannot be branded from a guide it cannot read. ' + whoFix);
  } else {
    fail('brand guide', 'unknown guide status "' + status + '" for ' + owner, 'the resolver contract names: confirmed, starter, missing, invalid, private');
  }

  if (guide.can_edit === false && status !== 'confirmed') {
    fail('brand confirm', 'this identity cannot edit ' + owner + '/brand, so the Brand step cannot confirm it here', whoFix);
  }
  const marks = (guide.assets && guide.assets.mark) || null;
  if (status !== 'missing' && !marks) {
    fail('brand logo', owner + '/brand resolves no logo mark, so the hero falls back to a wordmark-free header',
      'add logos.mark to brand.json (svg/png/webp/jpg) and push it with the guide');
  } else if (marks) {
    pass('brand logo', 'mark resolves (' + String(marks).split('/').pop() + ')');
  }
  for (const w of (guide.warnings || [])) checks.push({ name: 'brand warning', ok: false, detail: w, fix: 'a warning does not block confirm; fix it in brand.json when you next edit the guide' });

  return { ok: checks.every((c) => c.ok), owner, status, guide, checks };
}

/// One line for a resolver hint. The contract's shape is
/// {level, code, message, data}; a bare string is accepted too.
export function hintLine(h) {
  if (!h || typeof h === 'string') return String(h || '');
  const code = h.code ? '[' + h.code + '] ' : '';
  return code + (h.message || h.text || JSON.stringify(h));
}

export function printBrandReport(report, out = console.error) {
  out('brand guide for ' + report.owner + ': ' + report.status);
  if (report.guide && report.guide.url) out('  guide: ' + report.guide.url);
  for (const c of report.checks) {
    out((c.ok ? '  ok   ' : '  FAIL ') + c.name + ': ' + c.detail);
    if (!c.ok && c.fix) out('    fix: ' + c.fix);
  }
  for (const h of ((report.guide && report.guide.hints) || [])) out('  hint: ' + hintLine(h));
  if (!report.ok) {
    out('');
    out('The hero wears the PAGE OWNER\'s brand, so an unconfirmed guide is a decision');
    out('waiting for a human, not something this tool routes around. Pose the page and');
    out('walk the wizard\'s Brand step; Confirm needs a human at the terminal.');
  }
}

export function printReport(report, out = console.error) {
  for (const c of report.checks) {
    // a failed check that the chosen render path does not need is a miss, not
    // a failure: with the local renderer green, a silent pup relay is fine,
    // and with pup green, the local Chrome stack is optional fidelity.
    // An optional leg (pup) is only a failure when it was the last path left
    // and the report failed; otherwise it reads as optional, not as a FAIL.
    const tag = c.ok ? '  ok   ' : (report.ok || c.optional && !PLAT.inContainer() ? (c.optional ? '  opt  ' : '  miss ') : '  FAIL ');
    out(tag + c.name + ': ' + c.detail);
    if (!c.ok && c.fix && !(c.optional && report.ok)) out('    fix: ' + c.fix);
  }
  if (report.ok) {
    if (report.renderer === 'pup') {
      out('renderer ready via pup fallback: the HOST browser will render Generate.');
      out('Full fidelity comes from the local runtime; the fixes above get you there.');
    } else {
      out('renderer ready (local): Generate will work ' + PLAT.whereIn() + '.');
    }
  } else {
    out('');
    if (PLAT.inContainer()) {
      out('RENDERER_NOT_READY: Generate renders headless Chrome INSIDE this container,');
      out('or falls back to the host browser through Adom Desktop; neither path works');
      out('here. The checks above name what is missing and the exact fix. Run the fix');
      out('commands yourself (you are already in the container; no one needs to SSH');
      out('anywhere), then re-run: hero-studio doctor');
    } else {
      out('RENDERER_NOT_READY: Generate renders headless Chrome on this machine, and');
      out('that does not work yet. The checks above name what is missing and the exact');
      out('fix; apply them, then re-run: hero-studio doctor');
    }
  }
}

// CLI entry: `hero-studio doctor` (also run by install.sh in warn mode)
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
  const report = await runChecks();
  printReport(report);
  // `doctor --owner <handle>` (or --page <owner/slug>) also reports the brand
  // gate. Advisory: it prints and rides the JSON, but only the renderer
  // decides the exit code, because a guide awaiting a human is not a broken
  // install.
  const argv = process.argv.slice(2);
  const argOf = (n) => { const i = argv.indexOf('--' + n); return i >= 0 ? argv[i + 1] : null; };
  const owner = argOf('owner') || String(argOf('page') || '').split('/')[0] || null;
  let brand = null;
  if (owner) {
    console.error('');
    brand = await runBrandChecks(owner);
    printBrandReport(brand);
  }
  console.log(JSON.stringify({ ok: report.ok, renderer: report.renderer, checks: report.checks,
    ...(brand ? { brand: { ok: brand.ok, owner: brand.owner, status: brand.status, checks: brand.checks } } : {}) }));
  // exitCode, not process.exit(): on Windows, exiting while fetch's sockets
  // are still closing trips a libuv assertion (UV_HANDLE_CLOSING) and prints
  // a crash line after a clean report. The unref'd timer only fires if
  // something else were to hold the loop open.
  process.exitCode = report.ok ? 0 : 1;
  setTimeout(() => process.exit(process.exitCode), 8000).unref();
}