app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
#!/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();
}