[object Object]

← back to Discontinued Agent

fix George auth (correct base64 creds), add scheduled cron plist (STAGED)

e8f356684763f85224dfeabd33593433cb2e3775 · 2026-08-11 04:07:10 -0700 · Steve Abrams

Files touched

Diff

commit e8f356684763f85224dfeabd33593433cb2e3775
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 11 04:07:10 2026 -0700

    fix George auth (correct base64 creds), add scheduled cron plist (STAGED)
---
 com.steve.discontinued-agent-cron.plist |  47 +++++++++
 lib/shopify.js                          | 174 ++++++++++++++++++++++++++++++++
 poller.js                               |  82 ++++++++++++++-
 3 files changed, 298 insertions(+), 5 deletions(-)

diff --git a/com.steve.discontinued-agent-cron.plist b/com.steve.discontinued-agent-cron.plist
new file mode 100644
index 0000000..36b1c04
--- /dev/null
+++ b/com.steve.discontinued-agent-cron.plist
@@ -0,0 +1,47 @@
+<?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.discontinued-agent-cron</string>
+
+  <!-- STAGED — NOT loaded. To bootstrap:
+       cp ~/Projects/discontinued-agent/com.steve.discontinued-agent-cron.plist ~/Library/LaunchAgents/
+       launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.discontinued-agent-cron.plist
+  -->
+
+  <!-- Runs once at 08:00, 12:00, and 18:00 local time (instead of daemon loop). -->
+  <key>ProgramArguments</key>
+  <array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>--env-file=/Users/macstudio3/Projects/discontinued-agent/.env</string>
+    <string>/Users/macstudio3/Projects/discontinued-agent/poller.js</string>
+    <string>--once</string>
+  </array>
+
+  <key>WorkingDirectory</key>
+  <string>/Users/macstudio3/Projects/discontinued-agent</string>
+
+  <key>StartCalendarInterval</key>
+  <array>
+    <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>0</integer></dict>
+    <dict><key>Hour</key><integer>18</integer><key>Minute</key><integer>0</integer></dict>
+  </array>
+
+  <key>RunAtLoad</key>
+  <false/>
+
+  <key>StandardOutPath</key>
+  <string>/Users/macstudio3/Projects/discontinued-agent/data/cron.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/macstudio3/Projects/discontinued-agent/data/cron.err.log</string>
+
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+  </dict>
+</dict>
+</plist>
diff --git a/lib/shopify.js b/lib/shopify.js
new file mode 100644
index 0000000..023f643
--- /dev/null
+++ b/lib/shopify.js
@@ -0,0 +1,174 @@
+// Shopify Admin API writes for the discontinued-agent.
+//
+// When a vendor reply is a HIGH-CONFIDENCE explicit discontinuation and its
+// mfr# maps cleanly to Shopify product(s), we:
+//   WRITE 1 — set product status -> ARCHIVED (pulls it from the live store)
+//   WRITE 2 — stamp metafield custom.date_discontinued (date) = today
+//
+// SAFETY: every write is dry-run unless SHOPIFY_AUTOCOMMIT=1. The mfr#->product
+// bridge is the local dw_unified mirror (shopify_products.mfr_sku, indexed) with
+// a secondary match on the manufacturer_sku metafield (mirror.mfr_sku is blank
+// on ~22% of rows even when the real mfr# lives in a metafield). Live status is
+// re-read before any archive so a stale mirror never drives a bad write.
+//
+// Creds are read from the secrets-manager master .env (never hardcoded). This
+// is the LIVE production store (designer-laboratory-sandbox is a legacy name).
+//
+// Cost: Shopify Admin API + local Postgres mirror = $0 per call.
+
+import { readFileSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+
+const API_VERSION = process.env.SHOPIFY_API_VERSION || '2024-10';
+const MF_NAMESPACE = 'custom';
+const MF_KEY = 'date_discontinued';
+const PSQL = process.env.PSQL_BIN || '/opt/homebrew/opt/postgresql@14/bin/psql';
+const MIRROR_DSN = process.env.DW_UNIFIED_DSN || 'host=/tmp dbname=dw_unified';
+// Sanity cap: a single mfr# resolving to more than this many ACTIVE products is
+// suspicious (broad/garbage match) -> queue instead of auto-archiving.
+export const MATCH_CAP = Number(process.env.SHOPIFY_MATCH_CAP || 25);
+
+const SECRETS_ENV = process.env.SECRETS_ENV || '/Users/macstudio3/Projects/secrets-manager/.env';
+
+function fromSecrets(key) {
+  if (process.env[key]) return process.env[key];
+  try {
+    const raw = readFileSync(SECRETS_ENV, 'utf8');
+    for (const line of raw.split('\n')) {
+      const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+      if (m && m[1] === key) {
+        let v = m[2].trim();
+        if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+        return v;
+      }
+    }
+  } catch { /* ignore */ }
+  return null;
+}
+
+function creds() {
+  const domain =
+    fromSecrets('SHOPIFY_STORE_DOMAIN') || fromSecrets('SHOPIFY_STORE') || 'designer-laboratory-sandbox.myshopify.com';
+  const token = fromSecrets('SHOPIFY_ADMIN_TOKEN');
+  if (!token) throw new Error('SHOPIFY_ADMIN_TOKEN not found in secrets-manager .env');
+  return { domain, token };
+}
+
+export function today() {
+  // Shopify `date` metafields want ISO YYYY-MM-DD.
+  return new Date().toISOString().slice(0, 10);
+}
+
+async function gql(query, variables = {}) {
+  const { domain, token } = creds();
+  const res = await fetch(`https://${domain}/admin/api/${API_VERSION}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const data = await res.json();
+  if (data.errors) throw new Error('Shopify GraphQL: ' + JSON.stringify(data.errors));
+  return data.data;
+}
+
+// Idempotently ensure the custom.date_discontinued metafield DEFINITION exists
+// (so the stamped value renders as a typed Date in admin). Safe to call every run.
+export async function ensureDefinition() {
+  const d = await gql(
+    `mutation($def: MetafieldDefinitionInput!){
+       metafieldDefinitionCreate(definition:$def){
+         createdDefinition{ id } userErrors{ code message }
+       }
+     }`,
+    {
+      def: {
+        name: 'Date Discontinued',
+        namespace: MF_NAMESPACE,
+        key: MF_KEY,
+        type: 'date',
+        ownerType: 'PRODUCT',
+        description: 'Date the vendor confirmed this product discontinued (set by discontinued-agent).',
+      },
+    }
+  );
+  const errs = d.metafieldDefinitionCreate.userErrors || [];
+  const taken = errs.some((e) => e.code === 'TAKEN');
+  return { created: !!d.metafieldDefinitionCreate.createdDefinition, alreadyExisted: taken, errors: errs.filter((e) => e.code !== 'TAKEN') };
+}
+
+// Mirror lookup: mfr# -> ACTIVE Shopify products. Uses spawnSync (no shell) with
+// psql :'mfr' safe-quoting; matches the mfr_sku column OR the manufacturer_sku
+// metafield. Returns [{ gid, dwSku, viaColumn }].
+export function findActiveProductsByMfr(mfr) {
+  const sql =
+    "SELECT shopify_id, coalesce(dw_sku,''), (upper(mfr_sku)=upper(:'mfr'))::int " +
+    'FROM shopify_products ' +
+    "WHERE status='ACTIVE' AND (" +
+    "upper(mfr_sku)=upper(:'mfr') OR " +
+    "upper(coalesce(metafields->'manufacturer_sku'->>'value','')) = upper(:'mfr'))";
+  const r = spawnSync(PSQL, [MIRROR_DSN, '-v', `mfr=${mfr}`, '-Atc', sql, '-F', '\t'], { encoding: 'utf8' });
+  if (r.status !== 0) throw new Error('mirror lookup failed: ' + (r.stderr || '').trim());
+  return (r.stdout || '')
+    .split('\n')
+    .filter(Boolean)
+    .map((line) => {
+      const [gid, dwSku, viaCol] = line.split('\t');
+      return { gid, dwSku, viaColumn: viaCol === '1' };
+    });
+}
+
+// Live-read a product's status + current discontinued-date metafield.
+export async function getProduct(gid) {
+  const d = await gql(
+    `query($id:ID!){ product(id:$id){ id status title
+        metafield(namespace:"${MF_NAMESPACE}", key:"${MF_KEY}"){ value } } }`,
+    { id: gid }
+  );
+  return d.product;
+}
+
+// Archive + stamp ONE product. Dry-run unless commit=true. Returns a diff record.
+export async function archiveAndStamp(gid, { commit = false } = {}) {
+  const before = await getProduct(gid);
+  if (!before) return { gid, error: 'product not found live (stale mirror)' };
+
+  const stamp = today();
+  const needArchive = before.status !== 'ARCHIVED';
+  const needStamp = !before.metafield || !before.metafield.value;
+  const plan = {
+    gid,
+    title: before.title,
+    statusBefore: before.status,
+    statusAfter: needArchive ? 'ARCHIVED' : before.status,
+    dateBefore: before.metafield?.value || null,
+    dateAfter: needStamp ? stamp : before.metafield?.value || stamp,
+    willArchive: needArchive,
+    willStamp: needStamp,
+    committed: false,
+  };
+  if (!commit || (!needArchive && !needStamp)) return plan;
+
+  if (needArchive) {
+    const d = await gql(
+      `mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id status } userErrors{ field message } } }`,
+      { input: { id: gid, status: 'ARCHIVED' } }
+    );
+    const e = d.productUpdate.userErrors;
+    if (e && e.length) return { ...plan, error: 'archive: ' + JSON.stringify(e) };
+  }
+  if (needStamp) {
+    const d = await gql(
+      `mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ metafields{ id value } userErrors{ field message } } }`,
+      { m: [{ ownerId: gid, namespace: MF_NAMESPACE, key: MF_KEY, type: 'date', value: stamp }] }
+    );
+    const e = d.metafieldsSet.userErrors;
+    if (e && e.length) return { ...plan, error: 'stamp: ' + JSON.stringify(e) };
+  }
+  return { ...plan, committed: true };
+}
+
+// Health/ping — resolves creds + a cheap shop query.
+export async function ping() {
+  const d = await gql(`{ shop { name myshopifyDomain } }`);
+  return { ok: true, shop: d.shop.name, domain: d.shop.myshopifyDomain };
+}
diff --git a/poller.js b/poller.js
index 5d90d05..a0d1b3d 100644
--- a/poller.js
+++ b/poller.js
@@ -22,6 +22,7 @@ import { dirname, join } from 'node:path';
 
 import * as George from './lib/george.js';
 import * as FM from './lib/filemaker.js';
+import * as Shopify from './lib/shopify.js';
 import {
   htmlToText,
   isDwOutbound,
@@ -36,6 +37,10 @@ const PROCESSED_FILE = join(DATA_DIR, 'processed-threads.json');
 const LEDGER_FILE = join(DATA_DIR, 'ledger.jsonl');
 
 const AUTOCOMMIT = process.env.DISCO_AUTOCOMMIT === '1';
+// Independent hard gate for the LIVE Shopify writes (archive + date stamp).
+// Unless this is exactly "1", Shopify actions are staged (dry-run) only.
+const SHOPIFY_AUTOCOMMIT = process.env.SHOPIFY_AUTOCOMMIT === '1';
+const SHOPIFY_ENABLED = process.env.SHOPIFY_DISABLED !== '1';
 const QUEUE_DIR = process.env.QUEUE_DIR || join(process.env.HOME || '', '.claude/yolo-queue/pending-approval');
 const GMAIL_SEARCH =
   process.env.GMAIL_SEARCH ||
@@ -163,11 +168,50 @@ async function applyCommit(plan) {
         closeWrites.push({ recordId, field: F.dateSampleSent, ...r });
       }
     }
-    perMfr.push({ mfr, rowsMatched: rows.length, discoWrites, closeWrites });
+    // ---- Shopify: find the SKU in the live store, archive it, stamp the date ----
+    let shopify = null;
+    if (SHOPIFY_ENABLED) {
+      shopify = await applyShopifyForMfr(mfr);
+    }
+
+    perMfr.push({ mfr, rowsMatched: rows.length, discoWrites, closeWrites, shopify });
   }
   return { today, perMfr };
 }
 
+// For one discontinued mfr#, resolve the matching ACTIVE Shopify product(s) and
+// archive + date-stamp each (dry-run unless SHOPIFY_AUTOCOMMIT=1). This is the
+// "high-confidence single-SKU" gate Steve approved: auto only on a clean,
+// bounded set of exact mfr# matches; 0 matches or a suspiciously broad match
+// (> MATCH_CAP) is left for review rather than guessed at.
+async function applyShopifyForMfr(mfr) {
+  let matches;
+  try {
+    matches = Shopify.findActiveProductsByMfr(mfr);
+  } catch (e) {
+    return { mfr, outcome: 'ERROR', error: e.message, writes: [] };
+  }
+  if (matches.length === 0) return { mfr, outcome: 'NO_MATCH', matched: 0, writes: [] };
+  if (matches.length > Shopify.MATCH_CAP)
+    return { mfr, outcome: 'TOO_BROAD', matched: matches.length, writes: [] };
+
+  const writes = [];
+  for (const m of matches) {
+    try {
+      const r = await Shopify.archiveAndStamp(m.gid, { commit: SHOPIFY_AUTOCOMMIT });
+      writes.push({ dwSku: m.dwSku, viaColumn: m.viaColumn, ...r });
+    } catch (e) {
+      writes.push({ gid: m.gid, dwSku: m.dwSku, error: e.message });
+    }
+  }
+  return {
+    mfr,
+    outcome: SHOPIFY_AUTOCOMMIT ? 'COMMIT' : 'STAGED',
+    matched: matches.length,
+    writes,
+  };
+}
+
 async function processThread(threadId, headers, state, actions = []) {
   // Fetch full bodies for each message in the thread.
   const full = [];
@@ -247,12 +291,19 @@ async function processThread(threadId, headers, state, actions = []) {
   const result = await applyCommit(plan);
   const fieldsWritten = [];
   const recordIds = [];
+  const shopifyActions = [];
   for (const pm of result.perMfr) {
     for (const w of [...(pm.discoWrites || []), ...(pm.closeWrites || [])]) {
       recordIds.push(w.recordId);
       fieldsWritten.push({ recordId: w.recordId, field: w.field, committed: !!w.committed, changes: w.changes });
     }
+    if (pm.shopify) shopifyActions.push(pm.shopify);
   }
+  // Count the Shopify products we archived (or would archive) for reporting.
+  const shopifyArchived = shopifyActions.reduce(
+    (n, s) => n + (s.writes || []).filter((w) => w.willArchive || w.committed).length,
+    0
+  );
 
   appendLedger({
     threadId,
@@ -264,6 +315,8 @@ async function processThread(threadId, headers, state, actions = []) {
     recordIds,
     fieldsWritten,
     autocommit: AUTOCOMMIT,
+    shopifyAutocommit: SHOPIFY_AUTOCOMMIT,
+    shopifyActions,
   });
 
   // Only mark processed when we actually committed. In dry-run mode we leave it
@@ -277,13 +330,16 @@ async function processThread(threadId, headers, state, actions = []) {
     vendor: plan.vendorFrom,
     mfrNumbers: plan.mfrNumbers,
     recordIds,
+    shopifyArchived,
+    shopifyMode: SHOPIFY_AUTOCOMMIT ? 'COMMIT' : 'STAGED',
   });
   log(
     AUTOCOMMIT ? 'COMMIT' : 'STAGED(dry-run)',
     threadId,
     plan.vendorFrom,
     'mfr#', plan.mfrNumbers.join(','),
-    'rows', recordIds.length
+    'FM-rows', recordIds.length,
+    `shopify(${SHOPIFY_AUTOCOMMIT ? 'commit' : 'stage'})-archived`, shopifyArchived
   );
 }
 
@@ -295,13 +351,17 @@ async function sendRunReport(actions) {
   const queued = [...byVerdict('QUEUE'), ...byVerdict('QUEUE_NO_MFR')];
   const line = (a) =>
     `<li><b>${a.vendor || 'unknown vendor'}</b> — mfr# ${(a.mfrNumbers || []).join(', ') || '(none)'}` +
-    (a.recordIds ? ` — ${a.recordIds.length} row(s)` : '') +
+    (a.recordIds ? ` — FM ${a.recordIds.length} row(s)` : '') +
+    (a.shopifyArchived ? ` — Shopify ${a.shopifyArchived} archived (${a.shopifyMode})` : '') +
     (a.reasons ? ` — <i>${a.reasons.join('; ')}</i>` : '') +
     ` <span style="color:#888">[thread ${a.threadId}]</span></li>`;
-  const mode = AUTOCOMMIT ? 'AUTO-COMMITTED to FileMaker' : 'STAGED (dry-run — DISCO_AUTOCOMMIT is OFF)';
+  const fmMode = AUTOCOMMIT ? 'AUTO-COMMITTED to FileMaker' : 'STAGED (DISCO_AUTOCOMMIT off)';
+  const shopMode = SHOPIFY_AUTOCOMMIT ? 'AUTO-ARCHIVED on Shopify' : 'STAGED (SHOPIFY_AUTOCOMMIT off)';
+  const totalArchived = committed.reduce((n, a) => n + (a.shopifyArchived || 0), 0);
   const body = [
     `<p><b>discontinued-agent run report</b> — ${new Date().toISOString()}</p>`,
-    `<p>Discontinuations ${mode}: <b>${committed.length}</b> · Queued for review: <b>${queued.length}</b></p>`,
+    `<p>Discontinuations: <b>${committed.length}</b> · Queued for review: <b>${queued.length}</b></p>`,
+    `<p>FileMaker: ${fmMode} · Shopify: ${shopMode} — <b>${totalArchived}</b> product(s) archived + date-stamped</p>`,
     committed.length ? `<p><b>Discontinued / closed:</b></p><ul>${committed.map(line).join('')}</ul>` : '',
     queued.length ? `<p><b>Queued for Steve (not auto-written):</b></p><ul>${queued.map(line).join('')}</ul>` : '',
     `<p style="color:#888">Ledger: data/ledger.jsonl · Queue memos: ~/.claude/yolo-queue/pending-approval/</p>`,
@@ -359,6 +419,18 @@ async function main() {
   } catch (e) {
     log('WARN FileMaker ping failed:', e.message);
   }
+  if (SHOPIFY_ENABLED) {
+    log(`Shopify autocommit=${SHOPIFY_AUTOCOMMIT ? 'ON (LIVE archive+stamp)' : 'OFF (stage/dry-run only)'}`);
+    try {
+      const s = await Shopify.ping();
+      log('Shopify ping:', `ok (${s.shop} @ ${s.domain})`);
+      // Ensure the custom.date_discontinued metafield definition exists (idempotent).
+      const def = await Shopify.ensureDefinition();
+      log('Shopify metafield def custom.date_discontinued:', def.created ? 'created' : def.alreadyExisted ? 'exists' : 'unknown');
+    } catch (e) {
+      log('WARN Shopify preflight failed:', e.message);
+    }
+  }
 
   if (once) {
     await scanOnce();

← 65e4ec9 chore: v1.1.0 (session close — info@ report + disco trigger  ·  back to Discontinued Agent  ·  (newest)