[object Object]

← 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

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 →