[object Object]

← back to Dw Marketing Reels

Multi-account Instagram picker: DW-family account registry + per-reel Post-to selector + account-routed publish

582f1f1442aa1201faa97d71ddcdeb05136714f4 · 2026-07-13 00:09:27 -0700 · Steve Abrams

Files touched

Diff

commit 582f1f1442aa1201faa97d71ddcdeb05136714f4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 00:09:27 2026 -0700

    Multi-account Instagram picker: DW-family account registry + per-reel Post-to selector + account-routed publish
---
 data/ig-accounts.json      | 65 ++++++++++++++++++++++++++++++++++++++++++++++
 data/reels.json            |  3 ++-
 public/index.html          | 59 ++++++++++++++++++++++++++++++++++++++++-
 scripts/publish-social.mjs | 35 +++++++++++++++++++------
 server.js                  | 41 ++++++++++++++++++++++++++++-
 5 files changed, 192 insertions(+), 11 deletions(-)

diff --git a/data/ig-accounts.json b/data/ig-accounts.json
new file mode 100644
index 0000000..68385a9
--- /dev/null
+++ b/data/ig-accounts.json
@@ -0,0 +1,65 @@
+[
+  {
+    "id": "dw",
+    "label": "Designer Wallcoverings",
+    "handle": "designerwallcoverings",
+    "business": "designerwallcoverings.com",
+    "agentUrl": "http://127.0.0.1:9810",
+    "status": "ready",
+    "note": "Main DW account via Norma's instagram-agent (Meta Graph). Creds live on the publish host."
+  },
+  {
+    "id": "phillipe-romano",
+    "label": "Phillipe Romano",
+    "handle": "philipperomano",
+    "business": "philipperomano.com",
+    "agentUrl": null,
+    "status": "pending-creds",
+    "note": "Handle + Meta creds TBD — set handle and wire creds to activate."
+  },
+  {
+    "id": "apartment-wallpaper",
+    "label": "Apartment Wallpaper",
+    "handle": null,
+    "business": "apartmentwallpaper.com",
+    "agentUrl": null,
+    "status": "pending-creds",
+    "note": "Peel-and-stick line. Handle + creds TBD."
+  },
+  {
+    "id": "novasuede",
+    "label": "Novasuede",
+    "handle": null,
+    "business": "novasuede.com",
+    "agentUrl": null,
+    "status": "pending-creds",
+    "note": "Handle + creds TBD."
+  },
+  {
+    "id": "architectural-wallcoverings",
+    "label": "Architectural Wallcoverings",
+    "handle": null,
+    "business": "architecturalwallcoverings.com",
+    "agentUrl": null,
+    "status": "pending-creds",
+    "note": "Contract/commercial line. Handle + creds TBD."
+  },
+  {
+    "id": "hollywood-wallcoverings",
+    "label": "Hollywood Wallcoverings",
+    "handle": null,
+    "business": "hollywood-wallcoverings",
+    "agentUrl": null,
+    "status": "pending-creds",
+    "note": "Private-label line (never name the upstream vendor publicly). Handle + creds TBD."
+  },
+  {
+    "id": "malibu-wallpaper",
+    "label": "Malibu Wallpaper",
+    "handle": null,
+    "business": "malibu wallpaper line",
+    "agentUrl": null,
+    "status": "pending-creds",
+    "note": "Private-label line (never name the upstream vendor publicly). Handle + creds TBD."
+  }
+]
\ No newline at end of file
diff --git a/data/reels.json b/data/reels.json
index ba07f98..8a06bf3 100644
--- a/data/reels.json
+++ b/data/reels.json
@@ -40,7 +40,8 @@
         "note": "TikTok channel not connected (OAuth pending)",
         "at": "2026-07-13T06:55:00.000Z"
       }
-    }
+    },
+    "igAccount": "dw"
   },
   {
     "file": "new-arrivals-2026-07-12-19-00.mp4",
diff --git a/public/index.html b/public/index.html
index 7d7f8e3..606d9da 100644
--- a/public/index.html
+++ b/public/index.html
@@ -46,6 +46,15 @@
   .card .pat { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:17px; }
   .card .met { font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); margin-top:3px; }
   .card .when { font-size:11px; color:#9a917f; margin-top:7px; }
+  .accts { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin:6px 0 4px; }
+  .acct { display:flex; gap:8px; align-items:center; background:#fff; border:1px solid var(--line); border-radius:999px; padding:6px 12px; font-size:12.5px; }
+  .acct .dot { width:8px; height:8px; border-radius:50%; background:#c9a35a; }
+  .acct.ready .dot { background:#2f9e57; }
+  .acct .h { color:#8a8272; font-size:11.5px; }
+  .acct button { font-size:11px; padding:2px 8px; border-radius:999px; }
+  .postto { display:flex; gap:8px; align-items:center; margin-top:10px; }
+  .postto label { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; }
+  .postto select { font-size:12.5px; padding:5px 8px; }
 </style>
 </head>
 <body>
@@ -61,6 +70,9 @@
 <main>
   <div class="status" id="status"></div>
 
+  <h2>Instagram accounts <span style="font-size:13px;color:#8a8272">(choose per reel below)</span></h2>
+  <div class="accts" id="accts"></div>
+
   <h2>Reels</h2>
   <div class="reels" id="reels"></div>
 
@@ -99,6 +111,41 @@ function pubBadges(pub){
   return `<div class="badges">${bits.join('')}</div>`;
 }
 
+// ---- instagram accounts ----
+let ACCTS = [];
+async function loadAccounts() {
+  ACCTS = await (await fetch('api/ig-accounts')).json();
+  $('#accts').innerHTML = ACCTS.map(a => `
+    <div class="acct ${a.status==='ready'?'ready':''}" title="${escapeAttr(a.note||'')}">
+      <span class="dot"></span>
+      <span>${escapeHtml(a.label)}</span>
+      <span class="h">${a.handle ? '@'+escapeHtml(a.handle) : 'handle TBD'} · ${a.status==='ready'?'connected':'needs creds'}</span>
+      <button class="ghost tiny edit-acct" data-id="${a.id}">✎</button>
+    </div>`).join('') +
+    `<button class="ghost tiny" id="addAcct">+ Add account</button>`;
+}
+function acctOptions(sel) {
+  return ACCTS.map(a => `<option value="${a.id}" ${a.id===sel?'selected':''}>${escapeHtml(a.label)}${a.status==='ready'?'':' (needs creds)'}</option>`).join('');
+}
+$('#accts').addEventListener('click', async e => {
+  if (e.target.id === 'addAcct') {
+    const label = prompt('Business / account name (e.g. "Phillipe Romano"):'); if (!label) return;
+    const id = label.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
+    const handle = prompt('Instagram handle (without @, blank if unknown):') || null;
+    await fetch('api/ig-accounts', { method:'POST', headers:{'content-type':'application/json'},
+      body: JSON.stringify({ id, label, handle, status:'pending-creds', note:'added from console' }) });
+    loadAccounts();
+  }
+  const ed = e.target.closest('.edit-acct');
+  if (ed) {
+    const a = ACCTS.find(x => x.id === ed.dataset.id); if (!a) return;
+    const handle = prompt(`Instagram handle for ${a.label} (without @):`, a.handle||''); if (handle===null) return;
+    await fetch('api/ig-accounts', { method:'POST', headers:{'content-type':'application/json'},
+      body: JSON.stringify({ id:a.id, handle: handle||null }) });
+    loadAccounts();
+  }
+});
+
 // ---- reels gallery ----
 async function loadReels() {
   const reels = await (await fetch('api/reels')).json();
@@ -109,6 +156,9 @@ async function loadReels() {
         <div class="when" title="${r.created_at}">🕓 ${fmt(r.created_at)}</div>
         <div class="titles">${(r.titles||[]).join(' · ')}</div>
         ${pubBadges(r.publish)}
+        <div class="postto"><label>Post to</label>
+          <select class="pick-acct" data-file="${escapeAttr(r.file)}">${acctOptions(r.igAccount||'dw')}</select>
+        </div>
         ${r.caption ? `<div class="cap-box"><div class="cap-txt">${escapeHtml(r.caption)}</div>
           <button class="ghost tiny copy" data-cap="${escapeAttr(r.caption)}">Copy caption</button></div>` : ''}
         <div class="row"><a href="reels/${encodeURIComponent(r.file)}" download>Download</a>
@@ -116,6 +166,13 @@ async function loadReels() {
       </div>
     </div>`).join('') : '<p style="color:#8a8272">No reels yet — hit “Generate reel”.</p>';
 }
+// account picker per reel (delegated)
+$('#reels').addEventListener('change', async e => {
+  const sel = e.target.closest('.pick-acct'); if (!sel) return;
+  const r = await fetch('api/reel-account', { method:'POST', headers:{'content-type':'application/json'},
+    body: JSON.stringify({ file: sel.dataset.file, accountId: sel.value }) });
+  $('#status').textContent = r.ok ? `✓ ${sel.dataset.file} → ${sel.options[sel.selectedIndex].text}` : '✗ failed to set account';
+});
 // copy caption (delegated)
 $('#reels').addEventListener('click', e => {
   const b = e.target.closest('.copy'); if (!b) return;
@@ -183,7 +240,7 @@ $('#btnBuild').addEventListener('click', ()=>job('api/build','▶ generating ree
   } catch {}
 })();
 
-loadReels(); loadNA();
+loadAccounts().then(loadReels); loadNA();
 </script>
 </body>
 </html>
diff --git a/scripts/publish-social.mjs b/scripts/publish-social.mjs
index 94e37f6..d864fd5 100644
--- a/scripts/publish-social.mjs
+++ b/scripts/publish-social.mjs
@@ -32,22 +32,41 @@ async function withTimeout(p, ms = 12000) {
   return Promise.race([p, new Promise((_, r) => setTimeout(() => r(new Error('timeout')), ms))]);
 }
 
+// Multi-account: each reel carries igAccount (set in the console; default 'dw').
+// Per-account auth via IG_AGENT_AUTH_<ID> (uppercased, dashes→underscores),
+// falling back to IG_AGENT_AUTH for the main DW account only.
+function loadAccounts() {
+  try { return JSON.parse(readFileSync(join(ROOT, 'data', 'ig-accounts.json'), 'utf8')); } catch { return []; }
+}
+function accountFor(reel) {
+  const id = reel.igAccount || 'dw';
+  return loadAccounts().find(a => a.id === id) || { id, label: id, status: 'pending-creds', agentUrl: null };
+}
+function authFor(acc) {
+  const key = `IG_AGENT_AUTH_${acc.id.toUpperCase().replace(/-/g, '_')}`;
+  return process.env[key] || (acc.id === 'dw' ? IG_AUTH : '');
+}
+
 async function postInstagram(reel) {
-  if (!IG_AUTH) return { status: 'pending-creds', note: 'IG_AGENT_AUTH not set (route via secrets skill)', at: now() };
-  if (!ARMED) return { status: 'ready-pending-arm', note: 'IG creds present; set SOCIAL_LIVE_ARMED=1 (Steve arm) to enable the first live post', at: now() };
+  const acc = accountFor(reel);
+  const auth = authFor(acc);
+  const tag = `${acc.label}${acc.handle ? ' @' + acc.handle : ''}`;
+  if (acc.status !== 'ready') return { status: 'pending-creds', account: acc.id, note: `${tag} not connected — wire creds + set status "ready" in ig-accounts.json`, at: now() };
+  if (!auth) return { status: 'pending-creds', account: acc.id, note: `${tag}: IG_AGENT_AUTH${acc.id === 'dw' ? '' : '_' + acc.id.toUpperCase().replace(/-/g, '_')} not set (route via secrets skill)`, at: now() };
+  if (!ARMED) return { status: 'ready-pending-arm', account: acc.id, note: `${tag} creds present; set SOCIAL_LIVE_ARMED=1 (Steve arm) to enable the live post`, at: now() };
   const mediaUrl = `${PUBLIC_BASE}/reels/${encodeURIComponent(reel.file)}`;
   try {
-    const res = await withTimeout(fetch(`${IG_URL}/api/skill/reel`, {
+    const res = await withTimeout(fetch(`${acc.agentUrl || IG_URL}/api/skill/reel`, {
       method: 'POST',
-      headers: { 'content-type': 'application/json', authorization: IG_AUTH },
-      body: JSON.stringify({ caption: reel.caption, mediaUrl, source: 'dw-marketing-reels' }),
+      headers: { 'content-type': 'application/json', authorization: auth },
+      body: JSON.stringify({ caption: reel.caption, mediaUrl, account: acc.id, source: 'dw-marketing-reels' }),
     }));
     const txt = await res.text().catch(() => '');
     let body = {}; try { body = JSON.parse(txt); } catch {}
-    if (!res.ok) return { status: res.status === 401 ? 'pending-creds' : 'error', http: res.status, at: now() };
+    if (!res.ok) return { status: res.status === 401 ? 'pending-creds' : 'error', account: acc.id, http: res.status, at: now() };
     const simulated = body.simulated || /simulat/i.test(txt);
-    return { status: simulated ? 'simulated' : 'posted', postId: body.id || body.postId || null, at: now() };
-  } catch (e) { return { status: 'error', note: e.message, at: now() }; }
+    return { status: simulated ? 'simulated' : 'posted', account: acc.id, postId: body.id || body.postId || null, at: now() };
+  } catch (e) { return { status: 'error', account: acc.id, note: e.message, at: now() }; }
 }
 
 async function postTikTok(reel) {
diff --git a/server.js b/server.js
index d6a1ab6..34d7b45 100644
--- a/server.js
+++ b/server.js
@@ -4,7 +4,7 @@
 // the current New Arrivals grid (sort + density per standing rule), and lets marketing
 // refresh the feed + generate a new reel on demand ($0 local render).
 import http from 'node:http';
-import { readFile, stat } from 'node:fs/promises';
+import { readFile, writeFile, stat } from 'node:fs/promises';
 import { createReadStream, existsSync } from 'node:fs';
 import { spawn } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
@@ -43,6 +43,18 @@ function run(script, res) {
   res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ started: script }));
 }
 
+function readBody(req) {
+  return new Promise((resolve, reject) => {
+    let b = ''; req.on('data', d => { b += d; if (b.length > 1e6) req.destroy(); });
+    req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (e) { reject(e); } });
+    req.on('error', reject);
+  });
+}
+
+async function readJson(fp, fallback) {
+  return existsSync(fp) ? JSON.parse(await readFile(fp, 'utf8')) : fallback;
+}
+
 async function serveFile(fp, req, res) {
   const st = await stat(fp);
   const type = MIME[extname(fp)] || 'application/octet-stream';
@@ -84,6 +96,33 @@ const server = http.createServer(async (req, res) => {
     }
     if (p === '/api/config') return res.writeHead(200, { 'content-type': 'application/json' })
       .end(JSON.stringify({ allowBuild: ALLOW_BUILD }));
+    if (p === '/api/ig-accounts') {
+      const fp = join(ROOT, 'data', 'ig-accounts.json');
+      if (req.method === 'POST') {
+        // upsert one account (id required); console-side edit of handle/status/label
+        const acc = await readBody(req);
+        if (!acc.id || !/^[a-z0-9-]+$/.test(acc.id)) return res.writeHead(400).end(JSON.stringify({ error: 'bad id' }));
+        const list = await readJson(fp, []);
+        const i = list.findIndex(a => a.id === acc.id);
+        if (i >= 0) list[i] = { ...list[i], ...acc }; else list.push({ status: 'pending-creds', ...acc });
+        await writeFile(fp, JSON.stringify(list, null, 2));
+        return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(list));
+      }
+      return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await readJson(fp, [])));
+    }
+    if (p === '/api/reel-account' && req.method === 'POST') {
+      // set which IG account a reel will publish to: {file, accountId}
+      const { file, accountId } = await readBody(req);
+      const rp = join(ROOT, 'data', 'reels.json');
+      const reels = await readJson(rp, []);
+      const r = reels.find(x => x.file === file);
+      if (!r) return res.writeHead(404).end(JSON.stringify({ error: 'reel not found' }));
+      const accounts = await readJson(join(ROOT, 'data', 'ig-accounts.json'), []);
+      if (!accounts.some(a => a.id === accountId)) return res.writeHead(400).end(JSON.stringify({ error: 'unknown account' }));
+      r.igAccount = accountId;
+      await writeFile(rp, JSON.stringify(reels, null, 2));
+      return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, file, igAccount: accountId }));
+    }
     if (p === '/api/status') return res.writeHead(200, { 'content-type': 'application/json' })
       .end(JSON.stringify({ building, log: lastLog.slice(-4000) }));
     if (p === '/api/refresh' && req.method === 'POST') return run('fetch-new-arrivals.mjs', res);

← 808fac7 Dry-run publish status for cork reel: IG ready-pending-arm (  ·  back to Dw Marketing Reels  ·  chore: lint, refactor (hoist SLUG), doc outroBig, v0.2.0 (se 2c90228 →