← back to Rebel Walls Push
scripts/relabel-units.js
260 lines
#!/usr/bin/env node
/**
* TK-10029: Rebel Walls mural unit relabel.
* Finds products with wrong variant labels ("Roll", "Single Roll", "Sold Per Bolt ...",
* "Complete Mural", or "Default Title" on the mural variant) and relabels them to
* "Sold Per Square Meter".
*
* Dry-run by default. Pass --apply to execute.
* Logs every action. Safe to re-run (idempotent — skips already-correct products).
*
* Usage:
* node scripts/relabel-units.js # dry-run, full audit
* node scripts/relabel-units.js --apply # live Shopify writes
* node scripts/relabel-units.js --limit 10 # cap actions
*/
const https = require('https');
const fs = require('fs');
const SECRETS_ENV = '/Users/macstudio3/Projects/secrets-manager/.env';
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API_VERSION = '2024-10';
function getToken() {
const env = fs.readFileSync(SECRETS_ENV, 'utf8');
const m = env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m); // TK-10979 DTD-A: full-access routing (bridge; target=capability-scoped token)
if (!m) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not found');
return m[1].trim();
}
const TOKEN = getToken();
const args = process.argv.slice(2);
const DRY = !args.includes('--apply');
const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? parseInt(args[i+1]) : Infinity; })();
// TK-10029 Scope B (Steve GO 2026-09-03): --hold-rollbolt excises the roll/bolt unit-of-sale
// cohort from the wrong-label set entirely, so those variants are NEITHER FIXed NOR REVIEWed —
// they are held pending the separate roll/bolt unit-of-sale review (TK-10405). Purely subtractive:
// when absent, scope-A behavior is unchanged.
const HOLD_ROLLBOLT = args.includes('--hold-rollbolt');
const HELD_ROLLBOLT_LABELS = new Set([
'roll', 'single roll', 'sold per roll', 'sold per bolt (20.5in x 33ft)',
]);
// TK-10029 / DTD 2026-08-10: canonical mural unit label = "Mural (per m²)" (Option A,
// unanimous 7/7). This is what the live importer push.js already mints, so we converge
// every OTHER mural-unit label onto it — including the legacy roll/bolt labels AND the
// interim "Sold Per Square Meter" cohort AND any mojibake-corrupted "Mural (per m<?>)".
const CORRECT_LABEL = 'Mural (per m²)';
// Values that indicate "this is the mural variant but has the wrong unit label"
const WRONG_MURAL_LABELS = new Set([
'roll', 'single roll', 'sold per roll', 'complete mural',
'sold per bolt (20.5in x 33ft)', 'default title',
'sold per square meter', 'sold per square metre',
]);
// Robust matcher: catches the fixed set above AND any corrupted "Mural (per m…)" variant
// (e.g. the mojibake "Mural (per m�)") that is NOT already the exact canonical.
function isWrongMuralLabel(value) {
const lower = value.toLowerCase();
// Scope B: hold the roll/bolt unit-of-sale cohort — never FIX, never REVIEW-into-rename.
if (HOLD_ROLLBOLT && HELD_ROLLBOLT_LABELS.has(lower)) return false;
if (WRONG_MURAL_LABELS.has(lower)) return true;
// mojibake / near-miss of the canonical: starts like "mural (per m" but isn't exact
if (lower.startsWith('mural (per m') && value !== CORRECT_LABEL) return true;
return false;
}
async function gqlOnce(query, vars) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ query, variables: vars || {} });
const req = https.request({
hostname: DOMAIN, path: `/admin/api/${API_VERSION}/graphql.json`,
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d)); } catch(e) { reject(new Error(`non-JSON response (${res.statusCode}): ${String(d).slice(0,80)}`)); } });
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// TK-10029: Shopify's gateway intermittently returns a non-JSON "upstream connect error"
// (transient 502/503) that would otherwise crash a long apply run mid-flight. Retry transient
// failures with exponential backoff; surface a permanent failure only after N attempts.
async function gql(query, vars) {
let lastErr;
for (let attempt = 1; attempt <= 6; attempt++) {
try { return await gqlOnce(query, vars); }
catch (e) {
lastErr = e;
if (attempt === 6) break;
const backoff = Math.min(8000, 400 * 2 ** (attempt - 1));
console.log(` [gql retry ${attempt}/5] ${e.message} — waiting ${backoff}ms`);
await sleep(backoff);
}
}
throw lastErr;
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function fetchAll() {
const products = [];
let cursor = null;
while (true) {
const q = `query($after: String) {
products(first: 250, query: "vendor:Rebel Walls", after: $after) {
pageInfo { hasNextPage endCursor }
edges { node {
id title
options { id name values }
variants(first: 10) { edges { node { id sku title selectedOptions { name value } } } }
} }
}
}`;
const r = await gql(q, { after: cursor });
const page = r.data.products;
for (const e of page.edges) products.push(e.node);
if (!page.pageInfo.hasNextPage) break;
cursor = page.pageInfo.endCursor;
await sleep(300);
}
return products;
}
function isSampleProduct(product) {
// Memo samples and sample-only products should not be relabeled to "Sold Per Square Meter"
const titleLower = product.title.toLowerCase();
return titleLower.includes('memo sample') || titleLower.includes(' sample') || titleLower.endsWith('sample');
}
function needsFix(product) {
if (isSampleProduct(product)) return false;
for (const v of product.variants.edges.map(e => e.node)) {
for (const o of v.selectedOptions) {
if (isWrongMuralLabel(o.value)) return true;
}
}
return false;
}
async function relabelProduct(product) {
// Find the option that actually carries a wrong mural label (robust — not name-bound).
const optionToFix =
product.options.find(o => o.values.some(v => isWrongMuralLabel(v))) ||
product.options.find(o => o.name === 'Size' || o.name === 'Title');
if (!optionToFix) {
console.log(` SKIP ${product.id} — no mural option found`);
return false;
}
const wrongValues = optionToFix.values.filter(v => isWrongMuralLabel(v));
if (!wrongValues.length) { console.log(` SKIP ${product.title} — already correct`); return false; }
const canonicalAlreadyPresent = optionToFix.values.includes(CORRECT_LABEL);
// NON-DESTRUCTIVE plan (renames only — never deletes a live variant, never creates a
// duplicate canonical). "Default Title" is ambiguous: sometimes it IS the mural variant,
// sometimes a leftover orphan. So:
// - if the canonical is already on the option → the unit label is ALREADY correct;
// leave the product untouched and just FLAG any redundant wrong value for review.
// - else rename exactly ONE mural-unit value to canonical (prefer a real unit label
// over generic "Default Title"); FLAG any additional wrong values for review rather
// than renaming them (which would duplicate) or deleting them (destructive).
const nonDefault = wrongValues.filter(v => v.toLowerCase() !== 'default title');
const primary = nonDefault[0] || wrongValues[0];
const toRename = canonicalAlreadyPresent ? [] : [primary];
const toReview = canonicalAlreadyPresent ? wrongValues : wrongValues.filter(v => v !== primary);
if (!toRename.length) {
// Unit label already canonical — not a unit-label defect. Report the orphan, don't touch.
console.log(` REVIEW ${product.title} — canonical already present; redundant value(s) ${JSON.stringify(toReview)} left for manual cleanup (no auto-delete)`);
return 'review';
}
console.log(` FIX ${product.title}`);
console.log(` Option "${optionToFix.name}": ${JSON.stringify(optionToFix.values)}`);
console.log(` RENAME ["${primary}"] → "${CORRECT_LABEL}"`);
if (toReview.length) console.log(` REVIEW leftover ${JSON.stringify(toReview)} — NOT modified (avoids destructive delete / duplicate); flag for manual cleanup`);
if (DRY) return true;
// Fetch option value IDs (values themselves have no id in the list query).
const detailQ = `query { product(id: "${product.id}") { options { id name optionValues { id name } } } }`;
const detail = await gql(detailQ);
const opt = detail.data.product.options.find(o => o.id === optionToFix.id);
if (!opt) { console.log(` ERR can't fetch option values for ${product.id}`); return false; }
const optionValuesToUpdate = opt.optionValues
.filter(ov => ov.name === primary)
.map(ov => ({ id: ov.id, name: CORRECT_LABEL }));
if (!optionValuesToUpdate.length) { console.log(` SKIP already done`); return false; }
const mutation = `
mutation UpdateOption($productId: ID!, $option: OptionUpdateInput!,
$optionValuesToUpdate: [OptionValueUpdateInput!]!) {
productOptionUpdate(productId: $productId, option: $option,
optionValuesToUpdate: $optionValuesToUpdate) {
product { id }
userErrors { field message }
}
}
`;
const res = await gql(mutation, {
productId: product.id,
option: { id: optionToFix.id, name: optionToFix.name },
optionValuesToUpdate,
});
const errs = res.data?.productOptionUpdate?.userErrors;
if (errs && errs.length) {
console.log(` ERR ${product.id}: ${errs.map(e => e.message).join('; ')}`);
return false;
}
return true;
}
(async () => {
console.log(`[relabel-units] mode=${DRY ? 'DRY-RUN' : 'LIVE'} limit=${LIMIT === Infinity ? 'none' : LIMIT}`);
console.log('[relabel-units] fetching all Rebel Walls products...');
const all = await fetchAll();
console.log(`[relabel-units] fetched ${all.length} products`);
const toFix = all.filter(needsFix);
console.log(`[relabel-units] ${toFix.length} products need relabel`);
const counts = {};
for (const p of all) {
for (const v of p.variants.edges.map(e => e.node)) {
for (const o of v.selectedOptions) {
counts[o.value] = (counts[o.value] || 0) + 1;
}
}
}
const mural = Object.entries(counts).filter(([k]) => k !== 'Sample' && k !== 'Default Title').sort((a,b) => b[1]-a[1]);
console.log('[relabel-units] current mural variant value distribution:');
for (const [k, v] of mural) console.log(` ${v.toString().padStart(5)} "${k}"`);
if (!toFix.length) { console.log('[relabel-units] nothing to fix — done'); return; }
let fixed = 0, skipped = 0, review = 0;
for (const p of toFix) {
if (fixed >= LIMIT) { console.log(`[relabel-units] hit limit ${LIMIT}`); break; }
const did = await relabelProduct(p);
if (did === 'review') { review++; }
else if (did) { fixed++; await sleep(DRY ? 0 : 500); }
else { skipped++; }
}
console.log(`[relabel-units] done. fixed=${fixed} skipped=${skipped} review=${review} dry=${DRY}`);
if (review) console.log(`[relabel-units] ${review} products FLAGGED for manual review (canonical already present + a redundant orphan value; not auto-modified). See REVIEW lines above.`);
})().catch(e => { console.error('[relabel-units] FATAL', e.message); process.exit(1); });