← back to Dw Yolo Loop
refactor: add scripts/lib/shopify.mjs shared GQL client + migrate 2 read-only auditors (proof of pattern)
d2d776f89f93249210b1a28a325535a4e0e1a46c · 2026-06-16 00:28:08 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A scripts/cole-son-price-audit.mjsA scripts/kravet-2026-coverage-audit.mjsA scripts/lib/shopify.mjs
Diff
commit d2d776f89f93249210b1a28a325535a4e0e1a46c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 16 00:28:08 2026 -0700
refactor: add scripts/lib/shopify.mjs shared GQL client + migrate 2 read-only auditors (proof of pattern)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
scripts/cole-son-price-audit.mjs | 52 ++++++++++++++++++++
scripts/kravet-2026-coverage-audit.mjs | 41 ++++++++++++++++
scripts/lib/shopify.mjs | 87 ++++++++++++++++++++++++++++++++++
3 files changed, 180 insertions(+)
diff --git a/scripts/cole-son-price-audit.mjs b/scripts/cole-son-price-audit.mjs
new file mode 100644
index 0000000..7cd7a19
--- /dev/null
+++ b/scripts/cole-son-price-audit.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/* Read-only Cole & Son pricing audit: join every live product to MAP (kravet_master_price,
+ MAP = wholesale x 1.5) via the custom.manufacturer_sku metafield. Classifies:
+ - sample-only $4.25 with a MAP available → fixable (set yard variant to MAP)
+ - real price that differs from MAP → off-MAP (over/under)
+ - no MAP match → can't auto-fix
+ Writes /tmp/cs_audit.json with the full fix plan. NO writes to Shopify. */
+import fs from 'node:fs';
+import { rest, restAll } from './lib/shopify.mjs';
+
+const MAP = {};
+fs.readFileSync('/tmp/cs_map.txt', 'utf8').trim().split('\n').forEach(l => {
+ const [sku, m] = l.split('|'); if (sku) MAP[sku.trim().toUpperCase()] = parseFloat(m);
+});
+
+const getAll = () => restAll(`/products.json?vendor=Cole%20%26%20Son&limit=250&fields=id,title,status,variants`, 'products');
+const isSample = v => (v.option1 || '').toLowerCase() === 'sample' || /-sample$/i.test(v.sku || '');
+
+(async () => {
+ const prods = await getAll();
+ console.log(`auditing ${prods.length} Cole & Son products...`);
+ const r = { fixable: [], offMap: [], noMap: [], ok: [] };
+ let i = 0;
+ for (const p of prods) {
+ if (++i % 200 === 0) console.log(` …${i}/${prods.length}`);
+ const mf = await (await rest(`/products/${p.id}/metafields.json`)).json();
+ const msku = (mf.metafields || []).find(m => m.namespace === 'custom' && m.key === 'manufacturer_sku');
+ const code = msku ? String(msku.value).trim().toUpperCase() : null;
+ const map = code ? MAP[code] : null;
+ const yard = p.variants.filter(v => !isSample(v)).sort((a, b) => parseFloat(b.price) - parseFloat(a.price))[0] || p.variants[0];
+ const yardPrice = parseFloat(yard.price);
+ const rec = { id: p.id, vid: yard.id, title: p.title, status: p.status, code, map, cur: yardPrice };
+ if (!map) { r.noMap.push(rec); continue; }
+ if (yardPrice <= 4.25) r.fixable.push(rec); // sample-only, MAP available
+ else if (Math.abs(yardPrice - map) > 0.5) r.offMap.push(rec); // real price ≠ MAP
+ else r.ok.push(rec); // at MAP
+ }
+ fs.writeFileSync('/tmp/cs_audit.json', JSON.stringify(r, null, 0));
+ const sum = k => r[k].length;
+ const active = a => a.filter(x => x.status === 'active').length;
+ console.log('\n=== COLE & SON PRICING AUDIT ===');
+ console.log(`at MAP (correct): ${sum('ok')} (active ${active(r.ok)})`);
+ console.log(`FIXABLE ($4.25 → MAP): ${sum('fixable')} (active+live ${active(r.fixable)})`);
+ console.log(`OFF-MAP (real ≠ MAP): ${sum('offMap')} (active ${active(r.offMap)})`);
+ console.log(`no MAP match: ${sum('noMap')} (active ${active(r.noMap)})`);
+ const overMap = r.offMap.filter(x => x.cur > x.map);
+ console.log(` of off-MAP, OVER map: ${overMap.length} (total $${overMap.reduce((s,x)=>s+(x.cur-x.map),0).toFixed(0)} above MAP)`);
+ console.log('\nsample FIXABLE (now $4.25 → should be MAP):');
+ r.fixable.slice(0, 6).forEach(x => console.log(` ${x.code} $4.25 → $${x.map} ${x.title.slice(0,45)}`));
+ console.log('\nsample OFF-MAP:');
+ r.offMap.slice(0, 6).forEach(x => console.log(` ${x.code} $${x.cur} → MAP $${x.map} (${x.cur>x.map?'+':''}${(x.cur-x.map).toFixed(0)}) ${x.title.slice(0,40)}`));
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/kravet-2026-coverage-audit.mjs b/scripts/kravet-2026-coverage-audit.mjs
new file mode 100644
index 0000000..0268e65
--- /dev/null
+++ b/scripts/kravet-2026-coverage-audit.mjs
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+/* READ-ONLY: do we have a 2026 price for every live Kravet-family item?
+ For each Kravet-family vendor, pull active products + their manufacturer_sku metafield,
+ check membership in the 2026-priced SKU set (auth_pricing.new_map>0 → /tmp/auth2026_skus.txt).
+ Reports per-vendor coverage + writes /tmp/kravet_2026_gaps.csv (items with NO 2026 price). */
+import fs from 'node:fs';
+import { rest, restAll } from './lib/shopify.mjs';
+
+const SET = new Set(fs.readFileSync('/tmp/auth2026_skus.txt','utf8').trim().split('\n').map(s=>s.trim().toUpperCase()));
+const VENDORS = ['Kravet','Kravet Couture','Kravet Design','Lee Jofa','Lee Jofa Modern','Brunschwig & Fils',
+ 'Cole & Son','GP & J Baker','Clarke And Clarke','Mulberry','Threads','Baker Lifestyle','Gaston y Daniela','Andrew Martin'];
+const norm = s => (s||'').toUpperCase().replace(/\s+/g,' ').trim();
+
+const getAll = vendor => restAll(`/products.json?vendor=${encodeURIComponent(vendor)}&status=active&limit=250&fields=id,title,handle,variants`, 'products');
+const isSample = v => (v.option1||'').toLowerCase()==='sample' || /-sample$/i.test(v.sku||'');
+
+(async()=>{
+ const summary=[];
+ const csv=['vendor,mfr_sku,dw_sku,title,handle'];
+ for(const vendor of VENDORS){
+ let prods; try{ prods=await getAll(vendor); }catch(e){ console.log(` ${vendor}: ERR`); continue; }
+ if(!prods.length) continue;
+ let have=0, miss=0, noSku=0, n=0;
+ for(const p of prods){
+ n++;
+ const mf=await(await rest(`/products/${p.id}/metafields.json`)).json();
+ const code=norm(((mf.metafields||[]).find(x=>x.namespace==='custom'&&x.key==='manufacturer_sku')||{}).value||'');
+ const yard=p.variants.find(v=>!isSample(v))||p.variants[0];
+ if(!code){ noSku++; csv.push([vendor,'(no mfr_sku)',yard&&yard.sku||'',JSON.stringify(p.title),p.handle].join(',')); continue; }
+ if(SET.has(code)) have++;
+ else { miss++; csv.push([vendor,code,yard&&yard.sku||'',JSON.stringify(p.title),p.handle].join(',')); }
+ }
+ const pct=(100*have/prods.length).toFixed(1);
+ summary.push({vendor,total:prods.length,have,miss,noSku,pct});
+ console.log(` ${vendor.padEnd(20)} ${prods.length} total | 2026:${have} (${pct}%) | missing:${miss} | no-mfr-sku:${noSku}`);
+ }
+ fs.writeFileSync('/tmp/kravet_2026_gaps.csv', csv.join('\n'));
+ const T=summary.reduce((a,s)=>({total:a.total+s.total,have:a.have+s.have,miss:a.miss+s.miss,noSku:a.noSku+s.noSku}),{total:0,have:0,miss:0,noSku:0});
+ console.log(`\n=== TOTAL: ${T.total} live Kravet items | have 2026 price: ${T.have} (${(100*T.have/T.total).toFixed(1)}%) | missing: ${T.miss} | no mfr_sku: ${T.noSku} ===`);
+ console.log(`gap list → /tmp/kravet_2026_gaps.csv (${T.miss+T.noSku} rows)`);
+})().catch(e=>{console.error(e);process.exit(1);});
diff --git a/scripts/lib/shopify.mjs b/scripts/lib/shopify.mjs
new file mode 100644
index 0000000..267920b
--- /dev/null
+++ b/scripts/lib/shopify.mjs
@@ -0,0 +1,87 @@
+/**
+ * Shared Shopify Admin client seam for DW scripts.
+ *
+ * One place that owns the store endpoint, API version, token load, retry/backoff,
+ * and Shopify cost-throttle sleep — so individual scripts stop re-inlining their own
+ * `X-Shopify-Access-Token` + fetch-retry boilerplate. The `gql()` THROTTLED backoff +
+ * cost-throttle pattern is lifted verbatim from scripts/price-sheets/add-roll-variant.mjs.
+ *
+ * Centralizing the write path here is also what makes the "writes go through
+ * shopify_api_queue" rule enforceable: there's now a single function a guard can wrap.
+ *
+ * Exports:
+ * SHOP, VER, TOKEN, ENDPOINT — connection constants
+ * gql(query, vars) — GraphQL Admin call w/ THROTTLED backoff + throttle sleep
+ * rest(path, { method, body }) — REST Admin call (path begins after /admin/api/<VER>)
+ * restAll(path) — REST GET that follows Link rel="next" pagination
+ * getLocation() — primary location gid (read_locations scope required)
+ */
+import fs from 'node:fs';
+
+export const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+export const VER = '2024-10';
+export const TOKEN = (
+ fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+ .match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || []
+)[1]?.trim();
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN in ~/Projects/secrets-manager/.env'); process.exit(1); }
+
+export const ENDPOINT = `https://${SHOP}/admin/api/${VER}`;
+const GQL_URL = `${ENDPOINT}/graphql.json`;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+export async function gql(query, vars) {
+ for (let a = 0; a < 8; a++) {
+ let j;
+ try {
+ const r = await fetch(GQL_URL, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables: vars }),
+ });
+ j = await r.json();
+ } catch (e) { await sleep(1500 * (a + 1)); continue; }
+ if (j.errors) {
+ if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
+ return { __err: j.errors };
+ }
+ const t = j.extensions?.cost?.throttleStatus;
+ if (t && t.currentlyAvailable < 400) await sleep(1200);
+ return j.data;
+ }
+ throw new Error('gql retries exhausted');
+}
+
+export async function rest(path, { method = 'GET', body } = {}, tries = 5) {
+ for (let i = 0; i < tries; i++) {
+ const r = await fetch(`${ENDPOINT}${path}`, {
+ method,
+ headers: { 'X-Shopify-Access-Token': TOKEN, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ });
+ if (r.status === 429 || r.status >= 500) { await sleep(1500 * (i + 1)); continue; }
+ await sleep(90);
+ return r;
+ }
+ throw new Error('rest fail ' + path);
+}
+
+export async function restAll(path, key) {
+ const collection = key || path.replace(/^\/([a-z_]+)\.json.*/, '$1');
+ const out = [];
+ let url = path;
+ while (url) {
+ const r = await rest(url);
+ const link = r.headers.get('Link') || '';
+ out.push(...(((await r.json())[collection]) || []));
+ const m = link.split(',').find(s => s.includes('rel="next"'));
+ url = m ? m.slice(m.indexOf('<') + 1, m.indexOf('>')).replace(/^https:\/\/[^/]+\/admin\/api\/[^/]+/, '') : null;
+ }
+ return out;
+}
+
+export async function getLocation() {
+ const loc = await gql(`{locations(first:5){nodes{id}}}`);
+ if (loc?.__err) throw new Error('cannot read locations: ' + JSON.stringify(loc.__err).slice(0, 200));
+ return loc.locations.nodes[0].id;
+}
← 540e242 Contract-scope auditor: read-only dw_unified canary for vend
·
back to Dw Yolo Loop
·
docs: verify resume-roll-adds REMAIN-counter edge-set concer 5159ca6 →