← 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 : ''}`);