← back to Dw Add Sellable Variant Tk10902
add-variant.mjs
186 lines
#!/usr/bin/env node
// TK-10902 / TK-10875 — Add a sellable product variant to ACTIVE DW products that have ONLY a $4.25 sample.
// Steve-APPROVED. Reversible + ledgered. Verify-before-write. NO EMAIL. $0 (Shopify Admin API).
//
// For each candidate:
// 1. Re-read the LIVE product. HARD-STOP (skip+log) on any drift:
// - exactly 1 variant
// - that variant SKU ends in -sample (case-insensitive) at $4.25, option value == 'Sample'
// 2. Derive sellable SKU = sample SKU with the -sample suffix stripped.
// - HARD-STOP if derived SKU is blank, unchanged, or already used by another live variant/product.
// 3. POST a NEW variant on the product's existing single option axis:
// option value = candidate.sellable_label, price = candidate.final_price,
// inventory_management=null (no tracking), inventory_policy='continue'.
// (Existing sample variant is left untouched.)
// 4. Ledger the created variant_id + product_id + exact delete undo command.
//
// Usage: node add-variant.mjs <candidates.json> [--limit N] [--live]
// default = DRY-RUN (no writes). --live performs the writes.
import fs from 'fs';
import { execSync } from 'child_process';
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const TOKEN = execSync(`grep -E '^SHOPIFY_ADMIN_TOKEN=' ${process.env.HOME}/Projects/secrets-manager/.env | cut -d= -f2-`).toString().trim();
if (!TOKEN) { console.error('NO SHOPIFY TOKEN'); process.exit(1); }
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const RUNLOG = `${process.cwd()}/run-log.jsonl`;
const args = process.argv.slice(2);
const file = args[0];
const LIVE = args.includes('--live');
const limIdx = args.indexOf('--limit');
const LIMIT = limIdx >= 0 ? parseInt(args[limIdx + 1], 10) : Infinity;
const base = `https://${DOMAIN}/admin/api/${API}`;
const hdr = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function shopify(path, opts = {}) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${base}${path}`, { headers: hdr, ...opts });
if (res.status === 429) { await sleep(2500); continue; }
const body = await res.text();
let json; try { json = JSON.parse(body); } catch { json = { _raw: body }; }
return { status: res.status, json };
}
return { status: 429, json: { error: 'rate-limited' } };
}
function stripSampleSuffix(sku) {
// strip a trailing -sample / -Sample / -SAMPLE (only the exact suffix)
return sku.replace(/-sample$/i, '');
}
function appendJsonl(path, obj) {
fs.mkdirSync(path.substring(0, path.lastIndexOf('/')), { recursive: true });
fs.appendFileSync(path, JSON.stringify(obj) + '\n');
}
const candidates = JSON.parse(fs.readFileSync(file, 'utf8'));
const batch = candidates.slice(0, LIMIT);
let built = 0, skipped = 0;
const skips = [];
console.log(`\n=== ${LIVE ? 'LIVE' : 'DRY-RUN'} — ${batch.length} candidates (of ${candidates.length}) ===\n`);
for (const c of batch) {
const pid = c.product_id;
// 1. Re-read live
const { status, json } = await shopify(`/products/${pid}.json`);
if (status !== 200 || !json.product) {
skipped++; skips.push({ pid, reason: `fetch_failed_${status}`, vendor: c.vendor });
console.log(`SKIP ${pid} (${c.vendor}) fetch_failed_${status}`);
continue;
}
const p = json.product;
const variants = p.variants || [];
const options = p.options || [];
// Guard: exactly one variant
if (variants.length !== 1) {
skipped++; skips.push({ pid, reason: `variant_count=${variants.length}`, vendor: c.vendor, title: p.title });
console.log(`SKIP ${pid} (${c.vendor}) variant_count=${variants.length}`);
continue;
}
const sv = variants[0];
// Guard: the lone variant is a genuine sample at $4.25 on option value 'Sample'
const svSku = (sv.sku || '').trim();
const svPrice = parseFloat(sv.price);
const svOpt = (sv.option1 || '').trim();
if (!/-sample$/i.test(svSku) || svPrice !== 4.25 || svOpt.toLowerCase() !== 'sample') {
skipped++; skips.push({ pid, reason: `not_clean_sample sku=${svSku} price=${svPrice} opt=${svOpt}`, vendor: c.vendor, title: p.title });
console.log(`SKIP ${pid} (${c.vendor}) not_clean_sample sku=${svSku} price=${svPrice} opt=${svOpt}`);
continue;
}
// Guard: single option axis
if (options.length !== 1) {
skipped++; skips.push({ pid, reason: `option_count=${options.length}`, vendor: c.vendor, title: p.title });
console.log(`SKIP ${pid} (${c.vendor}) option_count=${options.length}`);
continue;
}
const optName = options[0].name;
// 2. Derive sellable SKU (self-copy, never mint)
const sellSku = stripSampleSuffix(svSku);
if (!sellSku || sellSku === svSku) {
skipped++; skips.push({ pid, reason: `sku_strip_failed from=${svSku}`, vendor: c.vendor, title: p.title });
console.log(`SKIP ${pid} (${c.vendor}) sku_strip_failed from=${svSku}`);
continue;
}
// Guard: sellable SKU not already used on THIS product
if (variants.some(v => (v.sku || '').trim().toLowerCase() === sellSku.toLowerCase())) {
skipped++; skips.push({ pid, reason: `sellable_sku_exists_on_product ${sellSku}`, vendor: c.vendor, title: p.title });
console.log(`SKIP ${pid} (${c.vendor}) sellable_sku_exists ${sellSku}`);
continue;
}
// Guard: option value not already present
if (options[0].values.some(val => val.toLowerCase() === c.sellable_label.toLowerCase())) {
skipped++; skips.push({ pid, reason: `option_value_exists ${c.sellable_label}`, vendor: c.vendor, title: p.title });
console.log(`SKIP ${pid} (${c.vendor}) option_value_exists ${c.sellable_label}`);
continue;
}
const price = Number(c.final_price).toFixed(2);
// Guard: price sanity
if (!(parseFloat(price) > 4.25)) {
skipped++; skips.push({ pid, reason: `bad_price ${price}`, vendor: c.vendor, title: p.title });
console.log(`SKIP ${pid} (${c.vendor}) bad_price ${price}`);
continue;
}
const plan = {
pid, vendor: c.vendor, product_type: c.product_type, title: p.title,
optName, sample_sku: svSku, sellable_sku: sellSku,
sellable_label: c.sellable_label, price, basis: c.price_basis, cost: c.cost
};
if (!LIVE) {
console.log(`WOULD BUILD ${pid} (${c.vendor}) ${sellSku} @ $${price} [${c.price_basis}] opt="${optName}":"${c.sellable_label}" (sample ${svSku} $4.25 kept)`);
built++;
appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'dry', action: 'would_build', ...plan });
continue;
}
// 3. Create the variant (REST preserves existing variants)
const payload = {
variant: {
option1: c.sellable_label,
price: price,
sku: sellSku,
inventory_management: null,
inventory_policy: 'continue',
taxable: sv.taxable !== undefined ? sv.taxable : true
}
};
const cr = await shopify(`/products/${pid}/variants.json`, { method: 'POST', body: JSON.stringify(payload) });
if (cr.status !== 201 && cr.status !== 200) {
skipped++; skips.push({ pid, reason: `create_failed_${cr.status} ${JSON.stringify(cr.json).slice(0,200)}`, vendor: c.vendor, title: p.title });
console.log(`SKIP ${pid} (${c.vendor}) create_failed_${cr.status} ${JSON.stringify(cr.json).slice(0,160)}`);
continue;
}
const nv = cr.json.variant;
built++;
const undo = `curl -s -X DELETE "https://${DOMAIN}/admin/api/${API}/products/${pid}/variants/${nv.id}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN"`;
const ledgerEntry = {
ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10902',
action: `added sellable variant ${sellSku} @ $${price} [${c.price_basis}] to product ${pid} (${c.vendor})`,
product_id: pid, variant_id: nv.id, variant_sku: sellSku, price, basis: c.price_basis,
sample_variant_preserved: svSku,
blast_radius: 1, undo_cmd: undo,
verify: `curl -s "https://${DOMAIN}/admin/api/${API}/products/${pid}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" | jq '.product.variants[]|{sku,option1,price}'`
};
appendJsonl(LEDGER, ledgerEntry);
appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'live', action: 'built', variant_id: nv.id, ...plan });
console.log(`BUILT ${pid} (${c.vendor}) variant ${nv.id} sku=${sellSku} @ $${price} [${c.price_basis}]`);
await sleep(700); // gentle pacing between individual creates
}
fs.writeFileSync(`${process.cwd()}/skips.json`, JSON.stringify(skips, null, 1));
console.log(`\n=== DONE: built=${built} skipped=${skipped} (skips -> skips.json) ===`);