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
← Commit history
Publish 0.22.0
13 files changed
+429−76
SKILL.md+33−1@@ -171,7 +171,7 @@ gate, which opens the studio in front of the human and BLOCKS until they click Generate: ```bash-hero-studio pose <owner>/<slug> --surface webview|pup [--title "T"] [--brief "one-liner"] [--type app,service] [--headline "H"] [--timeout 900] [--port N]+hero-studio pose <owner>/<slug> --surface webview|pup|browser [--title "T"] [--brief "one-liner"] [--type app,service] [--headline "H"] [--timeout 900] [--port N] # blocks; exits 0 with {file, path, port, latest} JSON on stdout when the HUMAN generates, # exit 2 on timeout, exit 1 on setup errors, exit 3 when the pup surface failed # (the reason is printed; there is NO silent webview fallback)@@ -223,6 +223,38 @@ Do not call POST /generate yourself to produce a hero; that bypasses the human a the result should not be published. Publishes of new app/skill heroes are rejected server-side (HERO_STUDIO_REQUIRED) unless the PNG carries the studio provenance stamp. +## On a desktop (Windows, macOS, Linux without an Adom container)++The studio runs on a plain desktop too. What changes:++- **Surface.** Use `--surface browser`: pose opens `http://127.0.0.1:<port>/` in+ the default browser (`start`, `open` or `xdg-open`) and prints the URL either+ way, then blocks for the human's Generate exactly as in a container. Without+ `VSCODE_PROXY_URI`, `webview` and `pup` refuse with NO_CONTAINER_PROXY.+- **Identity.** Wiki calls read as the first identity found, in the adom-wiki+ CLI's order: `ADOM_WIKI_TOKEN`, the adom-wiki stored token, the container key,+ the adom-cli login, then the Adom Bridge session in `~/.adom/session.json`.+ With none, pose prints WIKI_ANONYMOUS and the Brand step says so: private+ guides such as adom/brand look private or missing until you sign in.+- **Renderer.** Chrome is found in the puppeteer cache for any OS or at its+ standard install (Program Files, LOCALAPPDATA, /Applications); `CHROME`+ overrides. On Windows and macOS the headless launch is the check, not+ `chrome --version`. The pup leg reads `opt` (not used on a desktop).+- **Provenance stamp.** Needs Python with Pillow: `python3`, `python`, or+ `py -3` (on Windows `python3` is usually the Store alias stub), else+ ImageMagick 7 (`magick`; `convert` is never run on Windows). With neither,+ Generate fails with STAMP_FAILED and the fix, instead of keeping a PNG the+ registry would refuse.+- **Windows install.** install.sh runs under Git Bash: it hands Node native+ paths, writes `~/.local/bin/hero-studio` as a wrapper plus `hero-studio.cmd`+ for PowerShell and cmd, and reports a failed renderer step instead of+ stopping silently. Instance state lives under the real home directory, so+ a restart from PowerShell finds the pose saved from Git Bash.++`npm run test:platform` covers the identity order, Windows import paths, Chrome+locations per OS, a server in a desktop-only environment, and the STAMP_FAILED+refusal.+ ## Light containers: doctor must pass before the gate opens Generate renders headless Chrome INSIDE the container the server runs in, and
bin/hero-studio+10@@ -4,6 +4,16 @@ # studio in front of the human and blocks until they click Generate. SELF="$(readlink -f "$0")" DIR="$(cd "$(dirname "$SELF")/.." && pwd)"+# A launcher COPIED out of the package (Git Bash `ln -s` without native+# symlinks) resolves to ~/.local; refuse to guess and say how to repair it.+if [ ! -f "$DIR/server.js" ] && [ -n "$HERO_STUDIO_HOME" ]; then DIR="$HERO_STUDIO_HOME"; fi+if [ ! -f "$DIR/server.js" ]; then+ echo "hero-studio: cannot find the package from $SELF (no server.js in $DIR)." >&2+ echo " Re-run the package's install.sh, or set HERO_STUDIO_HOME to the package directory." >&2+ exit 1+fi+# Windows Node cannot open Git Bash's /c/... paths; hand it a native one.+DIR="$(cygpath -m "$DIR" 2>/dev/null || echo "$DIR")" if [ "$1" = "--ai-thread" ]; then export ADOM_AI_THREAD="$2" shift 2
data/pages.json+1−1@@ -342,7 +342,7 @@ "updated_at": "2026-07-17T15:21:36.819Z", "owner": "adom", "slug": "adom-wiki-cli",- "aiThread": "cli-thread hero refresh",+ "aiThread": "desk-test3", "org": "adom", "live": true },
doctor.mjs+55−20@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'fs'; import { execFile } from 'child_process'; import { homedir } from 'os'; import path from 'path';-import { fileURLToPath } from 'url';+import { fileURLToPath, pathToFileURL } from 'url'; const DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -20,14 +20,25 @@ const DIR = path.dirname(fileURLToPath(import.meta.url)); // 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(c)).default; } catch {}+ 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()); }@@ -91,13 +102,19 @@ export async function runChecks() { } catch (e) { fail('chrome binary', e.message, 'npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer\n' +- ' # --path matters: findChrome() scans ~/.cache/puppeteer/chrome/');+ ' # --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;- if (chrome) {+ // 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;@@ -140,6 +157,13 @@ export async function runChecks() { // 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');@@ -151,18 +175,20 @@ export async function runChecks() { 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 this container'+ ? '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 pil = await run('python3', ['-c', 'import PIL']);- const pilOk = pil.ok;- if (pil.ok) pass('python3 + PIL', 'provenance stamping ready');- else fail('python3 + PIL', 'the export downscale + provenance stamp needs Pillow',- 'sudo apt-get install -y python3-pil # or: pip3 install pillow');+ 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 };@@ -180,7 +206,7 @@ export async function runBrandChecks(owner) { const pass = (name, detail) => checks.push({ name, ok: true, detail }); const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};- try { headers.Authorization = 'Bearer ' + readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); let guide = null; try {@@ -190,7 +216,7 @@ export async function runBrandChecks(owner) { 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 this container\'s API key; until it answers, the hero holds rather than wearing Adom\'s look');+ '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;@@ -270,23 +296,32 @@ export function printReport(report, out = console.error) { // 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.- out((c.ok ? ' ok ' : (report.ok ? ' miss ' : ' FAIL ')) + c.name + ': ' + c.detail);- if (!c.ok && c.fix) out(' fix: ' + c.fix);+ // 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 in this container.');+ out('renderer ready (local): Generate will work ' + PLAT.whereIn() + '.'); } } else { out('');- 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');+ 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');+ } } }
install.sh+41−14@@ -1,26 +1,53 @@ #!/bin/sh set -e+# Windows (Git Bash / MSYS / Cygwin): Node cannot resolve Git Bash's /c/...+# paths, and `ln -s` makes a COPY unless MSYS=winsymlinks:nativestrict, which+# leaves a launcher that cannot find its package (hero-studio#62). Node gets a+# native path (cygpath -m), and the launcher is a wrapper, not a symlink.+case "$(uname -s 2>/dev/null)" in+ MINGW*|MSYS*|CYGWIN*) ON_WINDOWS=1 ;;+ *) ON_WINDOWS= ;;+esac+PKG="$(cygpath -m "$PWD" 2>/dev/null || echo "$PWD")"+ mkdir -p "$HOME/.local/bin" chmod +x "$PWD/bin/hero-studio"-ln -sfn "$PWD/bin/hero-studio" "$HOME/.local/bin/hero-studio"-echo "installed: hero-studio"-echo "run 'hero-studio' (serves on port 8846), then open /proxy/8846/ in a webview"+if [ -n "$ON_WINDOWS" ]; then+ rm -f "$HOME/.local/bin/hero-studio"+ printf '#!/bin/sh\nexec "%s/bin/hero-studio" "$@"\n' "$PWD" > "$HOME/.local/bin/hero-studio"+ chmod +x "$HOME/.local/bin/hero-studio"+ # PowerShell and cmd: run the same launcher through Git's bash.+ WINBIN="$(cygpath -w "$PWD/bin/hero-studio" 2>/dev/null || echo "$PWD/bin/hero-studio")"+ printf '@echo off\r\nwhere bash >nul 2>nul && (bash "%s" %%*) || ("%%ProgramFiles%%\\Git\\bin\\bash.exe" "%s" %%*)\r\n' "$WINBIN" "$WINBIN" > "$HOME/.local/bin/hero-studio.cmd"+ echo "installed: hero-studio (wrapper + hero-studio.cmd in ~/.local/bin; add it to PATH for PowerShell)"+ echo "run 'hero-studio', then open http://127.0.0.1:8846/ in a browser (pose: --surface browser)"+else+ ln -sfn "$PWD/bin/hero-studio" "$HOME/.local/bin/hero-studio"+ echo "installed: hero-studio"+ echo "run 'hero-studio' (serves on port 8846), then open /proxy/8846/ in a webview"+fi echo "refresh the sample data with ./refresh-data.sh (needs the adom-wiki CLI)" # hero-studio#58: a package update replaces this directory, node_modules-# included, so puppeteer-core lives OUTSIDE it (~/.cache/hero-studio) and an-# update cannot take the renderer away from a running studio. Installed here-# when nothing resolves; the pin follows this Node (24.x below 22.12).-RH="$HOME/.cache/hero-studio"-if node -e "process.exit(require('$PWD/renderer-paths.cjs').puppeteerPath() ? 0 : 1)" 2>/dev/null; then+# included, so puppeteer-core lives OUTSIDE it and an update cannot take the+# renderer away from a running studio. The directory comes from Node itself+# (renderer-paths.cjs), so it is the same one the studio reads on every OS.+# A failure here is reported, never silent, and never ends the script early.+RENDERER_OK=+if node -e "process.exit(require('$PKG/renderer-paths.cjs').puppeteerPath() ? 0 : 1)" 2>/dev/null; then echo "renderer dependency: puppeteer-core resolves"+ RENDERER_OK=1 else- PIN="$(node -e "console.log(require('$PWD/renderer-paths.cjs').puppeteerPin())")"- mkdir -p "$RH"- if command -v npm >/dev/null 2>&1 && npm install --prefix "$RH" --no-audit --no-fund --loglevel=error "puppeteer-core@$PIN" >/dev/null 2>&1; then+ RH="$(node -e "console.log(require('$PKG/renderer-paths.cjs').RENDERER_HOME)" 2>/dev/null || true)"+ PIN="$(node -e "console.log(require('$PKG/renderer-paths.cjs').puppeteerPin())" 2>/dev/null || echo 24)"+ if [ -z "$RH" ]; then+ echo "!! renderer dependency: FAILED: node could not load $PKG/renderer-paths.cjs, so puppeteer-core was not installed."+ echo "!! run 'hero-studio doctor' for the exact fix."+ elif command -v npm >/dev/null 2>&1 && npm install --prefix "$RH" --no-audit --no-fund --loglevel=error "puppeteer-core@$PIN" >/dev/null 2>&1; then echo "renderer dependency: installed puppeteer-core@$PIN into $RH (outside the package dir; updates keep it)"+ RENDERER_OK=1 else- echo "!! renderer dependency: could not install puppeteer-core@$PIN; run: npm install --prefix $RH puppeteer-core@$PIN"+ echo "!! renderer dependency: FAILED: could not install puppeteer-core@$PIN. Run: npm install --prefix \"$RH\" puppeteer-core@$PIN" fi fi @@ -29,7 +56,7 @@ fi # say which renderer actually verified. "OK" while local Chrome was missing is # how hero-studio#8 happened: the pup fallback carried a fresh install further # than its fidelity deserved, silently.-DOCTOR_JSON="$(node "$PWD/doctor.mjs" 2>/dev/null || true)"+DOCTOR_JSON="$(node "$PKG/doctor.mjs" 2>/dev/null || true)" case "$DOCTOR_JSON" in *'"renderer":"local"'*) echo "renderer: OK (local headless Chrome verified; Generate renders in-container)" ;;@@ -45,5 +72,5 @@ case "$DOCTOR_JSON" in echo "!! Do NOT open the pose gate for a human yet: run 'hero-studio doctor', !!" echo "!! run the fix commands it prints (you have the shell; no SSH needed), !!" echo "!! and re-run it until it passes. pose refuses to open until then. !!"- node "$PWD/doctor.mjs" 2>&1 | sed 's/^/ /' || true ;;+ node "$PKG/doctor.mjs" 2>&1 | sed 's/^/ /' || true ;; esac
package.json+5−3@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.21.5",+ "version": "0.22.0", "description": "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 PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is fixed for all. The floating app window is the showcase: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.", "license": "MIT", "tags": [@@ -68,8 +68,9 @@ "test:render": "node test/render-view.mjs", "test:renderer": "node test/renderer.mjs", "test:syntax": "node test/syntax.mjs",- "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs",- "prepublishOnly": "node test/syntax.mjs"+ "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs && node test/platform.mjs",+ "prepublishOnly": "node test/syntax.mjs",+ "test:platform": "node test/platform.mjs" }, "needs_sudo": false, "hero": {@@ -87,6 +88,7 @@ "drive.mjs", "doctor.mjs", "renderer-paths.cjs",+ "platform.cjs", "bridge.mjs", "capture.mjs", "bin/hero-studio",
page.json+5−3@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.21.5",+ "version": "0.22.0", "description": "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 PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is fixed for all. The floating app window is the showcase: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.", "license": "MIT", "tags": [@@ -68,8 +68,9 @@ "test:render": "node test/render-view.mjs", "test:renderer": "node test/renderer.mjs", "test:syntax": "node test/syntax.mjs",- "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs",- "prepublishOnly": "node test/syntax.mjs"+ "test": "node test/syntax.mjs && node test/brand.mjs && node test/render-view.mjs && node test/renderer.mjs && node test/platform.mjs",+ "prepublishOnly": "node test/syntax.mjs",+ "test:platform": "node test/platform.mjs" }, "needs_sudo": false, "hero": {@@ -88,6 +89,7 @@ "drive.mjs", "doctor.mjs", "renderer-paths.cjs",+ "platform.cjs", "bridge.mjs", "capture.mjs", "bin/hero-studio",
platform.cjsadded+151@@ -0,0 +1,151 @@+// Where Hero Studio is running, answered once for every module (server.js,+// doctor.mjs, pose.mjs, render-pup.mjs, install.sh). The studio was born in+// Adom containers and assumed them: a token file at /var/run/adom/api-key,+// HOME set, python3 on PATH, a VS Code proxy URL. On a Windows or macOS+// desktop none of that holds, and each assumption failed silently in its own+// place. Everything that depends on the host asks here instead.+const fs = require('fs');+const os = require('os');+const path = require('path');+const { execFileSync } = require('child_process');++const IS_WIN = process.platform === 'win32';+const IS_MAC = process.platform === 'darwin';+// Overridable for tests only: this box HAS a container key, and the identity+// order below must be testable without it.+const CONTAINER_TOKEN_FILE = process.env.HERO_STUDIO_CONTAINER_KEY || '/var/run/adom/api-key';++/// The home directory. os.homedir() reads USERPROFILE on Windows, so it is+/// right whichever shell started the process (PowerShell sets no HOME).+function homeDir() {+ return os.homedir();+}++/// Inside an Adom container: the container key exists, or the VS Code proxy+/// is set. Wording ("this container" vs "this machine") and the fix lines+/// branch on it.+function inContainer() {+ return !!process.env.VSCODE_PROXY_URI || fs.existsSync(CONTAINER_TOKEN_FILE);+}++function where() {+ return inContainer() ? 'this container' : 'this machine';+}++/// The same, with its preposition: "in this container" / "on this machine".+function whereIn() {+ return inContainer() ? 'in this container' : 'on this machine';+}++/// The per-user config directory, the same one the adom-wiki and adom-cli+/// CLIs use (Rust dirs::config_dir): %APPDATA% on Windows,+/// ~/Library/Application Support on macOS, $XDG_CONFIG_HOME or ~/.config.+function configDir() {+ if (IS_WIN) return process.env.APPDATA || path.join(homeDir(), 'AppData', 'Roaming');+ if (IS_MAC) return path.join(homeDir(), 'Library', 'Application Support');+ return process.env.XDG_CONFIG_HOME || path.join(homeDir(), '.config');+}++function readJsonField(file, field) {+ try {+ const v = JSON.parse(fs.readFileSync(file, 'utf8'));+ const t = v && typeof v[field] === 'string' ? v[field].trim() : '';+ return t || null;+ } catch { return null; }+}++function readTrimmed(file) {+ try { const t = fs.readFileSync(file, 'utf8').trim(); return t || null; } catch { return null; }+}++/// The wiki identity to read as, first match wins, in the adom-wiki CLI's+/// order: env ADOM_WIKI_TOKEN / ADOMPKG_TOKEN, the wiki CLI's stored token,+/// the container key, the adom-cli login, then the desktop session that Adom+/// Bridge writes to ~/.adom/session.json. Re-read on every call (cheap), so+/// a sign-in after the server started is picked up without a restart.+function wikiIdentity() {+ const env = (k) => (process.env[k] || '').trim() || null;+ const tries = [+ ['env:ADOM_WIKI_TOKEN', () => env('ADOM_WIKI_TOKEN')],+ ['env:ADOMPKG_TOKEN', () => env('ADOMPKG_TOKEN')],+ ['adom-wiki config', () => readJsonField(path.join(configDir(), 'adom-wiki', 'config.json'), 'api_token')],+ ['container key', () => readTrimmed(CONTAINER_TOKEN_FILE)],+ ['adom-cli login', () => readJsonField(path.join(configDir(), 'adom-cli', 'config.json'), 'api_token')],+ ['desktop session (~/.adom/session.json)', () => readJsonField(path.join(homeDir(), '.adom', 'session.json'), 'session_token')],+ ];+ for (const [source, get] of tries) {+ const token = get();+ if (token) return { token, source, anonymous: false };+ }+ return { token: null, source: 'none', anonymous: true };+}++/// { Authorization } for a wiki call, or {} when there is no identity.+function authHeader() {+ const id = wikiIdentity();+ return id.token ? { Authorization: 'Bearer ' + id.token } : {};+}++/// The interpreter that can run Pillow, probed once and cached: python3,+/// then python, then the Windows launcher `py -3`. On Windows `python3` is+/// usually the Microsoft Store alias stub, so it is tried last there.+/// Returns { cmd, args } (args to put before -c), or null.+let pythonCache;+function pythonWithPillow({ fresh = false } = {}) {+ if (pythonCache !== undefined && !fresh) return pythonCache;+ const order = IS_WIN+ ? [['python', []], ['py', ['-3']], ['python3', []]]+ : [['python3', []], ['python', []]];+ pythonCache = null;+ for (const [cmd, pre] of order) {+ try {+ execFileSync(cmd, [...pre, '-c', 'import PIL'], { stdio: 'ignore', timeout: 15000, windowsHide: true });+ pythonCache = { cmd, args: pre, label: [cmd, ...pre].join(' ') };+ break;+ } catch { /* next */ }+ }+ return pythonCache;+}++/// ImageMagick for the no-Pillow fallback. Never `convert` on Windows: that+/// name is the FAT-to-NTFS volume converter in System32. ImageMagick 7 is+/// `magick` everywhere.+function imageMagick() {+ const tries = IS_WIN ? ['magick'] : ['magick', 'convert'];+ for (const cmd of tries) {+ try {+ execFileSync(cmd, ['-version'], { stdio: 'ignore', timeout: 10000, windowsHide: true });+ return cmd;+ } catch { /* next */ }+ }+ return null;+}++/// Platform-aware fix lines for doctor and the gate.+function fixFor(what) {+ if (what === 'pillow') {+ if (IS_WIN) return 'py -m pip install pillow # or: python -m pip install pillow (python.org Python, not the Microsoft Store alias)';+ if (IS_MAC) return 'python3 -m pip install pillow';+ return 'sudo apt-get install -y python3-pil # or: pip3 install pillow';+ }+ if (what === 'chrome') {+ if (IS_WIN) return 'install Google Chrome, or set CHROME to a chrome.exe (e.g. C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe)';+ if (IS_MAC) return 'install Google Chrome, or set CHROME to "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"';+ return 'npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer';+ }+ return '';+}++/// The command that opens a URL in the default browser.+function openUrlCommand(url) {+ if (IS_WIN) return { cmd: 'cmd', args: ['/c', 'start', '""', url] };+ if (IS_MAC) return { cmd: 'open', args: [url] };+ return { cmd: 'xdg-open', args: [url] };+}++module.exports = {+ IS_WIN, IS_MAC, CONTAINER_TOKEN_FILE,+ homeDir, inContainer, where, whereIn, configDir,+ wikiIdentity, authHeader,+ pythonWithPillow, imageMagick, fixFor, openUrlCommand,+};
pose.mjs+40−6@@ -13,6 +13,7 @@ import { execFile, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path';+import os from 'os'; import fs from 'fs'; import { runChecks, printReport, hintLine } from './doctor.mjs'; import { makeBridge } from './bridge.mjs';@@ -49,12 +50,13 @@ if (!AI_THREAD) { process.env.ADOM_AI_THREAD = AI_THREAD; const ref = pos[0]; if (!ref || !ref.includes('/')) {- console.error('usage: hero-studio pose <owner/slug> --surface webview|pup [--title T] [--brief B] [--type app|skill] [--timeout SECS]');+ console.error('usage: hero-studio pose <owner/slug> --surface webview|pup|browser [--title T] [--brief B] [--type app|skill] [--timeout SECS]'); process.exit(1); } const surface = flags.surface;-if (surface !== 'webview' && surface !== 'pup') {- console.error('ERROR [SURFACE_REQUIRED]: say which surface the human is looking at: --surface webview | --surface pup');+if (surface !== 'webview' && surface !== 'pup' && surface !== 'browser') {+ console.error('ERROR [SURFACE_REQUIRED]: say which surface the human is looking at: --surface webview | --surface pup | --surface browser');+ console.error(' browser = the system default browser on this machine (a desktop with no Adom container); webview/pup are container surfaces.'); console.error(" why: opening the wrong surface means the human never sees the gate. Check their stored preference first:"); console.error(" adom-cli hydrogen probe # -> { window_mode: \"webview\" | \"pup\" }"); console.error(' then pass that value. Never open both surfaces for the same content.');@@ -76,6 +78,18 @@ if (!preflight.ok) { console.error('human until `hero-studio doctor` passes.'); process.exit(1); }+{+ const PLATI = (await import('module')).createRequire(import.meta.url)('./platform.cjs');+ const id = PLATI.wikiIdentity();+ if (id.anonymous) {+ console.error('WARNING [WIKI_ANONYMOUS]: no wiki identity found (ADOM_WIKI_TOKEN, adom-wiki or adom-cli login, the container key, or');+ console.error(' an Adom Bridge session in ~/.adom/session.json), so the studio reads the wiki anonymously: private pages and');+ console.error(' private brand guides (adom/brand is one) will look private or missing, and Generate can lock on them.');+ console.error(' Sign in first: adom-wiki auth login, or set ADOM_WIKI_TOKEN, then re-run pose.');+ } else {+ console.error('wiki identity: ' + id.source);+ }+} if (preflight.renderer === 'pup') { console.error('note: local renderer unavailable; Generate will render via the HOST browser'); console.error('(pup fallback). Works, slightly lower fidelity; `hero-studio doctor` lists');@@ -108,7 +122,7 @@ const sh = (cmd, a, timeout = 20000) => new Promise((res) => { // checklist. let port = Number(flags.port || process.env.HERO_STUDIO_PORT || 8846); let portPinned = Boolean(flags.port || process.env.HERO_STUDIO_PORT);-const INSTANCES_DIR = path.join(process.env.HOME || '/home/adom', '.adom', 'instances', 'hero-studio');+const INSTANCES_DIR = path.join(os.homedir(), '.adom', 'instances', 'hero-studio'); const THREAD_SLUG = AI_THREAD.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'unnamed'; if (!portPinned) { try {@@ -353,8 +367,16 @@ if (!(samePageRepose && !('fresh-showcase' in flags))) { } // 3. open the studio IN FRONT OF THE HUMAN on the surface they actually use.-const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', String(port));-if (!proxyBase) { console.error('VSCODE_PROXY_URI not set; cannot build the studio URL'); process.exit(1); }+// In a container the studio is reached through the VS Code proxy; on a+// desktop it is simply http://127.0.0.1:<port>/ and any browser opens it.+const proxyBase = process.env.VSCODE_PROXY_URI+ ? process.env.VSCODE_PROXY_URI.replace('{{port}}', String(port))+ : 'http://127.0.0.1:' + port + '/';+if (!process.env.VSCODE_PROXY_URI && surface !== 'browser') {+ console.error('ERROR [NO_CONTAINER_PROXY]: VSCODE_PROXY_URI is not set, so there is no ' + surface + ' surface to open here.');+ console.error(' On a desktop, re-run with --surface browser (opens ' + proxyBase + ' in the default browser).');+ process.exit(1);+} let tabName = port === 8846 ? 'Hero Studio' : 'Hero Studio :' + port; const SESSION = port === 8846 ? 'hero-studio' : 'hero-studio-' + port; if (surface === 'pup') {@@ -397,6 +419,18 @@ if (surface === 'pup') { process.exit(3); } await sh('adom-desktop', ['browser_alert_window', JSON.stringify({ sessionId: SESSION })]);+} else if (surface === 'browser') {+ // The system default browser (start / open / xdg-open). The URL is printed+ // either way, so a human can paste it if no browser opens.+ const PLAT = (await import('module')).createRequire(import.meta.url)('./platform.cjs');+ const o = PLAT.openUrlCommand(proxyBase);+ try {+ const child = spawn(o.cmd, o.args, { detached: true, stdio: 'ignore', windowsHide: true });+ child.on('error', (e) => console.error('could not launch the default browser (' + o.cmd + ': ' + e.message + '); open the URL by hand'));+ child.unref();+ } catch (e) { console.error('could not launch the default browser (' + e.message + '); open the URL by hand'); }+ tabName = proxyBase;+ console.error('studio URL: ' + proxyBase); } else { await openWebview(); }
public/js/wizard.js+5@@ -657,6 +657,11 @@ function brandStepBody(host) { return d; }; + // No wiki identity at all: a private guide then reads as private or+ // missing, and Generate would lock on a guide that is fine. Say why first.+ if (state.identity && state.identity.anonymous) {+ line('This studio is reading the wiki anonymously (no ADOM_WIKI_TOKEN, adom-wiki or adom-cli login, container key, or Adom Bridge session was found), so private brand guides look private or missing. Sign in, then refresh this step.', 'bad');+ } // Who may edit, in the server's own words. if (g.editable_by && g.editable_by.text) line(g.editable_by.text + (g.can_edit ? ' You can.' : ' You cannot, so this step is read-only.'));
render-pup.mjs+3−1@@ -1,4 +1,5 @@ #!/usr/bin/env node+import { createRequire } from 'module'; // Renders the studio's ?render=1 view via the HOST browser (Adom Desktop pup) // for containers that cannot run Chrome themselves (hero-studio#7). The host // opens the render view through the container proxy URL, screenshots it, and@@ -129,7 +130,8 @@ const py = [ 'print("%dx%d" % im2.size)', ].join('\n'); const crop = await new Promise((res) => {- execFile('python3', ['-c', py, srcPng, out, String(bx), String(by), String(bw), String(bh), String(dpr)],+ const PY = createRequire(import.meta.url)('./platform.cjs').pythonWithPillow() || { cmd: 'python3', args: [] };+ execFile(PY.cmd, [...PY.args, '-c', py, srcPng, out, String(bx), String(by), String(bw), String(bh), String(dpr)], { timeout: 30000 }, (err, stdout, stderr) => res({ code: err ? (err.code || 1) : 0, out: String(stdout || ''), err: String(stderr || '') })); }); if (srcPng === out + '.full.png') { try { fs.unlinkSync(srcPng); } catch {} }
renderer-paths.cjs+33−9@@ -60,16 +60,39 @@ function puppeteerPath() { 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;- const base = path.join(os.homedir(), '.cache', 'puppeteer', 'chrome');- if (fs.existsSync(base)) {- for (const d of fs.readdirSync(base).sort().reverse()) {- const p = path.join(base, d, 'chrome-linux64', 'chrome');- if (fs.existsSync(p)) return p;- }- }+ for (const c of chromeCandidates()) if (fs.existsSync(c)) return c; return null; } @@ -81,11 +104,12 @@ function puppeteerPin(nodeVersion) { } function puppeteerInstallCommand() {- return 'npm install --prefix ' + RENDERER_HOME + ' --no-audit --no-fund puppeteer-core@' + puppeteerPin() ++ 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'); } @@ -109,4 +133,4 @@ function quickStatus() { }; } -module.exports = { DIR, RENDERER_HOME, ISOLATED, puppeteerRoots, puppeteerCandidates, puppeteerPackages, puppeteerPath, chromePath, puppeteerPin, puppeteerInstallCommand, chromeInstallCommand, quickStatus };+module.exports = { DIR, RENDERER_HOME, ISOLATED, chromeCandidates, puppeteerRoots, puppeteerCandidates, puppeteerPackages, puppeteerPath, chromePath, puppeteerPin, puppeteerInstallCommand, chromeInstallCommand, quickStatus };
server.js+47−18@@ -3,6 +3,9 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process');+// Host facts (home dir, wiki identity, python, container or desktop): one+// module, loaded before anything below asks.+const PLAT = require('./platform.cjs'); // The useful line of a renderer's stderr. Node ends an uncaught exception with a blank line and // "Node.js v18.19.1", so taking the LAST line reported the Node version as the error (2026-09-22: // the human saw "Generate failed: local: Node.js v18.19.1"). Prefer the last line naming an error.@@ -36,7 +39,9 @@ if (!AI_THREAD) { const THREAD_SLUG = AI_THREAD.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'unnamed'; const TTL_HOURS = Number(process.env.APP_TTL_HOURS || argVal('--ttl') || 24); const KEEP_ALIVE = process.argv.includes('--keep-alive');-const INSTANCES_DIR = path.join(process.env.HOME || '/home/adom', '.adom', 'instances', 'hero-studio');+// os.homedir(), not HOME: PowerShell sets no HOME, and the old /home/adom+// fallback put a second, empty state tree at C:\\home\\adom on Windows.+const INSTANCES_DIR = path.join(PLAT.homeDir(), '.adom', 'instances', 'hero-studio'); const ROOT = path.join(__dirname, 'public'); const PAGES = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pages.json'), 'utf8')); // The provenance stamp reads the REAL version so it stays truthful across@@ -58,6 +63,7 @@ function rendererStatus() { return { ...s, running: PKG_VERSION, onDisk }; } function refreshRenderer() {+ { const id = PLAT.wikiIdentity(); state.identity = { source: id.source, anonymous: id.anonymous }; } const next = rendererStatus(); const strip = (x) => x ? JSON.stringify({ ...x, checkedAt: 0 }) : ''; const changed = strip(state.renderer) !== strip(next);@@ -82,6 +88,7 @@ let state = { // this replaces the two free-floating accent/bg fields it used to carry. guide: null, // { owner, status, sha, version, brand, assets, can_edit, editable_by, url } renderer: null, // hero-studio#58: { local:{ok,why,fix}, pup:{ok,why}, running, onDisk, checkedAt }+ identity: null, // { source, anonymous }: which wiki identity the studio reads as (never the token) brandEdits: {}, // staged Brand-step edits, dot path -> value, until a human confirms verify: false, // the pose gate turns this on: verification chrome + checklist confirm: { title: false, subtitle: false, showcase: false, type: false },@@ -283,9 +290,13 @@ try { poseRev += 1; merge(state, snap.state); if (snap.changes) changes = snap.changes;- logLine('info', 'restored session snapshot (' + (snap.state.page || '?') + ')');+ logLine('info', 'restored session snapshot (' + (snap.state.page || '?') + ') from ' + SNAP);+ } else {+ logLine('info', 'session snapshot at ' + SNAP + ' is older than 24 h; starting fresh'); }-} catch {}+} catch (e) {+ logLine('info', 'no session snapshot restored: looked for ' + SNAP + (e && e.code === 'ENOENT' ? ' (none there)' : ' (' + (e && e.message) + ')'));+} let snapTimer = null; function saveSnapshot() { clearTimeout(snapTimer);@@ -389,7 +400,7 @@ async function fetchRemoteGates() { try { const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); const who = await fetch(REG + '/auth/me', { headers }).then((r) => (r.ok ? r.json() : null)).catch(() => null); const handle = who && (who.username || who.name || who.handle || (who.user && (who.user.username || who.user.name || who.user.handle))); if (!handle) return;@@ -421,7 +432,7 @@ function isSeasoned() { async function refreshPageLive(ref) { const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); const r = await fetch(REG + '/api/pages/' + ref, { headers, redirect: 'follow' }); if (!r.ok) throw new Error('page fetch ' + r.status); const pj = await r.json();@@ -461,7 +472,7 @@ async function confirmBrandGuide(human, fromGate) { const owner = g.owner; const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = { 'Content-Type': 'application/json' };- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); let handle = String(human || '').trim(); if (!handle) { try {@@ -507,7 +518,7 @@ async function resolveBrandGuide(owner, { fresh = false } = {}) { if (!fresh && hit && Date.now() - hit.at < BRAND_TTL_MS) return hit.guide; const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); let guide; try { const r = await fetch(REG + '/api/owners/' + encodeURIComponent(owner) + '/brand', { headers });@@ -569,7 +580,7 @@ const starterOutcome = new Map(); async function draftStarter(owner) { const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = { 'Content-Type': 'application/json' };- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); let outcome; try { const r = await fetch(REG + '/api/owners/' + encodeURIComponent(owner) + '/brand/starter', { method: 'POST', headers, body: '{}' });@@ -591,7 +602,7 @@ async function draftStarter(owner) { // the step waiting for a draft that will never come. outcome.result = 'exists_unreadable'; outcome.error = owner + '/brand exists on the registry but this studio cannot read it';- outcome.hint = 'the studio reads the guide as the human at the gate (the container key at /var/run/adom/api-key); check that key is present and that this identity can read ' + owner + '/brand';+ { const id = PLAT.wikiIdentity(); outcome.hint = 'the studio reads the guide as ' + (id.anonymous ? 'NOBODY (no wiki identity found: set ADOM_WIKI_TOKEN, sign in with adom-wiki or Adom Bridge)' : 'the identity from ' + id.source) + '; check that this identity can read ' + owner + '/brand'; } logLine('warn', 'brand starter for ' + owner + ': exists, but the resolver answers missing (identity mismatch?)'); } else { outcome.result = 'timeout';@@ -656,7 +667,7 @@ async function refreshGuide({ fresh = false } = {}) { async function showcaseCandidates(ref) { const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); const slot = state.slotSize && state.slotSize.w ? state.slotSize.w + 'x' + state.slotSize.h : 'the size the left panel shows';@@ -772,7 +783,7 @@ async function showcaseCandidates(ref) { async function heroProvenance(ref) { const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); let heroPath = 'screenshots/hero.png'; try { const pj = await (await fetch(REG + '/api/pages/' + ref + '/files/page.json', { headers })).json();@@ -984,7 +995,7 @@ function route(req, res, url, p) { if (!fields.length) return finish({ ok: false, error: 'no page-field edits to apply' }); const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = { 'Content-Type': 'application/json' };- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); const body = { changelog: 'Edited in Hero Studio: ' + fields.join(', ') }; for (const f of fields) body[f] = changes.page[f].to; const stash = () => {@@ -1122,7 +1133,7 @@ function route(req, res, url, p) { const ref = url.searchParams.get('page') || state.page; const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); return (async () => { const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); }; try {@@ -1210,7 +1221,7 @@ function route(req, res, url, p) { } if (!target) { res.writeHead(404); return res.end('no such brand asset'); } const headers = {};- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); fetch(target, { headers }) .then(async (r) => { if (!r.ok) { res.writeHead(r.status); return res.end('brand asset ' + r.status); }@@ -1293,7 +1304,7 @@ function route(req, res, url, p) { const wantPath = url.searchParams.get('path') || ''; const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};- try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ Object.assign(headers, PLAT.authHeader()); const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); }; (async () => { const lr = await fetch(REG + '/api/pages/' + ref + '/files', { headers });@@ -1657,11 +1668,29 @@ function route(req, res, url, p) { ' im = im.resize((2000, round(2000 * im.size[1] / im.size[0])), Image.LANCZOS)\n' + 'info = PngInfo(); info.add_text("comment", meta)\n' + 'im.save(f, pnginfo=info)\n';- execFile('python3', ['-c', py, outFile, meta], (err) => {- if (err) return execFile('convert', [outFile, '-resize', '2000x', '-set', 'comment', meta, outFile], (err2) => {- if (err2) logLine('warn', 'no python3/PIL or convert; keeping full-size PNG without metadata');+ // The stamp is what the registry checks, so an unstamped PNG is a+ // failed Generate, not a success with a console warning (the human+ // would only learn at publish). Python with Pillow first (python3,+ // python, or py -3: on Windows python3 is usually the Store alias+ // stub), then ImageMagick (`magick`; never `convert` on Windows, which+ // is the volume converter there).+ const stampFailed = (why) => {+ logLine('error', 'provenance stamp failed: ' + why);+ finish({ ok: false, errorCode: 'STAMP_FAILED', error: 'the hero rendered but could not be stamped with its provenance, so it was not kept as a result: ' + why,+ hint: 'install Pillow for a Python on PATH (' + PLAT.fixFor('pillow') + ') or ImageMagick 7 (magick), then Generate again; hero-studio doctor checks this' });+ };+ const pyCmd = PLAT.pythonWithPillow();+ const viaMagick = () => {+ const magick = PLAT.imageMagick();+ if (!magick) return stampFailed('no Python with Pillow' + (pyCmd ? ' that ran cleanly' : '') + ' and no ImageMagick on PATH');+ execFile(magick, [outFile, '-resize', '2000x', '-set', 'comment', meta, outFile], { windowsHide: true }, (err2) => {+ if (err2) return stampFailed(magick + ' failed: ' + String(err2.message || err2).split('\n')[0]); afterFinalize(); });+ };+ if (!pyCmd) viaMagick();+ else execFile(pyCmd.cmd, [...pyCmd.args, '-c', py, outFile, meta], { windowsHide: true }, (err) => {+ if (err) { logLine('warn', pyCmd.label + ' stamp failed (' + String(err.message || err).split('\n')[0] + '); trying ImageMagick'); return viaMagick(); } afterFinalize(); }); function afterFinalize() {