@jhizzard/termdeck 1.0.12 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/packages/cli/src/doctor.js +72 -7
- package/packages/cli/src/index.js +115 -2
- package/packages/cli/src/init-mnestra.js +14 -5
- package/packages/cli/src/stack.js +39 -10
- package/packages/client/public/app.js +85 -0
- package/packages/client/public/style.css +5 -0
- package/packages/server/src/config.js +4 -2
- package/packages/server/src/index.js +77 -1
- package/packages/server/src/setup/dotenv-io.js +12 -3
- package/packages/server/src/setup/supabase-url.js +39 -8
- package/packages/server/src/spawn-shell.js +27 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# TermDeck
|
|
2
2
|
|
|
3
|
+
[](https://github.com/jhizzard/termdeck/actions/workflows/install-smoke.yml)
|
|
4
|
+
|
|
3
5
|
> **The terminal that remembers what you fixed last month.**
|
|
4
6
|
|
|
5
7
|
A browser-based terminal multiplexer with an onboarding tour, rich per-panel metadata, and **Flashback** — automatic recall of similar past errors, surfaced the moment a panel hits a problem. No asking, no querying, no manual search. TermDeck notices you're stuck and offers the memory.
|
|
@@ -16,6 +18,8 @@ npx @jhizzard/termdeck
|
|
|
16
18
|
|
|
17
19
|
Ninety seconds, one command. Node 18+ is all you need — prebuilt binaries mean no C++ toolchain. Your browser opens automatically at `http://127.0.0.1:3000`, an onboarding tour walks you through every button, and you're launching real PTY shells, Claude Code, Python servers, or anything else a normal terminal can run.
|
|
18
20
|
|
|
21
|
+
> **Linux x64 — one-line caveat.** If `npm config get omit` returns `optional`, append `--include=optional` to any global TermDeck or `@anthropic-ai/claude-code` install (`npm install -g @jhizzard/termdeck --include=optional`). On macOS this is unnecessary. Full explanation in [docs/GETTING-STARTED.md § Linux x64 install hint](docs/GETTING-STARTED.md#linux-x64-install-hint).
|
|
22
|
+
|
|
19
23
|
This is **Tier 1**. Works immediately, fully local, no accounts, no credentials, no database. You get the full dashboard — 7 grid layouts, 8 themes, per-panel metadata overlays, terminal switcher, reply button, status logs, session history in local SQLite. **Flashback is silent at this tier** because there's no memory store to query.
|
|
20
24
|
|
|
21
25
|
First-time user? The **config** button in the toolbar shows what's set up and what's next — click it for a live view of each tier's status with guided next steps.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
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"
|
|
@@ -160,6 +160,32 @@ function _compareSemver(a, b) {
|
|
|
160
160
|
return 0;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// Sprint 58 T2 (Brad #4) — Mnestra-version probe used to gate the hybrid-
|
|
164
|
+
// search RPC name in `_runSchemaCheck`. Mnestra ≤ 0.3.x exposes
|
|
165
|
+
// `search_memories(...)`; Mnestra ≥ 0.4.0 renamed it to
|
|
166
|
+
// `memory_hybrid_search(...)`. Pre-fix doctor hard-coded `search_memories`,
|
|
167
|
+
// false-flagging RED on every install at Mnestra 0.4.0+. Reuses
|
|
168
|
+
// `_detectInstalled` (the same `npm ls -g` probe the version-check section
|
|
169
|
+
// already runs). Returns the installed version string or null if not
|
|
170
|
+
// detectable. Exposed as its own module export so unit tests can monkey-
|
|
171
|
+
// patch it independently of the rest of the doctor pipeline.
|
|
172
|
+
async function _detectMnestraVersion() {
|
|
173
|
+
return module.exports._detectInstalled('@jhizzard/mnestra');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Sprint 58 T2 (Brad #4) — RPC names to probe for the Mnestra hybrid-search
|
|
177
|
+
// function, gated on the installed Mnestra version.
|
|
178
|
+
// ≥ 0.4.0 → ['memory_hybrid_search']
|
|
179
|
+
// ≤ 0.3.x → ['search_memories']
|
|
180
|
+
// null/unknown → ['memory_hybrid_search', 'search_memories'] (probe both;
|
|
181
|
+
// GREEN if either exists — graceful on offline / non-
|
|
182
|
+
// globally-installed cases).
|
|
183
|
+
function _selectHybridSearchRpcNames(mnestraVersion) {
|
|
184
|
+
if (!mnestraVersion) return ['memory_hybrid_search', 'search_memories'];
|
|
185
|
+
if (_compareSemver(mnestraVersion, '0.4.0') >= 0) return ['memory_hybrid_search'];
|
|
186
|
+
return ['search_memories'];
|
|
187
|
+
}
|
|
188
|
+
|
|
163
189
|
function classifyRow(installed, latest) {
|
|
164
190
|
if (latest === null) return STATUS.NETWORK_ERROR;
|
|
165
191
|
if (installed === null) return STATUS.NOT_INSTALLED;
|
|
@@ -354,13 +380,45 @@ async function _runSchemaCheck(opts = {}) {
|
|
|
354
380
|
status: (await probeSchema(client, SCHEMA_QUERIES.column('memory_items', 'source_session_id'))) ? 'pass' : 'fail',
|
|
355
381
|
hint: `migration 007 adds it — run: npm cache clean --force && npm i -g @jhizzard/termdeck@latest && termdeck init --mnestra --yes`,
|
|
356
382
|
});
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
383
|
+
modern.push({
|
|
384
|
+
label: `match_memories() RPC`,
|
|
385
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.rpc('match_memories'))) ? 'pass' : 'fail',
|
|
386
|
+
hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Sprint 58 T2 (Brad #4): version-gate the hybrid-search RPC name. The
|
|
390
|
+
// function was renamed `search_memories` → `memory_hybrid_search` at
|
|
391
|
+
// Mnestra 0.4.0. Pre-fix doctor probed only the legacy name, so every
|
|
392
|
+
// install at Mnestra ≥ 0.4.0 reported false-RED here. The version is
|
|
393
|
+
// passed in by `doctor()` (which already detected it for the version-
|
|
394
|
+
// check table); when called standalone, we self-detect.
|
|
395
|
+
const mnestraVersion = optsObj.mnestraVersion !== undefined
|
|
396
|
+
? optsObj.mnestraVersion
|
|
397
|
+
: await module.exports._detectMnestraVersion();
|
|
398
|
+
const hybridProbeNames = _selectHybridSearchRpcNames(mnestraVersion);
|
|
399
|
+
const hybridProbeLabel = hybridProbeNames.length === 1
|
|
400
|
+
? `${hybridProbeNames[0]}() RPC`
|
|
401
|
+
: `${hybridProbeNames.join(' or ')}() RPC`;
|
|
402
|
+
let hybridOk = false;
|
|
403
|
+
for (const name of hybridProbeNames) {
|
|
404
|
+
if (await probeSchema(client, SCHEMA_QUERIES.rpc(name))) {
|
|
405
|
+
hybridOk = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
363
408
|
}
|
|
409
|
+
modern.push({
|
|
410
|
+
label: hybridProbeLabel,
|
|
411
|
+
status: hybridOk ? 'pass' : 'fail',
|
|
412
|
+
hint: mnestraVersion
|
|
413
|
+
? `Mnestra ${mnestraVersion} expects ${hybridProbeNames[0]}() — re-run: termdeck init --mnestra --yes`
|
|
414
|
+
: `migration 005 (legacy) or 015+ (modern) creates it — re-run: termdeck init --mnestra --yes`,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
modern.push({
|
|
418
|
+
label: `memory_status_aggregation() RPC`,
|
|
419
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.rpc('memory_status_aggregation'))) ? 'pass' : 'fail',
|
|
420
|
+
hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
|
|
421
|
+
});
|
|
364
422
|
|
|
365
423
|
// Mnestra legacy (Sprint 35 T2 ships these via 008_legacy_rag_tables.sql)
|
|
366
424
|
const legacy = sections[1].checks;
|
|
@@ -510,10 +568,15 @@ async function doctor(argv) {
|
|
|
510
568
|
);
|
|
511
569
|
|
|
512
570
|
// Sprint 35 T3: schema check (skippable for tests / offline runs).
|
|
571
|
+
// Sprint 58 T2 (Brad #4): pass the already-detected Mnestra version
|
|
572
|
+
// through so `_runSchemaCheck` doesn't re-shell-out to `npm ls -g`. The
|
|
573
|
+
// version-check section detected it a few lines up; reuse it.
|
|
513
574
|
let schema = null;
|
|
514
575
|
if (!opts.noSchema) {
|
|
576
|
+
const mnestraRow = rows.find((r) => r.package === '@jhizzard/mnestra');
|
|
577
|
+
const mnestraVersion = mnestraRow ? mnestraRow.installed : null;
|
|
515
578
|
try {
|
|
516
|
-
schema = await module.exports._runSchemaCheck();
|
|
579
|
+
schema = await module.exports._runSchemaCheck({ mnestraVersion });
|
|
517
580
|
} catch (err) {
|
|
518
581
|
schema = {
|
|
519
582
|
skipped: false,
|
|
@@ -559,6 +622,8 @@ module.exports = doctor;
|
|
|
559
622
|
module.exports._detectInstalled = _detectInstalled;
|
|
560
623
|
module.exports._fetchLatest = _fetchLatest;
|
|
561
624
|
module.exports._compareSemver = _compareSemver;
|
|
625
|
+
module.exports._detectMnestraVersion = _detectMnestraVersion;
|
|
626
|
+
module.exports._selectHybridSearchRpcNames = _selectHybridSearchRpcNames;
|
|
562
627
|
module.exports._runSchemaCheck = _runSchemaCheck;
|
|
563
628
|
module.exports.STACK_PACKAGES = STACK_PACKAGES;
|
|
564
629
|
module.exports.STATUS = STATUS;
|
|
@@ -14,7 +14,93 @@
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const os = require('os');
|
|
17
|
-
const { exec, execSync } = require('child_process');
|
|
17
|
+
const { exec, execSync, spawn } = require('child_process');
|
|
18
|
+
|
|
19
|
+
// Sprint 59 — Brad #1 nohup-secrets bootstrap.
|
|
20
|
+
//
|
|
21
|
+
// Brad's environment: `nohup termdeck --no-stack ...` from a shell that has
|
|
22
|
+
// NOT sourced ~/.termdeck/secrets.env. In-process `setenv()` (which Node's
|
|
23
|
+
// loadSecretsEnv() uses) updates libc's `environ` pointer but does NOT
|
|
24
|
+
// propagate to /proc/<pid>/environ on Linux glibc — the kernel reads from
|
|
25
|
+
// the env_start..env_end memory range fixed at execve() time, and new keys
|
|
26
|
+
// added via setenv() get heap-allocated outside that range. A probe that
|
|
27
|
+
// introspects /proc therefore sees the empty initial env.
|
|
28
|
+
//
|
|
29
|
+
// Fix: when launched in non-TTY mode (nohup detaches stdin/stdout/stderr)
|
|
30
|
+
// AND secrets.env exists with at least one key not already in process.env,
|
|
31
|
+
// spawn a detached child node with the merged env and exit the parent. The
|
|
32
|
+
// child's /proc/<pid>/environ contains the merged keys because spawn() goes
|
|
33
|
+
// through fork+execve(), which sets the kernel env range with the new env.
|
|
34
|
+
//
|
|
35
|
+
// Guards (must ALL be true to spawn-and-exit):
|
|
36
|
+
// 1. __TERMDECK_BOOTSTRAPPED env marker absent (we're the original entry,
|
|
37
|
+
// not the re-execed child).
|
|
38
|
+
// 2. argv[0] is NOT a subcommand we hand off (`init`, `forge`, `doctor`,
|
|
39
|
+
// `stack`). Those subcommands have their own env-loading paths
|
|
40
|
+
// (init's --from-env, doctor's dotenv-io reader, stack's loadSecrets)
|
|
41
|
+
// and run interactively under piped stdio in tests / CI. Brad's bug
|
|
42
|
+
// is specifically the default server-launch path; bootstrap there.
|
|
43
|
+
// 3. neither stdout nor stderr is a TTY (interactive `termdeck` keeps the
|
|
44
|
+
// legacy in-process loadSecretsEnv path so Ctrl+C / signal handling /
|
|
45
|
+
// user-visible boot output stay attached to the user's terminal).
|
|
46
|
+
// 4. argv does NOT include --service or --non-interactive (T2 owns those
|
|
47
|
+
// flags for systemd Type=simple; that path runs in foreground so
|
|
48
|
+
// systemd's cgroup-tracked main process stays alive).
|
|
49
|
+
// 5. ~/.termdeck/secrets.env exists.
|
|
50
|
+
// 6. parsing the file yields at least one key that is NOT already in
|
|
51
|
+
// process.env (don't clobber pre-set shell vars; user env wins).
|
|
52
|
+
function maybeBootstrapAndDetach() {
|
|
53
|
+
if (process.env.__TERMDECK_BOOTSTRAPPED === '1') {
|
|
54
|
+
delete process.env.__TERMDECK_BOOTSTRAPPED;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
const SKIP_SUBCOMMANDS = new Set(['init', 'forge', 'doctor', 'stack']);
|
|
59
|
+
if (argv.length > 0 && SKIP_SUBCOMMANDS.has(argv[0])) return false;
|
|
60
|
+
if (process.stdout.isTTY || process.stderr.isTTY) return false;
|
|
61
|
+
const argvSet = new Set(argv);
|
|
62
|
+
if (argvSet.has('--service') || argvSet.has('--non-interactive')) return false;
|
|
63
|
+
const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
64
|
+
if (!fs.existsSync(secretsPath)) return false;
|
|
65
|
+
|
|
66
|
+
let raw;
|
|
67
|
+
try { raw = fs.readFileSync(secretsPath, 'utf-8'); }
|
|
68
|
+
catch (_e) { return false; }
|
|
69
|
+
|
|
70
|
+
const merged = {};
|
|
71
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
74
|
+
const eq = trimmed.indexOf('=');
|
|
75
|
+
if (eq === -1) continue;
|
|
76
|
+
const key = trimmed.slice(0, eq).trim();
|
|
77
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) continue;
|
|
78
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
79
|
+
if (val.length >= 2 && (val[0] === '"' || val[0] === "'") && val[val.length - 1] === val[0]) {
|
|
80
|
+
val = val.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
// Sprint 59 T4-CODEX residual fix: file value fills when parent env is undefined
|
|
83
|
+
// OR empty string (Brad's actual failure shape includes parent env present-but-blank,
|
|
84
|
+
// not only missing entirely). Non-empty parent env still wins.
|
|
85
|
+
if (process.env[key] === undefined || process.env[key] === '') merged[key] = val;
|
|
86
|
+
}
|
|
87
|
+
if (Object.keys(merged).length === 0) return false;
|
|
88
|
+
|
|
89
|
+
const env = { ...process.env, ...merged, __TERMDECK_BOOTSTRAPPED: '1' };
|
|
90
|
+
const child = spawn(process.execPath, [__filename, ...process.argv.slice(2)], {
|
|
91
|
+
env,
|
|
92
|
+
stdio: 'inherit',
|
|
93
|
+
detached: true,
|
|
94
|
+
});
|
|
95
|
+
child.unref();
|
|
96
|
+
// Parent exits immediately. The fixture's TD_PID points at the parent
|
|
97
|
+
// process; once the parent dies, /proc/<TD_PID>/environ becomes unreadable
|
|
98
|
+
// and the fixture's pgrep fallback finds the child (which has the merged
|
|
99
|
+
// env in its /proc/<pid>/environ via execve). The child keeps running.
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
maybeBootstrapAndDetach();
|
|
18
104
|
|
|
19
105
|
// Sprint 35 T4: stale-port reclaim. If the target port is held by a previous
|
|
20
106
|
// TermDeck instance (crash, runaway, prior `termdeck` left orphaned), kill it
|
|
@@ -215,9 +301,24 @@ const noStackIdx = args.indexOf('--no-stack');
|
|
|
215
301
|
const noStackRequested = noStackIdx !== -1;
|
|
216
302
|
if (noStackRequested) args.splice(noStackIdx, 1); // strip before flag parsing
|
|
217
303
|
|
|
304
|
+
// Sprint 59 T2 — Brad #7: --service / --non-interactive flag for systemd
|
|
305
|
+
// Type=simple deployment. When set, the launcher must (a) skip browser
|
|
306
|
+
// auto-open (no DISPLAY in service contexts), (b) bypass the auto-orchestrate
|
|
307
|
+
// child-spawn detour so ExecStart=/usr/local/bin/termdeck --service blocks
|
|
308
|
+
// for the lifetime of the server (Type=simple sees an active foreground
|
|
309
|
+
// process), (c) be tolerated everywhere `--no-stack` is. Strip both aliases
|
|
310
|
+
// repeatedly so duplicates don't survive into the flag-parsing loop.
|
|
311
|
+
let serviceMode = false;
|
|
312
|
+
while (true) {
|
|
313
|
+
const idx = args.findIndex((a) => a === '--service' || a === '--non-interactive');
|
|
314
|
+
if (idx === -1) break;
|
|
315
|
+
serviceMode = true;
|
|
316
|
+
args.splice(idx, 1);
|
|
317
|
+
}
|
|
318
|
+
|
|
218
319
|
const wantsHelp = args.includes('--help') || args.includes('-h');
|
|
219
320
|
|
|
220
|
-
if (!KNOWN_SUBCOMMANDS.has(args[0]) && !noStackRequested && !wantsHelp && shouldAutoOrchestrate()) {
|
|
321
|
+
if (!KNOWN_SUBCOMMANDS.has(args[0]) && !noStackRequested && !serviceMode && !wantsHelp && shouldAutoOrchestrate()) {
|
|
221
322
|
const stack = require(path.join(__dirname, 'stack.js'));
|
|
222
323
|
stack(args).then((code) => process.exit(code || 0)).catch((err) => {
|
|
223
324
|
console.error('[cli] auto-stack failed:', err && err.stack || err);
|
|
@@ -243,6 +344,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
243
344
|
termdeck Auto-orchestrate stack if configured, else Tier-1-only
|
|
244
345
|
termdeck stack Force boot Mnestra + check Rumen + start TermDeck
|
|
245
346
|
termdeck --no-stack Skip orchestrator (force Tier-1-only boot)
|
|
347
|
+
termdeck --service Non-interactive foreground mode for systemd Type=simple
|
|
348
|
+
(alias: --non-interactive; implies --no-stack + --no-open)
|
|
246
349
|
termdeck --port 8080 Start on custom port
|
|
247
350
|
termdeck --no-open Don't auto-open browser
|
|
248
351
|
termdeck --session-logs Write per-session markdown logs to ~/.termdeck/sessions/
|
|
@@ -277,6 +380,16 @@ if (flags.sessionLogs) {
|
|
|
277
380
|
process.env.TERMDECK_SESSION_LOGS = '1';
|
|
278
381
|
}
|
|
279
382
|
|
|
383
|
+
// Sprint 59 T2 — Brad #7: --service implies --no-open. The browser auto-open
|
|
384
|
+
// path runs `xdg-open` / `open` which has no meaning under systemd (no
|
|
385
|
+
// DISPLAY) and would just dump a non-fatal error to stderr/journalctl every
|
|
386
|
+
// boot. Honoring serviceMode here is in addition to the auto-orchestrate
|
|
387
|
+
// bypass above — a user could pass `--no-stack --service` and we still want
|
|
388
|
+
// noOpen to win.
|
|
389
|
+
if (serviceMode) {
|
|
390
|
+
flags.noOpen = true;
|
|
391
|
+
}
|
|
392
|
+
|
|
280
393
|
// First-run detection (Sprint 19 T3): surface a one-line hint pointing at
|
|
281
394
|
// the setup wizard when no config.yaml exists yet. Check happens before
|
|
282
395
|
// loadConfig() so the message reflects on-disk state, not defaults.
|
|
@@ -119,11 +119,20 @@ function parseFlags(argv) {
|
|
|
119
119
|
function inputsFromEnv() {
|
|
120
120
|
const env = process.env;
|
|
121
121
|
const missing = [];
|
|
122
|
+
// Brad #2 (Sprint 59): strip surrounding matched quotes from each value
|
|
123
|
+
// BEFORE shape-checks. The dotenv parsers (config.js / dotenv-io.js /
|
|
124
|
+
// launcher.js readSecrets) all strip at file-read time, but `--from-env`
|
|
125
|
+
// bypasses those — Brad's reproducer exports a literal-quoted DATABASE_URL
|
|
126
|
+
// directly into the shell's environment via
|
|
127
|
+
// export DATABASE_URL="\"$TEST_DATABASE_URL\""
|
|
128
|
+
// and the leading `"` makes new URL() throw 'Invalid URL'. Strip here at
|
|
129
|
+
// the validator boundary so a quoted env-var value gets the same handling
|
|
130
|
+
// as a quoted secrets.env line.
|
|
122
131
|
const required = {
|
|
123
|
-
SUPABASE_URL: env.SUPABASE_URL,
|
|
124
|
-
SUPABASE_SERVICE_ROLE_KEY: env.SUPABASE_SERVICE_ROLE_KEY,
|
|
125
|
-
DATABASE_URL: env.DATABASE_URL,
|
|
126
|
-
OPENAI_API_KEY: env.OPENAI_API_KEY
|
|
132
|
+
SUPABASE_URL: urlHelper.stripSurroundingQuotes((env.SUPABASE_URL || '').trim()),
|
|
133
|
+
SUPABASE_SERVICE_ROLE_KEY: urlHelper.stripSurroundingQuotes((env.SUPABASE_SERVICE_ROLE_KEY || '').trim()),
|
|
134
|
+
DATABASE_URL: urlHelper.stripSurroundingQuotes((env.DATABASE_URL || '').trim()),
|
|
135
|
+
OPENAI_API_KEY: urlHelper.stripSurroundingQuotes((env.OPENAI_API_KEY || '').trim())
|
|
127
136
|
};
|
|
128
137
|
for (const [k, v] of Object.entries(required)) {
|
|
129
138
|
if (!v || !v.trim()) missing.push(k);
|
|
@@ -155,7 +164,7 @@ function inputsFromEnv() {
|
|
|
155
164
|
const oaErr = urlHelper.looksLikeOpenAiKey(required.OPENAI_API_KEY);
|
|
156
165
|
if (oaErr) throw new Error(`OPENAI_API_KEY: ${oaErr}`);
|
|
157
166
|
|
|
158
|
-
const anthropicKey = (env.ANTHROPIC_API_KEY || '').trim() || null;
|
|
167
|
+
const anthropicKey = urlHelper.stripSurroundingQuotes((env.ANTHROPIC_API_KEY || '').trim()) || null;
|
|
159
168
|
if (anthropicKey) {
|
|
160
169
|
const aErr = urlHelper.looksLikeAnthropicKey(anthropicKey);
|
|
161
170
|
if (aErr) {
|
|
@@ -432,7 +432,7 @@ async function checkRumen() {
|
|
|
432
432
|
|
|
433
433
|
// ── Step 4: TermDeck ────────────────────────────────────────────────
|
|
434
434
|
|
|
435
|
-
function execTermDeck({ port, extra }) {
|
|
435
|
+
function execTermDeck({ port, extra }, deps = {}) {
|
|
436
436
|
// Spawn a fresh node process for the CLI rather than require()-ing it
|
|
437
437
|
// in-process. Two reasons:
|
|
438
438
|
// 1. require() hits Node's module cache after stack.js → index.js →
|
|
@@ -443,22 +443,45 @@ function execTermDeck({ port, extra }) {
|
|
|
443
443
|
// stack.js tried to re-require the (cached) CLI — silent exit.
|
|
444
444
|
// 2. Pass --no-stack on the way back so index.js definitively skips
|
|
445
445
|
// the auto-orchestrate detection. Defensive even with the spawn.
|
|
446
|
+
//
|
|
447
|
+
// Sprint 59 T2 — Brad #7: returns Promise<exitCode> that resolves when the
|
|
448
|
+
// child exits. Pre-Sprint-59 the function returned undefined synchronously,
|
|
449
|
+
// so main() returned 0 immediately and `process.exit(0)` fired before the
|
|
450
|
+
// child had bound the port. Under `systemd Type=simple`, ExecStart=
|
|
451
|
+
// /usr/local/bin/termdeck "succeeded" in milliseconds and the cgroup tore
|
|
452
|
+
// down the orphaned child — service stuck inactive even though everything
|
|
453
|
+
// looked fine to the operator. The await in main() now blocks ExecStart
|
|
454
|
+
// for the lifetime of the child server. `deps` exposes spawn + signals as
|
|
455
|
+
// injection points for tests/launcher-service-flag.test.js.
|
|
456
|
+
const _spawn = deps.spawn || spawn;
|
|
457
|
+
const _signals = deps.signals || process;
|
|
446
458
|
const cliPath = path.join(__dirname, 'index.js');
|
|
447
459
|
const argv = [cliPath, '--no-stack'];
|
|
448
460
|
if (port) argv.push('--port', String(port));
|
|
449
461
|
argv.push(...extra);
|
|
450
|
-
const child =
|
|
462
|
+
const child = _spawn(process.execPath, argv, {
|
|
451
463
|
stdio: 'inherit',
|
|
452
464
|
env: process.env,
|
|
453
465
|
});
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
});
|
|
458
|
-
// Forward Ctrl+C cleanly so the spawned server can shut down.
|
|
466
|
+
// Forward Ctrl+C / systemd-stop signals cleanly so the child can flush
|
|
467
|
+
// before exit. Listeners are attached to the parent's signal source so
|
|
468
|
+
// tests can simulate signal delivery without raising real signals.
|
|
459
469
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
460
|
-
|
|
470
|
+
_signals.on(sig, () => { try { child.kill(sig); } catch (_e) { /* gone */ } });
|
|
461
471
|
}
|
|
472
|
+
return new Promise((resolve) => {
|
|
473
|
+
child.on('exit', (code, signal) => {
|
|
474
|
+
if (signal) {
|
|
475
|
+
// Re-raise the signal on the parent so the caller (systemd / shell)
|
|
476
|
+
// sees the right termination state, then resolve so main() can
|
|
477
|
+
// unwind.
|
|
478
|
+
try { _signals.kill(_signals.pid, signal); } catch (_e) { /* fallthrough */ }
|
|
479
|
+
resolve(code == null ? 0 : code);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
resolve(code == null ? 0 : code);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
462
485
|
}
|
|
463
486
|
|
|
464
487
|
// ── Main ────────────────────────────────────────────────────────────
|
|
@@ -514,8 +537,11 @@ async function main(rawArgs) {
|
|
|
514
537
|
stepLine('4/4', 'Starting TermDeck', 'BOOT', `(port ${port})`);
|
|
515
538
|
process.stdout.write(`\n ${ANSI.bold}Stack:${ANSI.reset} ${ANSI.green}${summary.join(' | ')}${ANSI.reset}\n\n`);
|
|
516
539
|
|
|
517
|
-
|
|
518
|
-
|
|
540
|
+
// Sprint 59 T2 — Brad #7: await the child so ExecStart blocks for the
|
|
541
|
+
// child's full lifetime. Without the await, `Type=simple` units saw
|
|
542
|
+
// ExecStart return 0 in milliseconds and tore the service down.
|
|
543
|
+
const exitCode = await execTermDeck({ port, extra: args.extra });
|
|
544
|
+
return exitCode;
|
|
519
545
|
}
|
|
520
546
|
|
|
521
547
|
module.exports = function (argv) {
|
|
@@ -532,3 +558,6 @@ module.exports.CLAUDE_MCP_PATH_CANONICAL = CLAUDE_MCP_PATH_CANONICAL;
|
|
|
532
558
|
module.exports.CLAUDE_MCP_PATH_LEGACY = CLAUDE_MCP_PATH_LEGACY;
|
|
533
559
|
module.exports.CLAUDE_MCP_PATHS = CLAUDE_MCP_PATHS;
|
|
534
560
|
module.exports.hasMnestraMcpEntry = hasMnestraMcpEntry;
|
|
561
|
+
// Sprint 59 T2 — Brad #7: exposed for tests/launcher-service-flag.test.js so
|
|
562
|
+
// the wait-semantics fix can be verified without a real Node child spawn.
|
|
563
|
+
module.exports._execTermDeck = execTermDeck;
|
|
@@ -177,6 +177,15 @@
|
|
|
177
177
|
panel.addEventListener('drop', (e) => {
|
|
178
178
|
e.preventDefault();
|
|
179
179
|
panel.classList.remove('drag-over');
|
|
180
|
+
panel.classList.remove('file-drop-active');
|
|
181
|
+
// External file drop (zip / image / any binary) → upload + type @path.
|
|
182
|
+
// Detected when dataTransfer.files has entries and there's no internal panel drag.
|
|
183
|
+
const files = e.dataTransfer && e.dataTransfer.files;
|
|
184
|
+
const hasInternalDrag = !!document.querySelector('.term-panel.dragging');
|
|
185
|
+
if (!hasInternalDrag && files && files.length > 0) {
|
|
186
|
+
uploadFilesAndType(panel, Array.from(files));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
180
189
|
const draggedId = (() => {
|
|
181
190
|
try { return e.dataTransfer.getData('text/plain'); } catch (_e) { return ''; }
|
|
182
191
|
})();
|
|
@@ -188,6 +197,82 @@
|
|
|
188
197
|
const dropAfter = (e.clientX - rect.left) > rect.width / 2;
|
|
189
198
|
panel.parentNode.insertBefore(dragged, dropAfter ? panel.nextSibling : panel);
|
|
190
199
|
});
|
|
200
|
+
|
|
201
|
+
// Sprint 59 scope-expansion (Brad's "drop a zip into Codex" question 2026-05-07):
|
|
202
|
+
// file drop and clipboard image paste upload to /api/sessions/:id/upload, then type
|
|
203
|
+
// @<path> via the existing /input endpoint so the agent (Claude/Codex/Gemini/Grok)
|
|
204
|
+
// sees the standard @filepath attachment syntax.
|
|
205
|
+
panel.addEventListener('dragover', (e) => {
|
|
206
|
+
const types = (e.dataTransfer && e.dataTransfer.types) || [];
|
|
207
|
+
const hasFiles = Array.from(types).includes('Files');
|
|
208
|
+
if (!hasFiles) return;
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
try { e.dataTransfer.dropEffect = 'copy'; } catch (_e) {}
|
|
211
|
+
panel.classList.add('file-drop-active');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
panel.addEventListener('dragleave', (e) => {
|
|
215
|
+
if (!panel.contains(e.relatedTarget)) panel.classList.remove('file-drop-active');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
panel.addEventListener('paste', (e) => {
|
|
219
|
+
const items = (e.clipboardData && e.clipboardData.items) || [];
|
|
220
|
+
const blobs = [];
|
|
221
|
+
for (const item of items) {
|
|
222
|
+
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
|
|
223
|
+
const blob = item.getAsFile();
|
|
224
|
+
if (blob) blobs.push(blob);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (blobs.length === 0) return;
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
230
|
+
const named = blobs.map((b, i) => {
|
|
231
|
+
const ext = (b.type.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');
|
|
232
|
+
const name = b.name && b.name.length > 0
|
|
233
|
+
? b.name
|
|
234
|
+
: `pasted-${ts}${blobs.length > 1 ? '-' + i : ''}.${ext}`;
|
|
235
|
+
return new File([b], name, { type: b.type });
|
|
236
|
+
});
|
|
237
|
+
uploadFilesAndType(panel, named);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function uploadFilesAndType(panel, files) {
|
|
242
|
+
const sessionId = panel.id.replace(/^panel-/, '');
|
|
243
|
+
for (const file of files) {
|
|
244
|
+
try {
|
|
245
|
+
const url = `/api/sessions/${sessionId}/upload?name=${encodeURIComponent(file.name)}`;
|
|
246
|
+
const buf = await file.arrayBuffer();
|
|
247
|
+
const res = await fetch(url, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
250
|
+
credentials: 'same-origin',
|
|
251
|
+
body: buf,
|
|
252
|
+
});
|
|
253
|
+
if (!res.ok) {
|
|
254
|
+
const errText = await res.text().catch(() => '');
|
|
255
|
+
console.error('[upload] failed', res.status, errText);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const data = await res.json();
|
|
259
|
+
// Type "@<path> " into the panel via the existing /input endpoint so the
|
|
260
|
+
// shape matches a manually-typed @filepath. The trailing space lets the
|
|
261
|
+
// user keep typing the rest of their prompt.
|
|
262
|
+
const inputRes = await fetch(`/api/sessions/${sessionId}/input`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers: { 'Content-Type': 'application/json' },
|
|
265
|
+
credentials: 'same-origin',
|
|
266
|
+
body: JSON.stringify({ text: `@${data.path} `, source: 'file-drop' }),
|
|
267
|
+
});
|
|
268
|
+
if (!inputRes.ok) {
|
|
269
|
+
const errText = await inputRes.text().catch(() => '');
|
|
270
|
+
console.error('[upload] file uploaded but typing failed', inputRes.status, errText);
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('[upload] exception', err);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
191
276
|
}
|
|
192
277
|
|
|
193
278
|
// ===== Create Terminal Panel =====
|
|
@@ -413,6 +413,11 @@
|
|
|
413
413
|
outline: 2px solid var(--tg-accent);
|
|
414
414
|
outline-offset: -2px;
|
|
415
415
|
}
|
|
416
|
+
.term-panel.file-drop-active {
|
|
417
|
+
outline: 2px dashed var(--tg-accent);
|
|
418
|
+
outline-offset: -2px;
|
|
419
|
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.05) inset;
|
|
420
|
+
}
|
|
416
421
|
|
|
417
422
|
.status-dot {
|
|
418
423
|
width: 8px; height: 8px;
|
|
@@ -59,8 +59,10 @@ function loadSecretsEnv() {
|
|
|
59
59
|
const parsed = parseDotenv(raw);
|
|
60
60
|
const keys = [];
|
|
61
61
|
for (const [k, v] of Object.entries(parsed)) {
|
|
62
|
-
// Do not clobber pre-set process env; shell wins.
|
|
63
|
-
|
|
62
|
+
// Do not clobber pre-set process env; shell wins. Sprint 59 T4-CODEX residual
|
|
63
|
+
// fix: also fill when parent env is empty string (Brad's actual failure shape
|
|
64
|
+
// includes DATABASE_URL= in the parent service environment, not only missing).
|
|
65
|
+
if (process.env[k] === undefined || process.env[k] === '') {
|
|
64
66
|
process.env[k] = v;
|
|
65
67
|
}
|
|
66
68
|
keys.push(k);
|
|
@@ -82,6 +82,7 @@ const orchestrationPreview = require('./orchestration-preview');
|
|
|
82
82
|
const { createPtyReaper } = require('./pty-reaper');
|
|
83
83
|
const { AGENT_ADAPTERS } = require('./agent-adapters');
|
|
84
84
|
const { deriveRagMode } = require('./rag-mode');
|
|
85
|
+
const { resolveSpawnShell } = require('./spawn-shell');
|
|
85
86
|
|
|
86
87
|
// Sprint 48 T4 deliverable 2: PTY env-var propagation.
|
|
87
88
|
// Reads ~/.termdeck/secrets.env once per server lifetime so each PTY spawn
|
|
@@ -325,6 +326,26 @@ function createServer(config) {
|
|
|
325
326
|
if (orphaned.changes > 0) {
|
|
326
327
|
console.log(`[db] Marked ${orphaned.changes} orphaned session(s) as exited`);
|
|
327
328
|
}
|
|
329
|
+
// Sprint 59 T4-CODEX cleanup: reap upload tempdirs whose owning session is
|
|
330
|
+
// exited or unknown (crashed processes, hard kills, pre-this-version dirs).
|
|
331
|
+
try {
|
|
332
|
+
const uploadsRoot = path.join(os.tmpdir(), 'termdeck-uploads');
|
|
333
|
+
if (fs.existsSync(uploadsRoot)) {
|
|
334
|
+
const liveIds = new Set();
|
|
335
|
+
try {
|
|
336
|
+
for (const row of db.prepare('SELECT id FROM sessions WHERE exited_at IS NULL').all()) {
|
|
337
|
+
liveIds.add(row.id);
|
|
338
|
+
}
|
|
339
|
+
} catch (_e) { /* live-set empty → all dirs are stale */ }
|
|
340
|
+
let reaped = 0;
|
|
341
|
+
for (const dir of fs.readdirSync(uploadsRoot)) {
|
|
342
|
+
if (!liveIds.has(dir)) {
|
|
343
|
+
try { fs.rmSync(path.join(uploadsRoot, dir), { recursive: true, force: true }); reaped++; } catch (_e) {}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (reaped > 0) console.log(`[uploads] Reaped ${reaped} stale upload tempdir(s)`);
|
|
347
|
+
}
|
|
348
|
+
} catch (_err) { /* non-blocking */ }
|
|
328
349
|
console.log('[db] SQLite initialized');
|
|
329
350
|
} catch (err) {
|
|
330
351
|
console.warn('[db] SQLite init failed:', err.message);
|
|
@@ -955,7 +976,13 @@ function createServer(config) {
|
|
|
955
976
|
const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
|
|
956
977
|
const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
|
|
957
978
|
|
|
958
|
-
|
|
979
|
+
// Sprint 59 T2 — Brad #5: resolveSpawnShell chains config.shell →
|
|
980
|
+
// $SHELL → /bin/sh so a host without zsh (Alpine, minimal Ubuntu after
|
|
981
|
+
// `apt remove zsh`) still spawns a working interactive shell instead of
|
|
982
|
+
// failing silently from execvp(/bin/zsh).
|
|
983
|
+
const spawnShell = isPlainShell
|
|
984
|
+
? cmdTrim
|
|
985
|
+
: resolveSpawnShell('', config.shell, process.env.SHELL);
|
|
959
986
|
const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
|
|
960
987
|
|
|
961
988
|
try {
|
|
@@ -1042,6 +1069,14 @@ function createServer(config) {
|
|
|
1042
1069
|
onPanelClose(session).catch((err) => {
|
|
1043
1070
|
console.error('[onPanelClose] async error:', err && err.message ? err.message : err);
|
|
1044
1071
|
});
|
|
1072
|
+
|
|
1073
|
+
// Sprint 59 T4-CODEX UPLOAD-AUDIT-CONCERN closure: blow away the
|
|
1074
|
+
// per-session upload tempdir so dropped files don't outlive the panel
|
|
1075
|
+
// that received them. Fire-and-forget; never blocks teardown.
|
|
1076
|
+
try {
|
|
1077
|
+
const sessUploadDir = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
|
|
1078
|
+
fs.rmSync(sessUploadDir, { recursive: true, force: true });
|
|
1079
|
+
} catch (_err) { /* non-blocking */ }
|
|
1045
1080
|
});
|
|
1046
1081
|
|
|
1047
1082
|
// Wire command logging to SQLite + RAG
|
|
@@ -1292,6 +1327,47 @@ function createServer(config) {
|
|
|
1292
1327
|
res.json({ ok: true, bytes: normalized.length, replyCount: session.meta.replyCount });
|
|
1293
1328
|
});
|
|
1294
1329
|
|
|
1330
|
+
// POST /api/sessions/:id/upload?name=<filename> - File drop / clipboard image paste
|
|
1331
|
+
// Body: raw octet-stream of the file content (max 50MB).
|
|
1332
|
+
// Writes to /tmp/termdeck-uploads/<sessionId>/<sanitizedName>, returns {ok, path, name, size}.
|
|
1333
|
+
// Client typically follows up with POST /api/sessions/:id/input { text: "@<path> " } so
|
|
1334
|
+
// the agent (Claude/Codex/Gemini/Grok) sees the standard @filepath attachment syntax.
|
|
1335
|
+
// Added Sprint 59 (2026-05-07) to close Brad's "how do I drop a zip into Codex" gap.
|
|
1336
|
+
app.post('/api/sessions/:id/upload',
|
|
1337
|
+
express.raw({ type: '*/*', limit: '50mb' }),
|
|
1338
|
+
(req, res) => {
|
|
1339
|
+
const session = sessions.get(req.params.id);
|
|
1340
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1341
|
+
if (session.meta.status === 'exited' || !session.pty) {
|
|
1342
|
+
return res.status(404).json({ error: 'Session is exited' });
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const rawName = (req.query.name || '').toString();
|
|
1346
|
+
if (!rawName) return res.status(400).json({ error: 'Missing ?name=' });
|
|
1347
|
+
// Sanitize: strip path traversal + control chars; cap at 200 chars.
|
|
1348
|
+
// Replace anything not alphanumeric / dash / underscore / dot / space with _
|
|
1349
|
+
const safeName = rawName
|
|
1350
|
+
.replace(/[\x00-\x1f\x7f/\\]/g, '_')
|
|
1351
|
+
.replace(/^\.+/, '_')
|
|
1352
|
+
.replace(/\.\.+/g, '_')
|
|
1353
|
+
.slice(0, 200) || 'upload.bin';
|
|
1354
|
+
|
|
1355
|
+
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
|
1356
|
+
return res.status(400).json({ error: 'Empty body' });
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const uploadsRoot = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
|
|
1360
|
+
try {
|
|
1361
|
+
fs.mkdirSync(uploadsRoot, { recursive: true, mode: 0o700 });
|
|
1362
|
+
const fullPath = path.join(uploadsRoot, safeName);
|
|
1363
|
+
fs.writeFileSync(fullPath, req.body, { mode: 0o600 });
|
|
1364
|
+
res.json({ ok: true, path: fullPath, name: safeName, size: req.body.length });
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
return res.status(500).json({ error: err.message });
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1295
1371
|
// POST /api/sessions/:id/poke - PTY-flush recovery endpoint
|
|
1296
1372
|
// Body: { methods?: ('sigcont' | 'bracketed-paste' | 'cr-flood' | 'all')[] } default ['all']
|
|
1297
1373
|
// Used to recover from the post-stop PTY delivery gap where injected input via /input
|
|
@@ -40,13 +40,22 @@ function readSecretsRaw(filepath = SECRETS_PATH) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// Escape a value for safe re-serialization. Wraps in double quotes if the
|
|
43
|
-
// value contains whitespace, `#`, or
|
|
44
|
-
//
|
|
43
|
+
// value contains whitespace, `#`, or a quote char. `=` was previously in the
|
|
44
|
+
// regex but excluded after Sprint 59 Brad #2 — every Postgres URL with query
|
|
45
|
+
// params (e.g. `?sslmode=require`) contains `=`, and dotenv splits a line on
|
|
46
|
+
// the FIRST `=` only, so subsequent `=` chars in the value need no quoting.
|
|
47
|
+
// Quoting URLs broke the "writer must never add surrounding quotes to a
|
|
48
|
+
// DATABASE_URL" contract; the value still round-tripped because every reader
|
|
49
|
+
// strips matching quotes, but a downstream consumer that sourced the file
|
|
50
|
+
// via `set -a; . secrets.env` and didn't strip would see a literal-quoted
|
|
51
|
+
// value re-introduced into process.env. Keeping the regex tight to actual
|
|
52
|
+
// dotenv ambiguities (whitespace, `#` for comments, embedded quote chars)
|
|
53
|
+
// avoids that round-trip-but-not-quite class of bug at the source.
|
|
45
54
|
function formatValue(value) {
|
|
46
55
|
if (value == null) return '';
|
|
47
56
|
const str = String(value);
|
|
48
57
|
if (str === '') return '';
|
|
49
|
-
const needsQuoting = /[\s#"'
|
|
58
|
+
const needsQuoting = /[\s#"']/.test(str);
|
|
50
59
|
if (!needsQuoting) return str;
|
|
51
60
|
const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
52
61
|
return `"${escaped}"`;
|
|
@@ -9,6 +9,26 @@
|
|
|
9
9
|
// - init-rumen needs the project ref to run `supabase link --project-ref`
|
|
10
10
|
// and to substitute into the pg_cron schedule SQL.
|
|
11
11
|
|
|
12
|
+
// Brad #2 (Sprint 59) — strip ONE pair of matched surrounding single OR
|
|
13
|
+
// double quotes from a string. Idempotent: a value with no quotes returns
|
|
14
|
+
// unchanged, mismatched quotes (`"foo'`, `bar"`) return unchanged. The
|
|
15
|
+
// dotenv parsers in config.js / dotenv-io.js / launcher.js already strip
|
|
16
|
+
// at file-read time, but Brad's reproducer ships the literal-quoted value
|
|
17
|
+
// through process.env (shell `export DATABASE_URL="\"$URL\""`), bypassing
|
|
18
|
+
// the file parsers entirely. Adding the strip here defends the validator
|
|
19
|
+
// boundary so any caller that hands us a quoted env-var value gets the
|
|
20
|
+
// same handling as a quoted secrets.env line.
|
|
21
|
+
function stripSurroundingQuotes(value) {
|
|
22
|
+
if (typeof value !== 'string') return value;
|
|
23
|
+
if (value.length < 2) return value;
|
|
24
|
+
const first = value[0];
|
|
25
|
+
const last = value[value.length - 1];
|
|
26
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
27
|
+
return value.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
// A Supabase project URL looks like:
|
|
13
33
|
// https://<project-ref>.supabase.co
|
|
14
34
|
// The ref is 20 characters of lowercase alphanumerics, but we accept anything
|
|
@@ -17,7 +37,7 @@ function parseProjectUrl(url) {
|
|
|
17
37
|
if (!url || typeof url !== 'string') {
|
|
18
38
|
return { ok: false, error: 'empty url' };
|
|
19
39
|
}
|
|
20
|
-
const trimmed = url.trim().replace(/\/+$/, '');
|
|
40
|
+
const trimmed = stripSurroundingQuotes(url.trim()).replace(/\/+$/, '');
|
|
21
41
|
let u;
|
|
22
42
|
try {
|
|
23
43
|
u = new URL(trimmed);
|
|
@@ -82,9 +102,10 @@ function looksLikeAnthropicKey(key) {
|
|
|
82
102
|
// and direct connection URLs (`postgres://postgres:...@db.<ref>.supabase.co:5432/postgres`).
|
|
83
103
|
function looksLikePostgresUrl(url) {
|
|
84
104
|
if (!url || typeof url !== 'string') return 'empty';
|
|
105
|
+
const stripped = stripSurroundingQuotes(url.trim());
|
|
85
106
|
let u;
|
|
86
107
|
try {
|
|
87
|
-
u = new URL(
|
|
108
|
+
u = new URL(stripped);
|
|
88
109
|
} catch (_err) {
|
|
89
110
|
return 'not a valid URL';
|
|
90
111
|
}
|
|
@@ -137,16 +158,25 @@ function isTransactionPoolerUrl(parsedUrl) {
|
|
|
137
158
|
// because validation is the caller's job (looksLikePostgresUrl handles that).
|
|
138
159
|
function normalizeDatabaseUrl(url) {
|
|
139
160
|
if (!url || typeof url !== 'string') return { url, modified: false };
|
|
161
|
+
// Brad #2: strip surrounding quotes silently — `modified` stays scoped
|
|
162
|
+
// to "appended pgbouncer params" so the caller's user-facing message
|
|
163
|
+
// ("Detected transaction pooler URL — appending ...") doesn't fire for
|
|
164
|
+
// a no-op quote strip. The strip itself is reflected in the returned
|
|
165
|
+
// `url` so downstream `new URL(normalized.url)` / pg.Pool consumers
|
|
166
|
+
// don't re-throw.
|
|
167
|
+
const stripped = stripSurroundingQuotes(url.trim());
|
|
140
168
|
let u;
|
|
141
169
|
try {
|
|
142
|
-
u = new URL(
|
|
170
|
+
u = new URL(stripped);
|
|
143
171
|
} catch (_err) {
|
|
144
|
-
return { url, modified: false };
|
|
172
|
+
return { url: stripped, modified: false };
|
|
145
173
|
}
|
|
146
|
-
if (!isTransactionPoolerUrl(u)) return { url, modified: false };
|
|
174
|
+
if (!isTransactionPoolerUrl(u)) return { url: stripped, modified: false };
|
|
147
175
|
|
|
148
|
-
// Already has pgbouncer set? Don't touch
|
|
149
|
-
|
|
176
|
+
// Already has pgbouncer set? Don't touch — but still return the stripped URL,
|
|
177
|
+
// not the original (Sprint 59 T4-CODEX residual fix: pre-fix returned `url`,
|
|
178
|
+
// which would re-leak surrounding quotes from a quoted-pgbouncer-URL secrets.env).
|
|
179
|
+
if (u.searchParams.has('pgbouncer')) return { url: stripped, modified: false };
|
|
150
180
|
|
|
151
181
|
u.searchParams.set('pgbouncer', 'true');
|
|
152
182
|
// Set connection_limit only if not already set — preserve user intent.
|
|
@@ -171,5 +201,6 @@ module.exports = {
|
|
|
171
201
|
looksLikePostgresUrl,
|
|
172
202
|
isTransactionPoolerUrl,
|
|
173
203
|
normalizeDatabaseUrl,
|
|
174
|
-
maskSecret
|
|
204
|
+
maskSecret,
|
|
205
|
+
stripSurroundingQuotes
|
|
175
206
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Sprint 59 T2 — PTY shell fallback chain helper (Brad #5).
|
|
2
|
+
//
|
|
3
|
+
// Pre-Sprint-59 the call site at packages/server/src/index.js:958 was:
|
|
4
|
+
// const spawnShell = isPlainShell ? cmdTrim : (config.shell || '/bin/zsh');
|
|
5
|
+
// Three failure modes converged on minimal Linux: (a) config.shell empty/unread
|
|
6
|
+
// because the YAML key was wiped or never set, (b) $SHELL ignored entirely,
|
|
7
|
+
// (c) /bin/zsh absent on the host. Result was a silent
|
|
8
|
+
// `execvp(3) failed: No such file or directory` from pty.spawn. The user's
|
|
9
|
+
// login shell was bypassed.
|
|
10
|
+
//
|
|
11
|
+
// /bin/sh is universally present on POSIX; /bin/zsh is not. The chain is:
|
|
12
|
+
// explicit cmdTrim → user's config.shell → $SHELL → /bin/sh universal floor.
|
|
13
|
+
// Caller (index.js) still owns the isPlainShell vs. -c branching; this helper
|
|
14
|
+
// only resolves the FALLBACK chain for the !isPlainShell branch (and for any
|
|
15
|
+
// future caller that wants a single-source-of-truth shell pick).
|
|
16
|
+
//
|
|
17
|
+
// The function intentionally treats "" and undefined identically — both
|
|
18
|
+
// participate in the falsy-OR chain. That matches how config.shell ends up
|
|
19
|
+
// empty when the user has `shell:` (no value) in ~/.termdeck/config.yaml,
|
|
20
|
+
// and how process.env.SHELL is undefined on container-like environments
|
|
21
|
+
// that strip the inherited shell var.
|
|
22
|
+
|
|
23
|
+
function resolveSpawnShell(cmdTrim, configShell, envShell) {
|
|
24
|
+
return cmdTrim || configShell || envShell || '/bin/sh';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { resolveSpawnShell };
|