@jhizzard/termdeck 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -139,7 +139,7 @@ Restart Claude Code. Six MCP tools appear: `memory_remember`, `memory_recall`, `
139
139
 
140
140
  ### Tier 3 — Add Rumen for async learning
141
141
 
142
- Rumen is a separate npm package — `@jhizzard/rumen@0.4.3` — that ships as a Supabase Edge Function designed to run on a 15-minute `pg_cron` schedule. It's the async reflection layer over Mnestra: it reads recent session memories, cross-references them with your entire historical corpus via hybrid search, synthesizes insights via Claude Haiku, and writes the results back into `rumen_insights` (a new table alongside Mnestra's `memory_items`). TermDeck's Flashback and Claude Code's `memory_recall` both automatically benefit because insights flow back into the same database.
142
+ Rumen is a separate npm package — `@jhizzard/rumen@0.4.5` — that ships as a Supabase Edge Function designed to run on a 15-minute `pg_cron` schedule. It's the async reflection layer over Mnestra: it reads recent session memories, cross-references them with your entire historical corpus via hybrid search, synthesizes insights via Claude Haiku, and writes the results back into `rumen_insights` (a new table alongside Mnestra's `memory_items`). TermDeck's Flashback and Claude Code's `memory_recall` both automatically benefit because insights flow back into the same database.
143
143
 
144
144
  **Rumen is live.** First full-kickstart run against a production Mnestra store on 2026-04-15 19:47 UTC: **111 sessions processed, 111 insights generated** in one pass. Insights surfaced patterns like "the error detection regex in Flashback misses `No such file or directory` — same class of blind spot as X" and "Practice sessions exist as a separate model but frontend components were built and never wired into the schedule view." The cognitive loop is closed.
145
145
 
@@ -163,7 +163,7 @@ Honest limits, stated upfront so the skeptic has nothing to chase:
163
163
  - **Not a replacement for reading docs.** It's the shortest path to a memory you already wrote. If the memory isn't there, the feature does nothing.
164
164
  - **Not fully local by default.** Tier 2+ reaches out to Supabase for storage and OpenAI for embeddings. Tier 1 is fully local. A fully-local Tier 2 (local Postgres + local embeddings) is on the roadmap.
165
165
  - **Not free forever.** Tier 2+ pays OpenAI fractions of a cent per memory for embeddings. Self-hosted embeddings via Ollama are on the roadmap.
166
- - **Not proven at scale.** v0.4.3, validated against 3,527 memories in one developer's production store. First full Rumen kickstart on 2026-04-15 processed 111 sessions into 111 insights in one pass. No multi-user data yet. Bug reports and issues welcome.
166
+ - **Not proven at scale.** v0.4.5, validated against 3,527 memories in one developer's production store. First full Rumen kickstart on 2026-04-15 processed 111 sessions into 111 insights in one pass. No multi-user data yet. Bug reports and issues welcome.
167
167
 
168
168
  ---
169
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -1852,6 +1852,15 @@
1852
1852
  const grid = document.getElementById('termGrid');
1853
1853
  grid.className = `grid-container layout-${layout}`;
1854
1854
 
1855
+ // Orchestrator layout: set column count based on worker panels (total - 1)
1856
+ if (layout === 'orch') {
1857
+ const panelCount = grid.querySelectorAll('.term-panel').length;
1858
+ const workerCount = Math.max(0, panelCount - 1);
1859
+ grid.setAttribute('data-orch-cols', String(workerCount || panelCount));
1860
+ } else {
1861
+ grid.removeAttribute('data-orch-cols');
1862
+ }
1863
+
1855
1864
  // Remove focus/half states
1856
1865
  document.querySelectorAll('.term-panel').forEach(p => {
1857
1866
  p.classList.remove('focused', 'primary');
@@ -2019,6 +2028,20 @@
2019
2028
  }
2020
2029
  }
2021
2030
 
2031
+ // Debounce: collapse a burst of calls (e.g. a window-resize drag firing
2032
+ // dozens of events/sec) into a single invocation after `wait` ms of quiet.
2033
+ function debounce(fn, wait) {
2034
+ let timer = null;
2035
+ return function debounced(...args) {
2036
+ if (timer) clearTimeout(timer);
2037
+ timer = setTimeout(() => { timer = null; fn.apply(this, args); }, wait);
2038
+ };
2039
+ }
2040
+
2041
+ const fitAllDebounced = debounce(() => {
2042
+ requestAnimationFrame(() => fitAll());
2043
+ }, 100);
2044
+
2022
2045
  // ===== ONBOARDING TOUR =====
2023
2046
  // Spotlight + tooltip walkthrough of every TermDeck surface. Runs once on
2024
2047
  // first visit (localStorage gate) and replays on demand via the "how this
@@ -2038,7 +2061,7 @@
2038
2061
  {
2039
2062
  target: '.topbar-center',
2040
2063
  title: 'Layout modes',
2041
- body: `Eight preset grid layouts — <kbd>1x1</kbd> through <kbd>4x2</kbd>, <strong>orch</strong> (1 large + stacked, for 4+1 sprints), plus <strong>control</strong> (aggregate activity feed). Click any layout to switch instantly; all terminals re-fit to the new grid. Keyboard shortcuts <kbd>Cmd+Shift+1</kbd>–<kbd>Cmd+Shift+7</kbd> (or <kbd>Ctrl+Shift+1</kbd>–<kbd>7</kbd>) do the same.`,
2064
+ body: `Eight preset grid layouts — <kbd>1x1</kbd> through <kbd>4x2</kbd>, <strong>orch</strong> (4 workers across the top + 1 full-width orchestrator across the bottom, for 4+1 sprints), plus <strong>control</strong> (aggregate activity feed). Click any layout to switch instantly; all terminals re-fit to the new grid. Keyboard shortcuts <kbd>Cmd+Shift+1</kbd>–<kbd>Cmd+Shift+7</kbd> (or <kbd>Ctrl+Shift+1</kbd>–<kbd>7</kbd>) do the same.`,
2042
2065
  },
2043
2066
  {
2044
2067
  target: '#termSwitcher',
@@ -2555,15 +2578,21 @@
2555
2578
  ? 'active'
2556
2579
  : status === 'partial' ? 'partial' : 'not configured';
2557
2580
  const isCurrent = Number(tier.id) === currentTier + 1 && status !== 'active';
2558
- const cmds = (status === 'active' || tier.commands.length === 0)
2559
- ? ''
2560
- : `<div class="setup-cmds">${tier.commands.map((c) => {
2561
- const copyable = /^termdeck\s/.test(c);
2562
- return `<div class="setup-cmd">
2563
- <code>${escapeHtml(c)}</code>
2564
- ${copyable ? `<button type="button" class="setup-copy" data-copy="${escapeHtml(c)}">copy</button>` : ''}
2565
- </div>`;
2566
- }).join('')}</div>`;
2581
+
2582
+ // Sprint 23 T2: tier 2 renders a credential form instead of CLI commands
2583
+ // when not active, so users can paste URL/keys directly in the browser.
2584
+ const isCredentialForm = tier.id === '2' && status !== 'active';
2585
+ const cmds = isCredentialForm
2586
+ ? renderSetupCredentialForm()
2587
+ : (status === 'active' || tier.commands.length === 0)
2588
+ ? ''
2589
+ : `<div class="setup-cmds">${tier.commands.map((c) => {
2590
+ const copyable = /^termdeck\s/.test(c);
2591
+ return `<div class="setup-cmd">
2592
+ <code>${escapeHtml(c)}</code>
2593
+ ${copyable ? `<button type="button" class="setup-copy" data-copy="${escapeHtml(c)}">copy</button>` : ''}
2594
+ </div>`;
2595
+ }).join('')}</div>`;
2567
2596
 
2568
2597
  return `
2569
2598
  <div class="setup-tier setup-tier-${status}${isCurrent ? ' setup-tier-next' : ''}">
@@ -2600,26 +2629,263 @@
2600
2629
  }).catch(() => {});
2601
2630
  });
2602
2631
  });
2632
+
2633
+ const credSaveBtn = tiersEl.querySelector('#setupCredSave');
2634
+ if (credSaveBtn) {
2635
+ credSaveBtn.addEventListener('click', submitSetupCredentials);
2636
+ }
2637
+ const credForm = tiersEl.querySelector('#setupCredForm');
2638
+ if (credForm) {
2639
+ credForm.addEventListener('keydown', (e) => {
2640
+ if (e.key === 'Enter' && !e.shiftKey) {
2641
+ e.preventDefault();
2642
+ submitSetupCredentials();
2643
+ }
2644
+ });
2645
+ }
2646
+ }
2647
+
2648
+ // Sprint 23 T2 — credential form for Tier 2.
2649
+ // Uses inline styles because wizard CSS is owned by T1 this sprint; these
2650
+ // stubs fall back to CSS vars already defined in style.css so they inherit
2651
+ // theme colours automatically.
2652
+ function renderSetupCredentialForm() {
2653
+ const inputStyle = 'width:100%;padding:6px 8px;background:var(--bg, #0f1017);color:var(--text, #e2e3e8);border:1px solid var(--border, #2a2c3a);border-radius:4px;font-family:monospace;font-size:12px;margin-top:4px;box-sizing:border-box;';
2654
+ const labelStyle = 'display:block;font-size:11px;color:var(--text-dim, #8a8d9a);margin-top:10px;';
2655
+ const helpStyle = 'font-size:10px;color:var(--text-dim, #8a8d9a);margin-top:3px;min-height:12px;';
2656
+ const btnStyle = 'margin-top:14px;padding:8px 18px;background:var(--accent, #7aa2f7);color:#000;border:none;border-radius:4px;font-weight:600;cursor:pointer;font-size:12px;';
2657
+ return `
2658
+ <form class="setup-credentials" id="setupCredForm" autocomplete="off" style="margin-top:10px;padding:12px;background:rgba(0,0,0,0.2);border-radius:6px;border:1px solid var(--border, #2a2c3a);">
2659
+ <div style="font-size:11px;color:var(--text-dim, #8a8d9a);margin-bottom:4px;">
2660
+ Paste your Supabase + OpenAI credentials. They are written to <code>~/.termdeck/secrets.env</code> (chmod 600) and never leave this machine.
2661
+ </div>
2662
+ <label style="${labelStyle}">
2663
+ Supabase URL
2664
+ <input type="text" name="supabaseUrl" placeholder="https://xxxxxx.supabase.co" spellcheck="false" autocapitalize="off" autocorrect="off" style="${inputStyle}">
2665
+ </label>
2666
+ <div class="setup-cred-status" data-field="supabase" style="${helpStyle}"></div>
2667
+ <label style="${labelStyle}">
2668
+ Service Role Key
2669
+ <input type="password" name="supabaseServiceRoleKey" placeholder="sb_secret_..." spellcheck="false" autocomplete="new-password" style="${inputStyle}">
2670
+ </label>
2671
+ <label style="${labelStyle}">
2672
+ OpenAI API Key
2673
+ <input type="password" name="openaiApiKey" placeholder="sk-proj-..." spellcheck="false" autocomplete="new-password" style="${inputStyle}">
2674
+ </label>
2675
+ <div class="setup-cred-status" data-field="openai" style="${helpStyle}"></div>
2676
+ <label style="${labelStyle}">
2677
+ Database URL
2678
+ <input type="password" name="databaseUrl" placeholder="postgresql://postgres:...@...pooler.supabase.com:6543/postgres" spellcheck="false" autocomplete="new-password" style="${inputStyle}">
2679
+ </label>
2680
+ <div class="setup-cred-status" data-field="database" style="${helpStyle}"></div>
2681
+ <label style="${labelStyle}">
2682
+ Anthropic API Key <span style="opacity:0.6;">(optional — powers session-log summaries)</span>
2683
+ <input type="password" name="anthropicApiKey" placeholder="sk-ant-..." spellcheck="false" autocomplete="new-password" style="${inputStyle}">
2684
+ </label>
2685
+ <div class="setup-cred-error" id="setupCredError" style="font-size:11px;margin-top:10px;min-height:14px;"></div>
2686
+ <button type="button" class="setup-cred-save" id="setupCredSave" style="${btnStyle}">Save &amp; Connect</button>
2687
+ </form>
2688
+ `;
2689
+ }
2690
+
2691
+ async function submitSetupCredentials() {
2692
+ const form = document.getElementById('setupCredForm');
2693
+ if (!form) return;
2694
+ const btn = document.getElementById('setupCredSave');
2695
+ const errorEl = document.getElementById('setupCredError');
2696
+ const statuses = form.querySelectorAll('.setup-cred-status');
2697
+
2698
+ // Reset state
2699
+ if (errorEl) { errorEl.textContent = ''; errorEl.style.color = ''; }
2700
+ statuses.forEach((el) => { el.textContent = ''; el.style.color = ''; });
2701
+
2702
+ const body = {
2703
+ supabaseUrl: (form.supabaseUrl.value || '').trim(),
2704
+ supabaseServiceRoleKey: (form.supabaseServiceRoleKey.value || '').trim(),
2705
+ openaiApiKey: (form.openaiApiKey.value || '').trim(),
2706
+ anthropicApiKey: (form.anthropicApiKey.value || '').trim(),
2707
+ databaseUrl: (form.databaseUrl.value || '').trim()
2708
+ };
2709
+
2710
+ const missing = [];
2711
+ if (!body.supabaseUrl) missing.push('Supabase URL');
2712
+ if (!body.supabaseServiceRoleKey) missing.push('Service Role Key');
2713
+ if (!body.openaiApiKey) missing.push('OpenAI API Key');
2714
+ if (!body.databaseUrl) missing.push('Database URL');
2715
+ if (missing.length) {
2716
+ if (errorEl) {
2717
+ errorEl.textContent = `Please fill in: ${missing.join(', ')} (Anthropic is optional).`;
2718
+ errorEl.style.color = 'var(--red, #f7768e)';
2719
+ }
2720
+ return;
2721
+ }
2722
+
2723
+ if (btn) { btn.disabled = true; btn.textContent = 'Validating…'; }
2724
+ try {
2725
+ const res = await fetch(`${API}/api/setup/configure`, {
2726
+ method: 'POST',
2727
+ headers: { 'Content-Type': 'application/json' },
2728
+ body: JSON.stringify(body)
2729
+ });
2730
+ let data = {};
2731
+ try { data = await res.json(); } catch { data = {}; }
2732
+
2733
+ if (data && data.validation) {
2734
+ for (const field of ['supabase', 'openai', 'database']) {
2735
+ const result = data.validation[field];
2736
+ if (!result) continue;
2737
+ const el = form.querySelector(`.setup-cred-status[data-field="${field}"]`);
2738
+ if (el) {
2739
+ el.textContent = (result.ok ? '✓ ' : '✗ ') + (result.detail || '');
2740
+ el.style.color = result.ok ? 'var(--green, #9ece6a)' : 'var(--red, #f7768e)';
2741
+ }
2742
+ }
2743
+ }
2744
+
2745
+ if (res.ok && data && data.success) {
2746
+ if (errorEl) {
2747
+ errorEl.textContent = (data.detail || 'Credentials saved.') + ' Running migrations…';
2748
+ errorEl.style.color = 'var(--green, #9ece6a)';
2749
+ }
2750
+ if (btn) btn.textContent = 'Migrating…';
2751
+ await runSetupMigrations(errorEl);
2752
+ setTimeout(() => { refreshSetupStatus(); }, 600);
2753
+ } else {
2754
+ if (errorEl) {
2755
+ errorEl.textContent = (data && data.error) || `Configuration failed (HTTP ${res.status})`;
2756
+ errorEl.style.color = 'var(--red, #f7768e)';
2757
+ }
2758
+ }
2759
+ } catch (err) {
2760
+ if (errorEl) {
2761
+ errorEl.textContent = `Request failed: ${err && err.message ? err.message : String(err)}`;
2762
+ errorEl.style.color = 'var(--red, #f7768e)';
2763
+ }
2764
+ } finally {
2765
+ if (btn) { btn.disabled = false; btn.textContent = 'Save & Connect'; }
2766
+ }
2767
+ }
2768
+
2769
+ // Sprint 23 audit fix: chain /api/setup/migrate after credentials save so
2770
+ // the wizard fulfills the sprint mission ("write config AND run migrations")
2771
+ // in a single click instead of leaving the user to run `termdeck init`.
2772
+ async function runSetupMigrations(statusEl) {
2773
+ try {
2774
+ const res = await fetch(`${API}/api/setup/migrate`, {
2775
+ method: 'POST',
2776
+ headers: { 'Content-Type': 'application/json' },
2777
+ body: '{}'
2778
+ });
2779
+ let data = {};
2780
+ try { data = await res.json(); } catch { data = {}; }
2781
+ if (statusEl) {
2782
+ if (res.ok && data && data.ok) {
2783
+ statusEl.textContent = `Migrations applied (${data.applied}/${data.total}). Tier 2 active.`;
2784
+ statusEl.style.color = 'var(--green, #9ece6a)';
2785
+ } else {
2786
+ const applied = (data && data.applied != null) ? `${data.applied}/${data.total} applied` : '';
2787
+ const detail = (data && data.error) ? data.error : `HTTP ${res.status}`;
2788
+ statusEl.textContent = `Migrations failed: ${detail}${applied ? ` (${applied})` : ''}`;
2789
+ statusEl.style.color = 'var(--red, #f7768e)';
2790
+ }
2791
+ }
2792
+ } catch (err) {
2793
+ if (statusEl) {
2794
+ statusEl.textContent = `Migration request failed: ${err && err.message ? err.message : String(err)}`;
2795
+ statusEl.style.color = 'var(--red, #f7768e)';
2796
+ }
2797
+ }
2603
2798
  }
2604
2799
 
2605
2800
  async function maybeAutoOpenSetupWizard() {
2606
- // Auto-open only when the server reports firstRun=true and we're not
2607
- // already in the middle of the onboarding tour. Silent-fail if the
2608
- // endpoint doesn't exist (server predates Sprint 19 T1).
2801
+ // First-run users get the full wizard; returning users with at least
2802
+ // tier 1 configured get a brief welcome-back toast (Sprint 23 T4).
2803
+ // Silent-fail if the endpoint doesn't exist (server predates Sprint 19 T1).
2609
2804
  try {
2610
2805
  const res = await fetch(`${API}/api/setup`);
2611
2806
  if (!res.ok) return;
2612
2807
  const data = await res.json();
2613
- if (data && data.firstRun && !tourState.active) {
2808
+ if (!data || tourState.active) return;
2809
+ if (data.firstRun) {
2614
2810
  setTimeout(() => {
2615
2811
  if (!tourState.active && !setupModalOpen) openSetupModal();
2616
2812
  }, 800);
2813
+ } else if (Number(data.tier) >= 1) {
2814
+ setTimeout(() => {
2815
+ if (!tourState.active && !setupModalOpen) showWelcomeBackToast(data);
2816
+ }, 800);
2617
2817
  }
2618
2818
  } catch {
2619
2819
  // API not available — skip silently
2620
2820
  }
2621
2821
  }
2622
2822
 
2823
+ // Sprint 23 T4: returning-user welcome-back toast. Non-blocking, dismisses
2824
+ // on click or after 5s. Inline-styled so we don't touch style.css (T1's
2825
+ // ownership). The config button still opens the full wizard.
2826
+ function showWelcomeBackToast(data) {
2827
+ const existing = document.getElementById('welcomeBackToast');
2828
+ if (existing) existing.remove();
2829
+
2830
+ const tier = Number(data.tier) || 1;
2831
+ const tierNames = {
2832
+ 1: 'TermDeck core',
2833
+ 2: 'TermDeck + Mnestra',
2834
+ 3: 'TermDeck + Mnestra + Rumen',
2835
+ 4: 'Full stack + projects'
2836
+ };
2837
+ const stackLabel = tierNames[tier] || 'TermDeck';
2838
+
2839
+ const tiers = data.tiers || {};
2840
+ const mnestraDetail = (tiers[2] && tiers[2].detail) || '';
2841
+ const rumenDetail = (tiers[3] && tiers[3].detail) || '';
2842
+
2843
+ const memMatch = mnestraDetail.match(/([\d,]+)\s*memories/i);
2844
+ const memoryCount = memMatch ? memMatch[1] : null;
2845
+ const rumenMatch = rumenDetail.match(/last job\s+([^,]+?)(?:\s+ago|,|$)/i);
2846
+ const rumenAgo = rumenMatch ? rumenMatch[1].trim() : null;
2847
+
2848
+ const parts = [`Stack: ${stackLabel}.`];
2849
+ if (memoryCount) parts.push(`${memoryCount} memories.`);
2850
+ if (rumenAgo) parts.push(`Last Rumen job: ${rumenAgo} ago.`);
2851
+
2852
+ const toast = document.createElement('div');
2853
+ toast.id = 'welcomeBackToast';
2854
+ toast.setAttribute('role', 'status');
2855
+ toast.style.cssText = [
2856
+ 'position:fixed',
2857
+ 'top:64px',
2858
+ 'right:16px',
2859
+ 'z-index:9999',
2860
+ 'max-width:360px',
2861
+ 'padding:10px 14px',
2862
+ 'background:rgba(20,22,28,0.95)',
2863
+ 'color:#cdd6f4',
2864
+ 'border:1px solid rgba(137,180,250,0.35)',
2865
+ 'border-radius:6px',
2866
+ 'box-shadow:0 4px 18px rgba(0,0,0,0.4)',
2867
+ 'font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace',
2868
+ 'cursor:pointer',
2869
+ 'opacity:0',
2870
+ 'transition:opacity 180ms ease'
2871
+ ].join(';');
2872
+ toast.innerHTML = `
2873
+ <div style="font-weight:600;color:#89b4fa;margin-bottom:2px">Welcome back</div>
2874
+ <div>${parts.map(p => escapeHtml(p)).join(' ')}</div>
2875
+ `;
2876
+
2877
+ document.body.appendChild(toast);
2878
+ requestAnimationFrame(() => { toast.style.opacity = '1'; });
2879
+
2880
+ const dismiss = () => {
2881
+ clearTimeout(timer);
2882
+ toast.style.opacity = '0';
2883
+ setTimeout(() => toast.remove(), 200);
2884
+ };
2885
+ toast.addEventListener('click', dismiss);
2886
+ const timer = setTimeout(dismiss, 5000);
2887
+ }
2888
+
2623
2889
  function fmtUptime(sec) {
2624
2890
  const s = Math.floor(sec);
2625
2891
  const h = Math.floor(s / 3600);
@@ -2675,10 +2941,8 @@
2675
2941
  if (e.target.id === 'tourBackdrop') endTour();
2676
2942
  });
2677
2943
 
2678
- // Resize handler
2679
- window.addEventListener('resize', () => {
2680
- requestAnimationFrame(() => fitAll());
2681
- });
2944
+ // Resize handler — debounced so a resize drag doesn't re-fit every frame.
2945
+ window.addEventListener('resize', fitAllDebounced);
2682
2946
 
2683
2947
  // Re-render the tour on viewport changes so the spotlight tracks resizes
2684
2948
  window.addEventListener('resize', () => {
@@ -41,7 +41,7 @@
41
41
  <button class="layout-btn" data-layout="3x2">3x2</button>
42
42
  <button class="layout-btn" data-layout="2x4">2x4</button>
43
43
  <button class="layout-btn" data-layout="4x2">4x2</button>
44
- <button class="layout-btn" data-layout="orch" title="Orchestrator: 1 large left + stacked right">orch</button>
44
+ <button class="layout-btn" data-layout="orch" title="Orchestrator: 4 workers across top, 1 full-width orchestrator across bottom">orch</button>
45
45
  <button class="layout-btn control-btn" data-layout="control" title="Aggregate activity feed">control</button>
46
46
  </div>
47
47
  </div>
@@ -312,14 +312,25 @@
312
312
  .grid-container.layout-4x2 { grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
313
313
  .grid-container.layout-2x4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr 1fr 1fr; }
314
314
 
315
- /* Orchestrator: 1 large left panel (60%), remaining stack on the right (40%). */
315
+ /* Orchestrator: workers across the top (60%), one full-width orchestrator
316
+ panel across the bottom (40%). The last panel is always the orchestrator.
317
+ JS sets a data-orch-cols attribute on the grid to match the worker count. */
318
+ /* Orchestrator: 2x2 workers on top (60%), full-width orchestrator bottom (40%) */
316
319
  .grid-container.layout-orch {
317
- grid-template-columns: 3fr 2fr;
318
- grid-template-rows: repeat(4, 1fr);
320
+ grid-template-columns: repeat(2, 1fr);
321
+ grid-template-rows: 2fr 2fr 2fr;
319
322
  }
320
- .grid-container.layout-orch .term-panel:first-child {
321
- grid-row: 1 / span 4;
322
- grid-column: 1;
323
+ .grid-container.layout-orch .term-panel:last-child {
324
+ grid-column: 1 / -1;
325
+ grid-row: 3;
326
+ }
327
+ /* Single panel: fill entire grid */
328
+ .grid-container.layout-orch[data-orch-cols="0"] {
329
+ grid-template-rows: 1fr;
330
+ grid-template-columns: 1fr;
331
+ }
332
+ .grid-container.layout-orch[data-orch-cols="0"] .term-panel:last-child {
333
+ grid-row: 1;
323
334
  }
324
335
 
325
336
  /* Focus mode: single terminal fills the grid */
@@ -344,7 +355,8 @@
344
355
  border-radius: var(--tg-radius);
345
356
  overflow: hidden;
346
357
  transition: border-color 0.2s;
347
- min-height: 0;
358
+ min-height: 150px;
359
+ min-width: 200px;
348
360
  }
349
361
 
350
362
  .term-panel:hover { border-color: var(--tg-border-active); }
@@ -2278,3 +2290,57 @@
2278
2290
  ::-webkit-scrollbar-track { background: transparent; }
2279
2291
  ::-webkit-scrollbar-thumb { background: var(--tg-border); border-radius: 3px; }
2280
2292
  ::-webkit-scrollbar-thumb:hover { background: var(--tg-border-active); }
2293
+
2294
+ /* ===== RESPONSIVE BREAKPOINTS (Sprint 23 T1) =====
2295
+ 13" MacBook Air (1280x800 / 1440x900) up through 27" iMac (2560x1440).
2296
+ Goals: (1) compact toolbar on short viewports to reclaim vertical space
2297
+ for terminals, (2) collapse wide grids (3x2, 4x2) to 2 columns on
2298
+ narrow viewports so panels never drop below readable size (combined
2299
+ with the 200px min-width on .term-panel). */
2300
+
2301
+ /* Short viewports (13" laptops at 1280x800 / 1440x900): shrink the two
2302
+ toolbar rows from 74px to 64px total, and tighten button padding so
2303
+ the quick-launch + layout buttons still fit on one row at 1280px. */
2304
+ @media (max-height: 800px) {
2305
+ .topbar-row-1 { height: 36px; }
2306
+ .topbar-row-2 { height: 28px; }
2307
+ .layout-btn { padding: 3px 7px; font-size: 10px; }
2308
+ .topbar-right button { padding: 3px 6px; font-size: 10px; }
2309
+ .topbar-ql-btn { padding: 3px 6px; font-size: 10px; }
2310
+ }
2311
+
2312
+ /* Narrow viewports: 3x2 (6 panels) and 4x2 (8 panels) become
2313
+ too cramped below ~1280px given the 200px panel min-width. Degrade to
2314
+ 2 columns and add rows so every panel stays visible. */
2315
+ @media (max-width: 1280px) {
2316
+ .grid-container.layout-3x2 {
2317
+ grid-template-columns: repeat(2, 1fr);
2318
+ grid-template-rows: repeat(3, 1fr);
2319
+ }
2320
+ .grid-container.layout-4x2 {
2321
+ grid-template-columns: repeat(2, 1fr);
2322
+ grid-template-rows: repeat(4, 1fr);
2323
+ }
2324
+ }
2325
+
2326
+ /* Very narrow viewports (rare — sub-1024 widths): also collapse 2x4
2327
+ and orch to something sane. 2x4 already uses 2 columns, so we just
2328
+ let it scroll; orch falls back to a single-column stack. */
2329
+ @media (max-width: 900px) {
2330
+ .grid-container.layout-orch {
2331
+ grid-template-columns: 1fr;
2332
+ grid-template-rows: repeat(var(--orch-worker-rows, 3), 1fr) 1.2fr;
2333
+ }
2334
+ .grid-container.layout-orch .term-panel:last-child {
2335
+ grid-column: 1;
2336
+ }
2337
+ .grid-container { overflow: auto; }
2338
+ }
2339
+
2340
+ /* Large viewports (27" iMac and ultrawide monitors): keep panels filling
2341
+ their allocated grid area. Raise the panel floor so a single terminal
2342
+ in 1x1 mode on a 2560-wide display still fills its space rather than
2343
+ sitting at the 150px min. */
2344
+ @media (min-width: 1920px) {
2345
+ .term-panel { min-height: 200px; min-width: 300px; }
2346
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  const express = require('express');
5
5
  const http = require('http');
6
+ const https = require('https');
6
7
  const { WebSocketServer } = require('ws');
7
8
  const path = require('path');
8
9
  const os = require('os');
@@ -252,6 +253,171 @@ function createServer(config) {
252
253
  }
253
254
  });
254
255
 
256
+ // POST /api/setup/configure - Sprint 23 T2
257
+ // Accepts pasted credentials from the browser wizard, validates each,
258
+ // then writes ~/.termdeck/secrets.env (chmod 600) and updates
259
+ // ~/.termdeck/config.yaml with rag.enabled: true plus ${VAR} references.
260
+ // Security: the bind guardrail refuses non-loopback binds without auth,
261
+ // so this endpoint only ever responds on 127.0.0.1 in the default config.
262
+ app.post('/api/setup/configure', async (req, res) => {
263
+ const b = req.body || {};
264
+ const supabaseUrl = typeof b.supabaseUrl === 'string' ? b.supabaseUrl.trim() : '';
265
+ const supabaseServiceRoleKey = typeof b.supabaseServiceRoleKey === 'string' ? b.supabaseServiceRoleKey.trim() : '';
266
+ const openaiApiKey = typeof b.openaiApiKey === 'string' ? b.openaiApiKey.trim() : '';
267
+ const anthropicApiKey = typeof b.anthropicApiKey === 'string' ? b.anthropicApiKey.trim() : '';
268
+ const databaseUrl = typeof b.databaseUrl === 'string' ? b.databaseUrl.trim() : '';
269
+
270
+ const missing = [];
271
+ if (!supabaseUrl) missing.push('supabaseUrl');
272
+ if (!supabaseServiceRoleKey) missing.push('supabaseServiceRoleKey');
273
+ if (!openaiApiKey) missing.push('openaiApiKey');
274
+ if (!databaseUrl) missing.push('databaseUrl');
275
+ if (missing.length) {
276
+ return res.status(400).json({
277
+ success: false,
278
+ error: `Missing required credentials: ${missing.join(', ')}`
279
+ });
280
+ }
281
+
282
+ if (!/^https?:\/\//i.test(supabaseUrl)) {
283
+ return res.status(400).json({
284
+ success: false,
285
+ error: 'supabaseUrl must start with http:// or https://'
286
+ });
287
+ }
288
+
289
+ const [supaRes, oaiRes, dbRes] = await Promise.all([
290
+ validateSupabase(supabaseUrl, supabaseServiceRoleKey).catch((e) => ({ ok: false, detail: e.message })),
291
+ validateOpenAI(openaiApiKey).catch((e) => ({ ok: false, detail: e.message })),
292
+ validateDatabase(databaseUrl).catch((e) => ({ ok: false, detail: e.message }))
293
+ ]);
294
+ const validation = { supabase: supaRes, openai: oaiRes, database: dbRes };
295
+
296
+ const allValid = validation.supabase.ok && validation.openai.ok && validation.database.ok;
297
+ if (!allValid) {
298
+ return res.status(400).json({
299
+ success: false,
300
+ validation,
301
+ error: 'One or more credentials failed validation'
302
+ });
303
+ }
304
+
305
+ try {
306
+ if (!fs.existsSync(SETUP_CONFIG_DIR)) {
307
+ fs.mkdirSync(SETUP_CONFIG_DIR, { recursive: true });
308
+ }
309
+
310
+ const secretsBody = buildSecretsEnv({
311
+ SUPABASE_URL: supabaseUrl,
312
+ SUPABASE_SERVICE_ROLE_KEY: supabaseServiceRoleKey,
313
+ OPENAI_API_KEY: openaiApiKey,
314
+ ANTHROPIC_API_KEY: anthropicApiKey,
315
+ DATABASE_URL: databaseUrl
316
+ });
317
+ const tmpPath = SETUP_SECRETS_PATH + '.tmp';
318
+ fs.writeFileSync(tmpPath, secretsBody, { mode: 0o600 });
319
+ fs.renameSync(tmpPath, SETUP_SECRETS_PATH);
320
+ try { fs.chmodSync(SETUP_SECRETS_PATH, 0o600); } catch (err) {
321
+ console.warn('[setup] chmod 600 on secrets.env failed:', err.message);
322
+ }
323
+
324
+ process.env.SUPABASE_URL = supabaseUrl;
325
+ process.env.SUPABASE_SERVICE_ROLE_KEY = supabaseServiceRoleKey;
326
+ process.env.OPENAI_API_KEY = openaiApiKey;
327
+ if (anthropicApiKey) process.env.ANTHROPIC_API_KEY = anthropicApiKey;
328
+ process.env.DATABASE_URL = databaseUrl;
329
+
330
+ updateConfigYamlForRag(config);
331
+
332
+ _setupCache = null;
333
+ _setupCachedAt = 0;
334
+
335
+ console.log('[setup] Credentials saved, RAG enabled via wizard');
336
+
337
+ return res.json({
338
+ success: true,
339
+ tier: 2,
340
+ detail: 'Secrets saved, RAG enabled',
341
+ validation
342
+ });
343
+ } catch (err) {
344
+ console.error('[setup] /api/setup/configure write failed:', err.message);
345
+ return res.status(500).json({
346
+ success: false,
347
+ validation,
348
+ error: `Failed to write config: ${err.message}`
349
+ });
350
+ }
351
+ });
352
+
353
+ // POST /api/setup/migrate - auto-run all 7 bootstrap migrations (Sprint 23 T3)
354
+ // Invoked by the browser setup wizard after credentials are saved. Reloads
355
+ // ~/.termdeck/secrets.env so DATABASE_URL picks up T2's just-written value
356
+ // without a server restart, then streams per-migration status to the server
357
+ // log and returns an aggregate result to the client. Idempotent — all seven
358
+ // migration files (6 Mnestra + 1 transcript) are authored with IF NOT EXISTS
359
+ // / CREATE OR REPLACE so re-runs are safe.
360
+ const { migrationRunner: _migrationRunner, dotenv: _dotenv } = require('./setup');
361
+ let _migrateInFlight = false;
362
+ app.post('/api/setup/migrate', async (req, res) => {
363
+ if (_migrateInFlight) {
364
+ return res.status(409).json({ ok: false, error: 'Migration already in progress' });
365
+ }
366
+ _migrateInFlight = true;
367
+
368
+ // Invalidate the /api/setup cache — tier status will shift once migrations land.
369
+ _setupCache = null;
370
+ _setupCachedAt = 0;
371
+
372
+ try {
373
+ // Re-read secrets.env so a freshly saved DATABASE_URL is visible without
374
+ // a restart. dotenv-io will not clobber pre-set process.env entries.
375
+ try {
376
+ const secrets = _dotenv.readSecrets();
377
+ for (const [k, v] of Object.entries(secrets)) {
378
+ if (process.env[k] === undefined || process.env[k] === '') {
379
+ process.env[k] = v;
380
+ }
381
+ }
382
+ } catch (_err) { /* optional refresh — fall back to explicit lookup */ }
383
+
384
+ const databaseUrl = _migrationRunner.resolveDatabaseUrl(req.body && req.body.databaseUrl);
385
+ if (!databaseUrl) {
386
+ _migrateInFlight = false;
387
+ return res.status(400).json({
388
+ ok: false,
389
+ error: 'DATABASE_URL not set. Save credentials in the setup wizard first.'
390
+ });
391
+ }
392
+
393
+ const total = _migrationRunner.listAllMigrations().length;
394
+ console.log(`[setup] /api/setup/migrate starting (${total} migrations)`);
395
+
396
+ const events = [];
397
+ const result = await _migrationRunner.runAll({
398
+ databaseUrl,
399
+ onProgress: (event) => {
400
+ events.push(event);
401
+ if (event.type === 'step' && event.status === 'running') {
402
+ console.log(`[setup] Migration ${event.index}/${event.total}: ${event.file}...`);
403
+ } else if (event.type === 'step' && event.status === 'done') {
404
+ console.log(`[setup] Migration ${event.index}/${event.total}: ${event.file} ✓ (${event.elapsedMs}ms)`);
405
+ } else if (event.type === 'step' && event.status === 'failed') {
406
+ console.error(`[setup] Migration ${event.index}/${event.total}: ${event.file} ✗ ${event.error}`);
407
+ }
408
+ }
409
+ });
410
+
411
+ console.log(`[setup] Migrations ${result.ok ? 'complete' : 'halted'} (${result.applied}/${result.total} applied)`);
412
+ res.json({ ok: result.ok, ...result, events });
413
+ } catch (err) {
414
+ console.error('[setup] /api/setup/migrate failed:', err.message);
415
+ res.status(500).json({ ok: false, error: err.message, code: err.code || null });
416
+ } finally {
417
+ _migrateInFlight = false;
418
+ }
419
+ });
420
+
255
421
  // GET /api/sessions - list all active sessions
256
422
  app.get('/api/sessions', (req, res) => {
257
423
  res.json(sessions.getAll());
@@ -973,6 +1139,193 @@ function createServer(config) {
973
1139
  return { app, server, wss, sessions, rag, db, transcriptWriter };
974
1140
  }
975
1141
 
1142
+ // ==================== Setup-configure helpers (Sprint 23 T2) ====================
1143
+ // Scoped to module level so they can be unit tested without spinning the server.
1144
+ // Each validator resolves to { ok: boolean, detail: string } — never throws.
1145
+
1146
+ function validateSupabase(url, key) {
1147
+ return new Promise((resolve) => {
1148
+ let parsed;
1149
+ try {
1150
+ parsed = new URL(url);
1151
+ } catch (err) {
1152
+ return resolve({ ok: false, detail: `invalid URL: ${err.message}` });
1153
+ }
1154
+ const client = parsed.protocol === 'http:' ? http : https;
1155
+ const probePath = '/rest/v1/';
1156
+ const req = client.request({
1157
+ protocol: parsed.protocol,
1158
+ hostname: parsed.hostname,
1159
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
1160
+ path: probePath,
1161
+ method: 'GET',
1162
+ headers: {
1163
+ apikey: key,
1164
+ Authorization: `Bearer ${key}`
1165
+ },
1166
+ timeout: 8000
1167
+ }, (r) => {
1168
+ let body = '';
1169
+ r.on('data', (c) => { body += c; });
1170
+ r.on('end', () => {
1171
+ // 200 = PostgREST OpenAPI doc served, 404 = URL reachable but no doc —
1172
+ // both indicate the host + key passed the edge auth check.
1173
+ if (r.statusCode === 200 || r.statusCode === 404) {
1174
+ resolve({ ok: true, detail: `Supabase reachable (HTTP ${r.statusCode})` });
1175
+ } else if (r.statusCode === 401 || r.statusCode === 403) {
1176
+ resolve({ ok: false, detail: `Authentication failed (HTTP ${r.statusCode}) — check service role key` });
1177
+ } else {
1178
+ resolve({ ok: false, detail: `Unexpected response HTTP ${r.statusCode}` });
1179
+ }
1180
+ });
1181
+ });
1182
+ req.on('error', (err) => resolve({ ok: false, detail: err.message }));
1183
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, detail: 'timeout after 8s' }); });
1184
+ req.end();
1185
+ });
1186
+ }
1187
+
1188
+ function validateOpenAI(key) {
1189
+ return new Promise((resolve) => {
1190
+ const payload = JSON.stringify({
1191
+ model: 'text-embedding-3-small',
1192
+ input: 'termdeck setup test'
1193
+ });
1194
+ const req = https.request({
1195
+ hostname: 'api.openai.com',
1196
+ port: 443,
1197
+ path: '/v1/embeddings',
1198
+ method: 'POST',
1199
+ headers: {
1200
+ 'Content-Type': 'application/json',
1201
+ 'Authorization': `Bearer ${key}`,
1202
+ 'Content-Length': Buffer.byteLength(payload)
1203
+ },
1204
+ timeout: 10000
1205
+ }, (r) => {
1206
+ let body = '';
1207
+ r.on('data', (c) => { body += c; });
1208
+ r.on('end', () => {
1209
+ if (r.statusCode === 200) {
1210
+ resolve({ ok: true, detail: 'Embedding test succeeded' });
1211
+ return;
1212
+ }
1213
+ let msg = `HTTP ${r.statusCode}`;
1214
+ try {
1215
+ const parsed = JSON.parse(body);
1216
+ if (parsed && parsed.error && parsed.error.message) msg = parsed.error.message;
1217
+ } catch (_err) { /* ignore body parse */ }
1218
+ resolve({ ok: false, detail: msg });
1219
+ });
1220
+ });
1221
+ req.on('error', (err) => resolve({ ok: false, detail: err.message }));
1222
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, detail: 'timeout after 10s' }); });
1223
+ req.write(payload);
1224
+ req.end();
1225
+ });
1226
+ }
1227
+
1228
+ async function validateDatabase(connStr) {
1229
+ let pgMod;
1230
+ try { pgMod = require('pg'); } catch (err) { pgMod = null; }
1231
+ if (!pgMod) return { ok: false, detail: 'pg module not installed' };
1232
+
1233
+ const pool = new pgMod.Pool({
1234
+ connectionString: connStr,
1235
+ max: 1,
1236
+ connectionTimeoutMillis: 6000
1237
+ });
1238
+ try {
1239
+ const t0 = Date.now();
1240
+ const r = await pool.query('SELECT 1 AS ok');
1241
+ const ms = Date.now() - t0;
1242
+ if (r.rows[0] && r.rows[0].ok === 1) {
1243
+ return { ok: true, detail: `connected in ${ms}ms` };
1244
+ }
1245
+ return { ok: false, detail: 'unexpected query result' };
1246
+ } catch (err) {
1247
+ return { ok: false, detail: err.message };
1248
+ } finally {
1249
+ await pool.end().catch(() => {});
1250
+ }
1251
+ }
1252
+
1253
+ function buildSecretsEnv(vars) {
1254
+ const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
1255
+ const existing = {};
1256
+ if (fs.existsSync(secretsPath)) {
1257
+ try {
1258
+ const raw = fs.readFileSync(secretsPath, 'utf-8');
1259
+ for (const line of raw.split(/\r?\n/)) {
1260
+ const trimmed = line.trim();
1261
+ if (!trimmed || trimmed.startsWith('#')) continue;
1262
+ const eq = trimmed.indexOf('=');
1263
+ if (eq === -1) continue;
1264
+ const k = trimmed.slice(0, eq).trim();
1265
+ if (!k) continue;
1266
+ let v = trimmed.slice(eq + 1).trim();
1267
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
1268
+ v = v.slice(1, -1);
1269
+ }
1270
+ existing[k] = v;
1271
+ }
1272
+ } catch (err) {
1273
+ console.warn('[setup] Could not parse existing secrets.env:', err.message);
1274
+ }
1275
+ }
1276
+ const merged = { ...existing };
1277
+ for (const [k, v] of Object.entries(vars)) {
1278
+ if (v != null && v !== '') merged[k] = v;
1279
+ }
1280
+ const lines = [
1281
+ '# TermDeck secrets — written by setup wizard',
1282
+ '# Do not commit this file.',
1283
+ ''
1284
+ ];
1285
+ for (const [k, v] of Object.entries(merged)) {
1286
+ const needsQuote = /[\s#"']/.test(v);
1287
+ lines.push(needsQuote ? `${k}="${String(v).replace(/"/g, '\\"')}"` : `${k}=${v}`);
1288
+ }
1289
+ return lines.join('\n') + '\n';
1290
+ }
1291
+
1292
+ function updateConfigYamlForRag(runningConfig) {
1293
+ const yaml = require('yaml');
1294
+ const configPath = path.join(os.homedir(), '.termdeck', 'config.yaml');
1295
+ let parsed = {};
1296
+ if (fs.existsSync(configPath)) {
1297
+ try {
1298
+ parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8')) || {};
1299
+ } catch (err) {
1300
+ console.warn('[setup] config.yaml parse failed, starting from empty:', err.message);
1301
+ parsed = {};
1302
+ }
1303
+ }
1304
+ parsed.rag = parsed.rag || {};
1305
+ parsed.rag.enabled = true;
1306
+ if (!parsed.rag.supabaseUrl) parsed.rag.supabaseUrl = '${SUPABASE_URL}';
1307
+ if (!parsed.rag.supabaseKey) parsed.rag.supabaseKey = '${SUPABASE_SERVICE_ROLE_KEY}';
1308
+ if (!parsed.rag.openaiApiKey) parsed.rag.openaiApiKey = '${OPENAI_API_KEY}';
1309
+ if (!parsed.rag.anthropicApiKey) parsed.rag.anthropicApiKey = '${ANTHROPIC_API_KEY}';
1310
+
1311
+ if (fs.existsSync(configPath)) {
1312
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1313
+ try { fs.copyFileSync(configPath, `${configPath}.${ts}.bak`); } catch (err) {
1314
+ console.warn('[setup] config.yaml backup failed:', err.message);
1315
+ }
1316
+ }
1317
+ fs.writeFileSync(configPath, yaml.stringify(parsed), 'utf-8');
1318
+
1319
+ if (runningConfig) {
1320
+ runningConfig.rag = runningConfig.rag || {};
1321
+ runningConfig.rag.enabled = true;
1322
+ runningConfig.rag.supabaseUrl = process.env.SUPABASE_URL;
1323
+ runningConfig.rag.supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
1324
+ runningConfig.rag.openaiApiKey = process.env.OPENAI_API_KEY;
1325
+ if (process.env.ANTHROPIC_API_KEY) runningConfig.rag.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
1326
+ }
1327
+ }
1328
+
976
1329
  // Start server
977
1330
  if (require.main === module) {
978
1331
  // Minimal flag parsing for direct-invocation users (the CLI wrapper has its own).
@@ -10,5 +10,6 @@ module.exports = {
10
10
  yaml: require('./yaml-io'),
11
11
  supabaseUrl: require('./supabase-url'),
12
12
  migrations: require('./migrations'),
13
- pgRunner: require('./pg-runner')
13
+ pgRunner: require('./pg-runner'),
14
+ migrationRunner: require('./migration-runner')
14
15
  };
@@ -0,0 +1,136 @@
1
+ // Unified migration runner for the setup wizard and `termdeck init --mnestra`.
2
+ //
3
+ // Applies the full 7-migration bootstrap sequence in order:
4
+ // 1-6. Mnestra schema + RPCs (bundled under ./mnestra-migrations)
5
+ // 7. termdeck_transcripts table (repo root: config/transcript-migration.sql)
6
+ //
7
+ // Every migration file is authored with IF NOT EXISTS / CREATE OR REPLACE so
8
+ // re-running the sequence is a no-op on an already-configured database.
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const dotenv = require('./dotenv-io');
14
+ const migrations = require('./migrations');
15
+ const pgRunner = require('./pg-runner');
16
+
17
+ const TRANSCRIPT_MIGRATION = path.resolve(
18
+ __dirname, '..', '..', '..', '..', 'config', 'transcript-migration.sql'
19
+ );
20
+
21
+ // Build the ordered list of absolute migration file paths. The transcript
22
+ // migration lives outside the Mnestra bundle so we tack it on at the end.
23
+ function listAllMigrations() {
24
+ const mnestra = migrations.listMnestraMigrations();
25
+ const files = mnestra.slice();
26
+ if (fs.existsSync(TRANSCRIPT_MIGRATION)) {
27
+ files.push(TRANSCRIPT_MIGRATION);
28
+ }
29
+ return files;
30
+ }
31
+
32
+ // Resolve DATABASE_URL from (in order) an explicit argument, process.env, or
33
+ // a freshly-loaded ~/.termdeck/secrets.env. The wizard path needs the third
34
+ // branch because it may have just written secrets.env without restarting the
35
+ // server, so process.env won't have picked up the new value.
36
+ function resolveDatabaseUrl(explicit) {
37
+ if (explicit) return explicit;
38
+ if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
39
+ const secrets = dotenv.readSecrets();
40
+ return secrets.DATABASE_URL || null;
41
+ }
42
+
43
+ // Run the full migration sequence. Options:
44
+ // - databaseUrl: override URL (otherwise resolved per the rules above)
45
+ // - onProgress: (event) => void, fires for 'start'|'step'|'done'|'error'
46
+ // Returns { ok, applied, failed, results } where `results` is one entry per
47
+ // migration file: { file, ok, elapsedMs, error? }.
48
+ async function runAll({ databaseUrl, onProgress } = {}) {
49
+ const url = resolveDatabaseUrl(databaseUrl);
50
+ if (!url) {
51
+ const err = new Error(
52
+ 'DATABASE_URL not set. Save credentials in the setup wizard (or set it in ~/.termdeck/secrets.env) and try again.'
53
+ );
54
+ err.code = 'NO_DATABASE_URL';
55
+ throw err;
56
+ }
57
+
58
+ const files = listAllMigrations();
59
+ if (files.length === 0) {
60
+ const err = new Error('No migration files found. TermDeck install looks corrupted.');
61
+ err.code = 'NO_MIGRATIONS';
62
+ throw err;
63
+ }
64
+
65
+ const emit = (event) => { if (typeof onProgress === 'function') onProgress(event); };
66
+
67
+ emit({ type: 'start', total: files.length });
68
+
69
+ let client;
70
+ try {
71
+ client = await pgRunner.connect(url);
72
+ } catch (err) {
73
+ emit({ type: 'error', phase: 'connect', message: err.message });
74
+ throw err;
75
+ }
76
+
77
+ const results = [];
78
+ let appliedCount = 0;
79
+ let failedCount = 0;
80
+
81
+ try {
82
+ for (let i = 0; i < files.length; i++) {
83
+ const file = files[i];
84
+ const base = path.basename(file);
85
+ const stepIdx = i + 1;
86
+ emit({ type: 'step', index: stepIdx, total: files.length, file: base, status: 'running' });
87
+
88
+ const result = await pgRunner.applyFile(client, file);
89
+ const entry = {
90
+ file: base,
91
+ ok: result.ok,
92
+ elapsedMs: result.elapsedMs,
93
+ ...(result.error ? { error: result.error } : {})
94
+ };
95
+ results.push(entry);
96
+
97
+ if (result.ok) {
98
+ appliedCount++;
99
+ emit({
100
+ type: 'step',
101
+ index: stepIdx,
102
+ total: files.length,
103
+ file: base,
104
+ status: 'done',
105
+ elapsedMs: result.elapsedMs
106
+ });
107
+ } else {
108
+ failedCount++;
109
+ emit({
110
+ type: 'step',
111
+ index: stepIdx,
112
+ total: files.length,
113
+ file: base,
114
+ status: 'failed',
115
+ error: result.error
116
+ });
117
+ // Stop on first failure — later migrations usually depend on earlier ones.
118
+ break;
119
+ }
120
+ }
121
+ } finally {
122
+ try { await client.end(); } catch (_err) { /* ignore */ }
123
+ }
124
+
125
+ const ok = failedCount === 0 && appliedCount === files.length;
126
+ emit({ type: 'done', ok, applied: appliedCount, failed: failedCount, total: files.length });
127
+
128
+ return { ok, applied: appliedCount, failed: failedCount, total: files.length, results };
129
+ }
130
+
131
+ module.exports = {
132
+ listAllMigrations,
133
+ resolveDatabaseUrl,
134
+ runAll,
135
+ TRANSCRIPT_MIGRATION
136
+ };