[object Object]

← back to Dwha Harlequin Onboard

DWHA Harlequin onboard infrastructure — TK-10882

9d75a2efdab28952f2ac64da65ea023c4622ceed · 2026-08-28 04:09:17 -0700 · Steve

631 viable rows confirmed (25/day cadence, ~26 days). All products created
as DRAFT only; active publish is Steve-gated. Settlement gate required for
29 floral/bird/tropical-flagged rows before any ACTIVE publish.

Scripts: onboard-batch.mjs (main), rollback.mjs (undo), check-coverage.mjs (dry-run).
LaunchAgent plist drafted to PENDING-APPROVAL (not installed). Token sourced
from secrets-manager at runtime, not stored in plist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 9d75a2efdab28952f2ac64da65ea023c4622ceed
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 28 04:09:17 2026 -0700

    DWHA Harlequin onboard infrastructure — TK-10882
    
    631 viable rows confirmed (25/day cadence, ~26 days). All products created
    as DRAFT only; active publish is Steve-gated. Settlement gate required for
    29 floral/bird/tropical-flagged rows before any ACTIVE publish.
    
    Scripts: onboard-batch.mjs (main), rollback.mjs (undo), check-coverage.mjs (dry-run).
    LaunchAgent plist drafted to PENDING-APPROVAL (not installed). Token sourced
    from secrets-manager at runtime, not stored in plist.
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 .gitignore                                 |  10 +
 PENDING-APPROVAL-launchd.md                |  38 +++
 launchd/com.steve.dwha-onboard-daily.plist |  52 ++++
 package-lock.json                          | 161 ++++++++++
 package.json                               |  16 +
 scripts/check-coverage.mjs                 | 182 +++++++++++
 scripts/onboard-batch.mjs                  | 472 +++++++++++++++++++++++++++++
 scripts/rollback.mjs                       | 160 ++++++++++
 8 files changed, 1091 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7d23f33
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+images/
+restore-maps/
+data/cadence-counter.json
diff --git a/PENDING-APPROVAL-launchd.md b/PENDING-APPROVAL-launchd.md
new file mode 100644
index 0000000..f941432
--- /dev/null
+++ b/PENDING-APPROVAL-launchd.md
@@ -0,0 +1,38 @@
+# PENDING APPROVAL: Install DWHA Harlequin Daily Onboard LaunchAgent
+**TK-10882** | Agent: vp-dw-commerce | Created: 2026-08-28 04:08 AM
+
+## Action
+Install launchd plist for DWHA Harlequin 25/day onboard cadence (05:00 AM daily).
+
+```sh
+cp ~/Projects/dwha-harlequin-onboard/launchd/com.steve.dwha-onboard-daily.plist \
+   ~/Library/LaunchAgents/com.steve.dwha-onboard-daily.plist
+
+launchctl bootstrap gui/$(id -u) \
+   ~/Library/LaunchAgents/com.steve.dwha-onboard-daily.plist
+```
+
+## What it does
+- Fires daily at 05:00 AM: `node scripts/onboard-batch.mjs`
+- Reads harlequin_catalog for viable rows not yet on Shopify
+- Downloads images from harlequin.design CDN to local disk (cached)
+- Creates 25 Shopify DRAFT products per day — never ACTIVE
+- Saves restore-map per product; marks on_shopify=true in DB
+- Stops at the 25/day cadence cap via data/cadence-counter.json
+
+## Coverage (verified 2026-08-28)
+- 631 rows READY (dw_sku + trade price + image, not discontinued)
+- 29 settlement-flagged (require settlement gate before ACTIVE)
+- 602 clean (no settlement risk)
+- Cadence plan: 631 / 25 = ~26 days to full onboard
+- Price range: $168.33 to $981 retail
+
+## Spend
+$0 — local HTTP downloads + Shopify REST (no per-call cost under plan)
+
+## Rollback
+node ~/Projects/dwha-harlequin-onboard/scripts/rollback.mjs DWHA-XXXXXX
+node ~/Projects/dwha-harlequin-onboard/scripts/rollback.mjs --all
+
+## APPROVE: run the cp + launchctl commands above
+## BLOCK: ignore this file
diff --git a/launchd/com.steve.dwha-onboard-daily.plist b/launchd/com.steve.dwha-onboard-daily.plist
new file mode 100644
index 0000000..0508a09
--- /dev/null
+++ b/launchd/com.steve.dwha-onboard-daily.plist
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>Label</key>
+    <string>com.steve.dwha-onboard-daily</string>
+
+    <key>ProgramArguments</key>
+    <array>
+        <string>/usr/local/bin/node</string>
+        <string>/Users/macstudio3/Projects/dwha-harlequin-onboard/scripts/onboard-batch.mjs</string>
+    </array>
+
+    <!-- Runs daily at 05:00 AM local time -->
+    <key>StartCalendarInterval</key>
+    <dict>
+        <key>Hour</key>
+        <integer>5</integer>
+        <key>Minute</key>
+        <integer>0</integer>
+    </dict>
+
+    <!-- Env: token is read at runtime from secrets-manager .env (not stored here) -->
+    <key>EnvironmentVariables</key>
+    <dict>
+        <key>HOME</key>
+        <string>/Users/macstudio3</string>
+        <key>PATH</key>
+        <string>/usr/local/bin:/usr/bin:/bin</string>
+    </dict>
+
+    <key>WorkingDirectory</key>
+    <string>/Users/macstudio3/Projects/dwha-harlequin-onboard</string>
+
+    <key>StandardOutPath</key>
+    <string>/Users/macstudio3/Projects/dwha-harlequin-onboard/data/launchd-stdout.log</string>
+
+    <key>StandardErrorPath</key>
+    <string>/Users/macstudio3/Projects/dwha-harlequin-onboard/data/launchd-stderr.log</string>
+
+    <!-- Restart on crash (not on normal exit) -->
+    <key>KeepAlive</key>
+    <dict>
+        <key>Crashed</key>
+        <true/>
+    </dict>
+
+    <!-- Run missed jobs if machine was asleep at 05:00 -->
+    <key>RunAtLoad</key>
+    <false/>
+</dict>
+</plist>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..28d98ee
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,161 @@
+{
+  "name": "dwha-harlequin-onboard",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dwha-harlequin-onboard",
+      "version": "1.0.0",
+      "dependencies": {
+        "pg": "^8.11.0"
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.23.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+      "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.16.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+      "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..831aa3e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "dwha-harlequin-onboard",
+  "version": "1.0.0",
+  "description": "DWHA Harlequin 25/day Shopify onboard pipeline — TK-10882",
+  "type": "module",
+  "scripts": {
+    "check": "node scripts/check-coverage.mjs",
+    "check-verbose": "node scripts/check-coverage.mjs --verbose",
+    "dry-run": "node scripts/onboard-batch.mjs --dry-run",
+    "onboard": "node scripts/onboard-batch.mjs",
+    "rollback-all-dry": "node scripts/rollback.mjs --all --dry-run"
+  },
+  "dependencies": {
+    "pg": "^8.11.0"
+  }
+}
diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs
new file mode 100644
index 0000000..020e435
--- /dev/null
+++ b/scripts/check-coverage.mjs
@@ -0,0 +1,182 @@
+#!/usr/bin/env node
+/**
+ * DWHA Harlequin Check Coverage — check-coverage.mjs
+ * TK-10882 — VP DW Commerce
+ *
+ * Dry-run audit: shows exactly which harlequin_catalog rows are onboard-ready
+ * vs skipped, and why. No writes. No Shopify calls. No image downloads.
+ *
+ * Usage:
+ *   node scripts/check-coverage.mjs              # summary
+ *   node scripts/check-coverage.mjs --verbose    # full row list
+ *   node scripts/check-coverage.mjs --json       # machine-readable JSON
+ */
+
+import pg from 'pg';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = path.resolve(__dirname, '..');
+const RESTORE_DIR  = path.join(PROJECT_ROOT, 'restore-maps');
+
+const IS_VERBOSE = process.argv.includes('--verbose');
+const IS_JSON    = process.argv.includes('--json');
+
+function computeRetailPrice(price_trade) {
+  const raw = parseFloat(price_trade) / 0.65 / 0.85;
+  return Math.round(raw * 100) / 100;
+}
+
+// Settlement-risk keywords (floral/bird/tropical require settlement gate before ACTIVE)
+const SETTLEMENT_KEYWORDS = [
+  'floral', 'flower', 'botanical', 'bird', 'tropical', 'leaf', 'leaves',
+  'fauna', 'flora', 'butterfly', 'dragonfly', 'peacock', 'palm', 'fern',
+  'above and below', 'marine', 'seascape',
+];
+
+function hasSettlementRisk(row) {
+  const text = [row.pattern_name, row.design, row.features, row.color_name, row.collection]
+    .filter(Boolean).join(' ').toLowerCase();
+  return SETTLEMENT_KEYWORDS.some(kw => text.includes(kw));
+}
+
+async function main() {
+  const pool = new pg.Pool({ host: '/tmp', database: 'dw_unified' });
+
+  let allRows;
+  try {
+    const result = await pool.query(`
+      SELECT
+        id, dw_sku, mfr_sku, pattern_name, color_name, collection,
+        price_trade, price_retail, image_url, product_url,
+        width, material, discontinued, on_shopify, shopify_product_id,
+        image_rejected, image_rejection_reason, design, features
+      FROM harlequin_catalog
+      ORDER BY id ASC
+    `);
+    allRows = result.rows;
+  } catch (err) {
+    console.error('DB error:', err.message);
+    await pool.end();
+    process.exit(1);
+  }
+
+  await pool.end();
+
+  // Already rolled out (on_shopify = true)
+  const alreadyOnShopify = allRows.filter(r => r.on_shopify === true);
+
+  // Classify each row
+  const ready   = [];
+  const skipped = [];
+
+  for (const row of allRows) {
+    if (row.on_shopify) {
+      skipped.push({ ...row, skip_reason: 'already-on-shopify' });
+      continue;
+    }
+    const missing = [];
+    if (!row.dw_sku)       missing.push('dw_sku');
+    if (!row.price_trade)  missing.push('price_trade');
+    if (!row.image_url)    missing.push('image_url');
+    if (row.discontinued)  missing.push('discontinued');
+    if (row.image_rejected) missing.push('image_rejected');
+
+    if (missing.length) {
+      skipped.push({ ...row, skip_reason: missing.join(',') });
+    } else {
+      ready.push({
+        ...row,
+        retail_price: computeRetailPrice(row.price_trade),
+        settlement_risk: hasSettlementRisk(row),
+      });
+    }
+  }
+
+  const settlementFlagged = ready.filter(r => r.settlement_risk);
+
+  // Restore-map count (already created this run)
+  let restoreMaps = 0;
+  try {
+    restoreMaps = fs.readdirSync(RESTORE_DIR).filter(f => f.endsWith('.json') && !f.includes('.rolled-back')).length;
+  } catch {}
+
+  // Skip reason breakdown
+  const skipReasons = {};
+  skipped.forEach(r => {
+    const reasons = r.skip_reason.split(',');
+    reasons.forEach(reason => { skipReasons[reason] = (skipReasons[reason] || 0) + 1; });
+  });
+
+  if (IS_JSON) {
+    console.log(JSON.stringify({
+      generated: new Date().toISOString(),
+      total: allRows.length,
+      ready: ready.length,
+      skipped: skipped.length,
+      already_on_shopify: alreadyOnShopify.length,
+      settlement_flagged: settlementFlagged.length,
+      restore_maps: restoreMaps,
+      skip_reasons: skipReasons,
+      ready_rows: IS_VERBOSE ? ready : ready.slice(0, 20),
+      skip_rows: IS_VERBOSE ? skipped : skipped.slice(0, 10),
+    }, null, 2));
+    return;
+  }
+
+  // Human-readable output
+  console.log('\n=== DWHA Harlequin Coverage Check ===');
+  console.log(`Generated:          ${new Date().toLocaleString()}`);
+  console.log(`Total rows in DB:   ${allRows.length}`);
+  console.log(`─────────────────────────────────────`);
+  console.log(`READY to onboard:   ${ready.length}  (have dw_sku + price_trade + image_url, not discontinued, not rejected)`);
+  console.log(`Already on Shopify: ${alreadyOnShopify.length}`);
+  console.log(`Skipped (issues):   ${skipped.length - alreadyOnShopify.length}`);
+  console.log(`─────────────────────────────────────`);
+  console.log(`Settlement-flagged: ${settlementFlagged.length}  (ready but require settlement gate before ACTIVE publish)`);
+  console.log(`Clean (no risk):    ${ready.length - settlementFlagged.length}`);
+  console.log(`─────────────────────────────────────`);
+  console.log(`Restore-maps today: ${restoreMaps}  (DRAFT products created this session)`);
+
+  console.log('\nSkip reason breakdown:');
+  Object.entries(skipReasons).sort((a,b) => b[1]-a[1]).forEach(([reason, count]) => {
+    console.log(`  ${reason.padEnd(22)} ${count}`);
+  });
+
+  console.log('\nPrice range (retail):');
+  const prices = ready.map(r => r.retail_price).sort((a, b) => a - b);
+  if (prices.length) {
+    console.log(`  Min: $${prices[0]}   Max: $${prices[prices.length - 1]}   Avg: $${(prices.reduce((s,p)=>s+p,0)/prices.length).toFixed(2)}`);
+  }
+
+  console.log('\nCollections in ready set:');
+  const colls = {};
+  ready.forEach(r => { if (r.collection) colls[r.collection] = (colls[r.collection]||0) + 1; });
+  Object.entries(colls).sort((a,b)=>b[1]-a[1]).slice(0, 10).forEach(([c, n]) => {
+    console.log(`  ${c.padEnd(30)} ${n}`);
+  });
+
+  if (IS_VERBOSE) {
+    console.log('\n--- READY rows (first 50) ---');
+    ready.slice(0, 50).forEach((r, i) => {
+      const colorDisplay = r.color_name && !r.color_name.toLowerCase().includes('curation')
+        ? r.color_name : r.mfr_sku;
+      console.log(`  ${String(i+1).padStart(3)}. ${r.dw_sku}  "${r.pattern_name} ${colorDisplay}"  trade:$${r.price_trade}  retail:$${r.retail_price}${r.settlement_risk ? '  [SETTLEMENT-RISK]' : ''}`);
+    });
+    if (ready.length > 50) console.log(`  ... and ${ready.length - 50} more`);
+
+    console.log('\n--- Settlement-risk rows ---');
+    settlementFlagged.slice(0, 20).forEach(r => {
+      console.log(`  ${r.dw_sku}  "${r.pattern_name}"  — will need settlement gate before ACTIVE`);
+    });
+  }
+
+  console.log('\n=== Cadence plan ===');
+  console.log(`  25/day × ${Math.ceil(ready.length/25)} days = ~${Math.ceil(ready.length/25)} days to fully onboard`);
+  console.log(`  All created as DRAFT — active publish is a separate Steve-gated step.`);
+  console.log('');
+}
+
+main().catch(err => { console.error('Fatal:', err); process.exit(1); });
diff --git a/scripts/onboard-batch.mjs b/scripts/onboard-batch.mjs
new file mode 100644
index 0000000..2de71df
--- /dev/null
+++ b/scripts/onboard-batch.mjs
@@ -0,0 +1,472 @@
+#!/usr/bin/env node
+/**
+ * DWHA Harlequin Onboard Batch — onboard-batch.mjs
+ * TK-10882 — VP DW Commerce, 2026-08-28
+ *
+ * Reads harlequin_catalog viable rows → downloads images locally →
+ * creates DRAFT Shopify products (never ACTIVE) → saves restore-maps.
+ *
+ * Hard rails:
+ *   - All products created as status: 'draft'  (ACTIVE publish is gated/Steve)
+ *   - 25/day cadence cap enforced via data/cadence-counter.json
+ *   - Images downloaded to images/ before Shopify attach (no remote CDN direct)
+ *   - Settlement gate required before any floral/bird/tropical goes ACTIVE
+ *   - Every created product is logged in restore-maps/<dw_sku>.json (reversible)
+ *
+ * Usage:
+ *   node scripts/onboard-batch.mjs             # live run (max 25 today)
+ *   node scripts/onboard-batch.mjs --dry-run   # dry run, prints would-do plan
+ *   node scripts/onboard-batch.mjs --limit 5   # override day cap (for testing)
+ */
+
+import pg from 'pg';
+import fs from 'fs';
+import path from 'path';
+import https from 'https';
+import http from 'http';
+import { fileURLToPath } from 'url';
+import { execSync } from 'child_process';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = path.resolve(__dirname, '..');
+
+// ─── Config ───────────────────────────────────────────────────────────────────
+const SHOPIFY_STORE  = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN  = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+  try {
+    const env = fs.readFileSync(path.resolve('/Users/macstudio3/Projects/secrets-manager/.env'), 'utf8');
+    const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+    return m ? m[1].trim() : null;
+  } catch { return null; }
+})();
+
+const IMAGES_DIR     = path.join(PROJECT_ROOT, 'images');
+const RESTORE_DIR    = path.join(PROJECT_ROOT, 'restore-maps');
+const DATA_DIR       = path.join(PROJECT_ROOT, 'data');
+const CADENCE_FILE   = path.join(DATA_DIR, 'cadence-counter.json');
+const LEDGER_FILE    = path.join(DATA_DIR, 'onboard-ledger.jsonl');
+
+const DAY_LIMIT      = 25;  // Steve-approved cadence
+const SHOPIFY_API    = `https://${SHOPIFY_STORE}/admin/api/2024-10`;
+const SHOPIFY_DELAY  = 500; // ms between API calls (rate-limit safe)
+
+const IS_DRY_RUN     = process.argv.includes('--dry-run');
+const LIMIT_ARG      = (() => {
+  const i = process.argv.indexOf('--limit');
+  return i >= 0 ? parseInt(process.argv[i + 1], 10) : null;
+})();
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+function ensureDirs() {
+  [IMAGES_DIR, RESTORE_DIR, DATA_DIR].forEach(d => fs.mkdirSync(d, { recursive: true }));
+}
+
+function today() {
+  return new Date().toISOString().slice(0, 10);
+}
+
+function getCadenceState() {
+  try {
+    const s = JSON.parse(fs.readFileSync(CADENCE_FILE, 'utf8'));
+    if (s.date !== today()) return { date: today(), count: 0 };
+    return s;
+  } catch {
+    return { date: today(), count: 0 };
+  }
+}
+
+function saveCadenceState(state) {
+  fs.writeFileSync(CADENCE_FILE, JSON.stringify(state, null, 2));
+}
+
+function appendLedger(entry) {
+  fs.appendFileSync(LEDGER_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
+}
+
+function sleep(ms) {
+  return new Promise(r => setTimeout(r, ms));
+}
+
+// ─── Image download (with cache-check + retry) ────────────────────────────────
+async function downloadImage(url, dw_sku, attempt = 1) {
+  const ext = url.split('?')[0].split('.').pop().toLowerCase() || 'jpg';
+  const safeSku = dw_sku.replace(/[^A-Za-z0-9\-]/g, '_');
+  const localPath = path.join(IMAGES_DIR, `${safeSku}.${ext}`);
+
+  // Cache-check: skip if already downloaded and >0 bytes
+  if (fs.existsSync(localPath) && fs.statSync(localPath).size > 1000) {
+    return { localPath, cached: true };
+  }
+
+  return new Promise((resolve, reject) => {
+    const proto = url.startsWith('https') ? https : http;
+    const file  = fs.createWriteStream(localPath);
+    const req   = proto.get(url, { headers: { 'User-Agent': 'DW-Harlequin-Onboard/1.0' } }, res => {
+      if (res.statusCode === 301 || res.statusCode === 302) {
+        file.close();
+        fs.unlinkSync(localPath);
+        if (attempt <= 3) {
+          downloadImage(res.headers.location, dw_sku, attempt + 1).then(resolve).catch(reject);
+        } else {
+          reject(new Error(`Too many redirects for ${url}`));
+        }
+        return;
+      }
+      if (res.statusCode !== 200) {
+        file.close();
+        try { fs.unlinkSync(localPath); } catch {}
+        reject(new Error(`HTTP ${res.statusCode} for ${url}`));
+        return;
+      }
+      res.pipe(file);
+      file.on('finish', () => file.close(() => resolve({ localPath, cached: false })));
+    });
+    req.on('error', err => {
+      try { fs.unlinkSync(localPath); } catch {}
+      if (attempt <= 3) {
+        setTimeout(() => downloadImage(url, dw_sku, attempt + 1).then(resolve).catch(reject), 1000 * attempt);
+      } else {
+        reject(err);
+      }
+    });
+    req.setTimeout(30000, () => { req.destroy(); reject(new Error(`Timeout downloading ${url}`)); });
+  });
+}
+
+// ─── Price computation ────────────────────────────────────────────────────────
+function computeRetailPrice(price_trade) {
+  // trade price is already our "cost" from Harlequin
+  // retail = cost / 0.65 / 0.85, rounded to 2 decimals
+  const raw = parseFloat(price_trade) / 0.65 / 0.85;
+  return Math.round(raw * 100) / 100;
+}
+
+// ─── Build Shopify product payload ───────────────────────────────────────────
+function buildProductPayload(row) {
+  const {
+    dw_sku, pattern_name, color_name, collection,
+    price_trade, width, length, repeat_v, repeat_h,
+    material, product_type, mfr_sku,
+    fire_rating, finish, application, match_type, design, features
+  } = row;
+
+  const colorDisplay = color_name && !color_name.toLowerCase().includes('curation')
+    ? color_name
+    : mfr_sku;   // fall back to mfr_sku per DW rules (never "Unknown")
+
+  const title = `${pattern_name} ${colorDisplay} | Harlequin`;
+
+  // Body HTML — spec fields
+  const specLines = [
+    width         ? `<li><strong>Width:</strong> ${width}</li>` : '',
+    length        ? `<li><strong>Length:</strong> ${length}</li>` : '',
+    repeat_v      ? `<li><strong>Vertical Repeat:</strong> ${repeat_v}</li>` : '',
+    repeat_h      ? `<li><strong>Horizontal Repeat:</strong> ${repeat_h}</li>` : '',
+    material      ? `<li><strong>Material:</strong> ${material}</li>` : '',
+    match_type    ? `<li><strong>Match Type:</strong> ${match_type}</li>` : '',
+    fire_rating   ? `<li><strong>Fire Rating:</strong> ${fire_rating}</li>` : '',
+    finish        ? `<li><strong>Finish:</strong> ${finish}</li>` : '',
+    application   ? `<li><strong>Application:</strong> ${application}</li>` : '',
+    design        ? `<li><strong>Design:</strong> ${design}</li>` : '',
+    mfr_sku       ? `<li><strong>MFR SKU:</strong> ${mfr_sku}</li>` : '',
+    dw_sku        ? `<li><strong>DW SKU:</strong> ${dw_sku}</li>` : '',
+  ].filter(Boolean).join('\n');
+
+  const body_html = [
+    features ? `<p>${features}</p>` : '',
+    specLines ? `<ul>\n${specLines}\n</ul>` : '',
+  ].filter(Boolean).join('\n');
+
+  const retailPrice = computeRetailPrice(price_trade);
+
+  const tags = [
+    'Harlequin',
+    'DWHA',
+    collection    ? `Collection-${collection.replace(/\s+/g, '-')}` : '',
+    product_type  ? product_type : '',
+    'Needs-Width', // mandatory until width metafield confirmed
+    'harlequin-onboard-2026',
+  ].filter(Boolean).join(', ');
+
+  return {
+    product: {
+      title,
+      vendor: 'Harlequin',
+      product_type: 'Wallcovering',
+      status: 'draft',           // HARD RAIL — never 'active' here
+      tags,
+      body_html,
+      variants: [
+        {
+          title: 'Single Roll',
+          sku:   dw_sku,
+          price: String(retailPrice),
+          inventory_management: null,  // no tracking
+          fulfillment_service: 'manual',
+        },
+        {
+          title: 'Sample',
+          sku:   `${dw_sku}-Sample`,
+          price: '4.25',
+          inventory_management: null,
+          fulfillment_service: 'manual',
+        },
+      ],
+    },
+  };
+}
+
+// ─── Shopify REST calls ───────────────────────────────────────────────────────
+async function shopifyPost(endpoint, payload) {
+  const url = `${SHOPIFY_API}${endpoint}`;
+  const body = JSON.stringify(payload);
+
+  return new Promise((resolve, reject) => {
+    const opts = new URL(url);
+    const reqOpts = {
+      hostname: opts.hostname,
+      path: opts.pathname + opts.search,
+      method: 'POST',
+      headers: {
+        'X-Shopify-Access-Token': SHOPIFY_TOKEN,
+        'Content-Type': 'application/json',
+        'Content-Length': Buffer.byteLength(body),
+      },
+    };
+    const req = https.request(reqOpts, res => {
+      let data = '';
+      res.on('data', chunk => data += chunk);
+      res.on('end', () => {
+        if (res.statusCode >= 400) {
+          reject(new Error(`Shopify ${res.statusCode}: ${data.slice(0, 300)}`));
+        } else {
+          resolve(JSON.parse(data));
+        }
+      });
+    });
+    req.on('error', reject);
+    req.write(body);
+    req.end();
+  });
+}
+
+async function shopifyPostImage(productId, localPath, dw_sku) {
+  const imageData = fs.readFileSync(localPath);
+  const b64 = imageData.toString('base64');
+  const ext  = path.extname(localPath).slice(1) || 'jpg';
+  const payload = {
+    image: {
+      attachment: b64,
+      filename: `${dw_sku}.${ext}`,
+    },
+  };
+  return shopifyPost(`/products/${productId}/images.json`, payload);
+}
+
+// ─── Restore-map ──────────────────────────────────────────────────────────────
+function saveRestoreMap(dw_sku, productId, row) {
+  const mapPath = path.join(RESTORE_DIR, `${dw_sku}.json`);
+  const map = {
+    ts: new Date().toISOString(),
+    dw_sku,
+    shopify_product_id: productId,
+    action: 'created-draft',
+    undo_cmd: `node scripts/rollback.mjs ${dw_sku}`,
+    db_row_id: row.id,
+    row_snapshot: row,
+  };
+  fs.writeFileSync(mapPath, JSON.stringify(map, null, 2));
+  return mapPath;
+}
+
+// ─── DB update: mark on_shopify = true ───────────────────────────────────────
+async function markOnShopify(pool, id, shopify_product_id) {
+  await pool.query(
+    'UPDATE harlequin_catalog SET on_shopify = true, shopify_product_id = $1, updated_at = NOW() WHERE id = $2',
+    [shopify_product_id, id]
+  );
+}
+
+// ─── Main ─────────────────────────────────────────────────────────────────────
+async function main() {
+  ensureDirs();
+
+  if (!SHOPIFY_TOKEN) {
+    console.error('FATAL: SHOPIFY_ADMIN_TOKEN not found. Aborting.');
+    process.exit(1);
+  }
+
+  // Cadence check
+  const cadence = getCadenceState();
+  const effectiveLimit = LIMIT_ARG ?? DAY_LIMIT;
+  const remaining = effectiveLimit - cadence.count;
+
+  console.log(`\n=== DWHA Harlequin Onboard Batch ===`);
+  console.log(`Date: ${today()}  |  Cadence: ${cadence.count}/${effectiveLimit} used  |  Remaining today: ${remaining}`);
+  console.log(`Mode: ${IS_DRY_RUN ? 'DRY-RUN (no writes)' : 'LIVE'}`);
+
+  if (remaining <= 0 && !IS_DRY_RUN) {
+    console.log(`\nDay cadence cap reached (${effectiveLimit}/day). Run tomorrow or increase --limit.`);
+    process.exit(0);
+  }
+
+  // Query viable rows (not yet on Shopify)
+  const pool = new pg.Pool({ host: '/tmp', database: 'dw_unified' });
+
+  let rows;
+  try {
+    const result = await pool.query(`
+      SELECT
+        id, dw_sku, mfr_sku, pattern_name, color_name, collection,
+        product_type, width, length, repeat_v, repeat_h, material,
+        price_trade, price_retail, image_url, product_url,
+        fire_rating, finish, application, match_type, design, features,
+        color_primary, color_secondary
+      FROM harlequin_catalog
+      WHERE
+        price_trade IS NOT NULL
+        AND image_url IS NOT NULL
+        AND dw_sku IS NOT NULL
+        AND (discontinued IS FALSE OR discontinued IS NULL)
+        AND (image_rejected IS FALSE OR image_rejected IS NULL)
+        AND (on_shopify IS FALSE OR on_shopify IS NULL)
+      ORDER BY id ASC
+      LIMIT $1
+    `, [IS_DRY_RUN ? 9999 : remaining]);
+    rows = result.rows;
+  } catch (err) {
+    console.error('DB query failed:', err.message);
+    await pool.end();
+    process.exit(1);
+  }
+
+  console.log(`\nViable rows to process: ${rows.length}${IS_DRY_RUN ? ' (dry-run shows all)' : ` (capped at ${remaining} today)`}`);
+
+  if (IS_DRY_RUN) {
+    console.log('\n--- DRY-RUN PREVIEW (first 10 rows) ---');
+    rows.slice(0, 10).forEach((r, i) => {
+      const retail = computeRetailPrice(r.price_trade);
+      const colorDisplay = r.color_name && !r.color_name.toLowerCase().includes('curation')
+        ? r.color_name : r.mfr_sku;
+      console.log(`  ${i + 1}. ${r.dw_sku} — "${r.pattern_name} ${colorDisplay} | Harlequin"  trade:$${r.price_trade}  retail:$${retail}  img:${r.image_url ? 'yes' : 'NO'}`);
+    });
+    if (rows.length > 10) console.log(`  ... and ${rows.length - 10} more`);
+    console.log(`\nDRY-RUN TOTAL: ${rows.length} rows ready to onboard.`);
+    await pool.end();
+    return;
+  }
+
+  // ── LIVE RUN ──
+  let created = 0, skipped = 0, errors = [];
+
+  for (const row of rows) {
+    const { dw_sku, image_url } = row;
+    console.log(`\n[${created + skipped + 1}/${rows.length}] Processing ${dw_sku} ...`);
+
+    // 1. Download image
+    let localImagePath;
+    try {
+      const { localPath, cached } = await downloadImage(image_url, dw_sku);
+      localImagePath = localPath;
+      console.log(`  image: ${cached ? 'cached' : 'downloaded'} → ${path.basename(localPath)}`);
+    } catch (imgErr) {
+      console.warn(`  SKIP — image download failed: ${imgErr.message}`);
+      errors.push({ dw_sku, stage: 'image-download', error: imgErr.message });
+      skipped++;
+      continue;
+    }
+
+    // 2. Build + create Shopify product (DRAFT)
+    let productId;
+    try {
+      const payload  = buildProductPayload(row);
+      await sleep(SHOPIFY_DELAY);
+      const response = await shopifyPost('/products.json', payload);
+      productId = response.product?.id;
+      if (!productId) throw new Error('No product ID returned from Shopify');
+      console.log(`  shopify: created DRAFT product ${productId}`);
+    } catch (shopErr) {
+      console.warn(`  SKIP — Shopify create failed: ${shopErr.message}`);
+      errors.push({ dw_sku, stage: 'shopify-create', error: shopErr.message });
+      skipped++;
+      continue;
+    }
+
+    // 3. Attach image
+    try {
+      await sleep(SHOPIFY_DELAY);
+      await shopifyPostImage(productId, localImagePath, dw_sku);
+      console.log(`  image: attached to product ${productId}`);
+    } catch (imgAttachErr) {
+      // Non-fatal — product is created, image can be added later; log it
+      console.warn(`  WARN — image attach failed (product still created): ${imgAttachErr.message}`);
+      errors.push({ dw_sku, stage: 'image-attach', error: imgAttachErr.message, shopify_product_id: productId });
+    }
+
+    // 4. DB: mark on_shopify
+    try {
+      await markOnShopify(pool, row.id, String(productId));
+      console.log(`  db: on_shopify=true, shopify_product_id=${productId}`);
+    } catch (dbErr) {
+      console.warn(`  WARN — DB update failed: ${dbErr.message}`);
+    }
+
+    // 5. Save restore-map
+    const mapPath = saveRestoreMap(dw_sku, String(productId), row);
+    console.log(`  restore-map: ${path.basename(mapPath)}`);
+
+    // 6. Ledger
+    appendLedger({
+      agent: 'vp-dw-commerce',
+      ticket: 'TK-10882',
+      action: 'created-draft-product',
+      dw_sku,
+      shopify_product_id: String(productId),
+      blast_radius: 1,
+      undo_cmd: `node scripts/rollback.mjs ${dw_sku}`,
+      verify: `shopify product get ${productId}`,
+    });
+
+    // 7. Also log to global executed-reversible ledger
+    try {
+      const execLedger = '/Users/macstudio3/.claude/yolo-queue/executed-reversible/ledger.jsonl';
+      fs.mkdirSync(path.dirname(execLedger), { recursive: true });
+      fs.appendFileSync(execLedger, JSON.stringify({
+        ts: new Date().toISOString(),
+        agent: 'vp-dw-commerce',
+        ticket: 'TK-10882',
+        action: `DWHA onboard: created DRAFT Shopify product for ${dw_sku}`,
+        blast_radius: 1,
+        undo_cmd: `node ~/Projects/dwha-harlequin-onboard/scripts/rollback.mjs ${dw_sku}`,
+        verify: `curl -s -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" https://${SHOPIFY_STORE}/admin/api/2024-10/products/${productId}.json | jq .product.status`,
+      }) + '\n');
+    } catch {}
+
+    created++;
+    cadence.count++;
+    saveCadenceState(cadence);
+
+    if (cadence.count >= effectiveLimit) {
+      console.log(`\nDay cap of ${effectiveLimit} reached. Stopping.`);
+      break;
+    }
+  }
+
+  await pool.end();
+
+  console.log(`\n=== Batch complete ===`);
+  console.log(`  Created (DRAFT): ${created}`);
+  console.log(`  Skipped:         ${skipped}`);
+  console.log(`  Errors:          ${errors.length}`);
+  if (errors.length) {
+    console.log('\nError detail:');
+    errors.forEach(e => console.log(`  ${e.dw_sku} [${e.stage}]: ${e.error}`));
+  }
+  console.log(`\nAll created as DRAFT — publish/activate is a separate Steve-gated step.`);
+  console.log(`Settlement gate required for floral/bird/tropical before any ACTIVE publish.`);
+}
+
+main().catch(err => {
+  console.error('Fatal error:', err);
+  process.exit(1);
+});
diff --git a/scripts/rollback.mjs b/scripts/rollback.mjs
new file mode 100644
index 0000000..5d1f2b2
--- /dev/null
+++ b/scripts/rollback.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+/**
+ * DWHA Harlequin Rollback — rollback.mjs
+ * TK-10882 — VP DW Commerce
+ *
+ * Reads restore-map for one or all DWHA products and deletes the Shopify DRAFT
+ * products that were created by onboard-batch.mjs, then clears on_shopify in DB.
+ *
+ * Usage:
+ *   node scripts/rollback.mjs DWHA-570559         # roll back one SKU
+ *   node scripts/rollback.mjs --all               # roll back all in restore-maps/
+ *   node scripts/rollback.mjs --all --dry-run     # preview only
+ */
+
+import pg from 'pg';
+import fs from 'fs';
+import path from 'path';
+import https from 'https';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = path.resolve(__dirname, '..');
+
+const SHOPIFY_STORE  = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN  = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+  try {
+    const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
+    const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+    return m ? m[1].trim() : null;
+  } catch { return null; }
+})();
+
+const RESTORE_DIR    = path.join(PROJECT_ROOT, 'restore-maps');
+const DATA_DIR       = path.join(PROJECT_ROOT, 'data');
+const ROLLBACK_LOG   = path.join(DATA_DIR, 'rollback-ledger.jsonl');
+const SHOPIFY_API    = `https://${SHOPIFY_STORE}/admin/api/2024-10`;
+
+const IS_DRY_RUN     = process.argv.includes('--dry-run');
+const IS_ALL         = process.argv.includes('--all');
+const TARGET_SKU     = process.argv.find(a => a.startsWith('DWHA-'));
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function appendLog(entry) {
+  fs.mkdirSync(DATA_DIR, { recursive: true });
+  fs.appendFileSync(ROLLBACK_LOG, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
+}
+
+async function shopifyDelete(productId) {
+  return new Promise((resolve, reject) => {
+    const url = new URL(`${SHOPIFY_API}/products/${productId}.json`);
+    const req = https.request({
+      hostname: url.hostname,
+      path: url.pathname,
+      method: 'DELETE',
+      headers: { 'X-Shopify-Access-Token': SHOPIFY_TOKEN },
+    }, res => {
+      let data = '';
+      res.on('data', c => data += c);
+      res.on('end', () => {
+        if (res.statusCode === 200 || res.statusCode === 204) {
+          resolve(true);
+        } else if (res.statusCode === 404) {
+          resolve('not-found');  // already gone — treat as success
+        } else {
+          reject(new Error(`Shopify DELETE ${res.statusCode}: ${data.slice(0, 200)}`));
+        }
+      });
+    });
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function rollbackOne(mapPath, pool) {
+  const map = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
+  const { dw_sku, shopify_product_id, db_row_id } = map;
+
+  console.log(`  Rolling back ${dw_sku} (Shopify: ${shopify_product_id}) ...`);
+
+  if (IS_DRY_RUN) {
+    console.log(`    DRY-RUN: would DELETE /products/${shopify_product_id}.json`);
+    console.log(`    DRY-RUN: would UPDATE harlequin_catalog SET on_shopify=false WHERE id=${db_row_id}`);
+    return;
+  }
+
+  // 1. Delete Shopify product
+  let shopifyResult;
+  try {
+    await sleep(300);
+    shopifyResult = await shopifyDelete(shopify_product_id);
+    console.log(`    shopify: ${shopifyResult === 'not-found' ? 'already deleted (404)' : 'deleted'}`);
+  } catch (err) {
+    console.error(`    ERROR deleting Shopify product: ${err.message}`);
+    appendLog({ action: 'rollback-failed', dw_sku, shopify_product_id, stage: 'shopify-delete', error: err.message });
+    return;
+  }
+
+  // 2. Clear on_shopify in DB
+  try {
+    await pool.query(
+      'UPDATE harlequin_catalog SET on_shopify = false, shopify_product_id = NULL, updated_at = NOW() WHERE id = $1',
+      [db_row_id]
+    );
+    console.log(`    db: on_shopify=false cleared`);
+  } catch (dbErr) {
+    console.warn(`    WARN: DB rollback failed — ${dbErr.message}. Shopify product was deleted; fix DB manually.`);
+  }
+
+  // 3. Archive the restore-map (rename so it's not re-used)
+  const donePath = mapPath.replace('.json', '.rolled-back.json');
+  fs.renameSync(mapPath, donePath);
+  console.log(`    restore-map archived: ${path.basename(donePath)}`);
+
+  appendLog({ action: 'rolled-back', dw_sku, shopify_product_id, db_row_id, ticket: 'TK-10882' });
+}
+
+async function main() {
+  if (!SHOPIFY_TOKEN) {
+    console.error('FATAL: SHOPIFY_ADMIN_TOKEN not found.');
+    process.exit(1);
+  }
+
+  let mapPaths = [];
+
+  if (IS_ALL) {
+    if (!fs.existsSync(RESTORE_DIR)) {
+      console.log('No restore-maps directory found. Nothing to roll back.');
+      process.exit(0);
+    }
+    mapPaths = fs.readdirSync(RESTORE_DIR)
+      .filter(f => f.endsWith('.json') && !f.includes('.rolled-back'))
+      .map(f => path.join(RESTORE_DIR, f));
+  } else if (TARGET_SKU) {
+    const mapPath = path.join(RESTORE_DIR, `${TARGET_SKU}.json`);
+    if (!fs.existsSync(mapPath)) {
+      console.error(`No restore-map found for ${TARGET_SKU} at ${mapPath}`);
+      process.exit(1);
+    }
+    mapPaths = [mapPath];
+  } else {
+    console.error('Usage: rollback.mjs <DWHA-XXXXXX> | --all [--dry-run]');
+    process.exit(1);
+  }
+
+  console.log(`\n=== DWHA Rollback ===`);
+  console.log(`Mode: ${IS_DRY_RUN ? 'DRY-RUN' : 'LIVE DESTRUCTIVE'}  |  Maps: ${mapPaths.length}`);
+  if (!IS_DRY_RUN) console.log('WARNING: This permanently deletes Shopify DRAFT products.');
+
+  const pool = new pg.Pool({ host: '/tmp', database: 'dw_unified' });
+
+  for (const mapPath of mapPaths) {
+    await rollbackOne(mapPath, pool);
+  }
+
+  await pool.end();
+  console.log('\nRollback complete.');
+}
+
+main().catch(err => { console.error('Fatal:', err); process.exit(1); });

(oldest)  ·  back to Dwha Harlequin Onboard  ·  auto-data-snapshot: 2026-08-31T11:17:15 (3 data files) — .gi 9e7a440 →