← back to Rebel Walls Push
TK-10029: add relabel-units.js — dry-run verified, 454 products need unit relabel
5f49d7065e88d9378059a00039735fac2d38fae7 · 2026-07-29 04:20:48 -0700 · steve@designerwallcoverings.com
Files touched
A scripts/relabel-units.js
Diff
commit 5f49d7065e88d9378059a00039735fac2d38fae7
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Wed Jul 29 04:20:48 2026 -0700
TK-10029: add relabel-units.js — dry-run verified, 454 products need unit relabel
---
scripts/relabel-units.js | 195 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 195 insertions(+)
diff --git a/scripts/relabel-units.js b/scripts/relabel-units.js
new file mode 100644
index 0000000..e9917b5
--- /dev/null
+++ b/scripts/relabel-units.js
@@ -0,0 +1,195 @@
+#!/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_ADMIN_TOKEN=(.+)$/m);
+ if (!m) throw new Error('SHOPIFY_ADMIN_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; })();
+
+// 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',
+]);
+
+const CORRECT_LABEL = 'Sold Per Square Meter';
+
+async function gql(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(e); } });
+ });
+ req.on('error', reject);
+ req.write(body);
+ req.end();
+ });
+}
+
+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 needsFix(product) {
+ for (const v of product.variants.edges.map(e => e.node)) {
+ for (const o of v.selectedOptions) {
+ if (WRONG_MURAL_LABELS.has(o.value.toLowerCase())) return true;
+ }
+ }
+ return false;
+}
+
+async function relabelProduct(product) {
+ // Find the mural option (not Sample) and relabel its value
+ const optionToFix = product.options.find(o =>
+ o.name === 'Size' || o.name === 'Title'
+ );
+ if (!optionToFix) {
+ console.log(` SKIP ${product.id} — no Size/Title option found`);
+ return false;
+ }
+
+ // Build new values: replace wrong mural labels with CORRECT_LABEL
+ const newValues = optionToFix.values.map(v =>
+ WRONG_MURAL_LABELS.has(v.toLowerCase()) ? CORRECT_LABEL : v
+ );
+ // Deduplicate (e.g. if both "Roll" and "Sold Per Square Meter" existed)
+ const deduped = [...new Set(newValues)];
+
+ if (JSON.stringify(deduped) === JSON.stringify(optionToFix.values)) {
+ console.log(` SKIP ${product.title} — already correct`);
+ return false;
+ }
+
+ console.log(` FIX ${product.title}`);
+ console.log(` Option "${optionToFix.name}": ${JSON.stringify(optionToFix.values)} → ${JSON.stringify(deduped)}`);
+
+ if (DRY) return true;
+
+ // Use productOptionUpdate to rename the option values
+ const mutation = `
+ mutation UpdateOption($productId: ID!, $option: OptionUpdateInput!, $optionValuesToUpdate: [OptionValueUpdateInput!]!) {
+ productOptionUpdate(productId: $productId, option: $option, optionValuesToUpdate: $optionValuesToUpdate) {
+ product { id }
+ userErrors { field message }
+ }
+ }
+ `;
+
+ // Build optionValuesToUpdate: only the ones that need changing
+ const updates = optionToFix.values
+ .map((v, i) => ({ id: null, name: v, newName: deduped[i] }))
+ .filter(u => u.name !== u.newName);
+
+ // We need option value IDs — fetch them
+ 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 valUpdates = opt.optionValues
+ .filter(ov => WRONG_MURAL_LABELS.has(ov.name.toLowerCase()))
+ .map(ov => ({ id: ov.id, name: CORRECT_LABEL }));
+
+ if (!valUpdates.length) { console.log(` SKIP already done`); return false; }
+
+ const res = await gql(mutation, {
+ productId: product.id,
+ option: { id: optionToFix.id, name: optionToFix.name },
+ optionValuesToUpdate: valUpdates,
+ });
+
+ 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;
+ for (const p of toFix) {
+ if (fixed >= LIMIT) { console.log(`[relabel-units] hit limit ${LIMIT}`); break; }
+ const did = await relabelProduct(p);
+ if (did) { fixed++; await sleep(DRY ? 0 : 500); } else { skipped++; }
+ }
+
+ console.log(`[relabel-units] done. fixed=${fixed} skipped=${skipped} dry=${DRY}`);
+})().catch(e => { console.error('[relabel-units] FATAL', e.message); process.exit(1); });
← 2f475a9 auto-save: 2026-07-21T13:37:45 (1 files) — scripts/push.js.b
·
back to Rebel Walls Push
·
relabel-units: skip sample products from Roll→SqMeter relabe 09027cf →