@jhizzard/termdeck 0.6.5 → 0.6.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.6.5",
3
+ "version": "0.6.9",
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"
@@ -43,7 +43,8 @@ const {
43
43
  yaml,
44
44
  supabaseUrl: urlHelper,
45
45
  migrations,
46
- pgRunner
46
+ pgRunner,
47
+ preconditions
47
48
  } = require(SETUP_DIR);
48
49
 
49
50
  const HELP = [
@@ -385,12 +386,24 @@ async function verifyStatus(client) {
385
386
  // on disk first; config.yaml only flips to rag.enabled=true once the
386
387
  // schema is actually in place.
387
388
  function writeSecretsFile(inputs, dryRun) {
389
+ // Normalize transaction-pooler URLs by appending pgbouncer=true&
390
+ // connection_limit=1 when missing. Brad's Rumen logs (2026-04-26)
391
+ // surfaced the warning Supabase recommends: transaction-mode pooling
392
+ // (port 6543) needs those params or PgBouncer can return prepared-
393
+ // statement errors under load. Direct connections and session-mode
394
+ // pooler URLs are returned unchanged. See setup/supabase-url.js.
395
+ const normalized = urlHelper.normalizeDatabaseUrl(inputs.databaseUrl);
396
+ if (normalized.modified) {
397
+ step('Detected transaction pooler URL — appending ?pgbouncer=true&connection_limit=1...');
398
+ ok();
399
+ }
400
+
388
401
  step('Writing ~/.termdeck/secrets.env...');
389
402
  if (dryRun) { ok('(dry-run)'); return; }
390
403
  dotenv.writeSecrets({
391
404
  SUPABASE_URL: inputs.projectUrl.url,
392
405
  SUPABASE_SERVICE_ROLE_KEY: inputs.serviceRoleKey,
393
- DATABASE_URL: inputs.databaseUrl,
406
+ DATABASE_URL: normalized.url,
394
407
  OPENAI_API_KEY: inputs.openaiKey,
395
408
  ...(inputs.anthropicKey ? { ANTHROPIC_API_KEY: inputs.anthropicKey } : {})
396
409
  });
@@ -502,7 +515,19 @@ async function main(argv) {
502
515
  await checkExistingStore(client);
503
516
  await applyMigrations(client, false);
504
517
  writeYamlConfig(false);
518
+ // v0.6.9: post-write outcome verification. Confirms each migration's
519
+ // expected schema bits actually landed — including memory_items.
520
+ // source_session_id (the v0.6.5 column whose absence cascaded into
521
+ // Brad's Rumen failures). This is the test that, if it had existed
522
+ // before v0.6.5, would have caught the silent-shadow saga at install
523
+ // time instead of cron-tick time.
505
524
  if (!flags.skipVerify) {
525
+ const verify = await preconditions.verifyMnestraOutcomes({ secrets: { DATABASE_URL: inputs.databaseUrl }, _pgClient: client });
526
+ preconditions.printVerifyReport(verify, 'mnestra');
527
+ if (!verify.ok) {
528
+ printResumeHint();
529
+ return 8;
530
+ }
506
531
  const verified = await verifyStatus(client);
507
532
  if (!verified) {
508
533
  process.stdout.write(
@@ -34,7 +34,8 @@ const {
34
34
  dotenv,
35
35
  supabaseUrl: urlHelper,
36
36
  migrations,
37
- pgRunner
37
+ pgRunner,
38
+ preconditions
38
39
  } = require(SETUP_DIR);
39
40
 
40
41
  // Pinned fallback used only when the npm registry is unreachable. Bump this
@@ -166,6 +167,20 @@ function preflight() {
166
167
  }
167
168
  ok();
168
169
 
170
+ // Normalize DATABASE_URL for transaction-pooler usage (v0.6.6+). Users
171
+ // whose ~/.termdeck/secrets.env was written by an earlier wizard version
172
+ // may have a Shared Pooler URL without ?pgbouncer=true. The Edge Function
173
+ // logs a warning when that's the case (Brad's 2026-04-26 report). Fix it
174
+ // here in-memory before forwarding to `supabase secrets set` so the
175
+ // Function gets a clean URL even on partial-upgrade installs. Direct
176
+ // connections and session-mode pooler URLs are returned unchanged.
177
+ const normalized = urlHelper.normalizeDatabaseUrl(secrets.DATABASE_URL);
178
+ if (normalized.modified) {
179
+ step('Detected transaction pooler URL — appending ?pgbouncer=true&connection_limit=1 for the Edge Function...');
180
+ secrets.DATABASE_URL = normalized.url;
181
+ ok();
182
+ }
183
+
169
184
  // OPENAI_API_KEY is optional: when present, Rumen's Relate phase generates
170
185
  // real embeddings for semantic+keyword hybrid search. When absent, Rumen
171
186
  // falls back to keyword-only matching (still works, but loses cross-project
@@ -455,6 +470,78 @@ async function applySchedule(projectRef, secrets, dryRun) {
455
470
  }
456
471
  }
457
472
 
473
+ // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json's Supabase MCP
474
+ // server entry. Background: the meta-installer (`@jhizzard/termdeck-stack`)
475
+ // writes `SUPABASE_ACCESS_TOKEN: 'SUPABASE_PAT_HERE'` as a literal
476
+ // placeholder when it wires the Supabase MCP entry. The user is expected
477
+ // to replace it after install. v0.6.4 unblocked the Rumen install path by
478
+ // telling users to `export SUPABASE_ACCESS_TOKEN=sbp_...` in their shell —
479
+ // but that token only got used for `supabase link`, never propagated into
480
+ // `~/.claude/mcp.json`. So Brad's Claude Code was talking to a Supabase
481
+ // MCP server with a placeholder token. He had to update the JSON file
482
+ // manually. Reported 2026-04-26 — Brad's quote: "the token hadn't been
483
+ // written to the Json file which we updated manually, but you may want
484
+ // to put that in the patch at some point."
485
+ //
486
+ // This helper closes the loop. Idempotent and conservative:
487
+ // - Only runs if process.env.SUPABASE_ACCESS_TOKEN is set
488
+ // - Only updates when the existing value is the literal placeholder
489
+ // 'SUPABASE_PAT_HERE' — preserves any real token the user already set
490
+ // - No-op when ~/.claude/mcp.json doesn't exist (user never ran the
491
+ // meta-installer's Tier 4) or when there's no `supabase` MCP entry
492
+ // - No-op (with a soft warning) when the JSON is malformed
493
+ // - Atomic write via tmp-and-rename; mode 0600 to match the file's
494
+ // existing permissions (it already holds the placeholder)
495
+ // - All other mcpServers entries preserved verbatim
496
+ //
497
+ // Returns one of: { status: 'updated', path }, { status: 'already-set', path },
498
+ // { status: 'no-file' }, { status: 'no-supabase-entry', path },
499
+ // { status: 'no-token-in-env' }, { status: 'malformed', path, error }.
500
+ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
501
+ const fsImpl = _testFs || fs;
502
+ const tokenValue = token || process.env.SUPABASE_ACCESS_TOKEN;
503
+ if (!tokenValue) return { status: 'no-token-in-env' };
504
+
505
+ const targetPath = mcpJsonPath || path.join(os.homedir(), '.claude', 'mcp.json');
506
+ if (!fsImpl.existsSync(targetPath)) return { status: 'no-file' };
507
+
508
+ let raw;
509
+ try {
510
+ raw = fsImpl.readFileSync(targetPath, 'utf-8');
511
+ } catch (err) {
512
+ return { status: 'malformed', path: targetPath, error: err.message };
513
+ }
514
+
515
+ let cfg;
516
+ try {
517
+ cfg = JSON.parse(raw);
518
+ } catch (err) {
519
+ return { status: 'malformed', path: targetPath, error: err.message };
520
+ }
521
+
522
+ const supabaseEntry = cfg && cfg.mcpServers && cfg.mcpServers.supabase;
523
+ if (!supabaseEntry || typeof supabaseEntry !== 'object') {
524
+ return { status: 'no-supabase-entry', path: targetPath };
525
+ }
526
+
527
+ supabaseEntry.env = supabaseEntry.env || {};
528
+ const current = supabaseEntry.env.SUPABASE_ACCESS_TOKEN;
529
+ if (current === tokenValue) return { status: 'already-set', path: targetPath };
530
+ if (current && current !== 'SUPABASE_PAT_HERE') {
531
+ // User has set a real token already — don't touch it.
532
+ return { status: 'already-set', path: targetPath };
533
+ }
534
+
535
+ supabaseEntry.env.SUPABASE_ACCESS_TOKEN = tokenValue;
536
+
537
+ const tmpPath = `${targetPath}.tmp.${process.pid}`;
538
+ fsImpl.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
539
+ fsImpl.renameSync(tmpPath, targetPath);
540
+ try { fsImpl.chmodSync(targetPath, 0o600); } catch (_e) { /* best-effort */ }
541
+
542
+ return { status: 'updated', path: targetPath };
543
+ }
544
+
458
545
  function printNextSteps(projectRef) {
459
546
  const functionUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
460
547
  const now = new Date();
@@ -507,7 +594,39 @@ async function main(argv) {
507
594
  }
508
595
  }
509
596
 
597
+ // v0.6.9: front-loaded precondition audit. Runs BEFORE link so we don't
598
+ // create state (function deploy, function secrets, schedule SQL) that the
599
+ // user would have to manually clean up if a precondition is missing. Every
600
+ // gap is reported in one pass with actionable hints. The audit class — env
601
+ // tokens, pg extensions, Vault secret — covers the v0.6.4 / v0.6.6 / v0.6.7
602
+ // / v0.6.9-equivalent failure modes that previously surfaced one-per-patch.
603
+ if (!flags.dryRun) {
604
+ const audit = await preconditions.auditRumenPreconditions({ secrets, env: process.env });
605
+ preconditions.printAuditReport(audit, 'rumen');
606
+ if (!audit.ok) return 10;
607
+ }
608
+
510
609
  if (!(await link(projectRef, flags.dryRun))) return 4;
610
+
611
+ // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json now that
612
+ // `supabase link` succeeded (the token is verified-real). The
613
+ // meta-installer wrote a literal 'SUPABASE_PAT_HERE' placeholder
614
+ // there during Tier 4 install — this closes that loop.
615
+ if (!flags.dryRun) {
616
+ const r = wireAccessTokenInMcpJson();
617
+ if (r.status === 'updated') {
618
+ step('Backfilled SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json...');
619
+ ok();
620
+ } else if (r.status === 'malformed') {
621
+ process.stderr.write(
622
+ `\n ! ${r.path} is not valid JSON — skipping token backfill (${r.error}).\n` +
623
+ ` Update the supabase mcpServers entry manually if Claude Code's Supabase MCP is misbehaving.\n\n`
624
+ );
625
+ }
626
+ // Other statuses (no-file, no-supabase-entry, no-token-in-env,
627
+ // already-set) are silent — they're all expected paths.
628
+ }
629
+
511
630
  if (!(await applyRumenTables(secrets, flags.dryRun))) return 5;
512
631
 
513
632
  step('Resolving @jhizzard/rumen version from npm registry...');
@@ -524,6 +643,15 @@ async function main(argv) {
524
643
  if (!(await testFunction(projectRef, secrets, flags.dryRun))) return 8;
525
644
  if (!flags.skipSchedule) {
526
645
  if (!(await applySchedule(projectRef, secrets, flags.dryRun))) return 9;
646
+ // v0.6.9: post-write outcome verification. Confirms cron.job has the
647
+ // active rumen-tick row. Doesn't poll for the first 15-min tick — that's
648
+ // too long for an interactive wizard — but tells the user the exact
649
+ // query to run after waiting if they want firing-confirmation.
650
+ if (!flags.dryRun) {
651
+ const verify = await preconditions.verifyRumenOutcomes({ secrets });
652
+ preconditions.printVerifyReport(verify, 'rumen');
653
+ if (!verify.ok) return 11;
654
+ }
527
655
  } else {
528
656
  process.stdout.write('→ Skipping pg_cron schedule (per --skip-schedule) ✓\n');
529
657
  }
@@ -545,3 +673,4 @@ module.exports = main;
545
673
  // Test surface — kept on the same export object so the regression suite can
546
674
  // pin the access-token detection without spawning a real `supabase` binary.
547
675
  module.exports._looksLikeMissingAccessToken = looksLikeMissingAccessToken;
676
+ module.exports._wireAccessTokenInMcpJson = wireAccessTokenInMcpJson;
@@ -11,5 +11,6 @@ module.exports = {
11
11
  supabaseUrl: require('./supabase-url'),
12
12
  migrations: require('./migrations'),
13
13
  pgRunner: require('./pg-runner'),
14
- migrationRunner: require('./migration-runner')
14
+ migrationRunner: require('./migration-runner'),
15
+ preconditions: require('./preconditions')
15
16
  };
@@ -1,16 +1,28 @@
1
1
  // Discover the SQL migration files that ship bundled inside the TermDeck
2
- // package. Both init wizards call this — init-mnestra for the six Mnestra
2
+ // package. Both init wizards call this — init-mnestra for the seven Mnestra
3
3
  // migrations, init-rumen for the two Rumen migrations.
4
4
  //
5
5
  // The wizards intentionally do NOT fall back to a sibling `../../mnestra`
6
- // working copy. Resolution order:
6
+ // working copy. Resolution order (BUNDLED FIRST as of v0.6.8):
7
7
  //
8
8
  // 1. Files bundled at `packages/server/src/setup/mnestra-migrations/*.sql`
9
9
  // (this directory is covered by the root package.json `files` glob).
10
+ // ALWAYS preferred when it has any .sql files.
10
11
  // 2. Files at `node_modules/@jhizzard/mnestra/migrations/*.sql` if that
11
- // package is installed alongside TermDeck (future-proof path shipping
12
- // `@jhizzard/mnestra` as an optional peer would let us drop the bundled
13
- // copy).
12
+ // package is installed alongside TermDeck. Used ONLY as a fallback when
13
+ // the bundled directory is missing (e.g. someone deleted it manually).
14
+ //
15
+ // Why bundled-first: the meta-installer (`@jhizzard/termdeck-stack`) installs
16
+ // `@jhizzard/mnestra` globally as a peer. When TermDeck releases a new
17
+ // migration ahead of a Mnestra release, or when a user upgrades TermDeck
18
+ // without also upgrading the global Mnestra package, the previous loader
19
+ // silently picked the older Mnestra migration set. This bit Brad on
20
+ // 2026-04-26 with v0.6.5: he upgraded TermDeck, ran `init --mnestra --yes`,
21
+ // the wizard reported "6 migrations applied cleanly" (because his global
22
+ // mnestra@0.2.1 had only 6), and the bundled 007 — the one we shipped to
23
+ // fix his Rumen schema-drift issue — was never seen. Bundled is the source
24
+ // of truth TermDeck developed and tested against. Fall-back to node_modules
25
+ // is preserved as a safety valve, not a preference.
14
26
 
15
27
  const fs = require('fs');
16
28
  const path = require('path');
@@ -45,15 +57,20 @@ function tryNodeModules(packageName, migrationSubdir = 'migrations') {
45
57
  }
46
58
 
47
59
  function listMnestraMigrations() {
48
- const fromNm = tryNodeModules('@jhizzard/mnestra');
49
- if (fromNm.length > 0) return fromNm;
50
- return listBundled('mnestra-migrations');
60
+ // Bundled FIRST (v0.6.8+). See the file header for why — this prevents
61
+ // a stale `@jhizzard/mnestra` install in global node_modules from
62
+ // silently shadowing migrations TermDeck ships with the latest version.
63
+ const bundled = listBundled('mnestra-migrations');
64
+ if (bundled.length > 0) return bundled;
65
+ return tryNodeModules('@jhizzard/mnestra');
51
66
  }
52
67
 
53
68
  function listRumenMigrations() {
54
- const fromNm = tryNodeModules('@jhizzard/rumen');
55
- if (fromNm.length > 0) return fromNm;
56
- return listBundled(path.join('rumen', 'migrations'));
69
+ // Bundled FIRST (v0.6.8+). Same rationale as listMnestraMigrations —
70
+ // a stale global `@jhizzard/rumen` cannot shadow newer bundled migrations.
71
+ const bundled = listBundled(path.join('rumen', 'migrations'));
72
+ if (bundled.length > 0) return bundled;
73
+ return tryNodeModules('@jhizzard/rumen');
57
74
  }
58
75
 
59
76
  function rumenFunctionDir() {
@@ -0,0 +1,370 @@
1
+ // Front-loaded precondition audits and post-write outcome verifications for
2
+ // the `termdeck init --mnestra` and `termdeck init --rumen` wizards.
3
+ //
4
+ // Why this module exists (v0.6.9)
5
+ // ───────────────────────────────
6
+ // The v0.6.x lineage shipped 8 patch releases in 48 hours. Four of them —
7
+ // v0.6.4 (SUPABASE_ACCESS_TOKEN), v0.6.6 (pgbouncer params), v0.6.7
8
+ // (mcp.json placeholder), and what would have been v0.6.9 (pg_cron +
9
+ // pg_net extensions, Vault secret) — were the SAME failure mode: a
10
+ // precondition was DOCUMENTED in GETTING-STARTED.md or a migration-file
11
+ // header but not VERIFIED in code. Each unsupervised user (Brad) hit
12
+ // them sequentially because there was no audit step at the start of the
13
+ // wizard. Documentation is not verification.
14
+ //
15
+ // Shape of the defense
16
+ // ────────────────────
17
+ // `auditRumenPreconditions()` runs FIRST in init-rumen, before any
18
+ // state-changing operation. It collects EVERY external precondition gap
19
+ // in a single pass — supabase CLI auth, pg_cron + pg_net extensions,
20
+ // Vault secret presence — and returns a structured `{ ok, gaps[] }`.
21
+ // Callers that see `ok=false` print the gaps with actionable hints and
22
+ // refuse to proceed. No partial work, no half-applied state.
23
+ //
24
+ // `verifyRumenOutcomes()` runs LAST after the schedule SQL applies. It
25
+ // confirms `cron.job` has an active rumen-tick row. Doesn't poll for the
26
+ // first 15-min tick (too long for an interactive wizard) — but tells the
27
+ // user the exact query to run after waiting if they want to confirm
28
+ // firing.
29
+ //
30
+ // `verifyMnestraOutcomes()` runs after migrations apply and confirms
31
+ // the column we shipped in v0.6.5 (`source_session_id`) actually landed.
32
+ // This is the test that — if it had existed — would have caught Brad's
33
+ // v0.6.5/v0.6.8 saga at install time instead of pg_cron-tick time.
34
+ //
35
+ // All async, all defensive: any unexpected error is captured into a gap
36
+ // rather than thrown, so the audit always returns a complete picture.
37
+
38
+ 'use strict';
39
+
40
+ const { spawnSync } = require('child_process');
41
+ const pgRunner = require('./pg-runner');
42
+
43
+ // Render a single gap into 2-3 lines of CLI output (one indented hint per
44
+ // non-empty `hint` line). Format aligned with the rest of the wizard's
45
+ // step lines.
46
+ function printGap(gap, index) {
47
+ process.stdout.write(` ${index + 1}. ✗ ${gap.message}\n`);
48
+ if (gap.hint) {
49
+ for (const line of gap.hint.split('\n')) {
50
+ if (line.trim().length > 0) {
51
+ process.stdout.write(` ${line}\n`);
52
+ }
53
+ }
54
+ }
55
+ process.stdout.write('\n');
56
+ }
57
+
58
+ // Print the audit report. Returns no value — the caller decides what to
59
+ // do with `result.ok`.
60
+ function printAuditReport(result, context) {
61
+ if (result.ok) {
62
+ process.stdout.write(`→ Auditing ${context} preconditions... ✓\n`);
63
+ return;
64
+ }
65
+ process.stdout.write(`\n→ Auditing ${context} preconditions... ✗\n\n`);
66
+ process.stdout.write(`${result.gaps.length} precondition${result.gaps.length === 1 ? '' : 's'} failed:\n\n`);
67
+ result.gaps.forEach((g, i) => printGap(g, i));
68
+ process.stdout.write(
69
+ `Fix the items above and re-run \`termdeck init --${context}\`. The wizard ` +
70
+ `will not proceed; it would create state you'd have to manually clean up.\n\n`
71
+ );
72
+ }
73
+
74
+ // Same structure for outcome verification — same { ok, gaps[] } shape so
75
+ // callers don't branch on which kind of report they're handling.
76
+ function printVerifyReport(result, context) {
77
+ if (result.ok) {
78
+ process.stdout.write(`→ Verifying ${context} outcomes... ✓\n`);
79
+ return;
80
+ }
81
+ process.stdout.write(`\n→ Verifying ${context} outcomes... ✗\n\n`);
82
+ process.stdout.write(`${result.gaps.length} expected outcome${result.gaps.length === 1 ? '' : 's'} not found:\n\n`);
83
+ result.gaps.forEach((g, i) => printGap(g, i));
84
+ }
85
+
86
+ // ── Rumen precondition audit ────────────────────────────────────────────────
87
+
88
+ // Probe the supabase CLI's auth state without running `link`. If the user
89
+ // has run `supabase login` previously, `supabase projects list` succeeds.
90
+ // If they have SUPABASE_ACCESS_TOKEN in env, same. If neither, this exits
91
+ // non-zero and we surface the gap.
92
+ function probeSupabaseAuth() {
93
+ // Cheap, network-bound, ~1-2s. Capture both streams so we can inspect
94
+ // for the "Access token not provided" signal.
95
+ const r = spawnSync('supabase', ['projects', 'list', '--output', 'env'], {
96
+ encoding: 'utf-8',
97
+ timeout: 15000
98
+ });
99
+ return { ok: r.status === 0, stderr: r.stderr || '' };
100
+ }
101
+
102
+ // Run all Rumen preconditions in parallel where independent. Returns
103
+ // `{ ok: boolean, gaps: [{ key, message, hint }] }`.
104
+ //
105
+ // Inputs:
106
+ // - secrets: dotenv-loaded ~/.termdeck/secrets.env (must have DATABASE_URL)
107
+ // - env: process.env or test fixture
108
+ //
109
+ // Optional `_pgClient` injection lets tests substitute a fake client; in
110
+ // production we open one and close it at the end of the audit.
111
+ async function auditRumenPreconditions({ secrets, env, _pgClient } = {}) {
112
+ const gaps = [];
113
+
114
+ // 1. Supabase CLI auth — sync; doesn't need pg.
115
+ if (!env || !env.SUPABASE_ACCESS_TOKEN) {
116
+ const probe = probeSupabaseAuth();
117
+ if (!probe.ok) {
118
+ gaps.push({
119
+ key: 'SUPABASE_ACCESS_TOKEN',
120
+ message: 'Supabase CLI is not authenticated and no SUPABASE_ACCESS_TOKEN in environment',
121
+ hint:
122
+ 'Generate a Personal Access Token:\n' +
123
+ ' https://supabase.com/dashboard/account/tokens\n' +
124
+ 'Then export it in this shell:\n' +
125
+ ' export SUPABASE_ACCESS_TOKEN=sbp_...\n' +
126
+ '(`supabase login` works on desktops but opens a browser; not viable over SSH.)'
127
+ });
128
+ }
129
+ }
130
+
131
+ // 2-4. DB-side checks — open one connection, run them sequentially,
132
+ // close it at the end. Errors get captured per-check so a single
133
+ // flaky query doesn't blank the whole audit.
134
+ const client = _pgClient || (await safeConnect(secrets && secrets.DATABASE_URL));
135
+ if (!client) {
136
+ gaps.push({
137
+ key: 'DATABASE_URL',
138
+ message: 'Could not connect to Postgres using DATABASE_URL from ~/.termdeck/secrets.env',
139
+ hint:
140
+ 'Verify the URL is reachable from this host:\n' +
141
+ ' psql "$DATABASE_URL" -c "SELECT 1;"\n' +
142
+ 'If the connection is fine but you see another error, copy the wizard output and report it.'
143
+ });
144
+ return { ok: gaps.length === 0, gaps };
145
+ }
146
+
147
+ try {
148
+ // pg_cron extension
149
+ const cron = await safeQuery(client,
150
+ "SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_cron'");
151
+ if (!cron.ok) {
152
+ gaps.push({
153
+ key: 'pg_cron',
154
+ message: 'The pg_cron extension is not enabled on this Supabase project',
155
+ hint:
156
+ 'Enable it in the Supabase dashboard:\n' +
157
+ ' Database → Extensions → pg_cron → toggle ON\n' +
158
+ '(Without pg_cron, the rumen-tick schedule cannot run.)'
159
+ });
160
+ }
161
+
162
+ // pg_net extension
163
+ const net = await safeQuery(client,
164
+ "SELECT 1 AS ok FROM pg_extension WHERE extname = 'pg_net'");
165
+ if (!net.ok) {
166
+ gaps.push({
167
+ key: 'pg_net',
168
+ message: 'The pg_net extension is not enabled on this Supabase project',
169
+ hint:
170
+ 'Enable it in the Supabase dashboard:\n' +
171
+ ' Database → Extensions → pg_net → toggle ON\n' +
172
+ '(pg_net is what the cron schedule uses to call the Edge Function.)'
173
+ });
174
+ }
175
+
176
+ // Vault secret rumen_service_role_key — accessing vault.decrypted_secrets
177
+ // requires service_role privileges. Distinguish "no row" from "permission
178
+ // denied" so the hint is actionable.
179
+ const vault = await safeQuery(client,
180
+ "SELECT 1 AS ok FROM vault.decrypted_secrets WHERE name = 'rumen_service_role_key'");
181
+ if (vault.error) {
182
+ gaps.push({
183
+ key: 'vault.decrypted_secrets',
184
+ message: `Cannot read vault.decrypted_secrets: ${vault.error}`,
185
+ hint:
186
+ 'Verify your DATABASE_URL is using the service_role connection (port 6543 + service_role auth).\n' +
187
+ 'If permission is denied, the Vault is not accessible to this connection — double-check secrets.env.'
188
+ });
189
+ } else if (!vault.ok) {
190
+ gaps.push({
191
+ key: 'rumen_service_role_key',
192
+ message: 'Vault secret "rumen_service_role_key" is missing',
193
+ hint:
194
+ 'Create it in the Supabase dashboard:\n' +
195
+ ' Project Settings → Vault → New secret\n' +
196
+ ' Name: rumen_service_role_key (exact, case-sensitive)\n' +
197
+ ' Value: your service_role key from Project Settings → API\n' +
198
+ '(The pg_cron schedule calls the Edge Function with this key as the bearer token.)'
199
+ });
200
+ }
201
+ } finally {
202
+ if (!_pgClient) {
203
+ try { await client.end(); } catch (_e) { /* ignore */ }
204
+ }
205
+ }
206
+
207
+ return { ok: gaps.length === 0, gaps };
208
+ }
209
+
210
+ // ── Rumen outcome verification ──────────────────────────────────────────────
211
+ //
212
+ // Runs after the pg_cron schedule SQL applies. Confirms the row is in
213
+ // cron.job and active. Doesn't wait for first run (15 min is too long
214
+ // to block an interactive wizard) — instead tells the user the query to
215
+ // run after waiting if they want firing-confirmation.
216
+
217
+ async function verifyRumenOutcomes({ secrets, _pgClient } = {}) {
218
+ const gaps = [];
219
+ const client = _pgClient || (await safeConnect(secrets && secrets.DATABASE_URL));
220
+ if (!client) {
221
+ gaps.push({
222
+ key: 'DATABASE_URL',
223
+ message: 'Could not reconnect to Postgres to verify the schedule landed',
224
+ hint: 'Re-run `termdeck init --rumen` once connectivity is restored.'
225
+ });
226
+ return { ok: false, gaps };
227
+ }
228
+
229
+ try {
230
+ const job = await safeQuery(client,
231
+ "SELECT active FROM cron.job WHERE jobname = 'rumen-tick'",
232
+ { wantRows: true });
233
+ if (job.error) {
234
+ gaps.push({
235
+ key: 'cron.job',
236
+ message: `Cannot read cron.job: ${job.error}`,
237
+ hint: 'pg_cron may have been disabled after the schedule was applied. Re-enable it and re-run the wizard.'
238
+ });
239
+ } else if (!job.rows || job.rows.length === 0) {
240
+ gaps.push({
241
+ key: 'cron.job',
242
+ message: 'Schedule was applied but cron.job has no rumen-tick row',
243
+ hint:
244
+ 'This usually means the SELECT cron.schedule(...) call returned NULL. Re-run `termdeck init --rumen` ' +
245
+ 'or apply migrations/002_pg_cron_schedule.sql manually to investigate.'
246
+ });
247
+ } else if (!job.rows[0].active) {
248
+ gaps.push({
249
+ key: 'cron.job.active',
250
+ message: 'rumen-tick exists but is paused (active=false)',
251
+ hint:
252
+ 'Resume it with:\n' +
253
+ " SELECT cron.alter_job((SELECT jobid FROM cron.job WHERE jobname = 'rumen-tick'), active := true);"
254
+ });
255
+ }
256
+ } finally {
257
+ if (!_pgClient) {
258
+ try { await client.end(); } catch (_e) { /* ignore */ }
259
+ }
260
+ }
261
+
262
+ return { ok: gaps.length === 0, gaps };
263
+ }
264
+
265
+ // ── Mnestra outcome verification ────────────────────────────────────────────
266
+ //
267
+ // After the 7 migrations apply, confirm:
268
+ // - memory_items table exists
269
+ // - source_session_id column exists on it (the v0.6.5 fix actually landed)
270
+ // - memory_status_aggregation function exists
271
+ //
272
+ // This is the test that would have caught the v0.6.5 / v0.6.8 silent-shadow
273
+ // saga at install time instead of cron-tick time.
274
+
275
+ async function verifyMnestraOutcomes({ secrets, _pgClient } = {}) {
276
+ const gaps = [];
277
+ const client = _pgClient || (await safeConnect(secrets && secrets.DATABASE_URL));
278
+ if (!client) {
279
+ gaps.push({
280
+ key: 'DATABASE_URL',
281
+ message: 'Could not reconnect to Postgres to verify migrations landed',
282
+ hint: 'This is unexpected — the migrations just ran. Check connectivity and re-run with --yes.'
283
+ });
284
+ return { ok: false, gaps };
285
+ }
286
+
287
+ try {
288
+ // memory_items table
289
+ const tbl = await safeQuery(client,
290
+ "SELECT 1 AS ok FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'memory_items'");
291
+ if (!tbl.ok) {
292
+ gaps.push({
293
+ key: 'memory_items',
294
+ message: 'memory_items table is missing after migrations',
295
+ hint: 'Migration 001 should have created it. Re-run with --reset to start fresh, or report a bug.'
296
+ });
297
+ } else {
298
+ // source_session_id column (v0.6.5 fix)
299
+ const col = await safeQuery(client,
300
+ "SELECT 1 AS ok FROM information_schema.columns " +
301
+ "WHERE table_schema = 'public' AND table_name = 'memory_items' AND column_name = 'source_session_id'");
302
+ if (!col.ok) {
303
+ gaps.push({
304
+ key: 'memory_items.source_session_id',
305
+ message: 'memory_items.source_session_id column is missing — Rumen will fail',
306
+ hint:
307
+ 'Migration 007 should have added it. If you see this, the migration loader picked up a stale\n' +
308
+ 'set — upgrade with: npm cache clean --force && npm i -g @jhizzard/termdeck@latest\n' +
309
+ 'then re-run `termdeck init --mnestra --yes`.'
310
+ });
311
+ }
312
+ }
313
+
314
+ // memory_status_aggregation RPC
315
+ const rpc = await safeQuery(client,
316
+ "SELECT 1 AS ok FROM pg_proc WHERE proname = 'memory_status_aggregation'");
317
+ if (!rpc.ok) {
318
+ gaps.push({
319
+ key: 'memory_status_aggregation',
320
+ message: 'memory_status_aggregation() function is missing',
321
+ hint: 'Migration 006 should have created it. The wizard\'s status check will fall back to client-side aggregation, but that hits PostgREST row caps.'
322
+ });
323
+ }
324
+ } finally {
325
+ if (!_pgClient) {
326
+ try { await client.end(); } catch (_e) { /* ignore */ }
327
+ }
328
+ }
329
+
330
+ return { ok: gaps.length === 0, gaps };
331
+ }
332
+
333
+ // ── Internal helpers ────────────────────────────────────────────────────────
334
+
335
+ async function safeConnect(databaseUrl) {
336
+ if (!databaseUrl) return null;
337
+ try {
338
+ return await pgRunner.connect(databaseUrl);
339
+ } catch (_err) {
340
+ return null;
341
+ }
342
+ }
343
+
344
+ // Run a query that returns at most one row. Returns:
345
+ // { ok: true } when the row exists / value is truthy
346
+ // { ok: false } when no rows or the value is falsy
347
+ // { error: string } on query failure
348
+ //
349
+ // `wantRows: true` returns the rows array instead of just an ok bit.
350
+ async function safeQuery(client, sql, opts = {}) {
351
+ try {
352
+ const r = await client.query(sql);
353
+ if (opts.wantRows) return { rows: r.rows };
354
+ if (r.rows && r.rows.length > 0 && r.rows[0].ok) return { ok: true };
355
+ return { ok: false };
356
+ } catch (err) {
357
+ return { error: err && err.message ? err.message : String(err) };
358
+ }
359
+ }
360
+
361
+ module.exports = {
362
+ auditRumenPreconditions,
363
+ verifyRumenOutcomes,
364
+ verifyMnestraOutcomes,
365
+ printAuditReport,
366
+ printVerifyReport,
367
+ // Test surface
368
+ _probeSupabaseAuth: probeSupabaseAuth,
369
+ _safeQuery: safeQuery
370
+ };
@@ -97,6 +97,65 @@ function looksLikePostgresUrl(url) {
97
97
  return null;
98
98
  }
99
99
 
100
+ // Detect a Supabase Shared Pooler URL in TRANSACTION mode. Pattern:
101
+ // postgres://postgres.<ref>:<pw>@aws-0-<region>.pooler.supabase.com:6543/postgres
102
+ // Session mode (port 5432 on the pooler) and direct connections
103
+ // (db.<ref>.supabase.co:5432) do NOT need pgbouncer params and must be
104
+ // left alone. Returns true only for the transaction-pooler shape.
105
+ function isTransactionPoolerUrl(parsedUrl) {
106
+ if (!parsedUrl) return false;
107
+ const host = (parsedUrl.hostname || '').toLowerCase();
108
+ // pooler hosts end in `.pooler.supabase.com`. Be lenient on the regional
109
+ // prefix — Supabase has used `aws-0-` historically and may add others.
110
+ if (!host.endsWith('.pooler.supabase.com')) return false;
111
+ // Transaction mode is port 6543. Session mode on the same host is 5432
112
+ // and doesn't want pgbouncer flags.
113
+ return parsedUrl.port === '6543';
114
+ }
115
+
116
+ // Normalize a DATABASE_URL by appending the pgbouncer transaction-mode
117
+ // params Supabase requires when connecting through a transaction pooler.
118
+ //
119
+ // Brad's Rumen logs (2026-04-26) warned:
120
+ // "DATABASE_URL is a Shared Pooler URL but does not have ?pgbouncer=true.
121
+ // Append ?pgbouncer=true&connection_limit=1 for transaction-mode
122
+ // compatibility."
123
+ //
124
+ // The warning is harmless on its own — Rumen's runtime didn't fail because
125
+ // of it — but missing the params can manifest as prepared-statement errors
126
+ // or stuck connections under load with PgBouncer in transaction mode. The
127
+ // safest path is for the wizard to add them on the user's behalf when the
128
+ // URL shape clearly indicates they're needed.
129
+ //
130
+ // Returns `{ url, modified }`. `modified` is true when params were added.
131
+ // Idempotent: a URL that already has pgbouncer=true is returned unchanged.
132
+ // Only touches transaction-pooler URLs (port 6543 on *.pooler.supabase.com).
133
+ // Direct connections (port 5432, db.* hostname) and session-pooler URLs
134
+ // (port 5432 on pooler hostname) are returned unchanged.
135
+ //
136
+ // Errors are swallowed — a malformed URL returns `{ url: original, modified: false }`
137
+ // because validation is the caller's job (looksLikePostgresUrl handles that).
138
+ function normalizeDatabaseUrl(url) {
139
+ if (!url || typeof url !== 'string') return { url, modified: false };
140
+ let u;
141
+ try {
142
+ u = new URL(url);
143
+ } catch (_err) {
144
+ return { url, modified: false };
145
+ }
146
+ if (!isTransactionPoolerUrl(u)) return { url, modified: false };
147
+
148
+ // Already has pgbouncer set? Don't touch.
149
+ if (u.searchParams.has('pgbouncer')) return { url, modified: false };
150
+
151
+ u.searchParams.set('pgbouncer', 'true');
152
+ // Set connection_limit only if not already set — preserve user intent.
153
+ if (!u.searchParams.has('connection_limit')) {
154
+ u.searchParams.set('connection_limit', '1');
155
+ }
156
+ return { url: u.toString(), modified: true };
157
+ }
158
+
100
159
  // Mask all but the last 4 chars of a secret for logging.
101
160
  function maskSecret(value) {
102
161
  if (!value || typeof value !== 'string') return '';
@@ -110,5 +169,7 @@ module.exports = {
110
169
  looksLikeOpenAiKey,
111
170
  looksLikeAnthropicKey,
112
171
  looksLikePostgresUrl,
172
+ isTransactionPoolerUrl,
173
+ normalizeDatabaseUrl,
113
174
  maskSecret
114
175
  };