master
Colby Knox Publish 0.20.8 943e8e7 1mo ago
#!/usr/bin/env node
// hero-studio drive verbs: everything an AI pushes into a LIVE studio session.
// The open studio follows within a second (SSE + poll), so the human watches
// the pose change as commands land.
//
//   hero-studio showcase --file <png> [--full] [--cover] [--chrome none|fake|bare] load a real screenshot
//   hero-studio showcase --from-page <owner/slug> [--path <repo image>]         take one off a wiki page
//   hero-studio set '<json>' | key=value [...]            patch posing state
//   hero-studio state                                     print current state
//   hero-studio toast "<text>" [--kind info|success|warn] narrate to the human
//   hero-studio reroll                                    re-roll the circuitry pattern
import path from 'path';

// --port <n> / HERO_STUDIO_PORT target a specific instance (hero-studio#15).
const pi = process.argv.indexOf('--port');
const PORT = Number((pi >= 0 && process.argv[pi + 1]) || process.env.HERO_STUDIO_PORT || 8846);
if (pi >= 0) process.argv.splice(pi, 2);
// Global --ai-thread (#39): the settled platform term; travels as a top-level
// aiThread field on every POST. Env fallback is for single-agent boxes only.
const ti = process.argv.indexOf('--ai-thread');
const AI_THREAD = (ti >= 0 && process.argv[ti + 1]) || process.env.ADOM_AI_THREAD || '';
if (ti >= 0) process.argv.splice(ti, 2);
const BASE = 'http://localhost:' + PORT + '/';
const args = process.argv.slice(2);
const verb = args.shift();

const jf = async (p, body) => {
  if (body !== undefined && !AI_THREAD) {
    console.error('caller_identity_required: this verb changes state, so it needs the ai-thread name.');
    console.error('  Re-run: hero-studio --ai-thread "<name>" ' + (verb || '') + ' ...');
    console.error('  Pick a short name a HUMAN would recognize for THIS conversation (like');
    console.error("  'kicad board review', not 'agent' or a UUID) and reuse it on every call.");
    process.exit(1);
  }
  const r = await fetch(BASE + p, body === undefined ? undefined : {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(Object.assign({ aiThread: AI_THREAD }, body)),
  });
  const j = await r.json().catch(() => ({}));
  if (!r.ok) {
    if (j && j.errorCode === 'caller_identity_required') throw new Error(j.error + ' ' + j.hint);
    throw new Error(p + ' -> ' + r.status);
  }
  return j;
};
const die = (msg) => { console.error(msg); process.exit(1); };

try {
  if (verb === 'showcase') {
    const ui = args.indexOf('--from-url');
    if (ui >= 0) {
      // #41: the studio drives the capture itself: slot math, DPR, sentinel
      // verification, and aspect cropping all happen here, not in the caller.
      const url2 = args[ui + 1];
      if (!url2) die('usage: hero-studio showcase --from-url <url> [--selector <css>] [--full] [--cover]');
      const si = args.indexOf('--selector');
      const st = await jf('state');
      const slot = st.slotSize || { w: 1280, h: 960 };
      const { captureUrl } = await import('./capture.mjs');
      const os = await import('os');
      const tmp = path.join(os.tmpdir(), 'hs-showcase-' + Date.now() + '.png');
      console.error('capturing ' + url2 + ' at slot aspect ' + (slot.w / slot.h).toFixed(2) + ' (' + slot.w + 'x' + slot.h + ') via the desktop bridge...');
      const cap = await captureUrl({
        url: url2, selector: si >= 0 ? args[si + 1] : null,
        aspect: slot.w / slot.h, outFile: tmp,
        target: process.env.ADOM_TARGET || '', thread: AI_THREAD,
      });
      if (!cap.ok) die('ERROR: ' + cap.error);
      const out2 = await jf('showcase-file', { path: tmp, full: args.includes('--full'), cover: args.includes('--cover'), chrome: '' });
      if (!out2.ok) die('ERROR: ' + out2.error);
      console.log(JSON.stringify({ ok: true, captured: cap.width + 'x' + cap.height, bytes: out2.bytes }));
      console.error('showcase captured and loaded; the open studio updates within a second');
      process.exit(0);
    }
    const fi = args.indexOf('--file');
    const pi = args.indexOf('--from-page');
    if (pi >= 0) {
      const pref = args[pi + 1];
      if (!pref || pref.startsWith('--')) die('usage: hero-studio showcase --from-page <owner/slug> [--path <repo-relative image>]');
      const pathIdx = args.indexOf('--path');
      const want = pathIdx >= 0 ? args[pathIdx + 1] : '';
      const qs = 'showcase-from-page?page=' + encodeURIComponent(pref) + (want ? '&path=' + encodeURIComponent(want) : '');
      const r = await jf(qs, { method: 'POST' });
      if (!r.ok) die('showcase --from-page: ' + (r.error || 'failed'));
      console.log(JSON.stringify({ ok: true, picked: r.picked, bytes: r.bytes }));
      console.error('showcase <- ' + pref + '/' + r.picked + ' (' + Math.round(r.bytes / 1024) + ' KB); the open studio updates within a second');
      process.exit(0);
    }
    if (fi < 0 || !args[fi + 1]) die('usage: hero-studio showcase --file <png> | --from-page <owner/slug> [--path <p>] | --from-url <url> [--selector <css>] [--full] [--cover] [--chrome none|fake|bare]');
    const file = path.resolve(args[fi + 1]);
    const ci = args.indexOf('--chrome');
    const chrome = ci >= 0 ? args[ci + 1] : '';
    if (chrome && !['none', 'fake', 'bare'].includes(chrome)) die('--chrome takes none|fake|bare (bare: no frame, bg, or shadow at all; default: auto, which is none for imported screenshots)');
    const out = await jf('showcase-file', {
      path: file,
      full: args.includes('--full'),
      cover: args.includes('--cover'),
      chrome,
    });
    if (!out.ok) die('ERROR: ' + out.error + (out.error && out.error.includes('cannot read') ? '\n  hint: the studio server reads the path locally; pass an absolute path on this machine' : ''));
    const slot = out.slot ? ' (slot wants ' + out.slot.w + 'x' + out.slot.h + ')' : '';
    console.log(JSON.stringify({ ok: true, bytes: out.bytes, slot: out.slot }));
    console.error('showcase loaded' + slot + '; the open studio updates within a second');
  } else if (verb === 'set') {
    // --list: print the valid enum tokens (hero-studio#24 ask 2)
    if (args.includes('--list')) {
      const st = await jf('state');
      console.log(JSON.stringify({ enums: st.enums }, null, 2));
      process.exit(0);
    }
    let patch = {};
    if (args.length === 1 && args[0].trim().startsWith('{')) {
      patch = JSON.parse(args[0]);
    } else if (args.length) {
      for (const kv of args) {
        const eq = kv.indexOf('=');
        if (eq < 1) die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora fontSize.title=9 | set --list');
        const k = kv.slice(0, eq);
        const v = kv.slice(eq + 1);
        const val = /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v === 'true' ? true : v === 'false' ? false : v;
        // dot-paths build nested patches (#24 ask 1: fontSize.title=9 used to
        // land as a literal "fontSize.title" key the page ignored)
        const parts = k.split('.');
        let node = patch;
        for (let i = 0; i < parts.length - 1; i++) node = node[parts[i]] = node[parts[i]] || {};
        node[parts[parts.length - 1]] = val;
      }
    } else {
      die('usage: hero-studio set \'{"theme":"aurora"}\' | theme=aurora fontSize.title=9 | set --list');
    }
    const r = await fetch(BASE + 'state', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ aiThread: AI_THREAD }, patch)),
    });
    const out = await r.json().catch(() => ({}));
    if (!r.ok || out.ok === false) {
      die('ERROR: ' + (out.error || 'state rejected') + (out.valid ? '\n  valid: ' + out.valid.join(' | ') : ''));
    }
    console.log(JSON.stringify({ ok: true, applied: patch }));
  } else if (verb === 'bg') {
    const fi = args.indexOf('--file');
    if (fi < 0 || !args[fi + 1]) die('usage: hero-studio bg --file <image> [--cover]   (imports background art as theme "image")');
    const out = await jf('bg-file', { path: path.resolve(args[fi + 1]), cover: args.includes('--cover') });
    if (!out.ok) die('ERROR: ' + out.error);
    console.log(JSON.stringify({ ok: true, theme: 'image', bytes: out.bytes }));
    console.error('background art imported; theme switched to image (composed text, layouts, and the linter still apply)');
  } else if (verb === 'changes') {
    console.log(JSON.stringify(await jf('changes'), null, 2));
  } else if (verb === 'state') {
    console.log(JSON.stringify(await jf('state'), null, 2));
  } else if (verb === 'toast') {
    const text = args.filter((a) => !a.startsWith('--')).join(' ');
    const ki = args.indexOf('--kind');
    await jf('ui/toast', { text, kind: ki >= 0 ? args[ki + 1] : 'info' });
    console.log('{"ok":true}');
  } else if (verb === 'reroll') {
    console.log(JSON.stringify(await jf('reroll', {})));
  } else if (verb === 'preset') {
    // named family design systems (#34.2); no arg lists what exists
    const name = args.find((a) => !a.startsWith('--'));
    const r = await fetch(BASE + 'preset', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || '', aiThread: AI_THREAD }),
    });
    const out = await r.json().catch(() => ({}));
    if (!r.ok || out.ok === false) {
      if (out.valid) { console.log(JSON.stringify({ presets: out.valid }, null, 2)); process.exit(name ? 1 : 0); }
      die('ERROR: ' + (out.error || 'preset rejected'));
    }
    console.log(JSON.stringify(out));
    console.error('preset ' + out.applied + ' applied; the open studio updates within a second');
  } else if (verb === 'ls') {
    // the instance registry (#39): live studios, liveness-checked, dead
    // entries pruned, version drift vs the on-disk package flagged
    const fsm = await import('fs');
    const os = await import('os');
    const dir = path.join(os.homedir(), '.adom', 'instances', 'hero-studio');
    const diskVer = JSON.parse(fsm.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
    const rows = [];
    let files = [];
    try { files = fsm.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch {}
    for (const f of files) {
      let e;
      try { e = JSON.parse(fsm.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }
      try { process.kill(e.pid, 0); } catch { try { fsm.unlinkSync(path.join(dir, f)); } catch {}; continue; }
      const idleMin = Math.round((Date.now() - new Date(e.lastActivity || e.startedAt).getTime()) / 60000);
      rows.push({ port: e.port, thread: e.thread, task: e.task, version: e.version, idleMinutes: idleMin, pid: e.pid, drift: e.version !== diskVer });
    }
    rows.sort((a, b) => a.port - b.port);
    console.log(JSON.stringify({ instances: rows, diskVersion: diskVer }, null, 2));
    for (const r2 of rows) {
      if (r2.drift) console.error('WARN: port ' + r2.port + ' runs ' + r2.version + ', disk has ' + diskVer + '. Shut it down (hero-studio stop --port ' + r2.port + ') and respawn.');
    }
    if (!rows.length) console.error('no live instances; hero-studio --ai-thread "<name>" starts one (or pose does)');
  } else if (verb === 'fit') {
    // auto-fit the title (#40): solved in the open surface with real metrics
    const out = await jf('fit', {});
    if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n  hint: ' + out.hint : ''));
    console.log(JSON.stringify(out));
    console.error('fit requested; read state.fontSize.title in about a second');
  } else if (verb === 'style') {
    // style save <name> | style list -- the human-gated template for bulk
    const sub = args.find((a) => !a.startsWith('--'));
    if (sub === 'list' || !sub) {
      console.log(JSON.stringify(await jf('styles'), null, 2));
    } else if (sub === 'save') {
      const name = args[args.indexOf('save') + 1];
      if (!name) die('usage: hero-studio style save <name>   (after a human-approved Generate)');
      const out = await jf('style', { name });
      if (!out.ok) die('ERROR: ' + out.error + (out.hint ? '\n  hint: ' + out.hint : ''));
      console.log(JSON.stringify(out));
      console.error('style "' + out.name + '" saved, gated by the ' + out.gate.page + ' approval; batch with: hero-studio batch --style ' + out.name + ' --parts <parts.json>');
    } else die('usage: hero-studio style save <name> | style list');
  } else if (verb === 'batch') {
    // The bulk half of the once-through-the-gate model: apply a human-gated
    // style to many parts, generating each locally (no wiki calls, no rate
    // limits here); --pace spaces the renders.
    const si = args.indexOf('--style');
    const pi2 = args.indexOf('--parts');
    if (si < 0 || pi2 < 0) die('usage: hero-studio batch --style <name> --parts <parts.json> [--pace SECS] [--human <who>] [--quote "their words"]\n  parts.json: [{"ref":"adom/x","title":"X","showcase":"render.png","kind":"component"}, ...]');
    const styleName = args[si + 1];
    const fsm = await import('fs');
    const parts = JSON.parse(fsm.readFileSync(args[pi2 + 1], 'utf8'));
    const pace = Number(args[args.indexOf('--pace') + 1]) || 5;
    const human = args.includes('--human') ? args[args.indexOf('--human') + 1] : '';
    const quote = args.includes('--quote') ? args[args.indexOf('--quote') + 1] : '';
    if (!human || !quote) die('the batch carries the human\'s attestation: --human <who approved the style> --quote "their approval words"');
    const styles = await jf('styles');
    const st2 = (styles.styles || []).find((x) => x.name === styleName);
    if (!st2) die('unknown style "' + styleName + '"; hero-studio style list shows saved ones');
    const stylePatch = JSON.parse(fsm.readFileSync((await import('os')).homedir() + '/.adom/instances/hero-studio/styles/' + styleName + '.json', 'utf8')).style;
    const results = [];
    console.error('batch: ' + parts.length + ' part(s), style "' + styleName + '" (gated by ' + st2.gate.page + '), pace ' + pace + 's');
    for (let i = 0; i < parts.length; i++) {
      const part = parts[i];
      try {
        await jf('api/pages', { owner: part.ref.split('/')[0], slug: part.ref.split('/')[1], title: part.title, type: part.type || 'hardware', kind: part.kind });
        await jf('state', Object.assign({}, stylePatch, { page: part.ref, headline: part.headline || '', subtitle: part.subtitle || '', showcase: Object.assign({ src: '', full: false }, stylePatch.showcase || {}) }));
        if (part.showcase) {
          const v = await jf('showcase-file', { path: path.resolve(part.showcase), cover: !!(stylePatch.showcase || {}).cover, chrome: (stylePatch.showcase || {}).chrome || '' });
          if (!v.ok) throw new Error('showcase: ' + v.error);
        }
        const g = await jf('generate', { attest: { style: styleName, human, quote } });
        if (!g.ok) throw new Error(g.error);
        // wait for the finalize (poll state._gen)
        let done = null;
        for (let w = 0; w < 60 && !done; w++) {
          await new Promise((r) => setTimeout(r, 1000));
          const stt = await jf('state');
          if (stt._gen && stt._gen.ok && stt._gen.file && (!results.length || stt._gen.file !== results[results.length - 1].file)) done = stt._gen;
        }
        if (!done) throw new Error('generate did not finish in 60s');
        results.push({ ref: part.ref, ok: true, file: done.file });
        console.error('  [' + (i + 1) + '/' + parts.length + '] ' + part.ref + ' -> ' + done.file);
      } catch (e) {
        results.push({ ref: part.ref, ok: false, error: e.message });
        console.error('  [' + (i + 1) + '/' + parts.length + '] ' + part.ref + ' FAILED: ' + e.message);
      }
      if (i < parts.length - 1) await new Promise((r) => setTimeout(r, pace * 1000));
    }
    console.log(JSON.stringify({ style: styleName, results }, null, 1));
    const bad = results.filter((r) => !r.ok).length;
    console.error('batch done: ' + (results.length - bad) + ' ok, ' + bad + ' failed; PNGs in generated/ (copy each to its part\'s docs/hero.png before publish)');
  } else if (verb === 'instances') {
    // every live studio on this box (#26): scan the port neighborhood
    const ports = new Set([PORT]);
    for (let p2 = 8846; p2 <= 8866; p2++) ports.add(p2);
    const rows = [];
    await Promise.all([...ports].map(async (p2) => {
      try {
        const ac = new AbortController();
        const t = setTimeout(() => ac.abort(), 900);
        const r = await fetch('http://localhost:' + p2 + '/state', { signal: ac.signal });
        clearTimeout(t);
        if (!r.ok) return;
        const st = await r.json();
        if (!st || !st.page) return;
        rows.push({ port: p2, page: st.page, gateOpen: !!st.verify, posedAt: st.posedAt || null, approved: st.approved ? st.approved.file : null });
      } catch {}
    }));
    rows.sort((a, b) => a.port - b.port);
    console.log(JSON.stringify({ instances: rows }, null, 2));
    if (!rows.length) console.error('no live studio instances on ports 8846-8866; `hero-studio` or `hero-studio pose ...` starts one');
  } else if (verb === 'stop') {
    // polite shutdown of one instance (#26); refuses nothing, targets by --port
    const out = await jf('shutdown', {});
    console.log(JSON.stringify(out));
    console.error('studio on :' + (out.port || PORT) + ' is shutting down');
  } else if (verb === 'compose') {
    // headless compose of the CURRENT pose (#36.2): verify staging without a
    // browser. Not the gate: no provenance stamp, never publishable.
    const fsm = await import('fs');
    const outFile = path.resolve(args.find((a) => !a.startsWith('--')) || 'compose.png');
    const r = await fetch(BASE + 'compose.png');
    if (!r.ok) die('ERROR: compose render failed (' + r.status + '): ' + (await r.text()).slice(0, 300));
    const buf = Buffer.from(await r.arrayBuffer());
    fsm.writeFileSync(outFile, buf);
    // hero-studio#51: compose measures lint server-side in the same render;
    // print the fresh findings so this verb IS the reliable gate read.
    let lint = null, lintMeta = null;
    try {
      const st = await (await fetch(BASE + 'state')).json();
      lint = st.lint || [];
      lintMeta = st.lintMeta || null;
    } catch {}
    console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length, lint, lintMeta }));
    console.error('composed preview written (no provenance stamp; the pose gate + Generate remain the only publish path)');
    if (lint) {
      for (const l of lint) console.error('lint ' + l.level + ' [' + l.id + '] ' + l.msg);
      if (lintMeta && !lintMeta.stale) console.error('lint measured fresh by this compose (poseRev ' + lintMeta.poseRev + ')');
    }
  } else {
    die('verbs: pose | showcase | set | preset | fit | style | batch | state | toast | reroll | changes | bg | compose | ls | instances | stop');
  }
} catch (e) {
  die('ERROR: ' + e.message + '\n  hint: is the studio server up? `hero-studio` starts it; `hero-studio pose <ref> --surface ...` does everything');
}