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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
#!/usr/bin/env node
/* Reference implementation: bulk hardware pages with style-gated heroes.
Copy it, point it at a parts directory, run it. It is DELIBERATELY plain
node with no dependencies, and it resumes: every completed part lands in
bulk-state.json, so a crash or a rate-limit stop never repeats work.
Layout it expects (one dir per part):
parts/
lm317-dpak/
part.json { "title": "LM317 (DPAK)", "mpn": "LM317MDT", "kind": "component", "tags": [...] }
model.step (REQUIRED by the registry)
render.png (from chip-thumbnailer; becomes the hero showcase)
symbol.kicad_sym footprint.kicad_mod (optional, warnings without)
Usage:
node bulk-publish-reference.mjs --parts ./parts --org adom \
--style <saved-style> --human colby --quote "match the family style" \
[--pace <seconds>] [--existing | --new] [--dry-run] [--studio-port 8846]
Before running: pose ONE representative part, let a human Generate, then
`hero-studio style save <name>`. That approval gates every hero this
script produces (it rides each PNG's provenance).
New vs existing pages: a part whose page does NOT exist yet is scaffolded
and `pkg publish`ed. A part whose page ALREADY exists (Arav's basic-parts
fleet: 136 of 137) is not republished; its hero is pushed into the page
repo (`repo push`) and applied with `page hero --image` (POST .../hero).
Both paths get the same gated, provenance-stamped PNG. Pass --existing to
force the update path, --new to force publish; the default probes each
page once (`adom-wiki page stats`, a read).
Rate limits (wiki, 2026-10-06; the full table lives in the
wiki-publish-safely skill of adom/adom-wiki-skillpack): sliding
60-second window PER signed-in account.
pkg publish 30/min
repo push 60/min
page hero (POST) 20/min
page edit (PUT) 20/min title/brief/tags/source
page create (POST) 10/min this script never calls it
history reads (log) 600/min (60 anonymous)
Existing page = repo push + page hero (+ page edit when part.json has
"source"): 20 parts/min, 3 s apart. New page = pkg publish alone (it
creates the page itself; no separate page create call): 30/min, 2 s
apart; with "source" it also makes a page edit, so 3 s. The default
pace is picked PER PART from the path it takes; --pace <s> overrides it
for every part. 300 existing pages take about 15 minutes, 300 new pages
about 10.
A 429 carries {"error":"Rate limit exceeded","retry_after":N,
"code":"RATE_LIMITED"} plus a Retry-After header; this script honors
whichever the CLI printed (body, header, or its RATE_LIMITED hint's
"wait Ns and retry"), then falls back to exponential backoff. */
import { execFileSync, execFile } from 'child_process';
import fs from 'fs';
import path from 'path';
const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 ? process.argv[i + 1] : d; };
const has = (f) => process.argv.includes(f);
const PARTS = arg('--parts');
const ORG = arg('--org', 'adom');
const STYLE = arg('--style');
const HUMAN = arg('--human');
const QUOTE = arg('--quote');
const PACE_ARG = arg('--pace');
const PACE = PACE_ARG === undefined ? null : Number(PACE_ARG);
const PORT = arg('--studio-port', process.env.HERO_STUDIO_PORT || '8846');
const DRY = has('--dry-run');
const FORCE_MODE = has('--existing') ? 'existing' : has('--new') ? 'new' : '';
if (!PARTS || !STYLE || !HUMAN || !QUOTE) {
console.error('usage: bulk-publish-reference.mjs --parts <dir> --style <name> --human <who> --quote "<their words>" [--org adom] [--pace <seconds>] [--existing | --new] [--dry-run]');
process.exit(2);
}
const AI_THREAD = process.env.ADOM_AI_THREAD || 'hardware bulk publish';
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
// Seconds between parts, from the write that binds on the path taken (see
// the rate table above), unless --pace overrides it: page hero and page
// edit (20/min) bind at 3 s; pkg publish alone (30/min) at 2 s.
const paceFor = (existing, meta) => PACE !== null ? PACE : existing || meta.source ? 3 : 2;
// A 429's wait, in whatever form the CLI printed it: the JSON body's
// retry_after, the Retry-After header, or the RATE_LIMITED hint's
// "wait Ns and retry".
const retryAfter = (out) => {
const m = out.match(/"retry_after"\s*:\s*(\d+)/i) || out.match(/retry[- ]after[:\s]+(\d+)/i)
|| out.match(/wait (\d+)s and retry/i);
return m ? Number(m[1]) : null;
};
const rateLimited = (out) => /\b429\b|RATE_LIMITED|rate limit/i.test(out);
const STATE_FILE = path.join(PARTS, 'bulk-state.json');
const state = fs.existsSync(STATE_FILE) ? JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) : { done: {}, failed: {} };
const save = () => fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 1));
const dirs = fs.readdirSync(PARTS).filter((d) => fs.existsSync(path.join(PARTS, d, 'part.json')));
console.error(`bulk: ${dirs.length} part(s); ${Object.keys(state.done).length} already done (resume)`);
// 1. heroes first, one paced batch through the human-gated style
const partsManifest = [];
for (const d of dirs) {
if (state.done[d]) continue;
const meta = JSON.parse(fs.readFileSync(path.join(PARTS, d, 'part.json'), 'utf8'));
const render = path.join(PARTS, d, 'render.png');
partsManifest.push({
ref: `${ORG}/${d}`, title: meta.title, kind: meta.kind || 'component',
showcase: fs.existsSync(render) ? render : undefined, _dir: d,
});
}
if (partsManifest.length && !DRY) {
const manifestPath = path.join(PARTS, 'parts-batch.json');
fs.writeFileSync(manifestPath, JSON.stringify(partsManifest.map(({ _dir, ...p }) => p)));
console.error('generating heroes through the gated style (local, paced)...');
const out = execFileSync('hero-studio', ['--ai-thread', AI_THREAD, 'batch',
'--style', STYLE, '--parts', manifestPath, '--pace', '2',
'--human', HUMAN, '--quote', QUOTE, '--port', PORT], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
const results = JSON.parse(out).results;
// copy each generated hero into its part dir as docs/hero.png; generated/
// lives next to the hero-studio install (same resolution bin/hero-studio uses)
const pkgDir = path.join(path.dirname(fs.realpathSync(execFileSync('which', ['hero-studio'], { encoding: 'utf8' }).trim())), '..');
for (const r of results) {
if (!r.ok) { state.failed[r.ref] = r.error; continue; }
const p = partsManifest.find((x) => x.ref === r.ref);
const dst = path.join(PARTS, p._dir, 'docs');
fs.mkdirSync(dst, { recursive: true });
fs.copyFileSync(path.join(pkgDir, 'generated', r.file), path.join(dst, 'hero.png'));
}
save();
}
// 2. init + publish, serial, paced, 429-aware
const run = (cmd, args, cwd) => new Promise((res) => {
execFile(cmd, args, { cwd, encoding: 'utf8' }, (err, so, se) => res({ ok: !err, out: (so || '') + (se || '') }));
});
for (const p of partsManifest) {
if (state.done[p._dir] || state.failed[p.ref]) continue;
const dir = path.join(PARTS, p._dir);
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'part.json'), 'utf8'));
if (DRY) { console.error(`[dry] would ${FORCE_MODE || 'probe then publish/update'} ${p.ref}`); continue; }
// existing page: push the hero into its repo and point the page at it.
// No scaffold, no publish; the page keeps its README, files, and history.
let exists = FORCE_MODE === 'existing';
if (!FORCE_MODE) {
const probe = await run('adom-wiki', ['page', 'stats', p.ref, '--json'], dir);
exists = probe.ok && /"status":\s*"ok"/.test(probe.out);
}
if (exists) {
if (!fs.existsSync(path.join(dir, 'docs', 'hero.png'))) {
console.error(` ${p.ref}: exists but no docs/hero.png was generated for it; skipping`);
state.failed[p.ref] = 'no-hero';
save();
continue;
}
let applied = false;
for (let attempt = 0, backoff = 30; attempt < 4 && !applied; attempt++) {
const push = await run('adom-wiki', ['repo', 'push', p.ref, '--files', 'docs/hero.png', '-m', 'hero: gated style ' + STYLE], dir);
const hero = push.ok ? await run('adom-wiki', ['page', 'hero', p.ref, '--image', 'docs/hero.png'], dir) : push;
if (push.ok && hero.ok && !/"status":\s*"error"/.test(hero.out)) {
applied = true;
if (meta.source) await run('adom-wiki', ['page', 'edit', p.ref, '--source', meta.source], dir);
state.done[p._dir] = { at: new Date().toISOString(), mode: 'existing' };
delete state.failed[p.ref];
console.error(` ${p.ref}: hero updated (existing page)`);
} else if (rateLimited(push.out + hero.out)) {
const ra = retryAfter(push.out + hero.out);
const wait = ra !== null ? Math.max(ra, 1) : backoff;
console.error(` ${p.ref}: rate-limited; waiting ${wait}s (${ra !== null ? 'server retry_after' : 'backoff'}, attempt ${attempt + 1})`);
await sleep(wait);
backoff *= 2;
} else {
console.error(` ${p.ref}: FAILED: ${(push.out + hero.out).split('\n').find((l) => /error/i.test(l)) || 'unknown'}`);
state.failed[p.ref] = 'update';
break;
}
}
save();
await sleep(paceFor(true, meta));
continue;
}
// scaffold in place if package.json is absent
if (!fs.existsSync(path.join(dir, 'package.json'))) {
const pkg = {
slug: p._dir, title: meta.title, type: 'hardware', kind: meta.kind || 'component',
version: meta.version || '0.1.0', brief: meta.brief || meta.title,
description: meta.description || meta.title, tags: meta.tags || ['hardware'],
dependencies: {}, hero: { path: 'docs/hero.png' }, org: ORG, platform: 'any',
};
if (meta.mpn) pkg.mpn = meta.mpn;
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2));
}
const lint = await run('adom-wiki', ['pkg', 'lint'], dir);
if (/\berror\b/i.test(lint.out) && !/0 error/.test(lint.out)) {
console.error(` ${p.ref}: lint errors, skipping (fix and re-run; resume skips done parts)`);
state.failed[p.ref] = 'lint';
save();
continue;
}
let published = false;
for (let attempt = 0, backoff = 30; attempt < 4 && !published; attempt++) {
const pub = await run('adom-wiki', ['pkg', 'publish', '--org', ORG], dir);
if (pub.ok && /"published"/.test(pub.out)) {
published = true;
if (meta.source) await run('adom-wiki', ['page', 'edit', p.ref, '--source', meta.source], dir);
state.done[p._dir] = { at: new Date().toISOString() };
delete state.failed[p.ref];
console.error(` ${p.ref}: published`);
} else if (rateLimited(pub.out)) {
const ra = retryAfter(pub.out);
const wait = ra !== null ? Math.max(ra, 1) : backoff;
console.error(` ${p.ref}: rate-limited; waiting ${wait}s (${ra !== null ? 'server retry_after' : 'backoff'}, attempt ${attempt + 1})`);
await sleep(wait);
backoff *= 2;
} else {
console.error(` ${p.ref}: FAILED: ${pub.out.split('\n').find((l) => /error/i.test(l)) || 'unknown'}`);
state.failed[p.ref] = 'publish';
break;
}
}
save();
await sleep(paceFor(false, meta));
}
console.error(`done: ${Object.keys(state.done).length} published, ${Object.keys(state.failed).length} failed (bulk-state.json has the ledger; re-running resumes)`);