@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 +2 -2
- package/package.json +1 -1
- package/packages/client/public/app.js +282 -18
- package/packages/client/public/index.html +1 -1
- package/packages/client/public/style.css +73 -7
- package/packages/server/src/index.js +353 -0
- package/packages/server/src/setup/index.js +2 -1
- package/packages/server/src/setup/migration-runner.js +136 -0
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.
|
|
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.
|
|
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
|
+
"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> (
|
|
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
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
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 & 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
|
-
//
|
|
2607
|
-
//
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
318
|
-
grid-template-rows:
|
|
320
|
+
grid-template-columns: repeat(2, 1fr);
|
|
321
|
+
grid-template-rows: 2fr 2fr 2fr;
|
|
319
322
|
}
|
|
320
|
-
.grid-container.layout-orch .term-panel:
|
|
321
|
-
grid-
|
|
322
|
-
grid-
|
|
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:
|
|
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).
|
|
@@ -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
|
+
};
|