← back to Corkwallcovering

scripts/_cork-apply-template.mjs

97 lines

#!/usr/bin/env node
/**
 * SELF-CONTAINED cork PROD tag-apply. The __PLAN__ literal below is injected by
 * gen-selfcontained-prod.mjs (which computes Scope A + Scope B on a git checkout).
 * Needs ONLY: DW_PROD_STORE_DOMAIN, DW_PROD_SHOPIFY_TOKEN, DW_UNIFIED_DSN (env) + pg.
 * No git / repo / data files. Additive-only, idempotent, PG-first, kill-switch + cap.
 *   node apply-cork-tags-SELFCONTAINED.mjs                       # DRY-RUN (read-only)
 *   ...creds... node apply-cork-tags-SELFCONTAINED.mjs --apply   # LIVE (PG→Shopify)
 */
import { existsSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';

const PLAN = __PLAN__;

const APPLY = process.argv.includes('--apply');
const KILL = path.join(os.homedir(), '.dw-fixer-stop');
const CAP = 200, API = '2024-10';
const STORE = process.env.DW_PROD_STORE_DOMAIN || null;
const TOKEN = process.env.DW_PROD_SHOPIFY_TOKEN || null;
const DSN = process.env.DW_UNIFIED_DSN || null;

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) {
  const d = await gql(`query($q:String!){ products(first:1, query:$q){ edges{ node{ id title handle tags } } } }`, { q: `handle:${handle}` });
  const n = d?.products?.edges?.[0]?.node;
  return n ? { id: n.id, tags: n.tags || [] } : null;
}
async function applyShopifyTags(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;
}
let PgClient = null;
async function pgConnect() {
  if (!DSN) throw new Error('DW_UNIFIED_DSN not set');
  try { ({ Client: PgClient } = await import('pg')); } catch { throw new Error("node 'pg' package not available — install pg to run --apply"); }
  const c = new PgClient({ connectionString: DSN }); await c.connect(); return c;
}
async function pgAddTags(pg, handle, fresh) {
  const sel = await pg.query('SELECT id, tags FROM shopify_products WHERE handle = $1 LIMIT 1', [handle]);
  if (!sel.rows.length) return { matched: false };
  const row = sel.rows[0];
  const existing = String(row.tags || '').split(',').map(s => s.trim()).filter(Boolean);
  const have = new Set(existing.map(t => t.toLowerCase()));
  const add = fresh.filter(t => !have.has(t.toLowerCase()));
  if (!add.length) return { matched: true, wrote: false };
  await pg.query('UPDATE shopify_products SET tags = $1 WHERE id = $2', [[...existing, ...add].join(', '), row.id]);
  return { matched: true, wrote: true, added: add };
}

if (existsSync(KILL)) { console.error('REFUSED: kill-switch present at ' + KILL); process.exit(2); }
console.log(`Mode: ${APPLY ? 'LIVE APPLY (PG-first → Shopify)' : 'DRY-RUN (read-only)'}`);
console.log(`Store: ${STORE || '(DW_PROD_STORE_DOMAIN unset)'}  ·  dw_unified: ${DSN ? 'DSN present' : '(unset)'}`);
console.log(`In-scope products: ${PLAN.length} (baked plan)  cap=${CAP}  ·  cost $0\n`);
if (APPLY) {
  if (!STORE || !TOKEN) { console.error('REFUSED --apply: DW_PROD_STORE_DOMAIN and DW_PROD_SHOPIFY_TOKEN required.'); process.exit(3); }
  if (!DSN) { console.error('REFUSED --apply: DW_UNIFIED_DSN required (PG-first ordering is mandatory).'); process.exit(3); }
}

let resolved = 0, wouldChange = 0, noChange = 0, notFound = 0, failed = 0, applied = 0;
let pg = null; if (APPLY) pg = await pgConnect();
for (const { handle, add: wantTags } of PLAN.slice(0, CAP)) {
  try {
    let prod = null;
    if (STORE && TOKEN) prod = await resolveByHandle(handle);
    if (STORE && TOKEN && !prod) { console.log(`  ✗ ${handle}: NOT FOUND in store`); notFound++; continue; }
    resolved += (prod ? 1 : 0);
    const storeTags = prod ? prod.tags : [];
    const have = new Set(storeTags.map(t => t.toLowerCase()));
    const fresh = wantTags.filter(t => !have.has(t.toLowerCase()));
    if (!fresh.length) { if (prod) { console.log(`  · ${handle}: already has ${wantTags.join(', ')} — skip`); noChange++; } else { console.log(`  ~ ${handle}: would add ${wantTags.join(', ')} (unresolved)`); wouldChange++; } continue; }
    wouldChange++;
    if (APPLY) {
      const pgRes = await pgAddTags(pg, handle, fresh);
      if (!pgRes.matched) { console.log(`  ✗ ${handle}: no dw_unified row — SKIP (no Shopify write)`); failed++; continue; }
      const now = await applyShopifyTags(prod.id, [...storeTags, ...fresh]);
      console.log(`  ✓ ${handle} → PG+${(pgRes.added || []).join(',') || '∅'} · Shopify +${fresh.join(', ')} (now ${now.length})`);
      applied++;
    } else {
      console.log(`  ~ ${handle} [${prod ? prod.id.split('/').pop() : '?'}] would add +${fresh.join(', ')} (store has ${storeTags.length})`);
    }
  } catch (e) { console.log(`  ✗ ${handle}: ${e.message}`); failed++; }
}
if (pg) await pg.end();
console.log(`\n── ${APPLY ? 'APPLIED' : 'DRY-RUN'} SUMMARY ──`);
console.log(`in-scope=${PLAN.length}  resolved=${resolved}  wouldChange=${wouldChange}  alreadyPresent=${noChange}  notFound=${notFound}  failed=${failed}${APPLY ? '  applied=' + applied : ''}`);