@jhizzard/termdeck 0.6.5 → 0.6.7

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.7",
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"
@@ -385,12 +385,24 @@ async function verifyStatus(client) {
385
385
  // on disk first; config.yaml only flips to rag.enabled=true once the
386
386
  // schema is actually in place.
387
387
  function writeSecretsFile(inputs, dryRun) {
388
+ // Normalize transaction-pooler URLs by appending pgbouncer=true&
389
+ // connection_limit=1 when missing. Brad's Rumen logs (2026-04-26)
390
+ // surfaced the warning Supabase recommends: transaction-mode pooling
391
+ // (port 6543) needs those params or PgBouncer can return prepared-
392
+ // statement errors under load. Direct connections and session-mode
393
+ // pooler URLs are returned unchanged. See setup/supabase-url.js.
394
+ const normalized = urlHelper.normalizeDatabaseUrl(inputs.databaseUrl);
395
+ if (normalized.modified) {
396
+ step('Detected transaction pooler URL — appending ?pgbouncer=true&connection_limit=1...');
397
+ ok();
398
+ }
399
+
388
400
  step('Writing ~/.termdeck/secrets.env...');
389
401
  if (dryRun) { ok('(dry-run)'); return; }
390
402
  dotenv.writeSecrets({
391
403
  SUPABASE_URL: inputs.projectUrl.url,
392
404
  SUPABASE_SERVICE_ROLE_KEY: inputs.serviceRoleKey,
393
- DATABASE_URL: inputs.databaseUrl,
405
+ DATABASE_URL: normalized.url,
394
406
  OPENAI_API_KEY: inputs.openaiKey,
395
407
  ...(inputs.anthropicKey ? { ANTHROPIC_API_KEY: inputs.anthropicKey } : {})
396
408
  });
@@ -166,6 +166,20 @@ function preflight() {
166
166
  }
167
167
  ok();
168
168
 
169
+ // Normalize DATABASE_URL for transaction-pooler usage (v0.6.6+). Users
170
+ // whose ~/.termdeck/secrets.env was written by an earlier wizard version
171
+ // may have a Shared Pooler URL without ?pgbouncer=true. The Edge Function
172
+ // logs a warning when that's the case (Brad's 2026-04-26 report). Fix it
173
+ // here in-memory before forwarding to `supabase secrets set` so the
174
+ // Function gets a clean URL even on partial-upgrade installs. Direct
175
+ // connections and session-mode pooler URLs are returned unchanged.
176
+ const normalized = urlHelper.normalizeDatabaseUrl(secrets.DATABASE_URL);
177
+ if (normalized.modified) {
178
+ step('Detected transaction pooler URL — appending ?pgbouncer=true&connection_limit=1 for the Edge Function...');
179
+ secrets.DATABASE_URL = normalized.url;
180
+ ok();
181
+ }
182
+
169
183
  // OPENAI_API_KEY is optional: when present, Rumen's Relate phase generates
170
184
  // real embeddings for semantic+keyword hybrid search. When absent, Rumen
171
185
  // falls back to keyword-only matching (still works, but loses cross-project
@@ -455,6 +469,78 @@ async function applySchedule(projectRef, secrets, dryRun) {
455
469
  }
456
470
  }
457
471
 
472
+ // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json's Supabase MCP
473
+ // server entry. Background: the meta-installer (`@jhizzard/termdeck-stack`)
474
+ // writes `SUPABASE_ACCESS_TOKEN: 'SUPABASE_PAT_HERE'` as a literal
475
+ // placeholder when it wires the Supabase MCP entry. The user is expected
476
+ // to replace it after install. v0.6.4 unblocked the Rumen install path by
477
+ // telling users to `export SUPABASE_ACCESS_TOKEN=sbp_...` in their shell —
478
+ // but that token only got used for `supabase link`, never propagated into
479
+ // `~/.claude/mcp.json`. So Brad's Claude Code was talking to a Supabase
480
+ // MCP server with a placeholder token. He had to update the JSON file
481
+ // manually. Reported 2026-04-26 — Brad's quote: "the token hadn't been
482
+ // written to the Json file which we updated manually, but you may want
483
+ // to put that in the patch at some point."
484
+ //
485
+ // This helper closes the loop. Idempotent and conservative:
486
+ // - Only runs if process.env.SUPABASE_ACCESS_TOKEN is set
487
+ // - Only updates when the existing value is the literal placeholder
488
+ // 'SUPABASE_PAT_HERE' — preserves any real token the user already set
489
+ // - No-op when ~/.claude/mcp.json doesn't exist (user never ran the
490
+ // meta-installer's Tier 4) or when there's no `supabase` MCP entry
491
+ // - No-op (with a soft warning) when the JSON is malformed
492
+ // - Atomic write via tmp-and-rename; mode 0600 to match the file's
493
+ // existing permissions (it already holds the placeholder)
494
+ // - All other mcpServers entries preserved verbatim
495
+ //
496
+ // Returns one of: { status: 'updated', path }, { status: 'already-set', path },
497
+ // { status: 'no-file' }, { status: 'no-supabase-entry', path },
498
+ // { status: 'no-token-in-env' }, { status: 'malformed', path, error }.
499
+ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
500
+ const fsImpl = _testFs || fs;
501
+ const tokenValue = token || process.env.SUPABASE_ACCESS_TOKEN;
502
+ if (!tokenValue) return { status: 'no-token-in-env' };
503
+
504
+ const targetPath = mcpJsonPath || path.join(os.homedir(), '.claude', 'mcp.json');
505
+ if (!fsImpl.existsSync(targetPath)) return { status: 'no-file' };
506
+
507
+ let raw;
508
+ try {
509
+ raw = fsImpl.readFileSync(targetPath, 'utf-8');
510
+ } catch (err) {
511
+ return { status: 'malformed', path: targetPath, error: err.message };
512
+ }
513
+
514
+ let cfg;
515
+ try {
516
+ cfg = JSON.parse(raw);
517
+ } catch (err) {
518
+ return { status: 'malformed', path: targetPath, error: err.message };
519
+ }
520
+
521
+ const supabaseEntry = cfg && cfg.mcpServers && cfg.mcpServers.supabase;
522
+ if (!supabaseEntry || typeof supabaseEntry !== 'object') {
523
+ return { status: 'no-supabase-entry', path: targetPath };
524
+ }
525
+
526
+ supabaseEntry.env = supabaseEntry.env || {};
527
+ const current = supabaseEntry.env.SUPABASE_ACCESS_TOKEN;
528
+ if (current === tokenValue) return { status: 'already-set', path: targetPath };
529
+ if (current && current !== 'SUPABASE_PAT_HERE') {
530
+ // User has set a real token already — don't touch it.
531
+ return { status: 'already-set', path: targetPath };
532
+ }
533
+
534
+ supabaseEntry.env.SUPABASE_ACCESS_TOKEN = tokenValue;
535
+
536
+ const tmpPath = `${targetPath}.tmp.${process.pid}`;
537
+ fsImpl.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
538
+ fsImpl.renameSync(tmpPath, targetPath);
539
+ try { fsImpl.chmodSync(targetPath, 0o600); } catch (_e) { /* best-effort */ }
540
+
541
+ return { status: 'updated', path: targetPath };
542
+ }
543
+
458
544
  function printNextSteps(projectRef) {
459
545
  const functionUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
460
546
  const now = new Date();
@@ -508,6 +594,26 @@ async function main(argv) {
508
594
  }
509
595
 
510
596
  if (!(await link(projectRef, flags.dryRun))) return 4;
597
+
598
+ // Backfill SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json now that
599
+ // `supabase link` succeeded (the token is verified-real). The
600
+ // meta-installer wrote a literal 'SUPABASE_PAT_HERE' placeholder
601
+ // there during Tier 4 install — this closes that loop.
602
+ if (!flags.dryRun) {
603
+ const r = wireAccessTokenInMcpJson();
604
+ if (r.status === 'updated') {
605
+ step('Backfilled SUPABASE_ACCESS_TOKEN into ~/.claude/mcp.json...');
606
+ ok();
607
+ } else if (r.status === 'malformed') {
608
+ process.stderr.write(
609
+ `\n ! ${r.path} is not valid JSON — skipping token backfill (${r.error}).\n` +
610
+ ` Update the supabase mcpServers entry manually if Claude Code's Supabase MCP is misbehaving.\n\n`
611
+ );
612
+ }
613
+ // Other statuses (no-file, no-supabase-entry, no-token-in-env,
614
+ // already-set) are silent — they're all expected paths.
615
+ }
616
+
511
617
  if (!(await applyRumenTables(secrets, flags.dryRun))) return 5;
512
618
 
513
619
  step('Resolving @jhizzard/rumen version from npm registry...');
@@ -545,3 +651,4 @@ module.exports = main;
545
651
  // Test surface — kept on the same export object so the regression suite can
546
652
  // pin the access-token detection without spawning a real `supabase` binary.
547
653
  module.exports._looksLikeMissingAccessToken = looksLikeMissingAccessToken;
654
+ module.exports._wireAccessTokenInMcpJson = wireAccessTokenInMcpJson;
@@ -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
  };