← back to Dw Color Image Audit
scripts/live_color_hex_export.js
87 lines
#!/usr/bin/env node
/**
* live_color_hex_export.js — READ-ONLY Shopify Bulk Operation export of every product's
* live custom.color_hex (+ custom.color_details) metafield. This is the LIVE source of
* truth for the color-fix re-scope (the dw_unified mirror does NOT carry custom.color_hex,
* and the product_colors TABLE is independently/wrongly populated — see the 2026-06-21 memo).
*
* Output: full/live_color_hex.jsonl (one {id,status,color_hex,color_details} per line)
* No writes to Shopify. No writes to any DB. Safe / ungated.
*/
const https = require('https');
const fs = require('fs');
const os = require('os');
const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
const OUT = __dirname + '/../full/live_color_hex.jsonl';
const sleep = ms => new Promise(r => setTimeout(r, ms));
function gql(query) {
return new Promise((resolve, reject) => {
const b = JSON.stringify({ query });
const r = https.request({ hostname: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(b) } },
x => { let d = ''; x.on('data', c => d += c); x.on('end', () => { try { resolve(JSON.parse(d)); } catch { reject(new Error(d.slice(0, 300))); } }); });
r.on('error', reject); r.write(b); r.end();
});
}
function download(url, dest) {
return new Promise((resolve, reject) => {
const f = fs.createWriteStream(dest);
https.get(url, res => { res.pipe(f); f.on('finish', () => f.close(() => resolve())); }).on('error', e => { fs.unlink(dest, () => {}); reject(e); });
});
}
const BULK_QUERY = `
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges {
node {
id
status
color_hex: metafield(namespace: "custom", key: "color_hex") { value }
color_details: metafield(namespace: "custom", key: "color_details") { value }
}
}
}
}
"""
) {
bulkOperation { id status }
userErrors { field message }
}
}`;
async function main() {
if (!TOKEN) { console.error('FATAL no token'); process.exit(1); }
// guard: don't collide with a running bulk op
const cur = await gql(`{currentBulkOperation(type:QUERY){id status}}`);
const cs = cur.data && cur.data.currentBulkOperation && cur.data.currentBulkOperation.status;
if (cs === 'RUNNING' || cs === 'CREATED') { console.error('ABORT: a QUERY bulk op is already running:', cs); process.exit(2); }
const start = await gql(BULK_QUERY);
const ue = start.data && start.data.bulkOperationRunQuery && start.data.bulkOperationRunQuery.userErrors;
if (start.errors || (ue && ue.length)) { console.error('FATAL start:', JSON.stringify(start.errors || ue)); process.exit(1); }
console.log('bulk op started:', JSON.stringify(start.data.bulkOperationRunQuery.bulkOperation));
let url = null;
for (let i = 0; i < 240; i++) { // up to ~40 min
await sleep(10000);
const r = await gql(`{currentBulkOperation(type:QUERY){id status objectCount url errorCode}}`);
const op = r.data.currentBulkOperation;
process.stdout.write(`\r [${i}] status=${op.status} objects=${op.objectCount || 0} `);
if (op.status === 'COMPLETED') { url = op.url; console.log('\nCOMPLETED objects=' + op.objectCount); break; }
if (['FAILED', 'CANCELED', 'EXPIRED'].includes(op.status)) { console.error('\nFATAL bulk op', op.status, op.errorCode); process.exit(1); }
}
if (!url) { console.error('\nFATAL: timed out waiting for bulk op'); process.exit(1); }
await download(url, OUT);
const lines = fs.readFileSync(OUT, 'utf8').trim().split('\n').filter(Boolean).length;
console.log(`downloaded ${lines} lines -> ${OUT}`);
}
main().catch(e => { console.error('FATAL', e.message); process.exit(1); });