← back to Philipperomano
scripts/sample-add.mjs
394 lines
/**
* PhilRomano Sample-Add Script
* TK-10869 — approved 2026-08-26 by Steve
*
* Adds $4.25 Sample variant to quote-only Phillipe Romano products that only
* have a "Default Title" variant at $0. Renames option "Title" → "Size" and
* renames "Default Title" → "Per Yard (Quote)" to clarify the roll option.
*
* HARD RAILS:
* - do_not_price=TRUE: NO sell price written (roll stays $0)
* - Sample only: $4.25 / -sample SKU suffix / no inventory tracking
* - Batches of 50 with 90s gaps
* - Saves restore-map BEFORE every write
* - Skip idempotently if Sample already present
*
* Usage:
* DRY_RUN=true node sample-add.mjs # dry run, no writes
* CANARY=100 node sample-add.mjs # canary: first 100 only
* node sample-add.mjs # full run
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// --- Config ---
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
// Load from secrets manager .env
const envPath = '/Users/macstudio3/Projects/secrets-manager/.env';
if (fs.existsSync(envPath)) {
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
for (const l of lines) {
const m = l.match(/^SHOPIFY_ADMIN_TOKEN=(.+)/);
if (m) return m[1].trim();
}
}
throw new Error('SHOPIFY_ADMIN_TOKEN not found');
})();
const DRY_RUN = process.env.DRY_RUN === 'true';
const CANARY_LIMIT = process.env.CANARY ? parseInt(process.env.CANARY) : Infinity;
const BATCH_SIZE = 50;
const BATCH_GAP_MS = 90000; // 90 seconds between batches
// Restore map path
const RESTORE_MAP_PATH = path.join(__dirname, '../data/sample-add-restore-map.json');
const RESULTS_PATH = path.join(__dirname, '../data/sample-add-results.json');
const LEDGER_PATH = '/Users/macstudio3/.claude/yolo-queue/executed-reversible/ledger.jsonl';
// Ensure data dir exists
fs.mkdirSync(path.join(__dirname, '../data'), { recursive: true });
// --- Shopify REST helpers ---
async function shopifyGet(path) {
const url = `https://${SHOP}/admin/api/2024-10/${path}`;
const resp = await fetch(url, {
headers: { 'X-Shopify-Access-Token': TOKEN }
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`GET ${path} → ${resp.status}: ${body.slice(0, 200)}`);
}
return resp.json();
}
async function shopifyPut(path, body) {
const url = `https://${SHOP}/admin/api/2024-10/${path}`;
const resp = await fetch(url, {
method: 'PUT',
headers: {
'X-Shopify-Access-Token': TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`PUT ${path} → ${resp.status}: ${text.slice(0, 200)}`);
}
return resp.json();
}
async function shopifyPost(path, body) {
const url = `https://${SHOP}/admin/api/2024-10/${path}`;
const resp = await fetch(url, {
method: 'POST',
headers: {
'X-Shopify-Access-Token': TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`POST ${path} → ${resp.status}: ${text.slice(0, 200)}`);
}
return resp.json();
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// --- Fetch all quote-only PhilRomano products via REST pagination ---
async function fetchQuoteOnlyProducts() {
const products = [];
let url = `https://${SHOP}/admin/api/2024-10/products.json?vendor=Phillipe+Romano&status=active&limit=250`;
console.log('Fetching Phillipe Romano active products from Shopify...');
while (url) {
const resp = await fetch(url, {
headers: { 'X-Shopify-Access-Token': TOKEN }
});
if (!resp.ok) throw new Error(`Products list → ${resp.status}`);
const data = await resp.json();
const batch = data.products || [];
// Filter: only quote-only (tags contains "quote-only") AND has Default Title variant
for (const p of batch) {
const tags = (p.tags || '').toLowerCase();
const isQuoteOnly = tags.includes('quote-only');
if (!isQuoteOnly) continue;
const variants = p.variants || [];
// Check if already has a Sample variant
const hasSample = variants.some(v =>
v.title.toLowerCase() === 'sample' ||
(v.sku || '').toLowerCase().endsWith('-sample') ||
v.price === '4.25'
);
if (hasSample) continue; // Already done, skip
// Check it's a Default Title product
const hasDefaultTitle = variants.some(v => v.title === 'Default Title');
if (!hasDefaultTitle) continue;
products.push(p);
}
// Get next page from Link header
const linkHeader = resp.headers.get('link');
url = null;
if (linkHeader) {
const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
if (nextMatch) url = nextMatch[1];
}
console.log(` Fetched ${products.length} qualifying so far, page processed (${batch.length} products)...`);
if (url) await sleep(500); // polite gap between pages
}
return products;
}
// --- Process a single product ---
async function processProduct(product, restoreMap, dryRun) {
const id = product.id;
const variants = product.variants || [];
const options = product.options || [];
// Find the Default Title variant
const defaultVariant = variants.find(v => v.title === 'Default Title');
if (!defaultVariant) {
return { skipped: true, reason: 'no Default Title variant' };
}
// Derive sample SKU from base SKU
const baseSku = defaultVariant.sku || '';
const sampleSku = baseSku ? `${baseSku}-sample` : '';
// Save restore map entry BEFORE any write
restoreMap[id] = {
productId: id,
productTitle: product.title,
originalOptions: options.map(o => ({ id: o.id, name: o.name, values: o.values })),
originalVariants: variants.map(v => ({
id: v.id,
title: v.title,
sku: v.sku,
price: v.price,
option1: v.option1,
option2: v.option2,
inventory_management: v.inventory_management,
inventory_policy: v.inventory_policy
})),
timestamp: new Date().toISOString()
};
if (dryRun) {
return {
success: true,
dry: true,
productId: id,
title: product.title,
baseSku,
sampleSku,
action: 'would-add-sample-variant'
};
}
// Step 1: Update the product — rename option "Title" → "Size", rename "Default Title" → "Per Yard",
// and add Sample variant in one PUT
//
// Shopify: to add a variant to a single-option product (and change option name),
// use PUT /products/{id}.json with updated options + variants
const updatedProduct = {
product: {
id: id,
options: [
{
name: 'Size',
values: ['Per Yard', 'Sample']
}
],
variants: [
{
id: defaultVariant.id,
option1: 'Per Yard',
// Price stays 0 (do_not_price=TRUE — no sell price written)
price: defaultVariant.price,
sku: defaultVariant.sku
},
{
option1: 'Sample',
price: '4.25',
sku: sampleSku || undefined,
inventory_management: null,
inventory_policy: 'continue',
requires_shipping: true,
taxable: true
}
]
}
};
const result = await shopifyPut(`products/${id}.json`, updatedProduct);
const updatedVariants = result.product?.variants || [];
const sampleVariant = updatedVariants.find(v => v.option1 === 'Sample' || v.title === 'Sample');
return {
success: true,
productId: id,
title: product.title,
baseSku,
sampleSku,
sampleVariantId: sampleVariant?.id,
sampleVariantSku: sampleVariant?.sku,
samplePrice: sampleVariant?.price
};
}
// --- Append to ledger ---
function appendLedger(entry) {
try {
fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
fs.appendFileSync(LEDGER_PATH, JSON.stringify(entry) + '\n');
} catch (e) {
console.warn('Warning: could not write to ledger:', e.message);
}
}
// --- Main ---
async function main() {
console.log('=== PhilRomano Sample-Add TK-10869 ===');
console.log(`DRY_RUN: ${DRY_RUN}`);
console.log(`CANARY_LIMIT: ${CANARY_LIMIT === Infinity ? 'none (full run)' : CANARY_LIMIT}`);
console.log('');
// Load existing restore map (resume-safe)
let restoreMap = {};
if (fs.existsSync(RESTORE_MAP_PATH)) {
try {
restoreMap = JSON.parse(fs.readFileSync(RESTORE_MAP_PATH, 'utf8'));
console.log(`Loaded ${Object.keys(restoreMap).length} existing restore-map entries`);
} catch (e) {
console.warn('Could not load restore map, starting fresh');
}
}
// Fetch qualifying products
const products = await fetchQuoteOnlyProducts();
console.log(`\nFound ${products.length} quote-only products needing Sample variant\n`);
if (products.length === 0) {
console.log('Nothing to do. All quote-only products already have Sample variants.');
return;
}
// Apply canary limit
const toProcess = products.slice(0, CANARY_LIMIT === Infinity ? products.length : CANARY_LIMIT);
console.log(`Processing ${toProcess.length} products (${DRY_RUN ? 'DRY RUN' : 'LIVE'})\n`);
const results = {
startedAt: new Date().toISOString(),
dryRun: DRY_RUN,
totalFound: products.length,
toProcess: toProcess.length,
succeeded: 0,
failed: 0,
skipped: 0,
errors: [],
processed: []
};
// Process in batches of BATCH_SIZE
for (let batchStart = 0; batchStart < toProcess.length; batchStart += BATCH_SIZE) {
const batch = toProcess.slice(batchStart, batchStart + BATCH_SIZE);
const batchNum = Math.floor(batchStart / BATCH_SIZE) + 1;
const totalBatches = Math.ceil(toProcess.length / BATCH_SIZE);
console.log(`--- Batch ${batchNum}/${totalBatches} (products ${batchStart + 1}–${Math.min(batchStart + BATCH_SIZE, toProcess.length)}) ---`);
for (const product of batch) {
try {
const r = await processProduct(product, restoreMap, DRY_RUN);
if (r.skipped) {
results.skipped++;
console.log(` SKIP ${product.id} — ${r.reason}`);
} else if (r.success) {
results.succeeded++;
results.processed.push(r);
const tag = r.dry ? 'DRY' : 'OK';
console.log(` ${tag} ${product.title?.slice(0, 50)} | Sample SKU: ${r.sampleSku}`);
}
// Small gap between individual products (avoid rate limiting)
await sleep(300);
} catch (e) {
results.failed++;
const errEntry = { productId: product.id, title: product.title, error: e.message };
results.errors.push(errEntry);
console.error(` ERR ${product.id}: ${e.message}`);
await sleep(1000); // longer gap on error
}
}
// Save restore map after each batch
fs.writeFileSync(RESTORE_MAP_PATH, JSON.stringify(restoreMap, null, 2));
console.log(` Restore map saved (${Object.keys(restoreMap).length} entries)`);
// Gap between batches (except last)
if (batchStart + BATCH_SIZE < toProcess.length) {
const gapSec = Math.round(BATCH_GAP_MS / 1000);
console.log(` Waiting ${gapSec}s before next batch...\n`);
await sleep(BATCH_GAP_MS);
}
}
// Final results
results.completedAt = new Date().toISOString();
fs.writeFileSync(RESULTS_PATH, JSON.stringify(results, null, 2));
console.log('\n=== RESULTS ===');
console.log(`Succeeded: ${results.succeeded}`);
console.log(`Failed: ${results.failed}`);
console.log(`Skipped: ${results.skipped}`);
console.log(`Results: ${RESULTS_PATH}`);
console.log(`RestoreMap: ${RESTORE_MAP_PATH}`);
if (!DRY_RUN && results.succeeded > 0) {
appendLedger({
ts: new Date().toISOString(),
agent: 'vp-dw-commerce',
ticket: 'TK-10869',
action: 'philromano-sample-add',
blast_radius: results.succeeded,
undo_cmd: `node ${path.join(__dirname, 'sample-add-rollback.mjs')} --restore ${RESTORE_MAP_PATH}`,
verify: `node ${path.join(__dirname, 'sample-add.mjs')} --verify-only`,
summary: `Added $4.25 Sample variant to ${results.succeeded} quote-only PhilRomano products`
});
console.log(`\nLedger entry appended: ${LEDGER_PATH}`);
}
if (results.failed > 0) {
console.log('\nFailed products:');
results.errors.forEach(e => console.log(` ${e.productId}: ${e.error}`));
}
}
main().catch(e => {
console.error('FATAL:', e);
process.exit(1);
});