@jhizzard/termdeck 1.9.0 → 1.10.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.
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +11 -0
- package/packages/cli/src/init-mnestra.js +104 -13
- package/packages/cli/src/init-rumen.js +7 -0
- package/packages/server/src/health.js +21 -3
- package/packages/server/src/index.js +74 -8
- package/packages/server/src/preflight.js +14 -5
- package/packages/server/src/setup/rumen/functions/inbox-promote/index.ts +105 -0
- package/packages/server/src/setup/rumen/functions/inbox-promote/tsconfig.json +14 -0
- package/packages/server/src/setup/supabase-url.js +101 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
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"
|
|
@@ -346,6 +346,16 @@ async function _runSchemaCheck(opts = {}) {
|
|
|
346
346
|
let client = optsObj._pgClient || null;
|
|
347
347
|
let ownsClient = false;
|
|
348
348
|
if (!client) {
|
|
349
|
+
// Sprint 75 T2 (part C): classify + warn BEFORE the connect attempt —
|
|
350
|
+
// a direct-endpoint URL on an IPv4-only host doesn't fail fast, it
|
|
351
|
+
// hangs until a pool timeout, so the warning must print first.
|
|
352
|
+
// Warn-only; fail-soft if the helper is unavailable.
|
|
353
|
+
try {
|
|
354
|
+
const urlHelper = require(path.join(SETUP_DIR, 'supabase-url'));
|
|
355
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(secrets.DATABASE_URL))) {
|
|
356
|
+
process.stdout.write(` ${line}\n`);
|
|
357
|
+
}
|
|
358
|
+
} catch (_e) { /* warn-only — never block the doctor pass */ }
|
|
349
359
|
try {
|
|
350
360
|
client = await pgRunner.connect(secrets.DATABASE_URL);
|
|
351
361
|
ownsClient = true;
|
|
@@ -524,6 +534,7 @@ function renderSchemaResult(result, c) {
|
|
|
524
534
|
if (result.connectError) {
|
|
525
535
|
out.push(` ${c.yellow('✗')} could not connect: ${result.connectError}`);
|
|
526
536
|
out.push(` ${c.dim('Check DATABASE_URL in ~/.termdeck/secrets.env, then re-run.')}`);
|
|
537
|
+
out.push(` ${c.dim('If this host is IPv4-only and the URL is the db.<project-ref> direct endpoint, that is the cause — switch to the Shared Pooler.')}`);
|
|
527
538
|
return out.join('\n');
|
|
528
539
|
}
|
|
529
540
|
for (const section of result.sections) {
|
|
@@ -72,8 +72,9 @@ const HELP = [
|
|
|
72
72
|
' --skip-verify Skip the final memory_status_aggregation() sanity call',
|
|
73
73
|
'',
|
|
74
74
|
'What this does:',
|
|
75
|
-
' 1. Prompts for Supabase URL, service_role key,
|
|
76
|
-
' string, OpenAI API key, and (optional)
|
|
75
|
+
' 1. Prompts for Supabase URL, service_role key, Postgres connection',
|
|
76
|
+
' string (Shared Pooler; IPv4-safe), OpenAI API key, and (optional)',
|
|
77
|
+
' Anthropic API key — or reuses',
|
|
77
78
|
' saved values if a complete set already exists in secrets.env.',
|
|
78
79
|
' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
|
|
79
80
|
' pg connect or migration failure does not lose what you typed in.',
|
|
@@ -157,6 +158,11 @@ function inputsFromEnv() {
|
|
|
157
158
|
|
|
158
159
|
const dbErr = urlHelper.looksLikePostgresUrl(required.DATABASE_URL);
|
|
159
160
|
if (dbErr) throw new Error(`DATABASE_URL: ${dbErr}`);
|
|
161
|
+
// Sprint 75 T2 (part B): warn-only — a direct-endpoint URL passes
|
|
162
|
+
// validation (warn ≠ reject) but gets the IPv4 trap warning printed once.
|
|
163
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(required.DATABASE_URL))) {
|
|
164
|
+
process.stdout.write(` ${line}\n`);
|
|
165
|
+
}
|
|
160
166
|
|
|
161
167
|
const srErr = urlHelper.looksLikeServiceRole(required.SUPABASE_SERVICE_ROLE_KEY);
|
|
162
168
|
if (srErr) throw new Error(`SUPABASE_SERVICE_ROLE_KEY: ${srErr}`);
|
|
@@ -188,7 +194,7 @@ TermDeck Mnestra Setup
|
|
|
188
194
|
|
|
189
195
|
This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
|
|
190
196
|
1. Asking for your Supabase URL and service_role key
|
|
191
|
-
2. Asking for a
|
|
197
|
+
2. Asking for a Postgres connection string (Shared Pooler)
|
|
192
198
|
3. Asking for an OpenAI API key (embeddings)
|
|
193
199
|
4. Asking for an Anthropic API key (optional, summaries)
|
|
194
200
|
5. Writing ~/.termdeck/secrets.env (before any database work, so a
|
|
@@ -253,6 +259,12 @@ async function collectInputs({ yes, reset }) {
|
|
|
253
259
|
process.stdout.write(
|
|
254
260
|
`Found saved secrets in ~/.termdeck/secrets.env (project ${ref}, db ${masked}).\n`
|
|
255
261
|
);
|
|
262
|
+
// Sprint 75 T2 (part B): highest-value warn site — an operator whose
|
|
263
|
+
// EARLIER install stored a direct-endpoint URL (the Brad case) would
|
|
264
|
+
// otherwise sail through reuse with zero feedback. Warn-only.
|
|
265
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(found.inputs.databaseUrl))) {
|
|
266
|
+
process.stdout.write(` ${line}\n`);
|
|
267
|
+
}
|
|
256
268
|
const reuse = yes ? true : await prompts.confirm(' Reuse saved secrets?', { defaultYes: true });
|
|
257
269
|
if (reuse) {
|
|
258
270
|
process.stdout.write(' Reusing saved secrets. Skipping prompts.\n\n');
|
|
@@ -284,11 +296,19 @@ async function collectInputs({ yes, reset }) {
|
|
|
284
296
|
);
|
|
285
297
|
|
|
286
298
|
process.stdout.write(
|
|
287
|
-
'?
|
|
288
|
-
|
|
289
|
-
'
|
|
299
|
+
'? Postgres connection string (Shared Pooler)\n' +
|
|
300
|
+
' (Supabase dashboard → Connect (green button) → Transaction pooler →\n' +
|
|
301
|
+
' toggle ON "Use IPv4 connection (Shared Pooler)" — the OFF default shows an\n' +
|
|
302
|
+
' IPv6-only URL that hangs on IPv4-only hosts)\n' +
|
|
303
|
+
' postgres://postgres.<project-ref>:PW@aws-<n>-<region>.pooler.supabase.com:6543/postgres '
|
|
290
304
|
);
|
|
291
305
|
const databaseUrl = await promptSecretWithValidation(urlHelper.looksLikePostgresUrl);
|
|
306
|
+
// Sprint 75 T2 (part B): warn-only endpoint-shape feedback. The validator
|
|
307
|
+
// above ACCEPTS direct URLs (IPv6-capable hosts use them legitimately);
|
|
308
|
+
// this prints the IPv4 trap warning without changing acceptance.
|
|
309
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(databaseUrl))) {
|
|
310
|
+
process.stdout.write(` ${line}\n`);
|
|
311
|
+
}
|
|
292
312
|
|
|
293
313
|
process.stdout.write('? OpenAI API key (starts sk-proj- or sk-): ');
|
|
294
314
|
const openaiKey = await promptSecretWithValidation(urlHelper.looksLikeOpenAiKey);
|
|
@@ -713,13 +733,34 @@ function refreshBundledHookIfNewer(opts = {}) {
|
|
|
713
733
|
// actually run after upgrading the package.
|
|
714
734
|
|
|
715
735
|
const SETTINGS_JSON_PATH = path.join(require('os').homedir(), '.claude', 'settings.json');
|
|
716
|
-
|
|
736
|
+
|
|
737
|
+
// Sprint 75 T2 — hook commands are written into ~/.claude/settings.json with
|
|
738
|
+
// ABSOLUTE paths. The pre-1.10 literal `node ~/.claude/hooks/...` shape relied
|
|
739
|
+
// on shell tilde expansion — it worked on macOS/Linux only by luck of how the
|
|
740
|
+
// harness invokes hook commands, and is a hard break on Windows (audit item 4).
|
|
741
|
+
// Computed at CALL time (not require time) from os.homedir() so a process that
|
|
742
|
+
// re-points HOME (tests, sandboxed installs) gets the right path. The path is
|
|
743
|
+
// double-quoted so a home dir containing spaces (`/Users/First Last/`) still
|
|
744
|
+
// produces a command the harness shell can execute. Lockstep twin lives in
|
|
745
|
+
// packages/stack-installer/src/index.js (`_hookCommandFor`) — INSTALLER-
|
|
746
|
+
// PITFALLS Class N: change both or neither.
|
|
747
|
+
function _hookCommandFor(filename) {
|
|
748
|
+
return `node "${path.join(require('os').homedir(), '.claude', 'hooks', filename)}"`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// True when an entry's command still carries the legacy tilde shape and
|
|
752
|
+
// should be rewritten to the absolute form.
|
|
753
|
+
function _isTildeHookCommand(command) {
|
|
754
|
+
return typeof command === 'string' && command.includes('~/');
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const HOOK_COMMAND = _hookCommandFor('memory-session-end.js');
|
|
717
758
|
const HOOK_TIMEOUT_SECONDS = 30;
|
|
718
759
|
|
|
719
760
|
// Sprint 64 T3 — PreCompact hook (Investigation 2 of CRITICAL-READ-FIRST-
|
|
720
761
|
// 2026-05-07.md). Lives alongside the SessionEnd hook; refreshes via the
|
|
721
762
|
// same Sprint 51.6 T3 version-stamp gate.
|
|
722
|
-
const PRECOMPACT_HOOK_COMMAND = '
|
|
763
|
+
const PRECOMPACT_HOOK_COMMAND = _hookCommandFor('memory-pre-compact.js');
|
|
723
764
|
const PRECOMPACT_HOOK_TIMEOUT_SECONDS = 30;
|
|
724
765
|
|
|
725
766
|
function _isSessionEndHookEntry(entry) {
|
|
@@ -734,7 +775,8 @@ function _isSessionEndHookEntry(entry) {
|
|
|
734
775
|
// `packages/stack-installer/src/index.js:451` byte-for-byte (modulo
|
|
735
776
|
// constants pulled from this file's scope).
|
|
736
777
|
function _mergeSessionEndHookEntry(settings, opts = {}) {
|
|
737
|
-
|
|
778
|
+
// Command computed at call time (Sprint 75 T2) — see _hookCommandFor.
|
|
779
|
+
const command = opts.command || _hookCommandFor('memory-session-end.js');
|
|
738
780
|
const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
|
|
739
781
|
const entry = { type: 'command', command, timeout };
|
|
740
782
|
|
|
@@ -759,10 +801,29 @@ function _mergeSessionEndHookEntry(settings, opts = {}) {
|
|
|
759
801
|
|
|
760
802
|
if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
|
|
761
803
|
|
|
804
|
+
// Sprint 75 T2 — rewrite a stale literal-`~` command (written by installers
|
|
805
|
+
// ≤ v1.9.x) to the absolute form. The "already wired?" predicate matches by
|
|
806
|
+
// hook FILENAME substring, so without this rewrite a legacy entry would be
|
|
807
|
+
// reported already-installed and keep its `~` forever. Idempotent: absolute
|
|
808
|
+
// commands (and user-custom commands without `~/`) are never touched.
|
|
809
|
+
let tildeMigrated = false;
|
|
810
|
+
for (const group of settings.hooks.SessionEnd) {
|
|
811
|
+
if (!group || !Array.isArray(group.hooks)) continue;
|
|
812
|
+
for (const e of group.hooks) {
|
|
813
|
+
if (_isSessionEndHookEntry(e) && _isTildeHookCommand(e.command)) {
|
|
814
|
+
e.command = command;
|
|
815
|
+
tildeMigrated = true;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
762
820
|
for (const group of settings.hooks.SessionEnd) {
|
|
763
821
|
if (!group || !Array.isArray(group.hooks)) continue;
|
|
764
822
|
if (group.hooks.some(_isSessionEndHookEntry)) {
|
|
765
|
-
|
|
823
|
+
const status = tildeMigrated ? 'migrated-tilde-path'
|
|
824
|
+
: migrated ? 'migrated-from-stop'
|
|
825
|
+
: 'already-installed';
|
|
826
|
+
return { settings, status };
|
|
766
827
|
}
|
|
767
828
|
}
|
|
768
829
|
|
|
@@ -788,17 +849,30 @@ function _isPreCompactHookEntry(entry) {
|
|
|
788
849
|
}
|
|
789
850
|
|
|
790
851
|
function _mergePreCompactHookEntry(settings, opts = {}) {
|
|
791
|
-
|
|
852
|
+
// Command computed at call time (Sprint 75 T2) — see _hookCommandFor.
|
|
853
|
+
const command = opts.command || _hookCommandFor('memory-pre-compact.js');
|
|
792
854
|
const timeout = opts.timeout != null ? opts.timeout : PRECOMPACT_HOOK_TIMEOUT_SECONDS;
|
|
793
855
|
const entry = { type: 'command', command, timeout };
|
|
794
856
|
|
|
795
857
|
if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
|
|
796
858
|
if (!Array.isArray(settings.hooks.PreCompact)) settings.hooks.PreCompact = [];
|
|
797
859
|
|
|
860
|
+
// Sprint 75 T2 — same stale literal-`~` rewrite as the SessionEnd merge.
|
|
861
|
+
let tildeMigrated = false;
|
|
862
|
+
for (const group of settings.hooks.PreCompact) {
|
|
863
|
+
if (!group || !Array.isArray(group.hooks)) continue;
|
|
864
|
+
for (const e of group.hooks) {
|
|
865
|
+
if (_isPreCompactHookEntry(e) && _isTildeHookCommand(e.command)) {
|
|
866
|
+
e.command = command;
|
|
867
|
+
tildeMigrated = true;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
798
872
|
for (const group of settings.hooks.PreCompact) {
|
|
799
873
|
if (!group || !Array.isArray(group.hooks)) continue;
|
|
800
874
|
if (group.hooks.some(_isPreCompactHookEntry)) {
|
|
801
|
-
return { settings, status: 'already-installed' };
|
|
875
|
+
return { settings, status: tildeMigrated ? 'migrated-tilde-path' : 'already-installed' };
|
|
802
876
|
}
|
|
803
877
|
}
|
|
804
878
|
|
|
@@ -934,10 +1008,14 @@ function runSettingsJsonMigration({ dryRun = false } = {}) {
|
|
|
934
1008
|
ok(r.backup ? `installed (SessionEnd; backup: ${path.basename(r.backup)})` : 'installed (SessionEnd)');
|
|
935
1009
|
} else if (r.status === 'migrated-from-stop') {
|
|
936
1010
|
ok(r.backup ? `migrated Stop → SessionEnd (was firing on every turn; backup: ${path.basename(r.backup)})` : 'migrated Stop → SessionEnd (was firing on every turn)');
|
|
1011
|
+
} else if (r.status === 'migrated-tilde-path') {
|
|
1012
|
+
ok(r.backup ? `rewrote legacy ~ command to absolute path (backup: ${path.basename(r.backup)})` : 'rewrote legacy ~ command to absolute path');
|
|
937
1013
|
} else if (r.status === 'would-installed') {
|
|
938
1014
|
ok('would install (SessionEnd) (dry-run)');
|
|
939
1015
|
} else if (r.status === 'would-migrated-from-stop') {
|
|
940
1016
|
ok('would migrate Stop → SessionEnd (dry-run)');
|
|
1017
|
+
} else if (r.status === 'would-migrated-tilde-path') {
|
|
1018
|
+
ok('would rewrite legacy ~ command to absolute path (dry-run)');
|
|
941
1019
|
} else if (r.status === 'malformed') {
|
|
942
1020
|
ok(`(skipped: settings.json malformed: ${r.error})`);
|
|
943
1021
|
} else {
|
|
@@ -964,8 +1042,12 @@ function runSettingsJsonMigration({ dryRun = false } = {}) {
|
|
|
964
1042
|
ok('already wired (PreCompact)');
|
|
965
1043
|
} else if (r.status === 'installed') {
|
|
966
1044
|
ok(r.backup ? `installed (PreCompact; backup: ${path.basename(r.backup)})` : 'installed (PreCompact)');
|
|
1045
|
+
} else if (r.status === 'migrated-tilde-path') {
|
|
1046
|
+
ok(r.backup ? `rewrote legacy ~ command to absolute path (backup: ${path.basename(r.backup)})` : 'rewrote legacy ~ command to absolute path');
|
|
967
1047
|
} else if (r.status === 'would-installed') {
|
|
968
1048
|
ok('would install (PreCompact) (dry-run)');
|
|
1049
|
+
} else if (r.status === 'would-migrated-tilde-path') {
|
|
1050
|
+
ok('would rewrite legacy ~ command to absolute path (dry-run)');
|
|
969
1051
|
} else if (r.status === 'malformed') {
|
|
970
1052
|
ok(`(skipped: settings.json malformed: ${r.error})`);
|
|
971
1053
|
} else {
|
|
@@ -1162,7 +1244,10 @@ async function main(argv) {
|
|
|
1162
1244
|
} catch (err) {
|
|
1163
1245
|
fail(err.message);
|
|
1164
1246
|
process.stderr.write(
|
|
1165
|
-
'\nDouble-check the connection string
|
|
1247
|
+
'\nDouble-check the connection string: Supabase dashboard → Connect → Transaction pooler →\n' +
|
|
1248
|
+
'toggle ON "Use IPv4 connection (Shared Pooler)". If the connect HUNG (timeout rather than\n' +
|
|
1249
|
+
'auth error), the URL is probably the IPv6-only db.<project-ref> endpoint and this host has\n' +
|
|
1250
|
+
'no IPv6 route — use the Shared Pooler URL.\n'
|
|
1166
1251
|
);
|
|
1167
1252
|
printResumeHint();
|
|
1168
1253
|
return 3;
|
|
@@ -1233,3 +1318,9 @@ module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
|
|
|
1233
1318
|
module.exports.SETTINGS_JSON_PATH = SETTINGS_JSON_PATH;
|
|
1234
1319
|
module.exports.HOOK_COMMAND = HOOK_COMMAND;
|
|
1235
1320
|
module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
|
|
1321
|
+
// Sprint 75 T2 — absolute-path hook-command builders (lockstep twin in
|
|
1322
|
+
// packages/stack-installer/src/index.js; exported for tests).
|
|
1323
|
+
module.exports._hookCommandFor = _hookCommandFor;
|
|
1324
|
+
module.exports._isTildeHookCommand = _isTildeHookCommand;
|
|
1325
|
+
module.exports._mergePreCompactHookEntry = _mergePreCompactHookEntry;
|
|
1326
|
+
module.exports.migrateSettingsJsonPreCompactEntry = migrateSettingsJsonPreCompactEntry;
|
|
@@ -212,6 +212,13 @@ function preflight() {
|
|
|
212
212
|
ok();
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// Sprint 75 T2 (part B): warn-only endpoint-shape feedback — the same
|
|
216
|
+
// stored URL feeds the Edge Function secrets, so a direct-endpoint URL
|
|
217
|
+
// (IPv6-only) carried over from an earlier install gets flagged here too.
|
|
218
|
+
for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(secrets.DATABASE_URL))) {
|
|
219
|
+
process.stdout.write(` ${line}\n`);
|
|
220
|
+
}
|
|
221
|
+
|
|
215
222
|
// OPENAI_API_KEY is optional: when present, Rumen's Relate phase generates
|
|
216
223
|
// real embeddings for semantic+keyword hybrid search. When absent, Rumen
|
|
217
224
|
// falls back to keyword-only matching (still works, but loses cross-project
|
|
@@ -64,6 +64,21 @@
|
|
|
64
64
|
|
|
65
65
|
const http = require('http');
|
|
66
66
|
const https = require('https');
|
|
67
|
+
// Sprint 75 T2 (part C): endpoint-shape classifier — could-not-connect
|
|
68
|
+
// envelopes name the IPv6-only direct endpoint as the likely cause when
|
|
69
|
+
// DATABASE_URL has that shape. Warn-only suffix; never changes categories.
|
|
70
|
+
const { classifyDbEndpoint } = require('./setup/supabase-url');
|
|
71
|
+
|
|
72
|
+
// Suffix appended to connect-failure details when DATABASE_URL is the
|
|
73
|
+
// IPv6-only direct endpoint (db.<project-ref>.supabase.co — AAAA-only DNS).
|
|
74
|
+
function directEndpointSuffix(databaseUrl) {
|
|
75
|
+
try {
|
|
76
|
+
if (classifyDbEndpoint(databaseUrl).kind === 'direct') {
|
|
77
|
+
return ' (DATABASE_URL is the IPv6-only db.<project-ref> direct endpoint — on IPv4-only hosts pg clients hang until a pool/connect timeout; use the Shared Pooler URL)';
|
|
78
|
+
}
|
|
79
|
+
} catch (_e) { /* warn-only */ }
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
67
82
|
|
|
68
83
|
const TTL_SECONDS = 30;
|
|
69
84
|
const TTL_MS = TTL_SECONDS * 1000;
|
|
@@ -276,9 +291,10 @@ async function runPgChecks({ databaseUrl, _pgClient }) {
|
|
|
276
291
|
} else {
|
|
277
292
|
// URL set but connect failed → classify by code (timeout vs unreachable).
|
|
278
293
|
const cat = classifyDbFailure(connectEnvelope || {});
|
|
279
|
-
const why = connectEnvelope && connectEnvelope.error
|
|
294
|
+
const why = (connectEnvelope && connectEnvelope.error
|
|
280
295
|
? `could not connect to Postgres using DATABASE_URL — ${connectEnvelope.error}`
|
|
281
|
-
: 'could not connect to Postgres using DATABASE_URL'
|
|
296
|
+
: 'could not connect to Postgres using DATABASE_URL')
|
|
297
|
+
+ directEndpointSuffix(databaseUrl);
|
|
282
298
|
pushPgUnavailableChecks(checks, 'mnestra-pg', cat, why, 'pg unavailable — connect failed');
|
|
283
299
|
}
|
|
284
300
|
return checks;
|
|
@@ -479,7 +495,9 @@ async function checkRumenPool(config, options) {
|
|
|
479
495
|
return warnCheck('rumen-pool', CATEGORIES.DEPENDENCY_DOWN, 'SELECT 1 returned unexpected result');
|
|
480
496
|
} catch (err) {
|
|
481
497
|
const cat = classifyDbFailure(err);
|
|
482
|
-
|
|
498
|
+
const detail = (err && err.message ? err.message : String(err))
|
|
499
|
+
+ directEndpointSuffix(dbUrl);
|
|
500
|
+
return warnCheck('rumen-pool', cat, detail);
|
|
483
501
|
} finally {
|
|
484
502
|
try { await pool.end(); } catch (_e) { /* ignore */ }
|
|
485
503
|
}
|
|
@@ -2350,7 +2350,7 @@ function createServer(config) {
|
|
|
2350
2350
|
// Body: { text: string, source?: 'user' | 'reply' | 'ai', fromSessionId?: string }
|
|
2351
2351
|
// Used by T1.3 reply button and any agent-to-agent routing.
|
|
2352
2352
|
const inputRateLimit = new Map(); // sessionId -> { windowStart, count }
|
|
2353
|
-
app.post('/api/sessions/:id/input', (req, res) => {
|
|
2353
|
+
app.post('/api/sessions/:id/input', async (req, res) => {
|
|
2354
2354
|
const session = sessions.get(req.params.id);
|
|
2355
2355
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
2356
2356
|
// Sprint 72 T2 — web-chat panels have no PTY. Route the inject to the
|
|
@@ -2428,7 +2428,7 @@ function createServer(config) {
|
|
|
2428
2428
|
});
|
|
2429
2429
|
}
|
|
2430
2430
|
|
|
2431
|
-
const { text, source, fromSessionId } = req.body || {};
|
|
2431
|
+
const { text, source, fromSessionId, submit } = req.body || {};
|
|
2432
2432
|
if (typeof text !== 'string') {
|
|
2433
2433
|
return res.status(400).json({ error: 'Missing text' });
|
|
2434
2434
|
}
|
|
@@ -2449,11 +2449,65 @@ function createServer(config) {
|
|
|
2449
2449
|
// CRLF normalize: zsh/readline want \r for Enter
|
|
2450
2450
|
const normalized = text.replace(/\r\n?/g, '\r').replace(/\n/g, '\r');
|
|
2451
2451
|
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2452
|
+
// Sprint 76.1 (Bug B — Brad's "POST /input returns 200 but never submits"):
|
|
2453
|
+
// optional server-sequenced submit. The documented two-stage inject (paste
|
|
2454
|
+
// body, ~400ms settle, then a lone `\r` as a SECOND POST) is a CALLER-side
|
|
2455
|
+
// race — when the bracketed-paste close marker and the `\r` ride one PTY
|
|
2456
|
+
// write the foreground TUI absorbs the `\r` as paste content, so under
|
|
2457
|
+
// concurrent / mid-turn injects the submit is silently swallowed and the
|
|
2458
|
+
// text sits unsubmitted (a 200 here only ever meant "bytes written", not
|
|
2459
|
+
// "became a turn"). With `submit:true` the SERVER owns the ordering: write
|
|
2460
|
+
// the body, await the settle, then write a lone `\r` as its OWN PTY write —
|
|
2461
|
+
// the OS chunk-boundary race is impossible because the two writes are
|
|
2462
|
+
// distinct with a server-held gap between them. Mirrors the web-chat arm's
|
|
2463
|
+
// server-side assembly above. Absent/falsy `submit` ⇒ byte-identical to the
|
|
2464
|
+
// pre-76.1 pass-through (existing two-stage callers are untouched).
|
|
2465
|
+
let bytesWritten;
|
|
2466
|
+
let submitted;
|
|
2467
|
+
if (submit === true) {
|
|
2468
|
+
const rawSettle = process.env.TERMDECK_INPUT_SUBMIT_SETTLE_MS;
|
|
2469
|
+
const parsedSettle = Number(rawSettle);
|
|
2470
|
+
const settleMs = (rawSettle !== undefined && rawSettle !== ''
|
|
2471
|
+
&& Number.isFinite(parsedSettle) && parsedSettle >= 0) ? parsedSettle : 400;
|
|
2472
|
+
// Strip any caller-supplied trailing CR so the body never self-submits;
|
|
2473
|
+
// the lone `\r` below is the one and only submit keystroke.
|
|
2474
|
+
const body = normalized.replace(/\r+$/, '');
|
|
2475
|
+
try {
|
|
2476
|
+
if (body) { session.pty.write(body); session.trackInput(body); }
|
|
2477
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
2478
|
+
// The PTY can be torn down DURING the server-held settle (panel closed
|
|
2479
|
+
// mid-submit). Re-validate before the submit keystroke and return a
|
|
2480
|
+
// clean 410 (the route's exited-panel convention) instead of leaning on
|
|
2481
|
+
// the catch below to surface a generic 500 — so a caller can tell
|
|
2482
|
+
// "panel closed mid-submit, don't retry" from a real write error. The
|
|
2483
|
+
// try/catch remains the backstop for any unexpected write throw.
|
|
2484
|
+
if (session.meta.status === 'exited' || !session.pty) {
|
|
2485
|
+
const msg = `Panel ${req.params.id} exited during submit settle`;
|
|
2486
|
+
return res.status(410).json({
|
|
2487
|
+
ok: false, code: 'panel_exited', error: msg, message: msg,
|
|
2488
|
+
exitCode: session.meta.exitCode ?? null,
|
|
2489
|
+
exitedAt: session.meta.exitedAt || null,
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
session.pty.write('\r');
|
|
2493
|
+
session.trackInput('\r');
|
|
2494
|
+
} catch (err) {
|
|
2495
|
+
return res.status(500).json({ error: err.message });
|
|
2496
|
+
}
|
|
2497
|
+
bytesWritten = body.length + 1;
|
|
2498
|
+
// The server completed the atomic submit sequence. This is the mechanical
|
|
2499
|
+
// guarantee that removes the caller-side `\r`-swallow race; `status` below
|
|
2500
|
+
// is the best-effort "did the TUI start the turn" signal (adapter-derived,
|
|
2501
|
+
// so it may lag a beat on a freshly-idle panel).
|
|
2502
|
+
submitted = true;
|
|
2503
|
+
} else {
|
|
2504
|
+
try {
|
|
2505
|
+
session.pty.write(normalized);
|
|
2506
|
+
session.trackInput(normalized);
|
|
2507
|
+
} catch (err) {
|
|
2508
|
+
return res.status(500).json({ error: err.message });
|
|
2509
|
+
}
|
|
2510
|
+
bytesWritten = normalized.length;
|
|
2457
2511
|
}
|
|
2458
2512
|
|
|
2459
2513
|
session.meta.replyCount = (session.meta.replyCount || 0) + 1;
|
|
@@ -2471,7 +2525,19 @@ function createServer(config) {
|
|
|
2471
2525
|
}
|
|
2472
2526
|
}
|
|
2473
2527
|
|
|
2474
|
-
|
|
2528
|
+
// submit-confirm: callers (e.g. Brad's tg-poll re-inject) read
|
|
2529
|
+
// `status` / `inputBufferLength` to detect a stuck inject and retry
|
|
2530
|
+
// deterministically instead of separately polling GET /buffer. `submitted`
|
|
2531
|
+
// is present only when `submit:true` was requested.
|
|
2532
|
+
const responseBody = {
|
|
2533
|
+
ok: true,
|
|
2534
|
+
bytes: bytesWritten,
|
|
2535
|
+
replyCount: session.meta.replyCount,
|
|
2536
|
+
status: session.meta.status,
|
|
2537
|
+
inputBufferLength: (session._inputBuffer || '').length,
|
|
2538
|
+
};
|
|
2539
|
+
if (submit === true) responseBody.submitted = submitted;
|
|
2540
|
+
res.json(responseBody);
|
|
2475
2541
|
});
|
|
2476
2542
|
|
|
2477
2543
|
// POST /api/sessions/:id/upload?name=<filename> - File drop / clipboard image paste
|
|
@@ -10,6 +10,9 @@ const http = require('http');
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const os = require('os');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
// Sprint 75 T2 (part C): endpoint-shape classifier — used to explain
|
|
14
|
+
// connect failures against the IPv6-only direct endpoint. Warn-only.
|
|
15
|
+
const { classifyDbEndpoint } = require('./setup/supabase-url');
|
|
13
16
|
|
|
14
17
|
// Cache preflight results for 60s
|
|
15
18
|
let _cachedResult = null;
|
|
@@ -352,10 +355,16 @@ async function runPreflight(config) {
|
|
|
352
355
|
name: 'rumen_recent', passed: false,
|
|
353
356
|
detail: `check failed — ${err.message}`,
|
|
354
357
|
})),
|
|
355
|
-
checkDatabase().catch((err) =>
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
358
|
+
checkDatabase().catch((err) => {
|
|
359
|
+
// Sprint 75 T2 (part C): a connect failure against the IPv6-only
|
|
360
|
+
// direct endpoint (db.<project-ref>.supabase.co) on an IPv4-only
|
|
361
|
+
// host presents as a timeout — name the likely cause in the detail.
|
|
362
|
+
let detail = `connection failed — ${err.message}`;
|
|
363
|
+
if (classifyDbEndpoint(process.env.DATABASE_URL).kind === 'direct') {
|
|
364
|
+
detail += ' (DATABASE_URL is the IPv6-only db.<project-ref> direct endpoint — on IPv4-only hosts pg clients hang until a pool/connect timeout; use the Shared Pooler URL)';
|
|
365
|
+
}
|
|
366
|
+
return { name: 'database_url', passed: false, detail };
|
|
367
|
+
}),
|
|
359
368
|
checkProjectPaths(config).catch((err) => ({
|
|
360
369
|
name: 'project_paths', passed: false,
|
|
361
370
|
detail: `check failed — ${err.message}`,
|
|
@@ -413,7 +422,7 @@ const REMEDIATION = {
|
|
|
413
422
|
mnestra_reachable: 'Start Mnestra with `mnestra serve`',
|
|
414
423
|
mnestra_has_memories: 'Run `mnestra ingest` to populate the memory store',
|
|
415
424
|
rumen_recent: 'Check Rumen Edge Function deployment or run `termdeck init --rumen`',
|
|
416
|
-
database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env',
|
|
425
|
+
database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env (IPv4-only hosts: use the Shared Pooler URL)',
|
|
417
426
|
project_paths: 'Fix paths in ~/.termdeck/config.yaml → projects',
|
|
418
427
|
shell_sanity: 'Check $SHELL and your login profile (~/.zshrc or ~/.bashrc)',
|
|
419
428
|
graph_health: 'Run T2 inference cron or apply migrations 009/010 to populate edges',
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Rumen Sprint 76 — inbox-promote Supabase Edge Function entry point.
|
|
2
|
+
//
|
|
3
|
+
// Drains Mnestra's memory_inbox quarantine: web-chat proposals (written via
|
|
4
|
+
// the bridge's memory_propose channel into engram migration 026's table) are
|
|
5
|
+
// promoted to canonical memory_items or rejected with an audit trail. One
|
|
6
|
+
// promotion pass per invocation; see src/promote.ts in @jhizzard/rumen for
|
|
7
|
+
// the gate sequence (caps -> source whitelist -> rate cap -> dedup ->
|
|
8
|
+
// kitchen-vs-recipe) and the doctrine amendment notes.
|
|
9
|
+
//
|
|
10
|
+
// Sibling of rumen-tick by design (NOT a step inside it): budget isolation
|
|
11
|
+
// (the tick already spends its wall-clock on the insight cycle), independent
|
|
12
|
+
// cadence, and failure isolation. Same thin-wrapper pattern: the npm:
|
|
13
|
+
// specifier freezes the package version at DEPLOY time — upgrading
|
|
14
|
+
// @jhizzard/rumen does nothing until this function is redeployed (the
|
|
15
|
+
// Sprint 66 Brad-Rumen-zero lesson).
|
|
16
|
+
//
|
|
17
|
+
// IMPORTANT: This file targets the Deno runtime, NOT Node. It will not
|
|
18
|
+
// compile under the root tsconfig.json — it is intentionally excluded.
|
|
19
|
+
// A sibling tsconfig.json in this directory keeps the types sane for
|
|
20
|
+
// editors, but the canonical build target is Deno's own type checker
|
|
21
|
+
// (`deno check`) and Supabase's `supabase functions deploy`.
|
|
22
|
+
//
|
|
23
|
+
// Deployment (ORCH at sprint close — deployable, NOT deployed from a lane):
|
|
24
|
+
// supabase functions deploy inbox-promote
|
|
25
|
+
// supabase secrets set DATABASE_URL="$DATABASE_URL" # Shared Pooler IPv4 URL
|
|
26
|
+
// supabase secrets set OPENAI_API_KEY="$OPENAI_API_KEY" # dedup-gate embeddings (3-large@1536)
|
|
27
|
+
// supabase secrets set ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" # kitchen-vs-recipe Haiku gate
|
|
28
|
+
// # Optional tuning (defaults shown):
|
|
29
|
+
// supabase secrets set RUMEN_PROMOTE_BATCH=25
|
|
30
|
+
// supabase secrets set RUMEN_PROMOTE_RATE_CAP_24H=50
|
|
31
|
+
// supabase secrets set RUMEN_PROMOTE_MAX_ATTEMPTS=5
|
|
32
|
+
// supabase secrets set RUMEN_PROMOTE_CLAIM_LEASE_MINUTES=10
|
|
33
|
+
//
|
|
34
|
+
// Both model keys are REQUIRED: without them the pass skips (HTTP 503 below)
|
|
35
|
+
// rather than claiming rows it cannot gate — config absence must not burn
|
|
36
|
+
// promotion attempts across the inbox.
|
|
37
|
+
//
|
|
38
|
+
// Triggered on a schedule by pg_cron — see migrations/003_pg_cron_inbox_promote.sql.
|
|
39
|
+
|
|
40
|
+
// @ts-ignore Deno std import resolved at runtime.
|
|
41
|
+
import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
|
|
42
|
+
// @ts-ignore npm specifier resolved at runtime. Version is stamped at
|
|
43
|
+
// publish/deploy time by ORCH at sprint close — must be >= 0.6.0, the first
|
|
44
|
+
// version exporting promoteInbox.
|
|
45
|
+
import { promoteInbox, createPoolFromUrl } from 'npm:@jhizzard/rumen@0.6.0';
|
|
46
|
+
|
|
47
|
+
// @ts-ignore Deno global available at runtime.
|
|
48
|
+
declare const Deno: { env: { get: (k: string) => string | undefined } };
|
|
49
|
+
|
|
50
|
+
serve(async (_req: Request) => {
|
|
51
|
+
// Same fallback as rumen-tick: Supabase auto-injects SUPABASE_DB_URL.
|
|
52
|
+
const url = Deno.env.get('DATABASE_URL') ?? Deno.env.get('SUPABASE_DB_URL');
|
|
53
|
+
if (!url) {
|
|
54
|
+
console.error('[rumen-promote] DATABASE_URL / SUPABASE_DB_URL not set in Edge Function secrets');
|
|
55
|
+
return new Response(
|
|
56
|
+
JSON.stringify({ ok: false, error: 'DATABASE_URL not set' }),
|
|
57
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pool = createPoolFromUrl(url);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
console.log('[rumen-promote] edge function pass starting');
|
|
65
|
+
const summary = await promoteInbox(pool);
|
|
66
|
+
|
|
67
|
+
if (summary.skipped_reason) {
|
|
68
|
+
// Config-level skip (missing model key, rate-accounting failure):
|
|
69
|
+
// surface as a non-200 so pg_cron/operator dashboards notice, but the
|
|
70
|
+
// inbox is untouched and simply drains on a later pass.
|
|
71
|
+
console.error('[rumen-promote] pass skipped: ' + summary.skipped_reason);
|
|
72
|
+
return new Response(
|
|
73
|
+
JSON.stringify({ ok: false, skipped: summary.skipped_reason, summary }),
|
|
74
|
+
{ status: 503, headers: { 'Content-Type': 'application/json' } },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(
|
|
79
|
+
'[rumen-promote] edge function pass complete claimed=' +
|
|
80
|
+
summary.claimed +
|
|
81
|
+
' promoted=' +
|
|
82
|
+
summary.promoted +
|
|
83
|
+
' rejected=' +
|
|
84
|
+
summary.rejected,
|
|
85
|
+
);
|
|
86
|
+
// Row-level failures are fail-soft by design — the pass itself succeeded.
|
|
87
|
+
return new Response(
|
|
88
|
+
JSON.stringify({ ok: true, summary }),
|
|
89
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
93
|
+
console.error('[rumen-promote] edge function pass threw:', err);
|
|
94
|
+
return new Response(
|
|
95
|
+
JSON.stringify({ ok: false, error: message }),
|
|
96
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
|
97
|
+
);
|
|
98
|
+
} finally {
|
|
99
|
+
try {
|
|
100
|
+
await pool.end();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error('[rumen-promote] pool.end() failed:', err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -186,6 +186,104 @@ function normalizeDatabaseUrl(url) {
|
|
|
186
186
|
return { url: u.toString(), modified: true };
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// ── DATABASE_URL endpoint-shape classification (Sprint 75 T2) ──────────────
|
|
190
|
+
//
|
|
191
|
+
// Ported from engram src/db-endpoint.ts (Sprint 74 T2 — Brad's Dell R730
|
|
192
|
+
// field report, 2026-06-09). Supabase's direct endpoint
|
|
193
|
+
// `db.<project-ref>.supabase.co` — which also hosts the Dedicated Pooler on
|
|
194
|
+
// :6543 — publishes ONLY an AAAA record. On a host without IPv6 (many CI
|
|
195
|
+
// runners and VPSes) pg clients don't fail fast; they hang until a pool
|
|
196
|
+
// timeout. The IPv4-compatible alternative is the Shared Pooler:
|
|
197
|
+
//
|
|
198
|
+
// postgres://postgres.<project-ref>:<pw>@aws-<n>-<region>.pooler.supabase.com:6543/postgres
|
|
199
|
+
//
|
|
200
|
+
// This classifier lets every DATABASE_URL ingress warn BEFORE the first
|
|
201
|
+
// hang. It never rewrites or rejects anything — `looksLikePostgresUrl`
|
|
202
|
+
// stays the blocking validator; direct URLs remain accepted because
|
|
203
|
+
// IPv6-capable hosts use them legitimately. Warn ≠ reject.
|
|
204
|
+
|
|
205
|
+
const LOCAL_DB_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']);
|
|
206
|
+
|
|
207
|
+
// Classify a raw DATABASE_URL string by endpoint family. Returns
|
|
208
|
+
// { kind, host?, port?, username?, poolerUserMismatch? } where kind is one of:
|
|
209
|
+
// 'absent' — nothing usable provided
|
|
210
|
+
// 'invalid' — set but not parseable as postgres:// / postgresql://
|
|
211
|
+
// 'direct' — db.<project-ref>.supabase.co|in (IPv6-only: AAAA, no A
|
|
212
|
+
// record; covers BOTH :5432 direct and :6543 Dedicated
|
|
213
|
+
// Pooler — same hostname, same IPv4 unreachability)
|
|
214
|
+
// 'shared-pooler' — *.pooler.supabase.com (IPv4-compatible)
|
|
215
|
+
// 'local' — loopback/local Postgres
|
|
216
|
+
// 'other' — self-hosted, RDS, IPv6 literal, … (no Supabase concerns)
|
|
217
|
+
// poolerUserMismatch is true when the host is the Shared Pooler but the
|
|
218
|
+
// username lacks the mandatory `.<project-ref>` suffix — the documented
|
|
219
|
+
// "Tenant or user not found" failure.
|
|
220
|
+
function classifyDbEndpoint(raw) {
|
|
221
|
+
if (raw === undefined || raw === null || typeof raw !== 'string') {
|
|
222
|
+
return { kind: 'absent' };
|
|
223
|
+
}
|
|
224
|
+
const trimmed = stripSurroundingQuotes(raw.trim());
|
|
225
|
+
if (trimmed === '') return { kind: 'absent' };
|
|
226
|
+
|
|
227
|
+
let u;
|
|
228
|
+
try {
|
|
229
|
+
u = new URL(trimmed);
|
|
230
|
+
} catch (_err) {
|
|
231
|
+
return { kind: 'invalid' };
|
|
232
|
+
}
|
|
233
|
+
if (u.protocol !== 'postgres:' && u.protocol !== 'postgresql:') {
|
|
234
|
+
return { kind: 'invalid' };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Normalize: lowercase, drop a trailing FQDN dot.
|
|
238
|
+
const host = u.hostname.toLowerCase().replace(/\.$/, '');
|
|
239
|
+
let username = '';
|
|
240
|
+
try {
|
|
241
|
+
username = decodeURIComponent(u.username);
|
|
242
|
+
} catch (_err) {
|
|
243
|
+
username = u.username;
|
|
244
|
+
}
|
|
245
|
+
const base = { host, port: u.port, username };
|
|
246
|
+
|
|
247
|
+
if (LOCAL_DB_HOSTS.has(host)) return { kind: 'local', ...base };
|
|
248
|
+
|
|
249
|
+
if (/^db\.[a-z0-9-]+\.supabase\.(co|in)$/.test(host)) {
|
|
250
|
+
return { kind: 'direct', ...base };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (host.endsWith('.pooler.supabase.com')) {
|
|
254
|
+
// Shared Pooler logins are `postgres.<project-ref>` — a dotless
|
|
255
|
+
// username means the URL was hand-assembled from direct-connection
|
|
256
|
+
// parts and will fail with "Tenant or user not found".
|
|
257
|
+
const poolerUserMismatch = username !== '' && !username.includes('.');
|
|
258
|
+
return { kind: 'shared-pooler', ...base, poolerUserMismatch };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { kind: 'other', ...base };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Warning lines for a classification — [] when there is nothing to say.
|
|
265
|
+
// Wording kept byte-similar to engram's doctor probe messages so grep /
|
|
266
|
+
// troubleshooting stays consistent across the stack. Print-only: callers
|
|
267
|
+
// write these to stdout after a PASSING validation and never change exit
|
|
268
|
+
// codes on their account.
|
|
269
|
+
function directEndpointWarningLines(classification) {
|
|
270
|
+
if (!classification || typeof classification !== 'object') return [];
|
|
271
|
+
if (classification.kind === 'direct') {
|
|
272
|
+
return [
|
|
273
|
+
'⚠ this is the IPv6-only endpoint (db.<project-ref>.supabase.co — AAAA-only DNS, no IPv4)',
|
|
274
|
+
'on IPv4-only hosts pg clients hang until a pool/connect timeout',
|
|
275
|
+
'IPv4-safe: Connect modal → Transaction pooler → toggle ON "Use IPv4 connection (Shared Pooler)"',
|
|
276
|
+
'postgres://postgres.<project-ref>:<password>@aws-<n>-<region>.pooler.supabase.com:6543/postgres'
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
if (classification.kind === 'shared-pooler' && classification.poolerUserMismatch) {
|
|
280
|
+
return [
|
|
281
|
+
`⚠ Shared Pooler host but username "${classification.username}" — pooler logins must be postgres.<project-ref>; fails with "Tenant or user not found"`
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
|
|
189
287
|
// Mask all but the last 4 chars of a secret for logging.
|
|
190
288
|
function maskSecret(value) {
|
|
191
289
|
if (!value || typeof value !== 'string') return '';
|
|
@@ -202,5 +300,7 @@ module.exports = {
|
|
|
202
300
|
isTransactionPoolerUrl,
|
|
203
301
|
normalizeDatabaseUrl,
|
|
204
302
|
maskSecret,
|
|
205
|
-
stripSurroundingQuotes
|
|
303
|
+
stripSurroundingQuotes,
|
|
304
|
+
classifyDbEndpoint,
|
|
305
|
+
directEndpointWarningLines
|
|
206
306
|
};
|