master
Colby Knox Publish 0.22.0 540222b 19d ago
// Where the local renderer's pieces live, shared by doctor.mjs (the full
// preflight), server.js (the cheap status the gate shows) and install.sh.
// One list, so a path added here is seen by every reader (hero-studio#58).
//
// puppeteer-core is installed OUTSIDE the package directory, under
// ~/.cache/hero-studio, because a package update replaces the package
// directory including its node_modules, which is how a running studio lost
// its renderer between a passing doctor and the human's Generate click.
const fs = require('fs');
const path = require('path');
const os = require('os');

const DIR = __dirname;
const RENDERER_HOME = process.env.HERO_STUDIO_RENDERER_HOME || path.join(os.homedir(), '.cache', 'hero-studio');
// Test-only: consult nothing but the env override and RENDERER_HOME, so a
// box that has puppeteer-core in a shared node_modules can still exercise
// the "no renderer" path.
const ISOLATED = process.env.HERO_STUDIO_RENDERER_ISOLATED === '1';

// Package roots, in the order they win. The entry file inside differs by
// line (25.x: lib/puppeteer/puppeteer-core.js; 24.x: lib/esm/puppeteer/...),
// so it is read from each package.json rather than assumed.
function puppeteerRoots() {
  const list = [path.join(RENDERER_HOME, 'node_modules', 'puppeteer-core')];
  if (!ISOLATED) {
    list.push(path.join(DIR, 'node_modules', 'puppeteer-core'), '/home/adom/project/node_modules/puppeteer-core', path.join(os.homedir(), 'node_modules', 'puppeteer-core'));
  }
  return list;
}

function entryOf(root) {
  try {
    const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
    const ex = pkg.exports && pkg.exports['.'];
    const rel = (ex && (typeof ex === 'string' ? ex : (ex.import || ex.default || ex.require))) || pkg.main;
    const f = rel ? path.join(root, rel) : null;
    return f && fs.existsSync(f) ? f : null;
  } catch { return null; }
}

// Entry files worth trying, best first: the env override (a file), then each
// root's own entry.
function puppeteerCandidates() {
  const list = [];
  if (process.env.PUPPETEER_CORE) list.push(process.env.PUPPETEER_CORE);
  for (const r of puppeteerRoots()) { const e = entryOf(r); if (e) list.push(e); }
  return list;
}

// package.json paths for the same roots (the engines check reads them).
function puppeteerPackages() {
  return puppeteerRoots().map((r) => path.join(r, 'package.json')).filter((f) => fs.existsSync(f));
}

// The first puppeteer-core entry file that exists, else the package as Node
// resolves it from here, else null.
function puppeteerPath() {
  for (const c of puppeteerCandidates()) if (fs.existsSync(c)) return c;
  if (ISOLATED) return null;
  try { return require.resolve('puppeteer-core'); } catch { return null; }
}

// Every place a usable Chrome can live, best first: CHROME, the puppeteer
// cache (whose folder name depends on the OS: chrome-linux64, chrome-win64,
// chrome-mac-arm64 / chrome-mac-x64), then the standard system installs.
// Only the Linux cache layout used to be known, so a desktop with Chrome in
// Program Files was told Chrome was missing.
function chromeCandidates(platform = process.platform, env = process.env, home = os.homedir()) {
  const list = [];
  const cache = path.join(home, '.cache', 'puppeteer', 'chrome');
  let versions = [];
  try { versions = fs.readdirSync(cache).sort().reverse(); } catch { versions = []; }
  const inCache = (rel) => versions.map((d) => path.join(cache, d, ...rel));
  if (platform === 'win32') {
    list.push(...inCache(['chrome-win64', 'chrome.exe']), ...inCache(['chrome-win32', 'chrome.exe']));
    for (const base of [env.ProgramFiles, env['ProgramFiles(x86)'], env.LOCALAPPDATA]) {
      if (base) list.push(path.win32.join(base, 'Google', 'Chrome', 'Application', 'chrome.exe'));
    }
  } else if (platform === 'darwin') {
    for (const arch of ['chrome-mac-arm64', 'chrome-mac-x64', 'chrome-mac']) {
      list.push(...inCache([arch, 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing']));
    }
    list.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
      path.join(home, 'Applications', 'Google Chrome.app', 'Contents', 'MacOS', 'Google Chrome'));
  } else {
    list.push(...inCache(['chrome-linux64', 'chrome']));
    list.push('/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser');
  }
  return list;
}

function chromePath() {
  if (process.env.CHROME && fs.existsSync(process.env.CHROME)) return process.env.CHROME;
  if (ISOLATED) return null;
  for (const c of chromeCandidates()) if (fs.existsSync(c)) return c;
  return null;
}

// puppeteer-core 25.x wants Node >= 22.12 (it imports on 18 and then fails at
// launch); 24.x is the last line that runs on older Node.
function puppeteerPin(nodeVersion) {
  const [a, b] = String(nodeVersion || process.versions.node).split('.').map(Number);
  return a > 22 || (a === 22 && b >= 12) ? 'latest' : '24';
}

function puppeteerInstallCommand() {
  return 'npm install --prefix "' + RENDERER_HOME + '" --no-audit --no-fund puppeteer-core@' + puppeteerPin() +
    '    # outside the package dir, so a package update cannot remove it; no restart needed';
}

function chromeInstallCommand() {
  if (process.platform === 'win32' || process.platform === 'darwin') return require('./platform.cjs').fixFor('chrome');
  return 'npx @puppeteer/browsers install chrome@stable --path ' + path.join(os.homedir(), '.cache', 'puppeteer');
}

// The cheap, synchronous status the gate shows: files exist, nothing is
// launched. doctor.mjs is the ground truth (it launches Chrome); this is what
// can run before every Generate and once a minute without cost.
function quickStatus() {
  const pptr = puppeteerPath();
  const chrome = chromePath();
  const proxied = !!process.env.VSCODE_PROXY_URI;
  return {
    local: {
      ok: !!(pptr && chrome),
      puppeteer: pptr,
      chrome,
      why: !pptr ? 'puppeteer-core not found' : (!chrome ? 'Chrome not found' : null),
      fix: !pptr ? puppeteerInstallCommand() : (!chrome ? chromeInstallCommand() : null),
    },
    pup: { ok: proxied, why: proxied ? null : 'VSCODE_PROXY_URI is not set, so the host browser cannot reach the studio' },
    checkedAt: Date.now(),
  };
}

module.exports = { DIR, RENDERER_HOME, ISOLATED, chromeCandidates, puppeteerRoots, puppeteerCandidates, puppeteerPackages, puppeteerPath, chromePath, puppeteerPin, puppeteerInstallCommand, chromeInstallCommand, quickStatus };