[object Object]

← back to Dw Theme Toggle

add local web viewer (port 9769) for token paste + theme list + push

65aaf21cb62a4c9108c630eecf3630058f77e49a · 2026-05-08 08:17:49 -0700 · Steve Abrams

Files touched

Diff

commit 65aaf21cb62a4c9108c630eecf3630058f77e49a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 8 08:17:49 2026 -0700

    add local web viewer (port 9769) for token paste + theme list + push
---
 viewer.js | 250 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 250 insertions(+)

diff --git a/viewer.js b/viewer.js
new file mode 100644
index 0000000..c258bc6
--- /dev/null
+++ b/viewer.js
@@ -0,0 +1,250 @@
+/* DW Theme Toggle — local web viewer for pasting a Shopify Admin token,
+   listing themes, and pushing the snippet. Local-only (127.0.0.1:9769). */
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { exec } = require('child_process');
+
+const PORT = 9769;
+const HOST = '127.0.0.1';
+const SNIPPET_PATH = path.join(__dirname, 'snippets', 'theme-toggle.liquid');
+const SECRETS_ENV = path.join(process.env.HOME, 'Projects/secrets-manager/.env');
+
+function readEnvToken() {
+  try {
+    const raw = fs.readFileSync(SECRETS_ENV, 'utf8');
+    for (const line of raw.split('\n')) {
+      const m = line.match(/^(SHOPIFY_ADMIN_TOKEN|SHOPIFY_ACCESS_TOKEN)=(.*)$/);
+      if (m) return m[2].trim().replace(/^["']|["']$/g, '');
+    }
+  } catch (e) {}
+  return '';
+}
+
+function shopifyFetch(domain, token, pathPart, init = {}) {
+  return fetch(`https://${domain}/admin/api/2024-10${pathPart}`, {
+    ...init,
+    headers: {
+      'X-Shopify-Access-Token': token,
+      'Content-Type': 'application/json',
+      ...(init.headers || {}),
+    },
+  });
+}
+
+async function readBody(req) {
+  return new Promise((res, rej) => {
+    let buf = '';
+    req.on('data', (c) => (buf += c));
+    req.on('end', () => res(buf));
+    req.on('error', rej);
+  });
+}
+
+const HTML = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>DW Theme Toggle — Pusher</title>
+<style>
+  :root { color-scheme: dark; }
+  * { box-sizing: border-box; }
+  html, body { margin: 0; background: #0a0a0a; color: #f5f1e8; font: 15px/1.5 -apple-system, "SF Pro Text", system-ui, sans-serif; min-height: 100vh; }
+  .wrap { max-width: 760px; margin: 0 auto; padding: 36px 22px 90px; }
+  h1 { font-weight: 300; font-size: 28px; margin: 0 0 6px; letter-spacing: 0.01em; }
+  h1 .dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; background: #DDAB1E; margin-right: 10px; vertical-align: middle; }
+  p.sub { color: rgba(245,241,232,0.6); margin: 0 0 30px; }
+  .card { background: rgba(245,241,232,0.04); border: 1px solid rgba(245,241,232,0.08); border-radius: 4px; padding: 22px 24px; margin-bottom: 20px; }
+  label { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(245,241,232,0.55); margin-bottom: 6px; }
+  input[type=text], input[type=password] { width: 100%; background: #141414; color: #f5f1e8; border: 1px solid rgba(245,241,232,0.12); border-radius: 3px; padding: 11px 13px; font: inherit; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; }
+  input:focus { outline: 2px solid #DDAB1E; outline-offset: 1px; border-color: #DDAB1E; }
+  button { background: #f5f1e8; color: #0a0a0a; border: 0; border-radius: 3px; padding: 12px 22px; font: inherit; font-weight: 500; cursor: pointer; transition: background 200ms, transform 100ms; }
+  button:hover { background: #fff; }
+  button:active { transform: scale(0.98); }
+  button:disabled { opacity: 0.4; cursor: not-allowed; }
+  button.secondary { background: rgba(245,241,232,0.08); color: #f5f1e8; border: 1px solid rgba(245,241,232,0.18); }
+  button.secondary:hover { background: rgba(245,241,232,0.16); }
+  .row { display: flex; gap: 12px; align-items: end; }
+  .row > .grow { flex: 1; }
+  .theme-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid rgba(245,241,232,0.08); border-radius: 3px; margin-bottom: 6px; cursor: pointer; }
+  .theme-item:hover { background: rgba(245,241,232,0.04); }
+  .theme-item.selected { border-color: #DDAB1E; background: rgba(221,171,30,0.06); }
+  .theme-item input { accent-color: #DDAB1E; }
+  .theme-item .role { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #DDAB1E; padding: 2px 8px; border: 1px solid rgba(221,171,30,0.4); border-radius: 999px; }
+  .theme-item .role.unpublished { color: rgba(245,241,232,0.5); border-color: rgba(245,241,232,0.18); }
+  .status { padding: 12px 14px; margin-top: 14px; border-radius: 3px; font-size: 13px; line-height: 1.55; }
+  .status.ok    { background: rgba(56,142,60,0.16); border: 1px solid rgba(56,142,60,0.45); color: #c8e6c9; }
+  .status.err   { background: rgba(220,53,69,0.14); border: 1px solid rgba(220,53,69,0.4); color: #f8c7cd; }
+  .status.info  { background: rgba(245,241,232,0.05); border: 1px solid rgba(245,241,232,0.10); color: #f5f1e8; }
+  pre { background: #141414; border: 1px solid rgba(245,241,232,0.08); border-radius: 3px; padding: 12px 14px; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; color: rgba(245,241,232,0.85); white-space: pre-wrap; word-break: break-word; }
+  a { color: #DDAB1E; }
+  .hide { display: none !important; }
+  .small { font-size: 12px; color: rgba(245,241,232,0.55); }
+</style>
+</head>
+<body>
+  <div class="wrap">
+    <h1><span class="dot"></span>DW Theme Toggle Pusher</h1>
+    <p class="sub">Paste your Shopify Admin API token, pick a theme, push the snippet. Local-only — never leaves 127.0.0.1.</p>
+
+    <div class="card">
+      <label>Store domain</label>
+      <input id="domain" type="text" value="designer-laboratory-sandbox.myshopify.com" />
+
+      <div style="margin-top:14px"></div>
+      <label>Shopify Admin API token (shpat_…)</label>
+      <input id="token" type="password" placeholder="shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autocomplete="off" />
+      <div class="small" style="margin-top:6px">
+        Token must have <code>read_themes</code> + <code>write_themes</code> scope. Grant via Shopify Admin → Apps and sales channels → Develop apps → [your app] → Configuration.
+      </div>
+
+      <div style="margin-top:18px" class="row">
+        <button id="btn-validate">Validate & list themes</button>
+        <button id="btn-save-token" class="secondary" title="Save token to ~/Projects/secrets-manager/.env via /secrets">Save token to /secrets</button>
+      </div>
+      <div id="validate-status" class="status hide"></div>
+    </div>
+
+    <div id="themes-card" class="card hide">
+      <label>Pick a target theme</label>
+      <div id="themes-list"></div>
+      <div style="margin-top:18px">
+        <button id="btn-push">Push snippet to selected theme</button>
+      </div>
+      <div id="push-status" class="status hide"></div>
+    </div>
+
+    <div class="card">
+      <label>Snippet preview</label>
+      <pre id="snippet-preview">loading…</pre>
+      <div class="small" style="margin-top:6px">After push, add <code>{% render 'theme-toggle' %}</code> to <code>layout/theme.liquid</code> just inside <code>&lt;head&gt;</code>.</div>
+    </div>
+  </div>
+
+<script>
+  const $ = (s) => document.querySelector(s);
+  const setStatus = (el, kind, text) => { el.className = 'status ' + kind; el.textContent = text; el.classList.remove('hide'); };
+  let selectedThemeId = null;
+
+  fetch('/api/snippet').then(r => r.text()).then(t => { $('#snippet-preview').textContent = t.slice(0, 1200) + (t.length > 1200 ? '\\n\\n... (' + (t.length - 1200) + ' more chars)' : ''); });
+  fetch('/api/loaded-token').then(r => r.json()).then(j => { if (j.last4) $('#token').placeholder = 'Loaded from /secrets — last 4: ' + j.last4 + ' (paste new value to override)'; });
+
+  $('#btn-validate').addEventListener('click', async () => {
+    const domain = $('#domain').value.trim();
+    const token = $('#token').value.trim();
+    const status = $('#validate-status');
+    setStatus(status, 'info', 'Validating…');
+    const r = await fetch('/api/themes', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({domain, token}) });
+    const j = await r.json();
+    if (!j.ok) { setStatus(status, 'err', 'Failed: ' + j.error); $('#themes-card').classList.add('hide'); return; }
+    setStatus(status, 'ok', 'Listed ' + j.themes.length + ' theme(s).');
+    const list = $('#themes-list');
+    list.innerHTML = '';
+    j.themes.forEach((t, i) => {
+      const id = 'th-' + t.id;
+      const div = document.createElement('label');
+      div.className = 'theme-item' + (t.role === 'main' ? ' selected' : '');
+      div.htmlFor = id;
+      div.innerHTML = '<input type="radio" name="theme" id="' + id + '" value="' + t.id + '"' + (t.role === 'main' ? ' checked' : '') + '>' +
+        '<div style="flex:1"><strong>' + t.name + '</strong><div class="small">id: ' + t.id + '</div></div>' +
+        '<span class="role ' + (t.role === 'main' ? '' : 'unpublished') + '">' + t.role + '</span>';
+      div.querySelector('input').addEventListener('change', e => {
+        selectedThemeId = e.target.value;
+        document.querySelectorAll('.theme-item').forEach(x => x.classList.remove('selected'));
+        div.classList.add('selected');
+      });
+      if (t.role === 'main') selectedThemeId = String(t.id);
+      list.appendChild(div);
+    });
+    $('#themes-card').classList.remove('hide');
+  });
+
+  $('#btn-push').addEventListener('click', async () => {
+    const domain = $('#domain').value.trim();
+    const token = $('#token').value.trim();
+    const status = $('#push-status');
+    if (!selectedThemeId) { setStatus(status, 'err', 'Pick a theme first.'); return; }
+    setStatus(status, 'info', 'Uploading snippet to theme ' + selectedThemeId + '…');
+    const r = await fetch('/api/push', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({domain, token, themeId: selectedThemeId}) });
+    const j = await r.json();
+    if (!j.ok) { setStatus(status, 'err', 'Push failed: ' + j.error); return; }
+    setStatus(status, 'ok', 'Snippet uploaded as snippets/theme-toggle.liquid on theme ' + selectedThemeId + '.\\n\\nNext: open layout/theme.liquid in Shopify code editor and add  {% render \\'theme-toggle\\' %}  inside <head>.\\n\\nPreview URL: https://' + domain + '?preview_theme_id=' + selectedThemeId);
+  });
+
+  $('#btn-save-token').addEventListener('click', async () => {
+    const token = $('#token').value.trim();
+    const status = $('#validate-status');
+    if (!token) { setStatus(status, 'err', 'Paste a token first.'); return; }
+    setStatus(status, 'info', 'Saving token via /secrets…');
+    const r = await fetch('/api/save-token', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({token}) });
+    const j = await r.json();
+    if (!j.ok) { setStatus(status, 'err', 'Save failed: ' + j.error); return; }
+    setStatus(status, 'ok', 'Saved. Last 4: ' + j.last4 + '. Routes fan-out: ' + (j.routes || 'master only').slice(0, 200));
+  });
+</script>
+</body>
+</html>`;
+
+const server = http.createServer(async (req, res) => {
+  try {
+    if (req.url === '/' || req.url === '/index.html') {
+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+      return res.end(HTML);
+    }
+    if (req.url === '/api/snippet') {
+      const body = fs.readFileSync(SNIPPET_PATH, 'utf8');
+      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
+      return res.end(body);
+    }
+    if (req.url === '/api/loaded-token') {
+      const tok = readEnvToken();
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      return res.end(JSON.stringify({ last4: tok ? tok.slice(-4) : '' }));
+    }
+    if (req.method === 'POST' && req.url === '/api/themes') {
+      const { domain, token } = JSON.parse(await readBody(req));
+      if (!domain || !token) return res.end(JSON.stringify({ ok: false, error: 'domain and token required' }));
+      const r = await shopifyFetch(domain, token, '/themes.json');
+      const txt = await r.text();
+      let parsed; try { parsed = JSON.parse(txt); } catch { return res.end(JSON.stringify({ ok: false, error: 'non-JSON: ' + txt.slice(0,200) })); }
+      if (!r.ok) return res.end(JSON.stringify({ ok: false, error: 'HTTP ' + r.status + ': ' + (parsed.errors || JSON.stringify(parsed)).toString().slice(0,400) }));
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      return res.end(JSON.stringify({ ok: true, themes: (parsed.themes || []).map(t => ({ id: t.id, role: t.role, name: t.name })) }));
+    }
+    if (req.method === 'POST' && req.url === '/api/push') {
+      const { domain, token, themeId } = JSON.parse(await readBody(req));
+      if (!domain || !token || !themeId) return res.end(JSON.stringify({ ok: false, error: 'domain, token, themeId required' }));
+      const snippetBody = fs.readFileSync(SNIPPET_PATH, 'utf8');
+      const r = await shopifyFetch(domain, token, '/themes/' + themeId + '/assets.json', {
+        method: 'PUT',
+        body: JSON.stringify({ asset: { key: 'snippets/theme-toggle.liquid', value: snippetBody } }),
+      });
+      const txt = await r.text();
+      let parsed; try { parsed = JSON.parse(txt); } catch { return res.end(JSON.stringify({ ok: false, error: 'non-JSON: ' + txt.slice(0,200) })); }
+      if (!r.ok) return res.end(JSON.stringify({ ok: false, error: 'HTTP ' + r.status + ': ' + (parsed.errors || JSON.stringify(parsed)).toString().slice(0,400) }));
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      return res.end(JSON.stringify({ ok: true, asset: parsed.asset }));
+    }
+    if (req.method === 'POST' && req.url === '/api/save-token') {
+      const { token } = JSON.parse(await readBody(req));
+      if (!token) return res.end(JSON.stringify({ ok: false, error: 'token required' }));
+      const last4 = token.slice(-4);
+      const cli = path.join(process.env.HOME, 'Projects/secrets-manager/cli.js');
+      exec('node ' + JSON.stringify(cli) + ' add SHOPIFY_ADMIN_TOKEN ' + JSON.stringify(token), { timeout: 30000 }, (err, stdout, stderr) => {
+        const out = (stdout || '') + (stderr || '');
+        res.writeHead(200, { 'Content-Type': 'application/json' });
+        res.end(JSON.stringify({ ok: !err, last4, routes: out.slice(0, 600), error: err ? err.message : null }));
+      });
+      return;
+    }
+    res.writeHead(404);
+    res.end('not found');
+  } catch (e) {
+    res.writeHead(500, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ ok: false, error: e.message }));
+  }
+});
+
+server.listen(PORT, HOST, () => console.log('[' + new Date().toISOString() + '] dw-theme-toggle viewer on http://' + HOST + ':' + PORT));

← 150a0d6 initial scaffold — self-contained Shopify theme-toggle snipp  ·  back to Dw Theme Toggle  ·  snapshot: 1 file(s) changed, +1 new fcb360c →