@jhizzard/termdeck 0.2.0 → 0.2.1

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.
Files changed (23) hide show
  1. package/README.md +191 -169
  2. package/package.json +7 -6
  3. package/packages/cli/src/index.js +39 -1
  4. package/packages/cli/src/init-engram.js +344 -0
  5. package/packages/cli/src/init-rumen.js +425 -0
  6. package/packages/client/public/index.html +2 -2
  7. package/packages/server/src/setup/dotenv-io.js +116 -0
  8. package/packages/server/src/setup/engram-migrations/001_engram_tables.sql +116 -0
  9. package/packages/server/src/setup/engram-migrations/002_engram_search_function.sql +141 -0
  10. package/packages/server/src/setup/engram-migrations/003_engram_event_webhook.sql +28 -0
  11. package/packages/server/src/setup/engram-migrations/004_engram_match_count_cap_and_explain.sql +176 -0
  12. package/packages/server/src/setup/engram-migrations/005_v0_1_to_v0_2_upgrade.sql +23 -0
  13. package/packages/server/src/setup/engram-migrations/006_memory_status_rpc.sql +58 -0
  14. package/packages/server/src/setup/index.js +14 -0
  15. package/packages/server/src/setup/migrations.js +80 -0
  16. package/packages/server/src/setup/pg-runner.js +113 -0
  17. package/packages/server/src/setup/prompts.js +177 -0
  18. package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +85 -0
  19. package/packages/server/src/setup/rumen/functions/rumen-tick/tsconfig.json +14 -0
  20. package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +91 -0
  21. package/packages/server/src/setup/rumen/migrations/002_pg_cron_schedule.sql +40 -0
  22. package/packages/server/src/setup/supabase-url.js +114 -0
  23. package/packages/server/src/setup/yaml-io.js +99 -0
@@ -0,0 +1,177 @@
1
+ // Readline-based interactive prompts for the `termdeck init` wizards.
2
+ //
3
+ // No external deps. All prompts in a single wizard run share ONE readline
4
+ // interface (via the module-scoped `rl` below). We consume lines via the
5
+ // `line` event and an internal FIFO of pending resolvers instead of
6
+ // `rl.question()` — the latter has broken behavior when stdin is a piped
7
+ // stream in non-terminal mode, where the second `.question()` call silently
8
+ // hangs. The event-driven path works for both TTY and piped input, which lets
9
+ // us drive the wizard non-interactively in tests:
10
+ //
11
+ // printf 'a\nb\nc\n' | termdeck init --engram --dry-run
12
+ //
13
+ // Secret prompts still mute stdout echo when TTY is attached; on non-TTY
14
+ // stdin they fall back to visible input so piped test runs work.
15
+
16
+ const readline = require('readline');
17
+
18
+ let rl = null;
19
+ const waiting = []; // pending resolver functions
20
+ const buffered = []; // lines that arrived before anyone was waiting
21
+ let sawEnd = false;
22
+
23
+ function getRl() {
24
+ if (rl) return rl;
25
+ rl = readline.createInterface({
26
+ input: process.stdin,
27
+ output: process.stdout,
28
+ terminal: false
29
+ });
30
+ rl.on('line', (line) => {
31
+ if (waiting.length > 0) {
32
+ const resolver = waiting.shift();
33
+ resolver(line);
34
+ } else {
35
+ buffered.push(line);
36
+ }
37
+ });
38
+ rl.on('close', () => {
39
+ sawEnd = true;
40
+ while (waiting.length > 0) {
41
+ const resolver = waiting.shift();
42
+ resolver('');
43
+ }
44
+ });
45
+ return rl;
46
+ }
47
+
48
+ function readLine() {
49
+ return new Promise((resolve) => {
50
+ getRl();
51
+ if (buffered.length > 0) {
52
+ resolve(buffered.shift());
53
+ return;
54
+ }
55
+ if (sawEnd) { resolve(''); return; }
56
+ waiting.push(resolve);
57
+ });
58
+ }
59
+
60
+ // Basic non-secret prompt. Returns empty string if the user just hits enter.
61
+ async function ask(question, { defaultValue } = {}) {
62
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
63
+ if (question) process.stdout.write(`${question}${suffix}: `);
64
+ const line = await readLine();
65
+ const trimmed = (line || '').trim();
66
+ return trimmed || defaultValue || '';
67
+ }
68
+
69
+ // Required prompt — re-asks up to `maxAttempts` times if the answer is empty
70
+ // or fails `validate(value) → null | string`. A validator returning a string
71
+ // message means "invalid, re-ask with this error". Throws if all attempts fail.
72
+ async function askRequired(question, { validate, maxAttempts = 3 } = {}) {
73
+ for (let i = 0; i < maxAttempts; i++) {
74
+ const answer = await ask(question);
75
+ if (!answer) {
76
+ process.stdout.write(' (required)\n');
77
+ continue;
78
+ }
79
+ if (validate) {
80
+ const err = validate(answer);
81
+ if (err) {
82
+ process.stdout.write(` ${err}\n`);
83
+ continue;
84
+ }
85
+ }
86
+ return answer;
87
+ }
88
+ throw new Error(`No valid answer after ${maxAttempts} attempts: ${question}`);
89
+ }
90
+
91
+ // Optional prompt. Empty → null. Still validates non-empty answers.
92
+ async function askOptional(question, { validate } = {}) {
93
+ const answer = await ask(question);
94
+ if (!answer) return null;
95
+ if (validate) {
96
+ const err = validate(answer);
97
+ if (err) {
98
+ process.stdout.write(` ${err} — ignoring.\n`);
99
+ return null;
100
+ }
101
+ }
102
+ return answer;
103
+ }
104
+
105
+ // Secret prompt. On TTY we mute echo; on non-TTY we fall back to a visible
106
+ // line read. Callers typically pass an empty string for `question` and write
107
+ // their own label first.
108
+ async function askSecret(question) {
109
+ if (!process.stdin.isTTY) {
110
+ return ask(question);
111
+ }
112
+ if (question) process.stdout.write(`${question}: `);
113
+ // Raw-mode reader. Detach the shared readline for the duration so both
114
+ // consumers aren't racing on stdin 'data' events.
115
+ return new Promise((resolve) => {
116
+ if (rl) rl.pause();
117
+ const stdin = process.stdin;
118
+ stdin.setRawMode(true);
119
+ stdin.resume();
120
+ stdin.setEncoding('utf-8');
121
+
122
+ let buffer = '';
123
+ const onData = (chunk) => {
124
+ for (const ch of chunk) {
125
+ if (ch === '\n' || ch === '\r' || ch === '\u0004') {
126
+ stdin.setRawMode(false);
127
+ stdin.removeListener('data', onData);
128
+ process.stdout.write('\n');
129
+ if (rl) rl.resume();
130
+ resolve(buffer);
131
+ return;
132
+ }
133
+ if (ch === '\u0003') {
134
+ stdin.setRawMode(false);
135
+ stdin.removeListener('data', onData);
136
+ process.stdout.write('\n');
137
+ process.kill(process.pid, 'SIGINT');
138
+ return;
139
+ }
140
+ if (ch === '\u007f' || ch === '\b') {
141
+ if (buffer.length > 0) {
142
+ buffer = buffer.slice(0, -1);
143
+ process.stdout.write('\b \b');
144
+ }
145
+ continue;
146
+ }
147
+ buffer += ch;
148
+ process.stdout.write('*');
149
+ }
150
+ };
151
+ stdin.on('data', onData);
152
+ });
153
+ }
154
+
155
+ // Yes/no confirm. Returns boolean.
156
+ async function confirm(question, { defaultYes = true } = {}) {
157
+ const suffix = defaultYes ? '[Y/n]' : '[y/N]';
158
+ const answer = (await ask(`${question} ${suffix}`)).toLowerCase();
159
+ if (!answer) return defaultYes;
160
+ return answer === 'y' || answer === 'yes';
161
+ }
162
+
163
+ function closeRl() {
164
+ if (rl) {
165
+ rl.close();
166
+ rl = null;
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ ask,
172
+ askRequired,
173
+ askOptional,
174
+ askSecret,
175
+ confirm,
176
+ closeRl
177
+ };
@@ -0,0 +1,85 @@
1
+ // Rumen v0.1 Supabase Edge Function entry point.
2
+ //
3
+ // IMPORTANT: This file targets the Deno runtime, NOT Node. It will not
4
+ // compile under the root tsconfig.json — it is intentionally excluded.
5
+ // A sibling tsconfig.json in this directory keeps the types sane for
6
+ // editors, but the canonical build target is Deno's own type checker
7
+ // (`deno check`) and Supabase's `supabase functions deploy`.
8
+ //
9
+ // Dependencies are pulled via `npm:` specifiers, which Supabase Edge
10
+ // Functions support natively.
11
+ //
12
+ // Deployment:
13
+ // supabase functions deploy rumen-tick
14
+ // supabase secrets set DATABASE_URL="$DATABASE_URL"
15
+ //
16
+ // Triggered on a schedule by pg_cron — see migrations/002_pg_cron_schedule.sql.
17
+ //
18
+ // This function runs one Rumen job per invocation and returns a JSON summary.
19
+
20
+ // @ts-ignore Deno std import resolved at runtime.
21
+ import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
22
+ // @ts-ignore npm specifier resolved at runtime.
23
+ import { runRumenJob, createPoolFromUrl } from 'npm:@jhizzard/rumen@0.1.0';
24
+
25
+ // @ts-ignore Deno global available at runtime.
26
+ declare const Deno: { env: { get: (k: string) => string | undefined } };
27
+
28
+ serve(async (_req: Request) => {
29
+ const url = Deno.env.get('DATABASE_URL');
30
+ if (!url) {
31
+ console.error('[rumen] DATABASE_URL not set in Edge Function secrets');
32
+ return new Response(
33
+ JSON.stringify({
34
+ ok: false,
35
+ error: 'DATABASE_URL not set',
36
+ }),
37
+ {
38
+ status: 500,
39
+ headers: { 'Content-Type': 'application/json' },
40
+ },
41
+ );
42
+ }
43
+
44
+ const pool = createPoolFromUrl(url);
45
+
46
+ try {
47
+ console.log('[rumen] edge function tick starting');
48
+ const summary = await runRumenJob(pool, {
49
+ triggeredBy: 'schedule',
50
+ });
51
+ console.log(
52
+ '[rumen] edge function tick complete job_id=' +
53
+ summary.job_id +
54
+ ' status=' +
55
+ summary.status,
56
+ );
57
+
58
+ return new Response(
59
+ JSON.stringify({
60
+ ok: summary.status === 'done',
61
+ summary,
62
+ }),
63
+ {
64
+ status: summary.status === 'done' ? 200 : 500,
65
+ headers: { 'Content-Type': 'application/json' },
66
+ },
67
+ );
68
+ } catch (err) {
69
+ const message = err instanceof Error ? err.message : String(err);
70
+ console.error('[rumen] edge function tick threw:', err);
71
+ return new Response(
72
+ JSON.stringify({ ok: false, error: message }),
73
+ {
74
+ status: 500,
75
+ headers: { 'Content-Type': 'application/json' },
76
+ },
77
+ );
78
+ } finally {
79
+ try {
80
+ await pool.end();
81
+ } catch (err) {
82
+ console.error('[rumen] pool.end() failed:', err);
83
+ }
84
+ }
85
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "allowImportingTsExtensions": false,
11
+ "types": []
12
+ },
13
+ "include": ["index.ts"]
14
+ }
@@ -0,0 +1,91 @@
1
+ -- Rumen v0.1 schema
2
+ -- Non-destructive: creates three new tables under the rumen_ namespace.
3
+ -- Does NOT modify or reference Engram's existing memory_items / memory_sessions tables.
4
+ --
5
+ -- Apply with:
6
+ -- psql "$DIRECT_URL" -f migrations/001_rumen_tables.sql
7
+
8
+ BEGIN;
9
+
10
+ -- ---------------------------------------------------------------------------
11
+ -- rumen_jobs: one row per Rumen tick. Tracks what was processed.
12
+ -- ---------------------------------------------------------------------------
13
+ CREATE TABLE IF NOT EXISTS rumen_jobs (
14
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
15
+ triggered_by TEXT NOT NULL CHECK (triggered_by IN ('schedule', 'session_end', 'manual')),
16
+ status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed')),
17
+ sessions_processed INTEGER NOT NULL DEFAULT 0,
18
+ insights_generated INTEGER NOT NULL DEFAULT 0,
19
+ questions_generated INTEGER NOT NULL DEFAULT 0,
20
+ error_message TEXT,
21
+ source_session_ids UUID[] NOT NULL DEFAULT '{}',
22
+ started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
23
+ completed_at TIMESTAMPTZ
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_rumen_jobs_status
27
+ ON rumen_jobs (status);
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_rumen_jobs_started_at
30
+ ON rumen_jobs (started_at DESC);
31
+
32
+ -- GIN index so we can cheaply check "has this session already been processed?"
33
+ CREATE INDEX IF NOT EXISTS idx_rumen_jobs_source_session_ids
34
+ ON rumen_jobs USING GIN (source_session_ids);
35
+
36
+ -- ---------------------------------------------------------------------------
37
+ -- rumen_insights: synthesized cross-project findings.
38
+ -- v0.1 writes placeholder insight_text; v0.2 will add LLM synthesis.
39
+ -- ---------------------------------------------------------------------------
40
+ CREATE TABLE IF NOT EXISTS rumen_insights (
41
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
42
+ job_id UUID NOT NULL REFERENCES rumen_jobs(id) ON DELETE CASCADE,
43
+ source_memory_ids UUID[] NOT NULL DEFAULT '{}',
44
+ projects TEXT[] NOT NULL DEFAULT '{}',
45
+ insight_text TEXT NOT NULL,
46
+ confidence NUMERIC(4, 3) NOT NULL DEFAULT 0.000
47
+ CHECK (confidence >= 0.0 AND confidence <= 1.0),
48
+ acted_upon BOOLEAN NOT NULL DEFAULT FALSE,
49
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_rumen_insights_job_id
53
+ ON rumen_insights (job_id);
54
+
55
+ CREATE INDEX IF NOT EXISTS idx_rumen_insights_created_at
56
+ ON rumen_insights (created_at DESC);
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_rumen_insights_projects
59
+ ON rumen_insights USING GIN (projects);
60
+
61
+ CREATE INDEX IF NOT EXISTS idx_rumen_insights_source_memory_ids
62
+ ON rumen_insights USING GIN (source_memory_ids);
63
+
64
+ -- ---------------------------------------------------------------------------
65
+ -- rumen_questions: follow-up questions Rumen wants to ask the developer.
66
+ -- Reserved for v0.3. The table is created in v0.1 so the schema is stable,
67
+ -- but v0.1 never writes to it.
68
+ -- ---------------------------------------------------------------------------
69
+ CREATE TABLE IF NOT EXISTS rumen_questions (
70
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
71
+ job_id UUID NOT NULL REFERENCES rumen_jobs(id) ON DELETE CASCADE,
72
+ session_id UUID,
73
+ question TEXT NOT NULL,
74
+ context TEXT,
75
+ asked_at TIMESTAMPTZ,
76
+ answered_at TIMESTAMPTZ,
77
+ answer TEXT,
78
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
79
+ );
80
+
81
+ CREATE INDEX IF NOT EXISTS idx_rumen_questions_job_id
82
+ ON rumen_questions (job_id);
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_rumen_questions_session_id
85
+ ON rumen_questions (session_id);
86
+
87
+ CREATE INDEX IF NOT EXISTS idx_rumen_questions_unanswered
88
+ ON rumen_questions (created_at DESC)
89
+ WHERE answered_at IS NULL;
90
+
91
+ COMMIT;
@@ -0,0 +1,40 @@
1
+ -- Rumen v0.1 schedule
2
+ -- Schedules the rumen-tick Supabase Edge Function to run every 15 minutes
3
+ -- via pg_cron + pg_net.
4
+ --
5
+ -- Before applying:
6
+ -- 1. Enable the pg_cron extension in the Supabase dashboard:
7
+ -- Database -> Extensions -> pg_cron (toggle on)
8
+ -- 2. Enable the pg_net extension the same way (needed for net.http_post).
9
+ -- 3. Replace <project-ref> below with your actual Supabase project ref.
10
+ -- 4. Replace <service-role-jwt> below with a service-role key stored in
11
+ -- Supabase Vault, NOT pasted inline. The SELECT below shows the Vault
12
+ -- pattern; delete the inline-key variant before running.
13
+ --
14
+ -- Apply with:
15
+ -- psql "$DIRECT_URL" -f migrations/002_pg_cron_schedule.sql
16
+
17
+ -- Remove any prior schedule with the same name so re-running is idempotent.
18
+ SELECT cron.unschedule('rumen-tick')
19
+ WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'rumen-tick');
20
+
21
+ -- Schedule rumen-tick every 15 minutes.
22
+ -- The body is empty JSON; the Edge Function reads DATABASE_URL from its
23
+ -- own function secrets, not from the request body.
24
+ SELECT cron.schedule(
25
+ 'rumen-tick',
26
+ '*/15 * * * *',
27
+ $$
28
+ SELECT net.http_post(
29
+ url := 'https://<project-ref>.supabase.co/functions/v1/rumen-tick',
30
+ headers := jsonb_build_object(
31
+ 'Content-Type', 'application/json',
32
+ 'Authorization', 'Bearer ' || (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'rumen_service_role_key')
33
+ ),
34
+ body := '{}'::jsonb
35
+ );
36
+ $$
37
+ );
38
+
39
+ -- Verify:
40
+ -- SELECT jobname, schedule, active FROM cron.job WHERE jobname = 'rumen-tick';
@@ -0,0 +1,114 @@
1
+ // Parse + validate Supabase URLs and derive what we can from them without the
2
+ // database password. Useful for both init wizards:
3
+ //
4
+ // - init-engram needs the project ref to show in status output and also
5
+ // needs a full DATABASE_URL to apply migrations; since the DB password
6
+ // cannot be derived from the project URL alone, the wizard prompts for
7
+ // the direct connection string separately.
8
+ //
9
+ // - init-rumen needs the project ref to run `supabase link --project-ref`
10
+ // and to substitute into the pg_cron schedule SQL.
11
+
12
+ // A Supabase project URL looks like:
13
+ // https://<project-ref>.supabase.co
14
+ // The ref is 20 characters of lowercase alphanumerics, but we accept anything
15
+ // that matches `[a-z0-9-]+` to avoid being stricter than Supabase itself.
16
+ function parseProjectUrl(url) {
17
+ if (!url || typeof url !== 'string') {
18
+ return { ok: false, error: 'empty url' };
19
+ }
20
+ const trimmed = url.trim().replace(/\/+$/, '');
21
+ let u;
22
+ try {
23
+ u = new URL(trimmed);
24
+ } catch (_err) {
25
+ return { ok: false, error: 'not a valid URL' };
26
+ }
27
+ if (u.protocol !== 'https:') {
28
+ return { ok: false, error: 'must be https://' };
29
+ }
30
+ const m = u.hostname.match(/^([a-z0-9-]+)\.supabase\.(co|in)$/i);
31
+ if (!m) {
32
+ return { ok: false, error: 'hostname must end in .supabase.co' };
33
+ }
34
+ return {
35
+ ok: true,
36
+ url: `https://${u.hostname}`,
37
+ projectRef: m[1].toLowerCase(),
38
+ hostname: u.hostname
39
+ };
40
+ }
41
+
42
+ // Validate that a string has the shape of a Supabase service role key.
43
+ // Supabase now ships two formats:
44
+ // (1) legacy JWT: `eyJ...` (header.payload.signature), usually ~200 chars
45
+ // (2) prefixed v2: `sb_secret_...` or `sb_publishable_...`
46
+ // We accept both shapes and reject anything else with an explicit hint.
47
+ function looksLikeServiceRole(key) {
48
+ if (!key || typeof key !== 'string') return 'empty';
49
+ const trimmed = key.trim();
50
+ if (trimmed.startsWith('sb_secret_')) return null;
51
+ if (trimmed.startsWith('sb_publishable_')) {
52
+ return 'that looks like a publishable (anon) key, not the service_role key';
53
+ }
54
+ if (trimmed.startsWith('eyJ')) {
55
+ // Rough JWT shape check — 3 dot-separated base64url chunks.
56
+ const parts = trimmed.split('.');
57
+ if (parts.length === 3) return null;
58
+ return 'JWT-looking string but not 3 segments';
59
+ }
60
+ return 'does not look like a Supabase service_role key (expected sb_secret_… or eyJ…)';
61
+ }
62
+
63
+ // Shape-check an OpenAI API key. Both classic `sk-` and project `sk-proj-`
64
+ // formats are accepted.
65
+ function looksLikeOpenAiKey(key) {
66
+ if (!key || typeof key !== 'string') return 'empty';
67
+ const trimmed = key.trim();
68
+ if (trimmed.startsWith('sk-') && trimmed.length >= 20) return null;
69
+ return 'OpenAI keys start with sk-';
70
+ }
71
+
72
+ // Shape-check an Anthropic API key.
73
+ function looksLikeAnthropicKey(key) {
74
+ if (!key || typeof key !== 'string') return 'empty';
75
+ const trimmed = key.trim();
76
+ if (trimmed.startsWith('sk-ant-') && trimmed.length >= 30) return null;
77
+ return 'Anthropic keys start with sk-ant-';
78
+ }
79
+
80
+ // Shape-check a Postgres connection string. Accepts both Supabase pooler URLs
81
+ // (`postgres://postgres.<ref>:...@aws-0-<region>.pooler.supabase.com:<port>/postgres`)
82
+ // and direct connection URLs (`postgres://postgres:...@db.<ref>.supabase.co:5432/postgres`).
83
+ function looksLikePostgresUrl(url) {
84
+ if (!url || typeof url !== 'string') return 'empty';
85
+ let u;
86
+ try {
87
+ u = new URL(url);
88
+ } catch (_err) {
89
+ return 'not a valid URL';
90
+ }
91
+ if (u.protocol !== 'postgres:' && u.protocol !== 'postgresql:') {
92
+ return 'must start with postgres:// or postgresql://';
93
+ }
94
+ if (!u.username || !u.password) {
95
+ return 'missing username or password — paste the full Connection String from Supabase';
96
+ }
97
+ return null;
98
+ }
99
+
100
+ // Mask all but the last 4 chars of a secret for logging.
101
+ function maskSecret(value) {
102
+ if (!value || typeof value !== 'string') return '';
103
+ if (value.length <= 8) return '***';
104
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
105
+ }
106
+
107
+ module.exports = {
108
+ parseProjectUrl,
109
+ looksLikeServiceRole,
110
+ looksLikeOpenAiKey,
111
+ looksLikeAnthropicKey,
112
+ looksLikePostgresUrl,
113
+ maskSecret
114
+ };
@@ -0,0 +1,99 @@
1
+ // Targeted writer for ~/.termdeck/config.yaml that the `init --engram` wizard
2
+ // uses to flip `rag.enabled: true` and point secret fields at `${VAR}` refs
3
+ // instead of inline values.
4
+ //
5
+ // Uses the `yaml` package (already a dep) for a full parse + stringify round
6
+ // trip. Comments WILL be lost on rewrite — yaml.stringify doesn't preserve
7
+ // them. We back up the original to a timestamped `.bak` file first so the
8
+ // user can recover any hand-written comments.
9
+ //
10
+ // If config.yaml doesn't exist yet, this helper lets the server's own
11
+ // loadConfig() create the default template on next startup — it does NOT
12
+ // write a fresh template itself.
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+
18
+ const CONFIG_DIR = path.join(os.homedir(), '.termdeck');
19
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.yaml');
20
+
21
+ function loadYaml() {
22
+ const yaml = require('yaml');
23
+ if (!fs.existsSync(CONFIG_PATH)) return { parsed: {}, existed: false };
24
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
25
+ try {
26
+ const parsed = yaml.parse(raw) || {};
27
+ return { parsed, existed: true };
28
+ } catch (err) {
29
+ throw new Error(`config.yaml exists but is not valid YAML — refusing to rewrite: ${err.message}`);
30
+ }
31
+ }
32
+
33
+ function backup() {
34
+ if (!fs.existsSync(CONFIG_PATH)) return null;
35
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
36
+ const bak = `${CONFIG_PATH}.${ts}.bak`;
37
+ fs.copyFileSync(CONFIG_PATH, bak);
38
+ return bak;
39
+ }
40
+
41
+ // Update the `rag.*` section of config.yaml. Pass only fields you want to
42
+ // change — everything else (projects, themes, sessionLogs, etc.) is preserved.
43
+ // Secret fields should be `${VAR}` references, not raw keys.
44
+ function updateRagConfig(updates) {
45
+ const yaml = require('yaml');
46
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
47
+
48
+ const { parsed, existed } = loadYaml();
49
+ const rag = (parsed.rag && typeof parsed.rag === 'object') ? parsed.rag : {};
50
+
51
+ // Merge in a well-defined order so the output is readable.
52
+ const merged = {
53
+ enabled: updates.enabled != null ? updates.enabled : (rag.enabled != null ? rag.enabled : false),
54
+ supabaseUrl: updates.supabaseUrl || rag.supabaseUrl || '${SUPABASE_URL}',
55
+ supabaseKey: updates.supabaseKey || rag.supabaseKey || '${SUPABASE_SERVICE_ROLE_KEY}',
56
+ openaiApiKey: updates.openaiApiKey || rag.openaiApiKey || '${OPENAI_API_KEY}',
57
+ anthropicApiKey: updates.anthropicApiKey || rag.anthropicApiKey || '${ANTHROPIC_API_KEY}',
58
+ syncIntervalMs: rag.syncIntervalMs != null ? rag.syncIntervalMs : 10000,
59
+ engramMode: rag.engramMode || 'direct',
60
+ engramWebhookUrl: rag.engramWebhookUrl || 'http://localhost:37778/engram'
61
+ };
62
+
63
+ // Preserve any fields we didn't explicitly handle (e.g. tables, developerId).
64
+ for (const [k, v] of Object.entries(rag)) {
65
+ if (!(k in merged)) merged[k] = v;
66
+ }
67
+ parsed.rag = merged;
68
+
69
+ const bak = existed ? backup() : null;
70
+ fs.writeFileSync(CONFIG_PATH, yaml.stringify(parsed), 'utf-8');
71
+ return { path: CONFIG_PATH, backup: bak };
72
+ }
73
+
74
+ // Scan loaded parsed config for literal inline secrets in the rag section.
75
+ // Returns an array of dot-paths that still look like raw values (not ${VAR}).
76
+ function findInlineSecrets() {
77
+ const { parsed, existed } = loadYaml();
78
+ if (!existed) return [];
79
+ const rag = parsed.rag || {};
80
+ const hits = [];
81
+ const fields = ['supabaseKey', 'openaiApiKey', 'anthropicApiKey', 'supabaseUrl'];
82
+ for (const f of fields) {
83
+ const v = rag[f];
84
+ if (typeof v !== 'string') continue;
85
+ if (!v) continue;
86
+ const isEnvRef = /^\$\{[A-Z0-9_]+(?::-[^}]*)?\}$/i.test(v.trim());
87
+ if (!isEnvRef) hits.push(`rag.${f}`);
88
+ }
89
+ return hits;
90
+ }
91
+
92
+ module.exports = {
93
+ CONFIG_PATH,
94
+ CONFIG_DIR,
95
+ loadYaml,
96
+ updateRagConfig,
97
+ findInlineSecrets,
98
+ _backup: backup
99
+ };