← 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);
}
})();