← 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
A com.steve.discontinued-agent-cron.plistA lib/shopify.jsM poller.js
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)