← back to Corkwallcovering
auto-save: 2026-07-01T11:11:52 (1 files) — scripts/apply-shopify-tags.mjs
7f71902184dbe14e4e2197e92b1f3f1e6f2d9f64 · 2026-07-01 11:11:53 -0700 · Steve Abrams
Files touched
A scripts/apply-shopify-tags.mjs
Diff
commit 7f71902184dbe14e4e2197e92b1f3f1e6f2d9f64
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 1 11:11:53 2026 -0700
auto-save: 2026-07-01T11:11:52 (1 files) — scripts/apply-shopify-tags.mjs
---
scripts/apply-shopify-tags.mjs | 99 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 99 insertions(+)
diff --git a/scripts/apply-shopify-tags.mjs b/scripts/apply-shopify-tags.mjs
new file mode 100644
index 0000000..b56672a
--- /dev/null
+++ b/scripts/apply-shopify-tags.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+/**
+ * Live Shopify tag apply for the corkwallcovering facet adds.
+ *
+ * Reads reports/cork-tag-diff.json, takes every row with `add_tags`, resolves
+ * the SKU → Shopify product in the target store, merges the add tags into the
+ * product's EXISTING tags (never removes), and issues productUpdate.
+ *
+ * GATE (mirrors stage-shopify-tags.mjs):
+ * (1) refuses if kill-switch ~/.dw-fixer-stop present
+ * (2) caps at 200 products/run
+ * (3) DRY-RUN by default — must pass --apply to write
+ * (4) resolves productId per SKU, only ADDS tags, idempotent (skips if present)
+ *
+ * SCOPE NOTE: default target is the SANDBOX store (SHOPIFY_STORE_DOMAIN). The
+ * sandbox is NOT the dw_unified mirror, so this script does NOT write PG — it is
+ * Shopify-only. Promoting to the real DW production store is a separate, gated
+ * step that must add the PG-first write against the correct mirror.
+ *
+ * node scripts/apply-shopify-tags.mjs # DRY-RUN (resolve + preview, no write)
+ * node scripts/apply-shopify-tags.mjs --apply # LIVE write to the sandbox
+ */
+import fs from 'node:fs/promises';
+import { existsSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+
+const APPLY = process.argv.includes('--apply');
+const KILL = path.join(os.homedir(), '.dw-fixer-stop');
+const CAP = 200;
+const API = '2024-10';
+
+function envFrom(file, key) {
+ try { const m = readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : null; } catch { return null; }
+}
+const MASTER = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+const STORE = process.env.SHOPIFY_STORE_DOMAIN || envFrom(MASTER, 'SHOPIFY_STORE_DOMAIN');
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || envFrom(MASTER, 'SHOPIFY_ADMIN_TOKEN');
+if (!STORE || !TOKEN) { console.error('missing SHOPIFY_STORE_DOMAIN / SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+async function gql(query, variables) {
+ const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
+ body: JSON.stringify({ query, variables }),
+ });
+ const j = await r.json();
+ if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 300));
+ return j.data;
+}
+
+async function resolveByHandle(handle) {
+ // cork's products.json uses the Shopify HANDLE as its "sku"; real variant SKUs
+ // are short codes (DWKK-102147), so resolve by handle, not sku.
+ const d = await gql(
+ `query($q:String!){ products(first:1, query:$q){ edges{ node{ id title handle tags } } } }`,
+ { q: `handle:${handle}` }
+ );
+ const node = d?.products?.edges?.[0]?.node;
+ return node ? { id: node.id, title: node.title, tags: node.tags || [] } : null;
+}
+
+async function applyTags(id, tags) {
+ const d = await gql(
+ `mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id tags } userErrors{ field message } } }`,
+ { input: { id, tags } }
+ );
+ const ue = d?.productUpdate?.userErrors || [];
+ if (ue.length) throw new Error(ue.map(e => e.message).join('; '));
+ return d.productUpdate.product.tags;
+}
+
+// ── main ──
+if (existsSync(KILL)) { console.error('REFUSED: kill-switch present at ' + KILL); process.exit(2); }
+const diff = JSON.parse(await fs.readFile('reports/cork-tag-diff.json', 'utf8'));
+const rows = diff.rows.filter(r => r.add_tags?.length).slice(0, CAP);
+console.log(`Target store: ${STORE} · ${rows.length} product(s) to tag · ${APPLY ? 'LIVE APPLY' : 'DRY-RUN'} · cost $0 (Shopify Admin API)`);
+
+let applied = 0, skipped = 0, notFound = 0, failed = 0;
+for (const r of rows) {
+ try {
+ const prod = await resolveByHandle(r.handle || r.sku);
+ if (!prod) { console.log(` ✗ ${r.sku}: no product in store — SKIP`); notFound++; continue; }
+ const have = new Set(prod.tags.map(t => t.toLowerCase()));
+ const fresh = r.add_tags.filter(t => !have.has(t.toLowerCase()));
+ if (!fresh.length) { console.log(` · ${r.sku}: already has ${r.add_tags.join(',')} — skip`); skipped++; continue; }
+ const next = [...prod.tags, ...fresh];
+ if (APPLY) {
+ const now = await applyTags(prod.id, next);
+ console.log(` ✓ ${r.sku} → +${fresh.join(', ')} (now ${now.length} tags)`);
+ applied++;
+ } else {
+ console.log(` ~ ${r.sku} [${prod.id.split('/').pop()}] would add +${fresh.join(', ')}`);
+ applied++;
+ }
+ } catch (e) { console.log(` ✗ ${r.sku}: ${e.message}`); failed++; }
+}
+console.log(`\n${APPLY ? 'APPLIED' : 'WOULD APPLY'}=${applied} skipped=${skipped} notFound=${notFound} failed=${failed}`);
+if (!APPLY) console.log('Re-run with --apply to write to ' + STORE);
← 2941458 chore: lint + refactor enrich-cork-ai.mjs, correct 2.5-flash
·
back to Corkwallcovering
·
cork: prod-capable tag-apply (PG-first → Shopify, dual-scope c877799 →