@jhizzard/termdeck 0.6.2 → 0.6.4

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 CHANGED
@@ -171,7 +171,7 @@ Honest limits, stated upfront so the skeptic has nothing to chase:
171
171
  - **Not a replacement for reading docs.** It's the shortest path to a memory you already wrote. If the memory isn't there, the feature does nothing.
172
172
  - **Not fully local by default.** Tier 2+ reaches out to Supabase for storage and OpenAI for embeddings. Tier 1 is fully local. A fully-local Tier 2 (local Postgres + local embeddings) is on the roadmap.
173
173
  - **Not free forever.** Tier 2+ pays OpenAI fractions of a cent per memory for embeddings. Self-hosted embeddings via Ollama are on the roadmap.
174
- - **Not proven at scale.** v0.6.1, validated against 4,590 memories in one developer's production store. The Rumen 2026-04-19 re-kickstart processed 166 sessions into 166 insights in ~5.5 minutes. No multi-user data yet. Bug reports and issues welcome.
174
+ - **Not proven at scale.** v0.6.4, validated against 4,669 memories in one developer's production store. The Rumen 2026-04-19 re-kickstart processed 166 sessions into 166 insights in ~5.5 minutes. No multi-user data yet. Bug reports and issues welcome.
175
175
 
176
176
  ---
177
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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"
@@ -5,15 +5,28 @@
5
5
  //
6
6
  // Steps:
7
7
  // 1. Collect Supabase URL, service_role key, direct DB URL, OpenAI + Anthropic keys
8
- // 2. Connect via `pg` using the direct URL
9
- // 3. Apply the six bundled Mnestra migrations in order
10
- // 4. Write ~/.termdeck/secrets.env (merge-aware, preserves existing values)
8
+ // (or reuse the saved set in ~/.termdeck/secrets.env if present)
9
+ // 2. Persist ~/.termdeck/secrets.env immediately merge-aware, preserves
10
+ // existing values. Done BEFORE any database work so a later pg connect
11
+ // or migration failure doesn't lose the user's typed-in keys.
12
+ // 3. Connect via `pg` using the direct URL
13
+ // 4. Apply the six bundled Mnestra migrations in order
11
14
  // 5. Update ~/.termdeck/config.yaml to enable RAG + point at ${VAR} refs
15
+ // (only after migrations apply cleanly — otherwise the server would
16
+ // try to use an incomplete schema on next startup)
12
17
  // 6. Verify with a memory_status_aggregation() call
13
18
  //
14
19
  // Flags:
15
20
  // --help Print usage and exit
16
- // --yes Reserved (no-op as of v0.6.2 wizard no longer asks "Proceed?" after secrets)
21
+ // --yes Reuse saved secrets without re-prompting if a complete
22
+ // set is already on disk (otherwise the wizard asks
23
+ // interactively before reusing)
24
+ // --reset Ignore saved secrets and re-prompt for everything
25
+ // --from-env Skip every prompt; read all five secrets from the
26
+ // process environment (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY,
27
+ // DATABASE_URL, OPENAI_API_KEY, ANTHROPIC_API_KEY).
28
+ // Required for terminals that fight with our raw-mode
29
+ // secret prompt and for CI / scripted installs.
17
30
  // --dry-run Print what the wizard would do; don't touch the DB or filesystem
18
31
  // --skip-verify Skip the final memory_status_aggregation() check
19
32
  //
@@ -41,15 +54,25 @@ const HELP = [
41
54
  '',
42
55
  'Flags:',
43
56
  ' --help Print this message and exit',
44
- ' --yes Reserved (no-op as of v0.6.2 kept for forward compatibility)',
57
+ ' --yes Reuse saved secrets without prompting (if a complete',
58
+ ' set is already on disk in ~/.termdeck/secrets.env)',
59
+ ' --reset Ignore saved secrets and re-prompt for everything',
60
+ ' --from-env Skip every prompt; read SUPABASE_URL,',
61
+ ' SUPABASE_SERVICE_ROLE_KEY, DATABASE_URL, OPENAI_API_KEY,',
62
+ ' ANTHROPIC_API_KEY from environment variables instead.',
63
+ ' Useful for terminals that fight with raw-mode secret',
64
+ ' prompts (MobaXterm SSH, some Windows shells) and for',
65
+ ' CI / scripted installs.',
45
66
  ' --dry-run Print the plan without touching the database or filesystem',
46
67
  ' --skip-verify Skip the final memory_status_aggregation() sanity call',
47
68
  '',
48
69
  'What this does:',
49
70
  ' 1. Prompts for Supabase URL, service_role key, direct Postgres connection',
50
- ' string, OpenAI API key, and (optional) Anthropic API key.',
51
- ' 2. Applies the six Mnestra schema + RPC migrations via node-postgres.',
52
- ' 3. Writes ~/.termdeck/secrets.env (merge-aware, preserves existing values).',
71
+ ' string, OpenAI API key, and (optional) Anthropic API key — or reuses',
72
+ ' saved values if a complete set already exists in secrets.env.',
73
+ ' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
74
+ ' pg connect or migration failure does not lose what you typed in.',
75
+ ' 3. Connects to Postgres and applies the six Mnestra schema + RPC migrations.',
53
76
  ' 4. Updates ~/.termdeck/config.yaml to enable RAG and reference ${VAR} keys.',
54
77
  ' 5. Verifies the Mnestra store is reachable via memory_status_aggregation().',
55
78
  '',
@@ -58,16 +81,91 @@ const HELP = [
58
81
  ].join('\n');
59
82
 
60
83
  function parseFlags(argv) {
61
- const out = { help: false, yes: false, dryRun: false, skipVerify: false };
84
+ const out = {
85
+ help: false,
86
+ yes: false,
87
+ reset: false,
88
+ fromEnv: false,
89
+ dryRun: false,
90
+ skipVerify: false
91
+ };
62
92
  for (const a of argv) {
63
93
  if (a === '--help' || a === '-h') out.help = true;
64
94
  else if (a === '--yes' || a === '-y') out.yes = true;
95
+ else if (a === '--reset') out.reset = true;
96
+ else if (a === '--from-env') out.fromEnv = true;
65
97
  else if (a === '--dry-run') out.dryRun = true;
66
98
  else if (a === '--skip-verify') out.skipVerify = true;
67
99
  }
68
100
  return out;
69
101
  }
70
102
 
103
+ // Build inputs from process.env directly, skipping every askSecret prompt.
104
+ // Used by --from-env so callers on terminals that fight with our raw-mode
105
+ // secret prompt (Brad's MobaXterm SSH report 2026-04-25, fourth occurrence)
106
+ // can hand the wizard their secrets via env vars instead. Also makes the
107
+ // wizard scriptable for CI / one-shot installers. Returns the same shape
108
+ // as collectInputs() so the rest of main() doesn't care which path filled
109
+ // it. Throws with an actionable message when a required env var is missing
110
+ // or fails its shape check — `--from-env` is strict by design (no fallback
111
+ // to prompts), since callers using it have explicitly opted into the
112
+ // non-interactive path.
113
+ function inputsFromEnv() {
114
+ const env = process.env;
115
+ const missing = [];
116
+ const required = {
117
+ SUPABASE_URL: env.SUPABASE_URL,
118
+ SUPABASE_SERVICE_ROLE_KEY: env.SUPABASE_SERVICE_ROLE_KEY,
119
+ DATABASE_URL: env.DATABASE_URL,
120
+ OPENAI_API_KEY: env.OPENAI_API_KEY
121
+ };
122
+ for (const [k, v] of Object.entries(required)) {
123
+ if (!v || !v.trim()) missing.push(k);
124
+ }
125
+ if (missing.length > 0) {
126
+ throw new Error(
127
+ `--from-env is missing required environment variable(s): ${missing.join(', ')}.\n` +
128
+ 'Set every required key in your shell or pass them on the command line, e.g.:\n' +
129
+ ' SUPABASE_URL=https://xyz.supabase.co \\\n' +
130
+ ' SUPABASE_SERVICE_ROLE_KEY=sb_secret_... \\\n' +
131
+ ' DATABASE_URL=postgres://postgres.<ref>:<pw>@<pooler-host>:6543/postgres \\\n' +
132
+ ' OPENAI_API_KEY=sk-proj-... \\\n' +
133
+ ' ANTHROPIC_API_KEY=sk-ant-... \\\n' +
134
+ ' termdeck init --mnestra --from-env'
135
+ );
136
+ }
137
+
138
+ const projectUrl = urlHelper.parseProjectUrl(required.SUPABASE_URL);
139
+ if (!projectUrl.ok) {
140
+ throw new Error(`SUPABASE_URL is malformed: ${projectUrl.error}`);
141
+ }
142
+
143
+ const dbErr = urlHelper.looksLikePostgresUrl(required.DATABASE_URL);
144
+ if (dbErr) throw new Error(`DATABASE_URL: ${dbErr}`);
145
+
146
+ const srErr = urlHelper.looksLikeServiceRole(required.SUPABASE_SERVICE_ROLE_KEY);
147
+ if (srErr) throw new Error(`SUPABASE_SERVICE_ROLE_KEY: ${srErr}`);
148
+
149
+ const oaErr = urlHelper.looksLikeOpenAiKey(required.OPENAI_API_KEY);
150
+ if (oaErr) throw new Error(`OPENAI_API_KEY: ${oaErr}`);
151
+
152
+ const anthropicKey = (env.ANTHROPIC_API_KEY || '').trim() || null;
153
+ if (anthropicKey) {
154
+ const aErr = urlHelper.looksLikeAnthropicKey(anthropicKey);
155
+ if (aErr) {
156
+ process.stdout.write(` (warning: ANTHROPIC_API_KEY ${aErr} — storing anyway)\n`);
157
+ }
158
+ }
159
+
160
+ return {
161
+ projectUrl,
162
+ serviceRoleKey: required.SUPABASE_SERVICE_ROLE_KEY,
163
+ databaseUrl: required.DATABASE_URL,
164
+ openaiKey: required.OPENAI_API_KEY,
165
+ anthropicKey
166
+ };
167
+ }
168
+
71
169
  function printBanner() {
72
170
  process.stdout.write(`
73
171
  TermDeck Mnestra Setup
@@ -76,13 +174,20 @@ TermDeck Mnestra Setup
76
174
  This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
77
175
  1. Asking for your Supabase URL and service_role key
78
176
  2. Asking for a direct Postgres connection string
79
- 3. Applying six SQL migrations to the database
80
- 4. Asking for an OpenAI API key (embeddings)
81
- 5. Asking for an Anthropic API key (optional, summaries)
82
- 6. Writing ~/.termdeck/secrets.env
83
- 7. Updating ~/.termdeck/config.yaml to enable RAG
177
+ 3. Asking for an OpenAI API key (embeddings)
178
+ 4. Asking for an Anthropic API key (optional, summaries)
179
+ 5. Writing ~/.termdeck/secrets.env (before any database work, so a
180
+ pg failure cannot lose what you typed in)
181
+ 6. Connecting to Postgres + applying six SQL migrations
182
+ 7. Updating ~/.termdeck/config.yaml to enable RAG (only after
183
+ migrations apply cleanly)
84
184
  8. Verifying the connection with a memory_status call
85
185
 
186
+ If you already have a complete ~/.termdeck/secrets.env, the wizard will
187
+ offer to reuse it (or pass --yes to skip the prompt and resume directly).
188
+ If your terminal fights with the secret prompt, set the five env vars and
189
+ pass --from-env to skip every prompt entirely.
190
+
86
191
  Press Ctrl+C at any time to cancel.
87
192
 
88
193
  `);
@@ -92,7 +197,59 @@ function step(msg) { process.stdout.write(`→ ${msg}`); }
92
197
  function ok(suffix = '') { process.stdout.write(` ✓${suffix ? ' ' + suffix : ''}\n`); }
93
198
  function fail(err) { process.stdout.write(` ✗\n ${err}\n`); }
94
199
 
95
- async function collectInputs({ yes }) {
200
+ // Read whatever secrets are already on disk. Returns hydrated input shape if
201
+ // a complete set is present, or null if the user still needs to be prompted
202
+ // for at least one required value. Anthropic remains optional throughout.
203
+ function loadSavedSecrets() {
204
+ const saved = dotenv.readSecrets();
205
+ const required = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'DATABASE_URL', 'OPENAI_API_KEY'];
206
+ const present = required.filter((k) => saved[k]);
207
+ if (present.length < required.length) {
208
+ return { complete: false, present, saved };
209
+ }
210
+ const projectUrl = urlHelper.parseProjectUrl(saved.SUPABASE_URL);
211
+ if (!projectUrl.ok) return { complete: false, present, saved };
212
+ return {
213
+ complete: true,
214
+ present,
215
+ saved,
216
+ inputs: {
217
+ projectUrl,
218
+ serviceRoleKey: saved.SUPABASE_SERVICE_ROLE_KEY,
219
+ databaseUrl: saved.DATABASE_URL,
220
+ openaiKey: saved.OPENAI_API_KEY,
221
+ anthropicKey: saved.ANTHROPIC_API_KEY || null
222
+ }
223
+ };
224
+ }
225
+
226
+ async function collectInputs({ yes, reset }) {
227
+ // Resume path — Brad's case at 2026-04-25 17:50 ET ("If it got that far did
228
+ // it write the correct secret.env? If so, can I manually do the next steps?").
229
+ // If a complete set of secrets is already on disk, offer to reuse them so a
230
+ // re-run after a pg connect failure does not require retyping everything.
231
+ if (!reset) {
232
+ const found = loadSavedSecrets();
233
+ if (found.complete) {
234
+ const ref = found.inputs.projectUrl.projectRef;
235
+ const masked = urlHelper.maskSecret(found.inputs.databaseUrl);
236
+ process.stdout.write(
237
+ `Found saved secrets in ~/.termdeck/secrets.env (project ${ref}, db ${masked}).\n`
238
+ );
239
+ const reuse = yes ? true : await prompts.confirm(' Reuse saved secrets?', { defaultYes: true });
240
+ if (reuse) {
241
+ process.stdout.write(' Reusing saved secrets. Skipping prompts.\n\n');
242
+ return found.inputs;
243
+ }
244
+ process.stdout.write(' Re-prompting.\n\n');
245
+ } else if (found.present.length > 0) {
246
+ process.stdout.write(
247
+ `Note: ~/.termdeck/secrets.env has ${found.present.length}/4 required keys ` +
248
+ `(${found.present.join(', ')}). Re-prompting for the rest.\n\n`
249
+ );
250
+ }
251
+ }
252
+
96
253
  const projectUrlStr = await prompts.askRequired(
97
254
  '? Supabase Project URL (e.g. https://xyz.supabase.co)',
98
255
  {
@@ -129,16 +286,6 @@ async function collectInputs({ yes }) {
129
286
  }
130
287
  }
131
288
 
132
- // No confirm here — the user already opted in by typing
133
- // `termdeck init --mnestra` and supplying every secret. The previous
134
- // confirm gate was the consistent failure point in Brad's reports
135
- // (2026-04-25 twice + a third report after v0.6.1) on terminals that
136
- // emit stray bytes (CRLF, ANSI cursor reports, paste-bracketing) which
137
- // contaminated readline and made the confirm fast-resolve to "no" or
138
- // an empty cancel. Migrations are `IF NOT EXISTS` so a re-run is safe;
139
- // Ctrl-C still aborts cleanly. The `--yes` flag is preserved as a
140
- // stable CLI surface for callers/scripts and for future use.
141
- void yes;
142
289
  process.stdout.write('\n');
143
290
 
144
291
  return {
@@ -229,33 +376,39 @@ async function verifyStatus(client) {
229
376
  }
230
377
  }
231
378
 
232
- function writeLocalConfig(inputs, dryRun) {
379
+ // Persist secrets BEFORE any pg work so a connect/migration failure can't
380
+ // throw away what the user typed in. Brad's 2026-04-25 18:30 ET report
381
+ // ("It's killing before writing the file. Postgrep line not added to my
382
+ // existing file, so it wasn't changed") was caused by writeLocalConfig
383
+ // running AFTER applyMigrations — when migrations or pg connect failed,
384
+ // secrets.env was never updated. Splitting the writes lets secrets land
385
+ // on disk first; config.yaml only flips to rag.enabled=true once the
386
+ // schema is actually in place.
387
+ function writeSecretsFile(inputs, dryRun) {
233
388
  step('Writing ~/.termdeck/secrets.env...');
234
- if (dryRun) { ok('(dry-run)'); }
235
- else {
236
- dotenv.writeSecrets({
237
- SUPABASE_URL: inputs.projectUrl.url,
238
- SUPABASE_SERVICE_ROLE_KEY: inputs.serviceRoleKey,
239
- DATABASE_URL: inputs.databaseUrl,
240
- OPENAI_API_KEY: inputs.openaiKey,
241
- ...(inputs.anthropicKey ? { ANTHROPIC_API_KEY: inputs.anthropicKey } : {})
242
- });
243
- ok();
244
- }
389
+ if (dryRun) { ok('(dry-run)'); return; }
390
+ dotenv.writeSecrets({
391
+ SUPABASE_URL: inputs.projectUrl.url,
392
+ SUPABASE_SERVICE_ROLE_KEY: inputs.serviceRoleKey,
393
+ DATABASE_URL: inputs.databaseUrl,
394
+ OPENAI_API_KEY: inputs.openaiKey,
395
+ ...(inputs.anthropicKey ? { ANTHROPIC_API_KEY: inputs.anthropicKey } : {})
396
+ });
397
+ ok();
398
+ }
245
399
 
400
+ function writeYamlConfig(dryRun) {
246
401
  step('Updating ~/.termdeck/config.yaml (rag.enabled: true)...');
247
- if (dryRun) { ok('(dry-run)'); }
248
- else {
249
- const r = yaml.updateRagConfig({
250
- enabled: true,
251
- supabaseUrl: '${SUPABASE_URL}',
252
- supabaseKey: '${SUPABASE_SERVICE_ROLE_KEY}',
253
- openaiApiKey: '${OPENAI_API_KEY}',
254
- anthropicApiKey: '${ANTHROPIC_API_KEY}'
255
- });
256
- if (r.backup) ok(`(backup: ${path.basename(r.backup)})`);
257
- else ok();
258
- }
402
+ if (dryRun) { ok('(dry-run)'); return; }
403
+ const r = yaml.updateRagConfig({
404
+ enabled: true,
405
+ supabaseUrl: '${SUPABASE_URL}',
406
+ supabaseKey: '${SUPABASE_SERVICE_ROLE_KEY}',
407
+ openaiApiKey: '${OPENAI_API_KEY}',
408
+ anthropicApiKey: '${ANTHROPIC_API_KEY}'
409
+ });
410
+ if (r.backup) ok(`(backup: ${path.basename(r.backup)})`);
411
+ else ok();
259
412
  }
260
413
 
261
414
  function printNextSteps() {
@@ -270,6 +423,14 @@ Next steps:
270
423
  `);
271
424
  }
272
425
 
426
+ function printResumeHint() {
427
+ process.stderr.write(
428
+ '\nYour secrets are saved at ~/.termdeck/secrets.env.\n' +
429
+ 'To retry just the database step (no need to re-enter keys):\n' +
430
+ ' termdeck init --mnestra --yes\n'
431
+ );
432
+ }
433
+
273
434
  async function main(argv) {
274
435
  const flags = parseFlags(argv || []);
275
436
  if (flags.help) {
@@ -280,19 +441,46 @@ async function main(argv) {
280
441
  printBanner();
281
442
 
282
443
  let inputs;
444
+ if (flags.fromEnv) {
445
+ // Skip every interactive prompt — secrets come from process.env. Used
446
+ // when the user's terminal fights with the raw-mode secret prompt
447
+ // (Brad/MobaXterm SSH, 2026-04-25 fourth report) or when the wizard
448
+ // is being driven from a CI/install script.
449
+ process.stdout.write('Reading secrets from environment variables (--from-env).\n\n');
450
+ try {
451
+ inputs = inputsFromEnv();
452
+ } catch (err) {
453
+ process.stderr.write(`\n[init --mnestra] ${err.message}\n`);
454
+ return 2;
455
+ }
456
+ } else {
457
+ try {
458
+ inputs = await collectInputs({ yes: flags.yes, reset: flags.reset });
459
+ } catch (err) {
460
+ process.stderr.write(`\n[init --mnestra] ${err.message}\n`);
461
+ return 2;
462
+ }
463
+ }
464
+
465
+ // Persist secrets BEFORE pg work. If the wizard dies past this point
466
+ // (connect timeout, migration error, Ctrl-C), the saved file lets the
467
+ // user re-run with --yes and skip straight to the database step.
468
+ process.stdout.write('\n');
283
469
  try {
284
- inputs = await collectInputs({ yes: flags.yes });
470
+ writeSecretsFile(inputs, flags.dryRun);
285
471
  } catch (err) {
286
- process.stderr.write(`\n[init --mnestra] ${err.message}\n`);
287
- return 2;
472
+ fail(err.message);
473
+ process.stderr.write(
474
+ '\nFailed to write ~/.termdeck/secrets.env. Check the directory is writable.\n'
475
+ );
476
+ return 6;
288
477
  }
289
478
 
290
- process.stdout.write('\n');
291
479
  step('Connecting to Supabase...');
292
480
  if (flags.dryRun) {
293
481
  ok('(dry-run, skipped)');
294
482
  await applyMigrations(null, true);
295
- writeLocalConfig(inputs, true);
483
+ writeYamlConfig(true);
296
484
  process.stdout.write('\nDry run complete. No changes were made.\n');
297
485
  return 0;
298
486
  }
@@ -306,13 +494,14 @@ async function main(argv) {
306
494
  process.stderr.write(
307
495
  '\nDouble-check the connection string from Supabase → Project Settings → Database → Connection String.\n'
308
496
  );
497
+ printResumeHint();
309
498
  return 3;
310
499
  }
311
500
 
312
501
  try {
313
502
  await checkExistingStore(client);
314
503
  await applyMigrations(client, false);
315
- writeLocalConfig(inputs, false);
504
+ writeYamlConfig(false);
316
505
  if (!flags.skipVerify) {
317
506
  const verified = await verifyStatus(client);
318
507
  if (!verified) {
@@ -326,6 +515,7 @@ async function main(argv) {
326
515
  }
327
516
  } catch (err) {
328
517
  process.stderr.write(`\n[init --mnestra] ${err.message}\n`);
518
+ printResumeHint();
329
519
  return 5;
330
520
  } finally {
331
521
  try { await client.end(); } catch (_err) { /* ignore */ }
@@ -210,6 +210,35 @@ function runShellCaptured(command, args, opts = {}) {
210
210
  return { ok: r.status === 0, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
211
211
  }
212
212
 
213
+ // Detect the "no access token" signature in `supabase link` stderr so the
214
+ // wizard can surface a path-aware hint instead of dumping raw CLI output at
215
+ // the user. Brad hit this on 2026-04-26 00:25 UTC on MobaXterm SSH after
216
+ // v0.6.3 unblocked init --mnestra: he had no SUPABASE_ACCESS_TOKEN env var
217
+ // and `supabase login` requires a browser, which his SSH session doesn't
218
+ // have. The actionable path on a non-desktop install is always a PAT from
219
+ // the dashboard — link() now points users straight at it.
220
+ function looksLikeMissingAccessToken(stderr) {
221
+ if (!stderr) return false;
222
+ return /Access token not provided/i.test(stderr) ||
223
+ /SUPABASE_ACCESS_TOKEN environment variable/i.test(stderr);
224
+ }
225
+
226
+ function printAccessTokenHint() {
227
+ process.stderr.write(
228
+ '\nThe Supabase CLI needs a Personal Access Token to link your project.\n' +
229
+ 'On a desktop install you can run `supabase login`, but that opens a\n' +
230
+ 'browser, so SSH/headless users should use the env-var path instead:\n' +
231
+ '\n' +
232
+ ' 1. Generate a token: https://supabase.com/dashboard/account/tokens\n' +
233
+ ' 2. Export it in your shell:\n' +
234
+ ' export SUPABASE_ACCESS_TOKEN=sbp_...\n' +
235
+ ' 3. Re-run: termdeck init --rumen\n' +
236
+ '\n' +
237
+ 'TermDeck does not store this token — it only lives in your shell\n' +
238
+ 'environment for the duration of the install.\n'
239
+ );
240
+ }
241
+
213
242
  async function link(projectRef, dryRun) {
214
243
  step(`Running: supabase link --project-ref ${projectRef}...`);
215
244
  if (dryRun) { ok('(dry-run)'); return true; }
@@ -217,6 +246,7 @@ async function link(projectRef, dryRun) {
217
246
  if (!r.ok) {
218
247
  fail(`supabase link failed (exit ${r.code})`);
219
248
  if (r.stderr) process.stderr.write(r.stderr + '\n');
249
+ if (looksLikeMissingAccessToken(r.stderr)) printAccessTokenHint();
220
250
  return false;
221
251
  }
222
252
  ok();
@@ -512,3 +542,6 @@ if (require.main === module) {
512
542
  }
513
543
 
514
544
  module.exports = main;
545
+ // Test surface — kept on the same export object so the regression suite can
546
+ // pin the access-token detection without spawning a real `supabase` binary.
547
+ module.exports._looksLikeMissingAccessToken = looksLikeMissingAccessToken;