← back to Philipperomano
TK-10869: PhilRomano sample-add script + rollback (Steve-approved 2026-08-26)
b36ef94a57d5d3ae18c69f773bf94de3c54ba080 · 2026-08-28 04:16:29 -0700 · Steve
Files touched
A scripts/sample-add-rollback.mjsA scripts/sample-add.mjs
Diff
commit b36ef94a57d5d3ae18c69f773bf94de3c54ba080
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 28 04:16:29 2026 -0700
TK-10869: PhilRomano sample-add script + rollback (Steve-approved 2026-08-26)
---
scripts/sample-add-rollback.mjs | 120 ++++++++++++
scripts/sample-add.mjs | 393 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 513 insertions(+)
diff --git a/scripts/sample-add-rollback.mjs b/scripts/sample-add-rollback.mjs
new file mode 100644
index 0000000..3a5c8a2
--- /dev/null
+++ b/scripts/sample-add-rollback.mjs
@@ -0,0 +1,120 @@
+/**
+ * PhilRomano Sample-Add ROLLBACK Script
+ * TK-10869 — reverses sample-add.mjs via restore-map
+ *
+ * Usage:
+ * node sample-add-rollback.mjs --restore <restore-map.json>
+ * DRY_RUN=true node sample-add-rollback.mjs
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+ 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';
+
+// Find restore map path from args
+const args = process.argv.slice(2);
+const restoreIdx = args.indexOf('--restore');
+const RESTORE_MAP_PATH = restoreIdx >= 0
+ ? args[restoreIdx + 1]
+ : path.join(__dirname, '../data/sample-add-restore-map.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();
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function main() {
+ console.log('=== PhilRomano Sample-Add ROLLBACK TK-10869 ===');
+ console.log(`DRY_RUN: ${DRY_RUN}`);
+ console.log(`Restore map: ${RESTORE_MAP_PATH}`);
+
+ if (!fs.existsSync(RESTORE_MAP_PATH)) {
+ console.error('ERROR: Restore map not found:', RESTORE_MAP_PATH);
+ process.exit(1);
+ }
+
+ const restoreMap = JSON.parse(fs.readFileSync(RESTORE_MAP_PATH, 'utf8'));
+ const entries = Object.values(restoreMap);
+ console.log(`\nRestoring ${entries.length} products...\n`);
+
+ let ok = 0, err = 0;
+
+ for (const entry of entries) {
+ const numId = entry.productId;
+
+ if (DRY_RUN) {
+ console.log(`DRY ${numId} — ${entry.productTitle?.slice(0, 50)}`);
+ ok++;
+ continue;
+ }
+
+ try {
+ // Restore original options and variants
+ const restoreBody = {
+ product: {
+ id: numId,
+ options: entry.originalOptions.map(o => ({
+ name: o.name,
+ values: o.values
+ })),
+ variants: entry.originalVariants.map(v => ({
+ id: v.id,
+ option1: v.option1,
+ option2: v.option2,
+ price: v.price,
+ sku: v.sku,
+ inventory_management: v.inventory_management,
+ inventory_policy: v.inventory_policy
+ }))
+ }
+ };
+
+ await shopifyPut(`products/${numId}.json`, restoreBody);
+ console.log(`OK ${numId} — ${entry.productTitle?.slice(0, 50)}`);
+ ok++;
+ await sleep(400);
+ } catch (e) {
+ console.error(`ERR ${numId}: ${e.message}`);
+ err++;
+ await sleep(1000);
+ }
+ }
+
+ console.log(`\nRollback complete: OK=${ok} ERR=${err}`);
+}
+
+main().catch(e => {
+ console.error('FATAL:', e);
+ process.exit(1);
+});
diff --git a/scripts/sample-add.mjs b/scripts/sample-add.mjs
new file mode 100644
index 0000000..f0ba883
--- /dev/null
+++ b/scripts/sample-add.mjs
@@ -0,0 +1,393 @@
+/**
+ * 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);
+});
← 9a406f2 add ungated public-safe /api/products feed (cork+wallcoverin
·
back to Philipperomano
·
auto-data-snapshot: 2026-08-28T04:38:35 (2 data files) — dat 130d3af →