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