master
Colby Knox Publish 0.23.3 4360e0c 14d ago
#!/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)`);