← 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}`);