← back to Dw Photo Capture

scripts/camera-committrue-proof.mjs

438 lines

#!/usr/bin/env node
/* ════════════════════════════════════════════════════════════════════════════════════════
   TK-12126 — camera commit:true round-trip proof
   Memo: ~/.claude/yolo-queue/pending-approval/2026-09-24-TK-12126-dwphoto-camera-committrue-roundtrip-proof.md

   WHAT THIS PROVES (the one link the cycle-2 CTA run left NOT-MEASURED):
     A commit:true POST to /api/create-item creates a Shopify DRAFT product whose STORED
     image byte/pixel-matches the baked capture that was sent, within JPEG-re-encode
     tolerance. Everything upstream of that (preview==capture parity, the baked JPEG ==
     what the commit:false preview endpoint receives) was already proven by the CTA run —
     this script does not re-prove it and does not need a browser/canvas to do its job.

   TWO MODES, ONE SHARED comparison+ledger pipeline (verifyAndLedger below runs identically
   either way — only the transport that produces `storedBuf` differs):

     MOCK  (default, --self-test, --negative-test)
       Zero network. Touches nothing on Shopify. $0. Simulates the server's ImageMagick
       normalize step for real (same binary the deployed app shells out to) plus a
       synthetic "Shopify re-store" re-encode, so the compare/tolerance/ledger code is
       exercised against real re-encode noise, not a canned number. --negative-test
       corrupts the "stored" bytes to prove the comparator actually goes RED on a real
       defect (CLAUDE.md TK-11431 rule 3 — a check ships with a negative test or it
       doesn't ship). --self-test runs both and asserts the expected PASS/FAIL, in one
       network-free invocation — this IS the "unit-tested locally" proof required before
       this script is allowed to be handed to Steve as a live-run candidate.

     LIVE  (--live — HARD-GATED, Steve-only; this script's own author must never pass it)
       Real network. Calls the DEPLOYED app (https://photo.designerwallcoverings.com by
       default) exactly the way a real capture does: optionally /api/extract (Gemini OCR,
       ~$0.0006/call — printed + ledgered per the always-show-costs rule) then
       /api/create-item with commit:true. Independently RE-FETCHES the stored image via a
       direct Shopify Admin REST call (never trusts the create response's own echo),
       downloads the bytes, and runs the exact same pixelDiff+ledger code the mock path
       already proved. Creates exactly ONE Shopify DRAFT product, never publishes it,
       LEAVES it in place by default (--auto-delete deletes it only after a PASS verdict —
       the memo's second checkbox). The undo command is always printed + ledgered even
       when nothing is deleted.

   Run:
     node scripts/camera-committrue-proof.mjs --self-test        # $0, no network (prove it works)
     node scripts/camera-committrue-proof.mjs                    # $0, no network (single mock PASS run)
     node scripts/camera-committrue-proof.mjs --negative-test    # $0, no network (proves it goes RED)
     node scripts/camera-committrue-proof.mjs --live [...]       # REAL Shopify write — Steve only
     node scripts/camera-committrue-proof.mjs --delete-product <id>   # undo utility (real Shopify write)

   Zero npm deps (matches this repo's "zero runtime deps" rule). Uses the ImageMagick
   `convert` binary already on this Mac at /opt/homebrew/bin/convert for pixel decode —
   NOTE (side finding, not fixed here, out of scope for TK-12126): server.js's own
   normalizeImage() hardcodes /usr/bin/convert, which does NOT exist on this Mac2 (only
   the homebrew path does) — on this host that call silently falls back to the raw,
   un-normalized bytes (see normalizeImage()'s `err || !stdout ... ? buf : stdout`). On
   the actual Kamatera deploy target (Linux) /usr/bin/convert is expected to exist, so
   production normalize behavior differs from a local Mac2 run of server.js. This script
   works either way because it compares against whatever Shopify actually stored, not
   against an assumption about which host ran the normalize step.
   ════════════════════════════════════════════════════════════════════════════════════════ */
'use strict';

import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as http from 'node:http';
import * as https from 'node:https';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.join(__dirname, '..');
const TICKET = 'TK-12126';
const AGENT = process.env.TK_AGENT || 'iterm-camera-proof';

// ── CLI args ──────────────────────────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
function flag(name) { return argv.includes(name); }
function opt(name, dflt) { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : dflt; }

// ── Shopify config (mirrors server.js exactly — same store, same API version) ──────────────
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
function loadShopifyToken() {
  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
  try {
    const envPath = path.join(os.homedir(), 'Projects/secrets-manager/.env');
    for (const l of fs.readFileSync(envPath, 'utf8').split('\n')) {
      if (l.startsWith('SHOPIFY_ADMIN_TOKEN=')) return l.slice('SHOPIFY_ADMIN_TOKEN='.length).trim();
    }
  } catch (e) { /* live mode will fail loudly on the missing token; mock mode never needs it */ }
  return '';
}

// ── generic HTTP(S) helpers (zero deps) ─────────────────────────────────────────────────────
function request(urlStr, { method = 'GET', headers = {}, body = null, timeout = 30000 } = {}) {
  return new Promise((resolve, reject) => {
    let u; try { u = new URL(urlStr); } catch (e) { return reject(e); }
    const mod = u.protocol === 'https:' ? https : http;
    const data = body ? (Buffer.isBuffer(body) ? body : Buffer.from(body)) : null;
    const req = mod.request({
      hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
      path: u.pathname + u.search, method,
      headers: Object.assign({}, headers, data ? { 'Content-Length': data.length } : {}),
      timeout,
    }, (res) => {
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.concat(chunks) }));
    });
    req.on('error', reject);
    req.on('timeout', () => req.destroy(new Error(`timeout after ${timeout}ms: ${urlStr}`)));
    if (data) req.write(data);
    req.end();
  });
}
async function requestJson(urlStr, opts) {
  const r = await request(urlStr, opts);
  let json = null; try { json = JSON.parse(r.buf.toString('utf8')); } catch (e) { /* non-JSON body */ }
  return { status: r.status, headers: r.headers, json, raw: r.buf.toString('utf8').slice(0, 500) };
}
function basicAuthHeader(user, pass) { return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); }

// ── Shopify direct calls (used for the independent re-fetch + the undo/delete utility —
//    NEVER for the create itself; the create goes through the app's real /api/create-item
//    so this proof exercises the actual production code path, not a hand-rolled shortcut) ──
function shopifyReq(method, p, token, payload) {
  const data = payload ? JSON.stringify(payload) : null;
  return request(`https://${SHOP}/admin/api/${API}${p}`, {
    method,
    headers: Object.assign({ 'X-Shopify-Access-Token': token }, data ? { 'Content-Type': 'application/json' } : {}),
    body: data,
  }).then((r) => { let body = null; try { body = JSON.parse(r.buf.toString('utf8')); } catch (e) {} return { status: r.status, body, raw: r.buf.toString('utf8').slice(0, 300) }; });
}
async function deleteProduct(productId, token) {
  const r = await shopifyReq('DELETE', `/products/${productId}.json`, token);
  return { ok: r.status >= 200 && r.status < 300, status: r.status, raw: r.raw };
}

// ── ImageMagick pixel decode + diff (no npm deps; resolves the binary defensively since
//    server.js's own hardcoded /usr/bin/convert path is absent on this Mac — see header) ──
const CONVERT_CANDIDATES = ['/opt/homebrew/bin/convert', '/usr/local/bin/convert', '/usr/bin/convert', 'convert'];
let _convertBin = null;
function convertBin() {
  if (_convertBin) return _convertBin;
  for (const c of CONVERT_CANDIDATES) {
    try { if (c === 'convert' || fs.existsSync(c)) { _convertBin = c; return c; } } catch (e) {}
  }
  _convertBin = 'convert'; // last resort — rely on PATH, will error clearly if truly absent
  return _convertBin;
}
function runConvert(args, inputBuf) {
  return new Promise((resolve, reject) => {
    const cp = execFile(convertBin(), args, { maxBuffer: 64 * 1024 * 1024, timeout: 15000, encoding: 'buffer' },
      (err, stdout) => { if (err) return reject(err); resolve(stdout); });
    if (inputBuf) { try { cp.stdin.on('error', () => {}); cp.stdin.write(inputBuf); cp.stdin.end(); } catch (e) { reject(e); } }
  });
}
// Same normalize shape as server.js normalizeImage(), used ONLY by the mock path to
// simulate the server-side step with a real re-encode instead of a canned diff number.
async function normalizeLikeServer(buf) {
  try { return await runConvert(['-', '-auto-orient', '-resize', '1600x1600>', '-quality', '85', 'jpg:-'], buf); }
  catch (e) { return buf; } // matches server.js's fallback-to-original-on-failure behavior
}
async function identifyDims(buf) {
  const out = await runConvert(['-', '-format', '%w %h', 'info:'], buf);
  const [w, h] = out.toString('utf8').trim().split(/\s+/).map(Number);
  if (!w || !h) throw new Error('could not read image dimensions');
  return { w, h };
}
async function decodeRawRGB(buf, w, h) {
  return runConvert(['-', '-resize', `${w}x${h}!`, '-depth', '8', 'RGB:-'], buf);
}
// Mean absolute per-channel difference on a 0-255 scale (matches the "X/255" units the
// prior normalize-check evidence used), computed pixel-for-pixel after resizing BOTH
// images to the SENT image's own dimensions so a resize step never falsely inflates the
// diff. Also reports max per-channel delta and a plain byte-identical/SHA256 check
// (informational only — a JPEG re-encode is expected to NEVER be byte-identical).
async function pixelDiff(sentBuf, storedBuf) {
  const sha256 = (b) => createHash('sha256').update(b).digest('hex');
  const byteIdentical = sentBuf.equals(storedBuf);
  const dims = await identifyDims(sentBuf);
  const [rgbA, rgbB] = await Promise.all([decodeRawRGB(sentBuf, dims.w, dims.h), decodeRawRGB(storedBuf, dims.w, dims.h)]);
  const n = Math.min(rgbA.length, rgbB.length);
  let sum = 0, max = 0;
  for (let i = 0; i < n; i++) { const d = Math.abs(rgbA[i] - rgbB[i]); sum += d; if (d > max) max = d; }
  const mean = n ? sum / n : 0;
  return { meanAbsDiff255: mean, maxAbsDiff255: max, width: dims.w, height: dims.h, byteIdentical, sha256Sent: sha256(sentBuf), sha256Stored: sha256(storedBuf), sentBytes: sentBuf.length, storedBytes: storedBuf.length };
}

// ── fsync'd JSONL exec-ledger, tagged TK-12126, with a mkdir-lock so concurrent runs
//    can never interleave a partial line (same pattern as the global executed-reversible
//    ledger's log-exec.mjs) ──
const LEDGER_PATH = path.join(ROOT, 'data', 'camera-committrue-proof-ledger.jsonl');
function appendLedgerFsync(row) {
  fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
  const lock = LEDGER_PATH + '.lock';
  let held = false;
  for (let i = 0; i < 200 && !held; i++) {
    try { fs.mkdirSync(lock); held = true; }
    catch (e) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 15); }
  }
  try {
    const line = JSON.stringify(row) + '\n';
    const fd = fs.openSync(LEDGER_PATH, 'a');
    try { fs.writeSync(fd, line); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
  } finally { if (held) { try { fs.rmdirSync(lock); } catch (e) {} } }
}

// ── cost line — always shown, per Steve's standing "always show costs" rule ────────────────
function costLine(label, usd, note) {
  const s = usd > 0 ? `$${usd.toFixed(4)}` : '$0 (local)';
  console.log(`  💵 ${label}: ${s}${note ? '  — ' + note : ''}`);
}

// ── default fixture: a REAL photo already checked into this repo (500x500 JPEG), standing
//    in for "the locally-baked capture" — capture-pipeline.js's apply() bake was already
//    proven byte-identical to the commit:false preview by the cycle-2 CTA run (browser
//    canvas math, out of scope here); this script's job starts one step later, at whatever
//    JPEG a real bake would have produced. Override with --image <path> to point at a
//    fresher capture (e.g. a still-live CTA scratchpad evidence file) if one is available. ──
const DEFAULT_FIXTURE = path.join(ROOT, 'ref-roll.jpg');

// ── shared verify+ledger core — IDENTICAL code path for mock and live; only how
//    `storedBuf` was obtained differs between the two callers ──
async function verifyAndLedger({ mode, sentBuf, storedBuf, productId, productGid, mediaId, mediaGid, dwSku, tolerance, extra, undoCmd, leftInPlace }) {
  const diff = await pixelDiff(sentBuf, storedBuf);
  const verdict = diff.meanAbsDiff255 <= tolerance ? 'PASS' : 'FAIL';
  const row = Object.assign({
    ts: new Date().toISOString(), agent: AGENT, ticket: TICKET, mode,
    action: 'camera commit:true round-trip proof — commit:true capture -> Shopify DRAFT -> re-fetch -> byte/pixel compare',
    product_id: productId, product_gid: productGid, media_id: mediaId, media_gid: mediaGid, dw_sku: dwSku,
    sent_bytes: diff.sentBytes, stored_bytes: diff.storedBytes,
    byte_identical: diff.byteIdentical, sha256_sent: diff.sha256Sent, sha256_stored: diff.sha256Stored,
    width: diff.width, height: diff.height,
    diff_mean_abs_per_channel_0_255: Number(diff.meanAbsDiff255.toFixed(4)),
    diff_max_abs_per_channel_0_255: diff.maxAbsDiff255,
    tolerance_mean_abs_per_channel_0_255: tolerance,
    verdict, blast_radius: productId ? 1 : 0,
    undo_cmd: undoCmd || null, left_in_place: leftInPlace !== false,
    verify: `node scripts/camera-committrue-proof.mjs --self-test   # re-proves the comparator; for a live row, re-run --live to re-check this exact product`,
  }, extra || {});
  appendLedgerFsync(row);

  console.log(`\n  ── round-trip compare (${mode}) ──`);
  console.log(`  sent:    ${diff.sentBytes} bytes   sha256 ${diff.sha256Sent.slice(0, 12)}…`);
  console.log(`  stored:  ${diff.storedBytes} bytes   sha256 ${diff.sha256Stored.slice(0, 12)}…   byte-identical: ${diff.byteIdentical}`);
  console.log(`  pixel diff (${diff.width}x${diff.height}, 0-255 scale): mean ${diff.meanAbsDiff255.toFixed(3)}/255   max ${diff.maxAbsDiff255}/255   tolerance ${tolerance}/255`);
  console.log(`  VERDICT: ${verdict}`);
  console.log(`  ledger row appended (fsync'd): ${LEDGER_PATH}`);
  return { row, diff, verdict };
}

// ═══════════════════════════════════════ MOCK MODE ═══════════════════════════════════════
// Zero network, $0. Runs the REAL ImageMagick normalize (same shape as server.js) so the
// simulated "server processing" step is genuine re-encode noise, not a canned number, then
// simulates Shopify's own ingest re-encode with one more small quality step. --negative
// instead corrupts the result heavily to prove the comparator goes RED on a real defect.
async function runMock({ negative = false, tolerance, imagePath } = {}) {
  const sentBuf = fs.readFileSync(imagePath || DEFAULT_FIXTURE);
  console.log(`\n[MOCK${negative ? ' / NEGATIVE-TEST' : ''}] fixture: ${imagePath || DEFAULT_FIXTURE} (${sentBuf.length} bytes)`);
  costLine('Gemini OCR (mock — not called)', 0, 'mock mode never touches the network');

  // 1) simulate the server's normB64() step for real
  const normalized = await normalizeLikeServer(sentBuf);
  // 2) simulate Shopify's own ingest re-encode
  let stored;
  try { stored = await runConvert(['-', '-quality', '92', 'jpg:-'], normalized); } catch (e) { stored = normalized; }
  if (negative) {
    // inject a real, detectable defect: heavy gaussian-ish noise + a color roll, well past
    // any plausible JPEG re-encode tolerance — proves the comparator actually reddens.
    try { stored = await runConvert(['-', '-modulate', '40,300,180', '+noise', 'Gaussian', 'jpg:-'], stored); }
    catch (e) { stored = Buffer.concat([stored, Buffer.from('CORRUPT')]); }
  }

  const dw_sku = `PROV-MOCKTEST-${Date.now().toString(36).toUpperCase()}`;
  const productId = 900000000000 + Math.floor(Math.random() * 1e6);
  const mediaId = 800000000000 + Math.floor(Math.random() * 1e6);
  const tol = tolerance != null ? tolerance : DEFAULT_TOLERANCE;
  return verifyAndLedger({
    mode: negative ? 'mock-negative' : 'mock',
    sentBuf, storedBuf: stored,
    productId, productGid: `gid://shopify/Product/${productId}`,
    mediaId, mediaGid: `gid://shopify/ProductImage/${mediaId}`,
    dwSku: dw_sku, tolerance: tol,
    undoCmd: `node scripts/camera-committrue-proof.mjs --delete-product ${productId}   # NOT REAL — mock product, nothing exists on Shopify`,
    leftInPlace: false,
    extra: { simulated: true, note: 'mock run — no Shopify product was actually created' },
  });
}

// ═══════════════════════════════════════ LIVE MODE ═══════════════════════════════════════
// HARD-GATED. Real network. Real Shopify write. This function is NEVER invoked by the
// author of this script — only Steve, via the exact paste recorded in the TK-12126 memo.
const DEFAULT_TOLERANCE = 6; // mean abs diff per channel, 0-255 scale — see header for rationale
async function runLive(o) {
  const baseUrl = o.baseUrl || 'https://photo.designerwallcoverings.com';
  const authUser = o.authUser || process.env.AUTH_USER || 'admin';
  const authPass = o.authPass || process.env.AUTH_PASS || 'DW2024!';
  const token = loadShopifyToken();
  if (!token) { console.error('ERROR: no SHOPIFY_ADMIN_TOKEN (env or secrets-manager/.env) — cannot re-fetch/verify. Aborting before any write.'); process.exit(2); }

  const imagePath = o.imagePath || DEFAULT_FIXTURE;
  const sentBuf = fs.readFileSync(imagePath);
  const dataUrl = 'data:image/jpeg;base64,' + sentBuf.toString('base64');
  const authHeader = basicAuthHeader(authUser, authPass);

  console.log(`\n[LIVE] target: ${baseUrl}`);
  console.log(`[LIVE] fixture: ${imagePath} (${sentBuf.length} bytes)`);
  console.log('[LIVE] *** THIS WILL CREATE ONE REAL SHOPIFY DRAFT PRODUCT ON', SHOP, '***');

  // Step 1 (optional): Gemini OCR — mirrors a real "scan the label" capture step.
  let ocrCost = 0;
  if (!o.skipOcr) {
    try {
      const r = await requestJson(`${baseUrl}/api/extract`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ dataUrl }) });
      // NOTE: server.js's /api/extract returns a hardcoded cost_usd: 0.0006 EVEN WHEN the Gemini
      // call errored (e.g. depleted credits / bad key) — a rejected call is never billed, so
      // echoing that number would report spend that did not happen. Trust the cost ONLY when the
      // response carries no err (don't report an unmeasured/failed call as a measured cost).
      const ocrErr = r.json && r.json.err;
      ocrCost = (!ocrErr && r.json && r.json.cost_usd) || 0;
      costLine('Gemini OCR (/api/extract)', ocrCost, ocrErr ? `NOT BILLED — call failed: ${String(ocrErr).slice(0, 160)}` : 'actual, per response');
      if (r.json && r.json.fields) console.log('  OCR fields (expected mostly empty — fixture is a roll photo, not a label):', JSON.stringify(r.json.fields));
    } catch (e) { console.log(`  OCR call failed (non-fatal, proceeding): ${e.message}`); }
  } else {
    costLine('Gemini OCR', 0, 'skipped (--skip-ocr)');
  }

  // Step 2: THE step under test — commit:true through the real production endpoint.
  const mfr = o.mfr || `ZZTEST-CAM-${Date.now().toString(36).toUpperCase()}`;
  const vendor = o.vendor || 'ZZTESTVENDOR';
  const createBody = {
    mfr, vendor, name: 'TK-12126 Camera Round-Trip Proof — DELETE ME', back_present: true,
    photos: [dataUrl], commit: true,
  };
  const cr = await requestJson(`${baseUrl}/api/create-item`, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(createBody), timeout: 60000 });
  if (!cr.json || cr.json.ok !== true || !cr.json.product_id) {
    console.error('ERROR: create-item did not return a product — no draft was created (or response unparseable). Nothing to clean up.');
    console.error('  status:', cr.status, '  body:', JSON.stringify(cr.json || cr.raw).slice(0, 500));
    process.exit(2);
  }
  const productId = cr.json.product_id, dwSku = cr.json.dw_sku;
  const productGid = `gid://shopify/Product/${productId}`;
  console.log(`  ✅ created DRAFT product ${productId} (dw_sku ${dwSku})`);

  // Step 3: INDEPENDENT re-fetch — direct Shopify Admin REST call, never the create response's
  // own echo, so this genuinely proves what got PERSISTED, not just what got POSTed.
  const imgList = await shopifyReq('GET', `/products/${productId}/images.json`, token);
  const media = imgList.body && imgList.body.images && imgList.body.images[0];
  if (!media || !media.src) {
    console.error('ERROR: draft was created but has no image on re-fetch. Product left in place for inspection:', productId);
    appendLedgerFsync({ ts: new Date().toISOString(), agent: AGENT, ticket: TICKET, mode: 'live', action: 'camera commit:true round-trip proof', product_id: productId, product_gid: productGid, verdict: 'FAIL', err: 'no image on re-fetch', undo_cmd: `node scripts/camera-committrue-proof.mjs --delete-product ${productId}`, left_in_place: true });
    process.exit(2);
  }
  const mediaId = media.id;
  const mediaGid = `gid://shopify/ProductImage/${mediaId}`; // REST-legacy id (create used the REST endpoint, not GraphQL productCreateMedia)
  const dl = await request(media.src, { timeout: 30000 });
  const storedBuf = dl.buf;
  console.log(`  re-fetched image ${mediaId} (${storedBuf.length} bytes) from ${media.src.slice(0, 80)}…`);

  const undoCmd = `node scripts/camera-committrue-proof.mjs --delete-product ${productId}`;
  const { verdict } = await verifyAndLedger({
    mode: 'live', sentBuf, storedBuf, productId, productGid, mediaId, mediaGid, dwSku,
    tolerance: o.tolerance != null ? o.tolerance : DEFAULT_TOLERANCE,
    undoCmd, leftInPlace: true,
    extra: { gemini_ocr_cost_usd: ocrCost, target: baseUrl, mfr, vendor },
  });

  if (o.autoDelete) {
    if (verdict === 'PASS') {
      console.log(`\n  --auto-delete: verdict PASS, deleting draft product ${productId}…`);
      const del = await deleteProduct(productId, token);
      console.log(`  delete ${del.ok ? 'succeeded' : 'FAILED'} (HTTP ${del.status})`);
      appendLedgerFsync({ ts: new Date().toISOString(), agent: AGENT, ticket: TICKET, mode: 'live-cleanup', action: 'auto-delete after PASS', product_id: productId, product_gid: productGid, deleted: del.ok, http_status: del.status });
    } else {
      console.log(`\n  --auto-delete requested but verdict was ${verdict} — LEAVING the draft in place for inspection (never auto-delete evidence of a failure).`);
    }
  } else {
    console.log(`\n  draft product ${productId} LEFT IN PLACE (default). Undo when ready:\n    ${undoCmd}`);
  }
  process.exit(verdict === 'PASS' ? 0 : 1);
}

// ── self-test: proves the comparator PASSES clean data and FAILS corrupted data, entirely
//    offline. This is the "unit-tested locally (mocked Shopify calls, no live writes)" gate
//    this script must clear before it's allowed to be handed to Steve as a live candidate. ──
async function runSelfTest() {
  console.log('═══ SELF-TEST (mock, $0, no network) ═══');
  const good = await runMock({ negative: false });
  const bad = await runMock({ negative: true });
  let ok = true;
  if (good.verdict !== 'PASS') { console.error(`✗ FAIL: clean mock round-trip should PASS, got ${good.verdict} (mean diff ${good.diff.meanAbsDiff255})`); ok = false; }
  else console.log(`✓ clean mock round-trip PASSED (mean diff ${good.diff.meanAbsDiff255}/255, tolerance ${DEFAULT_TOLERANCE}/255)`);
  if (bad.verdict !== 'FAIL') { console.error(`✗ FAIL: negative-test (injected corruption) should FAIL, got ${bad.verdict} (mean diff ${bad.diff.meanAbsDiff255}) — the comparator cannot detect real defects!`); ok = false; }
  else console.log(`✓ negative-test correctly FAILED (mean diff ${bad.diff.meanAbsDiff255}/255 >> tolerance ${DEFAULT_TOLERANCE}/255) — comparator proven to go RED on a real defect`);
  console.log(`\n═══ SELF-TEST: ${ok ? 'PASS — script is ready to hand to Steve for a --live run' : 'FAIL — do not hand this to Steve yet'} ═══`);
  process.exit(ok ? 0 : 1);
}

// ── dispatch ─────────────────────────────────────────────────────────────────────────────
(async () => {
  try {
    if (flag('--help') || flag('-h')) {
      console.log(fs.readFileSync(__filename, 'utf8').split('════\n')[0]);
      process.exit(0);
    }
    if (flag('--self-test')) return await runSelfTest();
    const delId = opt('--delete-product', null);
    if (delId) {
      const token = loadShopifyToken();
      if (!token) { console.error('ERROR: no SHOPIFY_ADMIN_TOKEN available — cannot delete.'); process.exit(2); }
      console.log(`Deleting Shopify product ${delId} on ${SHOP} …`);
      const r = await deleteProduct(delId, token);
      console.log(r.ok ? `✅ deleted ${delId}` : `❌ delete FAILED (HTTP ${r.status}): ${r.raw}`);
      appendLedgerFsync({ ts: new Date().toISOString(), agent: AGENT, ticket: TICKET, mode: 'manual-delete', action: `delete product ${delId}`, product_id: Number(delId) || delId, deleted: r.ok, http_status: r.status });
      process.exit(r.ok ? 0 : 1);
    }
    if (flag('--live')) {
      return await runLive({
        baseUrl: opt('--base-url', null),
        authUser: opt('--auth-user', null),
        authPass: opt('--auth-pass', null),
        imagePath: opt('--image', null),
        mfr: opt('--mfr', null),
        vendor: opt('--vendor', null),
        tolerance: opt('--tolerance', null) != null ? Number(opt('--tolerance', null)) : null,
        skipOcr: flag('--skip-ocr'),
        autoDelete: flag('--auto-delete'),
      });
    }
    // default: single mock run (or --negative-test)
    const r = await runMock({ negative: flag('--negative-test'), imagePath: opt('--image', null), tolerance: opt('--tolerance', null) != null ? Number(opt('--tolerance', null)) : null });
    process.exit(r.verdict === 'PASS' ? 0 : 1);
  } catch (e) {
    console.error('FATAL:', e && e.stack || e);
    process.exit(3);
  }
})();