← back to Dw Gemini Skip TK11321

out/cleanup/revert-tier1.js

72 lines

'use strict';
/* TK-10446 Tier-1 REVERT (GATED). Sets ACTIVE -> DRAFT for the 892 live-verified
 * true-orphan products the rotation published in the last 7 days (sample-only, no
 * sellable roll). Reversible (re-publish / set ACTIVE).
 *
 *   node revert-tier1.js tier1-revert-892-ids.txt            # DRY-RUN (default): re-verifies each is
 *                                                            #   still ACTIVE + still orphan; NO writes.
 *   node revert-tier1.js tier1-revert-892-ids.txt --apply --yes-i-am-steve   # GATED: sets status DRAFT.
 *
 * Safety: even in --apply it RE-CHECKS live that the product is ACTIVE and has NO
 * variant priced > $4.25 before reverting — so a product that gained a roll variant
 * (or was already changed) since the scan is SKIPPED, never wrongly un-published.
 * Uses curl (node fetch is blocked in this sandbox). Cost $0 (Shopify Admin API).
 */
const fs = require('fs');
const { execFileSync } = require('child_process');
const SAMPLE = 4.25;
const ENV = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
const URL = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const args = process.argv.slice(2);
const FILE = args.find(a => !a.startsWith('--'));
const APPLY = args.includes('--apply');
const CONFIRMED = args.includes('--yes-i-am-steve');
const ids = fs.readFileSync(FILE, 'utf8').trim().split('\n').filter(Boolean);

// GATE: refuse to apply without explicit confirmation BEFORE touching anything.
if (APPLY && !CONFIRMED) { console.error('REFUSED: --apply requires --yes-i-am-steve. No writes performed.'); process.exit(2); }

function gql(query, variables) {
  const body = JSON.stringify({ query, variables });
  for (let a = 0; a < 6; a++) {
    try {
      const out = execFileSync('curl', ['-s', '--max-time', '25', '-X', 'POST', URL,
        '-H', `X-Shopify-Access-Token: ${TOKEN}`, '-H', 'Content-Type: application/json', '--data', body],
        { encoding: 'utf8', maxBuffer: 4e6 });
      const j = JSON.parse(out);
      if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { sleep(1500); continue; }
      if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 200));
      return j.data;
    } catch (e) { if (a === 5) throw e; sleep(1000 * (a + 1)); }
  }
}
function sleep(ms){ try{ execFileSync('sleep', [String(ms/1000)]); }catch{} }

let reverted = 0, skipped_hasRoll = 0, skipped_notActive = 0, failed = 0, wouldRevert = 0;
for (let i = 0; i < ids.length; i++) {
  const gid = ids[i];
  let d;
  try { d = gql(`{ product(id:"${gid}"){ status variants(first:40){ nodes{ price } } } }`); }
  catch (e) { failed++; continue; }
  const p = d && d.product;
  if (!p) { failed++; continue; }
  if (String(p.status).toUpperCase() !== 'ACTIVE') { skipped_notActive++; continue; }
  if (p.variants.nodes.some(v => parseFloat(v.price) > SAMPLE)) { skipped_hasRoll++; continue; } // gained a roll → leave
  // qualifies: ACTIVE + orphan
  if (!APPLY) { wouldRevert++; }
  else {
    try {
      const r = gql(`mutation($in:ProductInput!){ productUpdate(input:$in){ userErrors{ message } } }`,
        { in: { id: gid, status: 'DRAFT' } });
      const ue = r.productUpdate && r.productUpdate.userErrors;
      if (ue && ue.length) { failed++; if (failed <= 5) console.error('  ERR', gid, ue[0].message); }
      else reverted++;
    } catch (e) { failed++; if (failed <= 5) console.error('  ERR', gid, e.message); }
  }
  if ((i + 1) % 100 === 0) process.stderr.write(`  ...${i + 1}/${ids.length}\n`);
}
console.log(JSON.stringify({ mode: APPLY ? 'APPLY' : 'DRY-RUN', total: ids.length,
  would_revert: wouldRevert, reverted, skipped_has_roll: skipped_hasRoll,
  skipped_not_active: skipped_notActive, failed }, null, 2));