← back to Greenland Onboard

scripts/publish-online.mjs

81 lines

#!/usr/bin/env node
// Publish existing (active) Greenland cork products to the ONLINE STORE channel — go-live.
// Customer-facing. Safe by design:
//   • publishes ONLY to Online Store (never Google/other channels)
//   • per-SKU guard: title must contain "Phillipe Romano" and must NOT contain
//     "Greenland" or "Wallpaper" — else SKIP + flag (private-label + banned-word rails)
//   • skips products already published to Online Store (idempotent re-run)
//   • ledger to data/published-online.ndjson; throttled + 429 retry
//   Usage: node scripts/publish-online.mjs <limit> [startIndex] [--dry]   (limit=0 → all)
import { readFileSync, appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const env = readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`, 'utf8');
const pick = (k) => (env.match(new RegExp(`^${k}=(.*)$`, 'm'))?.[1] || '').replace(/^['"]|['"]$/g, '').trim();
const STORE = pick('SHOPIFY_STORE') || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = pick('SHOPIFY_ADMIN_TOKEN');
const API = `https://${STORE}/admin/api/2024-10/graphql.json`;
const ONLINE_STORE = 'gid://shopify/Publication/22208643184';
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const LIMIT = Number(process.argv[2] || 0);
const START = Number(process.argv[3] || 0);
const DRY = process.argv.includes('--dry');
const LEDGER = join(ROOT, 'data', 'published-online.ndjson');
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

async function gql(query, variables) {
  for (let i = 0; i < 5; i++) {
    const r = await fetch(API, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
    if (r.status === 429) { await sleep(2000 * (i + 1)); continue; }
    const j = await r.json();
    if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 300));
    return j.data;
  }
  throw new Error('429 exhausted');
}

const products = JSON.parse(readFileSync(join(ROOT, 'public', 'viewer.json'), 'utf8')).products
  .filter(p => p.shopify_pid);                       // must have a real Shopify product id
const slice = products.slice(START);
// LIMIT = max REAL publishes (canary), not first-N-products (most are already live)
console.log(`publish-online: scanning ${slice.length}${LIMIT ? `, stop after ${LIMIT} publish(es)` : ''}${DRY ? ' [DRY]' : ''} → Online Store`);

let published = 0, already = 0, skipped = 0, failed = 0;
for (const p of slice) {
  const gid = `gid://shopify/Product/${p.shopify_pid}`;
  const t = String(p.title || '');
  // rails
  if (!/phillipe romano/i.test(t) || /greenland/i.test(t) || /\bwallpaper\b/i.test(t)) {
    console.log(`  SKIP(rails) ${p.shopify_pid} :: ${t}`); skipped++;
    appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), pid: p.shopify_pid, title: t, action: 'skip-rails' }) + '\n'); continue;
  }
  // live state
  let d;
  try {
    d = await gql(`query($id:ID!){ product(id:$id){ id title status handle publishedOnPublication(publicationId:"${ONLINE_STORE}") } }`, { id: gid });
  } catch (e) { console.log(`  FAIL(read) ${p.shopify_pid} :: ${e.message}`); failed++; continue; }
  const prod = d.product;
  if (!prod) { console.log(`  FAIL(missing) ${p.shopify_pid}`); failed++; continue; }
  if (prod.status !== 'ACTIVE') {   // ARCHIVED = discontinued (hard rule) — NEVER publish/resurrect
    skipped++;
    appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), pid: p.shopify_pid, title: prod.title, status: prod.status, action: 'skip-not-active' }) + '\n');
    continue;
  }
  if (prod.publishedOnPublication) { already++; continue; }   // idempotent
  if (DRY) { console.log(`  WOULD publish ${p.shopify_pid} :: ${prod.title}`); published++; continue; }
  try {
    const r = await gql(
      `mutation($id:ID!,$input:[PublicationInput!]!){ publishablePublish(id:$id,input:$input){ userErrors{field message} } }`,
      { id: gid, input: [{ publicationId: ONLINE_STORE }] });
    const errs = r.publishablePublish.userErrors;
    if (errs.length) { console.log(`  ERR ${p.shopify_pid} :: ${JSON.stringify(errs)}`); failed++; continue; }
    published++; console.log(`  ✓ ${p.shopify_pid} :: ${prod.title}`);
    appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), pid: p.shopify_pid, title: prod.title, handle: prod.handle, action: 'published' }) + '\n');
  } catch (e) { console.log(`  FAIL(pub) ${p.shopify_pid} :: ${e.message}`); failed++; }
  await sleep(350);
  if (LIMIT && published >= LIMIT) break;   // canary cap on REAL publishes
}
console.log(`\nDONE — published:${published} already:${already} skipped(rails):${skipped} failed:${failed}`);