← back to Dw Photo Capture
TK-12126: add camera commit:true round-trip proof script (mock-tested, live path gated)
d9c961266e022b2594ab3423f354c34dca0d3640 · 2026-09-24 12:08:08 -0700 · Steve Abrams
Builds scripts/camera-committrue-proof.mjs per the approved memo — proves the one
remaining unmeasured link in the camera capture pipeline (commit:true -> Shopify DRAFT
-> re-fetch -> byte/pixel-match the baked capture) that the cycle-2 CTA run stopped
short of. Mock mode ($0, no network) runs the real ImageMagick normalize step plus a
simulated Shopify re-encode and proves the comparator via --self-test (PASS on clean
data, FAILs on injected corruption, per the negative-test rule). --live is hard-gated
and was never executed here. Ledger: data/camera-committrue-proof-ledger.jsonl (now
gitignored, runtime-generated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUWK5VKgXgemKLA9UqwMZ
Files touched
M .gitignoreA scripts/camera-committrue-proof.mjs
Diff
commit d9c961266e022b2594ab3423f354c34dca0d3640
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 12:08:08 2026 -0700
TK-12126: add camera commit:true round-trip proof script (mock-tested, live path gated)
Builds scripts/camera-committrue-proof.mjs per the approved memo — proves the one
remaining unmeasured link in the camera capture pipeline (commit:true -> Shopify DRAFT
-> re-fetch -> byte/pixel-match the baked capture) that the cycle-2 CTA run stopped
short of. Mock mode ($0, no network) runs the real ImageMagick normalize step plus a
simulated Shopify re-encode and proves the comparator via --self-test (PASS on clean
data, FAILs on injected corruption, per the negative-test rule). --live is hard-gated
and was never executed here. Ledger: data/camera-committrue-proof-ledger.jsonl (now
gitignored, runtime-generated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUWK5VKgXgemKLA9UqwMZ
---
.gitignore | 3 +
scripts/camera-committrue-proof.mjs | 432 ++++++++++++++++++++++++++++++++++++
2 files changed, 435 insertions(+)
diff --git a/.gitignore b/.gitignore
index 463e696..d51d6fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,6 @@ data/color-index.json
# 5x screen-capture test artifacts (regenerated by the 5x test pass)
5x/out/
+
+# TK-12126 camera commit:true round-trip proof — fsync'd exec-ledger, runtime-generated
+data/camera-committrue-proof-ledger.jsonl
diff --git a/scripts/camera-committrue-proof.mjs b/scripts/camera-committrue-proof.mjs
new file mode 100644
index 0000000..5210c18
--- /dev/null
+++ b/scripts/camera-committrue-proof.mjs
@@ -0,0 +1,432 @@
+#!/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 }) });
+ ocrCost = (r.json && r.json.cost_usd) || 0;
+ costLine('Gemini OCR (/api/extract)', ocrCost, r.json && r.json.err ? `err: ${r.json.err}` : '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);
+ }
+})();
← a1b8ee3 Extract shared acquireCamera() helper, adopt across 5 unboun
·
back to Dw Photo Capture
·
TK-12126: never report Gemini OCR cost for a call that error 8364615 →