← back to Dw Contact Us Pages

scripts/lib.mjs

233 lines

// lib.mjs — shared helpers. TK-11925. Node 20+, no deps.
// SAFETY: nothing here writes to Shopify. Every caller must pass --apply explicitly.
import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';

export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const API_VERSION = '2024-10';

// ---------------------------------------------------------------- cohorts (TK-11669)
// The contact-us mechanism is reused per COHORT. The default (no --cohort) is the original
// TK-11925 cohort with its ledgers in data/ — byte-for-byte the old behaviour. A named cohort
// (`--cohort maharam`, config in cohorts/<name>.json) gets its OWN data dir, so its targets,
// ledgers and --rollback are SCOPED to that cohort and can never replay another cohort's rows
// (the shared-ledger over-revert class — see the scoped-ledger-rollback skill).
function cohortArg() {
  const argv = process.argv.slice(2);
  const i = argv.indexOf('--cohort');
  if (i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--')) return argv[i + 1];
  const eq = argv.find((t) => t.startsWith('--cohort='));
  if (eq) return eq.slice('--cohort='.length);
  return process.env.DW_CU_COHORT || null;
}
export const COHORT = cohortArg();
const COHORT_CFG = COHORT
  ? (() => {
      const f = join(ROOT, 'cohorts', `${COHORT}.json`);
      if (!/^[a-z0-9-]+$/.test(COHORT) || !existsSync(f)) { console.error(`FATAL: unknown cohort "${COHORT}" (no ${f})`); process.exit(2); }
      return JSON.parse(readFileSync(f, 'utf8'));
    })()
  : null;
export const TICKET = COHORT_CFG?.ticket || 'TK-11925';
export const AGENT = COHORT_CFG?.agent || 'claude-run-11925-builder';
export const DATA_DIR = COHORT ? join(ROOT, 'data', 'cohorts', COHORT) : join(ROOT, 'data');
export const COHORT_FLAG = COHORT ? ` --cohort ${COHORT}` : '';

// TK-11925: Nina Campbell added 2026-09-20 (Steve — "a brand of Designers Guild"). 433 ACTIVE.
// NOTE: 'Alan Campbell' (Quadrille-associated) is deliberately EXCLUDED — Steve said "NOT Quadrille".
export const VENDORS = COHORT_CFG?.vendors || ['Designers Guild', 'Ralph Lauren', 'Christian Lacroix Europe', 'Nina Campbell'];

// The 3 sales channels Steve named. Nothing else is ever touched.
export const TARGET_PUBLICATIONS = [
  { id: 'gid://shopify/Publication/29646651457', name: 'Google & YouTube' },
  { id: 'gid://shopify/Publication/44317507635', name: 'Shop' },
  { id: 'gid://shopify/Publication/22497296496', name: 'Buy Button' },
];

export const LIVE_MAIN_THEME_ID = 145556635699; // role=main, "DW Sample-Shipping DEV"

// ---------------------------------------------------------------- env
// Parse ~/Projects/secrets-manager/.env line-by-line. NEVER `source` it:
// several values are unquoted and would be executed by a shell.
export function loadSecrets(file = `${process.env.HOME}/Projects/secrets-manager/.env`) {
  const out = {};
  if (!existsSync(file)) return out;
  for (const line of readFileSync(file, 'utf8').split('\n')) {
    const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
    if (!m) continue;
    let v = m[2].trim();
    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
    out[m[1]] = v;
  }
  return out;
}

const SEC = loadSecrets();
export const SHOP = process.env.SHOPIFY_SHOP || 'designer-laboratory-sandbox.myshopify.com';
// TK-11669: prefer SHOPIFY_ADMIN_TOKEN. SHOPIFY_FULL_ACCESS_TOKEN (…2ea5) has been DEAD (401) since
// Sep 2026 and ADMIN (…6755) is now the Full Access app (TK-12265) — preferring FULL here meant every
// call, including the TK-11925 --rollback paths, would 401. Explicit env override still wins.
export const TOKEN = process.env.SHOPIFY_TOKEN_OVERRIDE || SEC.SHOPIFY_ADMIN_TOKEN || SEC.SHOPIFY_FULL_ACCESS_TOKEN;

export function requireToken() {
  if (!TOKEN) {
    console.error('FATAL: no SHOPIFY_ADMIN_TOKEN in ~/Projects/secrets-manager/.env');
    process.exit(1);
  }
}

// ---------------------------------------------------------------- args
export function parseArgs(argv = process.argv.slice(2)) {
  const a = { _: [], apply: false };
  for (let i = 0; i < argv.length; i++) {
    const t = argv[i];
    if (t.startsWith('--')) {
      const eq = t.indexOf('=');
      if (eq > -1) a[t.slice(2, eq)] = t.slice(eq + 1);
      else if (argv[i + 1] && !argv[i + 1].startsWith('--')) a[t.slice(2)] = argv[++i];
      else a[t.slice(2)] = true;
    } else a._.push(t);
  }
  a.apply = a.apply === true || a.apply === 'true';
  return a;
}

export function banner(name, apply) {
  const mode = apply ? '\x1b[41m\x1b[97m APPLY — LIVE WRITES \x1b[0m' : '\x1b[42m\x1b[30m DRY-RUN (no writes) \x1b[0m';
  console.log(`\n${name} · ${SHOP} · ${mode}\n`);
}

// ---------------------------------------------------------------- graphql
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function gql(query, variables = {}, { retries = 6 } = {}) {
  requireToken();
  const url = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
      body: JSON.stringify({ query, variables }),
    });
    if (res.status === 429 || res.status >= 500) {
      if (attempt >= retries) throw new Error(`GraphQL HTTP ${res.status} after ${attempt} retries`);
      await sleep(Math.min(20000, 1000 * 2 ** attempt));
      continue;
    }
    if (!res.ok) throw new Error(`GraphQL HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
    const body = await res.json();
    if (body.errors?.length) {
      const throttled = body.errors.some((e) => e.extensions?.code === 'THROTTLED');
      if (throttled && attempt < retries) { await sleep(2000 * (attempt + 1)); continue; }
      throw new Error('GraphQL errors: ' + JSON.stringify(body.errors).slice(0, 600));
    }
    // Cost-aware pacing: back off before the leaky bucket empties.
    const t = body.extensions?.cost?.throttleStatus;
    if (t && t.currentlyAvailable < t.maximumAvailable * 0.2) {
      await sleep(Math.ceil((t.maximumAvailable * 0.35 - t.currentlyAvailable) / (t.restoreRate || 50)) * 1000);
    }
    return body.data;
  }
}

export function userErrors(...nodes) {
  const errs = [];
  for (const n of nodes) for (const e of (n?.userErrors || n?.errors || [])) errs.push(e);
  return errs;
}

// ---------------------------------------------------------------- rest (theme assets)
export async function rest(path, { method = 'GET', body } = {}) {
  requireToken();
  const url = `https://${SHOP}/admin/api/${API_VERSION}/${path.replace(/^\//, '')}`;
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, {
      method,
      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (res.status === 429 || res.status >= 500) {
      if (attempt >= 6) throw new Error(`REST HTTP ${res.status} on ${path}`);
      await sleep(Math.min(20000, 1000 * 2 ** attempt));
      continue;
    }
    const text = await res.text();
    let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
    return { ok: res.ok, status: res.status, json, text };
  }
}

// ---------------------------------------------------------------- variant classification
// A SAMPLE variant is NEVER touched (Steve: the $4.25 swatch stays orderable).
// Measured on the 785-product cohort: all 785 sample variants are titled exactly
// "Sample"; sku suffix alone is NOT sufficient (many sample skus carry no -Sample).
export function isSampleVariant(v) {
  const sku = String(v.sku || '').trim().toLowerCase();
  const title = String(v.title || '').trim().toLowerCase();
  if (sku.endsWith('-sample')) return true;
  if (title === 'sample') return true;
  if (title.includes('sample')) return true;
  return false;
}

// ---------------------------------------------------------------- ledgers
export function appendJsonl(file, obj) {
  mkdirSync(dirname(file), { recursive: true });
  appendFileSync(file, JSON.stringify(obj) + '\n');
}

export function readJsonl(file) {
  if (!existsSync(file)) return [];
  return readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
}

// Reversible-tier ledger (CLAUDE.md gate-temperature rule). Only on --apply.
export function logReversible({ action, blast, undo, verify }) {
  const script = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/log-exec.mjs`;
  if (!existsSync(script)) { console.log('  (log-exec.mjs absent — skipping reversible ledger)'); return; }
  const r = spawnSync('node', [script,
    '--agent', AGENT, '--ticket', TICKET,
    '--action', action, '--blast', String(blast),
    '--undo', undo, '--verify', verify || 'node scripts/verify.mjs'], { encoding: 'utf8' });
  if (r.status !== 0) console.error('  WARN: log-exec.mjs failed:', (r.stderr || '').slice(0, 300));
  else console.log('  reversible-ledger: logged');
}

export function loadTargets(file = join(DATA_DIR, 'targets.json')) {
  if (!existsSync(file)) {
    console.error(`FATAL: ${file} missing. Run: node scripts/enumerate.mjs${COHORT_FLAG}`);
    process.exit(1);
  }
  const d = JSON.parse(readFileSync(file, 'utf8'));
  return Array.isArray(d) ? d : d.products;
}

export function chunk(arr, n) {
  const out = [];
  for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
  return out;
}

// TK-11925 review fixes (Kimi): a NULL mutation payload (bad id, missing scope) must never
// count as success — only a payload with an empty userErrors list is a success.
export function payloadErrors(payload, label) {
  if (!payload) return [{ field: null, message: `${label}: null payload (bad id / scope / not found)` }];
  return payload.userErrors || [];
}
// Refuse to --apply against a stale enumeration: the preimage must be fresh, or the
// rollback restores stock counts that a real sale may have changed since capture.
import { statSync } from 'node:fs';
export function assertFreshTargets(args, maxHours = 2) {
  // A --rollback reads the LEDGER, never targets.json, so target freshness is irrelevant to it —
  // refusing a rollback because the forward enumeration is old would block the undo (TK-11669 fix).
  if (!args.apply || args['stale-ok'] || args.rollback) return;
  const f = join(DATA_DIR, 'targets.json');
  const ageH = (Date.now() - statSync(f).mtimeMs) / 3.6e6;
  if (ageH > maxHours) {
    console.error(`REFUSED: data/targets.json is ${ageH.toFixed(1)}h old (> ${maxHours}h). Run node scripts/enumerate.mjs${COHORT_FLAG} first, or pass --stale-ok.`);
    process.exit(2);
  }
}