@jhizzard/termdeck-stack 0.6.14 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck-stack",
3
- "version": "0.6.14",
3
+ "version": "1.1.0",
4
4
  "description": "One-command installer for the TermDeck developer memory stack: TermDeck + Mnestra + Rumen + Supabase MCP",
5
5
  "bin": {
6
6
  "termdeck-stack": "./src/index.js"
package/src/index.js CHANGED
@@ -137,6 +137,7 @@ function printHelp() {
137
137
  termdeck-stack start Boot the full stack (TermDeck + Mnestra)
138
138
  termdeck-stack stop Stop the running stack
139
139
  termdeck-stack status Print stack health
140
+ termdeck-stack uninstall Tear down all TermDeck-attributable state
140
141
 
141
142
  Install:
142
143
  npx @jhizzard/termdeck-stack Interactive wizard
@@ -752,7 +753,14 @@ function printNextSteps(plan, opts) {
752
753
  // to the wizard for backwards compat.
753
754
  async function _maybeRunSubcommand(argv) {
754
755
  const sub = argv[0];
755
- if (sub !== 'start' && sub !== 'stop' && sub !== 'status') return null;
756
+ if (sub !== 'start' && sub !== 'stop' && sub !== 'status' && sub !== 'uninstall') return null;
757
+ if (sub === 'uninstall') {
758
+ // Sprint 61 T1 — tear down all TermDeck-attributable state. Lazy-require so
759
+ // the wizard / launcher paths don't pay the uninstall module's load cost.
760
+ const uninstallMod = require('./uninstall');
761
+ const result = await uninstallMod.uninstall({ argv: argv.slice(1) });
762
+ return result.exitCode || 0;
763
+ }
756
764
  // Lazy-require so the wizard path doesn't pay the launcher's load cost.
757
765
  const launcher = require('./launcher');
758
766
  if (sub === 'start') {
@@ -0,0 +1,943 @@
1
+ #!/usr/bin/env node
2
+
3
+ // @jhizzard/termdeck-stack — uninstall command (Sprint 61, T1).
4
+ //
5
+ // Removes everything `termdeck-stack` (and `npx @jhizzard/termdeck-stack`)
6
+ // adds to a user's machine — surgically, idempotently, OS-aware. Does NOT
7
+ // touch the user's Supabase project or schemas unless --purge-supabase is
8
+ // explicitly passed and the user types the project ref to confirm.
9
+ //
10
+ // Public entry: `async function uninstall(opts) -> { ok, exitCode, summary }`.
11
+ //
12
+ // All side-effecting steps go through dependency-injection hooks on `opts`
13
+ // (`_fs`, `_spawnSync`, `_promptYesNo`, `_promptInputMatching`, `_now`) so
14
+ // the test suite can drive each step against a tempdir as fake $HOME without
15
+ // monkey-patching `os.homedir()` or shelling to real `launchctl` / `psql`.
16
+ //
17
+ // Steps run in this order (each independent: try/catch into a summary entry,
18
+ // one missing prior step never blocks a later one):
19
+ //
20
+ // 1. Pre-flight detection + summary + confirmation prompt (skip on --yes).
21
+ // 2. ~/.termdeck/ directory (preserve secrets.env* with --keep-secrets).
22
+ // 3. ~/.claude.json mnestra MCP entry (atomic surgical splice; abort on malformed).
23
+ // 4. ~/.claude/settings.json Stop+SessionEnd hook entry (atomic surgical splice; abort on malformed).
24
+ // 5. ~/.claude/hooks/memory-session-end.js (rename to .bak.<dashed-ISO>).
25
+ // 6. LaunchAgents on darwin (launchctl unload, then rm).
26
+ // 7. systemd user units on linux (systemctl --user disable --now, then rm).
27
+ // 8. --purge-supabase (two-step prompt: confirm + type project ref).
28
+ // 9. Final notice with `npm uninstall -g …` hint.
29
+ //
30
+ // Idempotency contract (per the lane brief):
31
+ // - Empty $HOME (no install state) → exit 0, "nothing to uninstall".
32
+ // - Already-uninstalled state → exit 0, "already uninstalled" (we
33
+ // detect this by presence of a sibling
34
+ // `~/.claude/hooks/memory-session-end.js
35
+ // .bak.*` from a prior uninstall).
36
+ // - Malformed ~/.claude.json → exit !=0, file bit-exact, others
37
+ // skipped (pre-flight validation aborts
38
+ // BEFORE any destructive step runs —
39
+ // including --purge-supabase).
40
+
41
+ 'use strict';
42
+
43
+ const fs = require('node:fs');
44
+ const os = require('node:os');
45
+ const path = require('node:path');
46
+ const readline = require('node:readline/promises');
47
+ const child_process = require('node:child_process');
48
+
49
+ const ANSI = {
50
+ green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m',
51
+ cyan: '\x1b[36m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m',
52
+ };
53
+
54
+ // Substring match on hook-entry .command — robust to ~ vs $HOME vs absolute
55
+ // paths. Same shape as the install-side `_isSessionEndHookEntry` in
56
+ // stack-installer/src/index.js, deliberately duplicated here so the uninstall
57
+ // path has no cross-module require risk on partial installs.
58
+ function _isSessionEndHookEntry(entry) {
59
+ return entry && typeof entry.command === 'string'
60
+ && entry.command.includes('memory-session-end.js');
61
+ }
62
+
63
+ // ── Args ────────────────────────────────────────────────────────────
64
+
65
+ function parseArgs(argv) {
66
+ const out = {
67
+ dryRun: false,
68
+ yes: false,
69
+ keepSecrets: false,
70
+ purgeSupabase: false,
71
+ help: false,
72
+ };
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const a = argv[i];
75
+ if (a === '--dry-run') { out.dryRun = true; continue; }
76
+ if (a === '--yes' || a === '-y') { out.yes = true; continue; }
77
+ if (a === '--keep-secrets') { out.keepSecrets = true; continue; }
78
+ if (a === '--purge-supabase') { out.purgeSupabase = true; continue; }
79
+ if (a === '--help' || a === '-h') { out.help = true; continue; }
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function printHelp(out = process.stdout) {
85
+ out.write(`
86
+ termdeck-stack uninstall — tear down all TermDeck-attributable state
87
+
88
+ Usage:
89
+ termdeck-stack uninstall [options]
90
+
91
+ Options:
92
+ --dry-run Print what would be removed; no changes
93
+ --purge-supabase Also drop Mnestra/Rumen schemas from the linked
94
+ Supabase project. BANNED unless explicitly
95
+ confirmed. Two-step prompt: first asks, then
96
+ requires the project ref typed for confirmation.
97
+ --keep-secrets Preserve ~/.termdeck/secrets.env (default: prompt;
98
+ in --yes / CI mode, secrets are removed).
99
+ --yes, -y Skip all confirmations (CI mode).
100
+ --help, -h Print usage.
101
+
102
+ What gets removed (default):
103
+ 1. ~/.termdeck/ (entire directory)
104
+ 2. ~/.claude.json mnestra MCP entry (surgical splice)
105
+ 3. ~/.claude/settings.json SessionEnd/Stop hook (surgical splice)
106
+ 4. ~/.claude/hooks/memory-session-end.js (renamed to .bak.<timestamp>)
107
+ 5. LaunchAgents (macOS) or systemd units (Linux) (unload + remove)
108
+
109
+ What is NEVER removed without an explicit flag:
110
+ - Your Supabase project (data preservation)
111
+ - The Mnestra/Rumen schemas inside the Supabase project
112
+ - Other MCP entries in ~/.claude.json
113
+ - Other hooks in ~/.claude/hooks/
114
+ - Other event wirings in ~/.claude/settings.json
115
+
116
+ `);
117
+ }
118
+
119
+ // ── Helpers ─────────────────────────────────────────────────────────
120
+
121
+ function _formatBytes(n) {
122
+ if (!Number.isFinite(n) || n < 0) return '?';
123
+ if (n < 1024) return `${n} B`;
124
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
125
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
126
+ }
127
+
128
+ // Walk a directory and sum bytes. Used purely for the pre-flight summary.
129
+ // Best-effort — any IO error short-circuits to 0 rather than aborting the run.
130
+ function _approxSize(_fs, dir) {
131
+ try {
132
+ const stat = _fs.statSync(dir);
133
+ if (!stat.isDirectory()) return stat.size || 0;
134
+ let total = 0;
135
+ for (const entry of _fs.readdirSync(dir, { withFileTypes: true })) {
136
+ const sub = path.join(dir, entry.name);
137
+ try {
138
+ if (entry.isDirectory()) total += _approxSize(_fs, sub);
139
+ else total += _fs.statSync(sub).size || 0;
140
+ } catch (_) { /* skip unreadable entries */ }
141
+ }
142
+ return total;
143
+ } catch (_) { return 0; }
144
+ }
145
+
146
+ // Atomic JSON write: temp file + rename. Used for both ~/.claude.json and
147
+ // ~/.claude/settings.json. Mirrors the pattern in mcp-config.js / index.js.
148
+ function _atomicWriteJson(_fs, filePath, value, mode) {
149
+ _fs.mkdirSync(path.dirname(filePath), { recursive: true });
150
+ const tmp = `${filePath}.tmp.${process.pid}`;
151
+ _fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: mode || 0o600 });
152
+ _fs.renameSync(tmp, filePath);
153
+ }
154
+
155
+ // Parse a `KEY=VALUE` env file. Mirrors the install-side `readTermdeckSecrets`
156
+ // and `readSecrets` parsers (deliberately self-contained — no cross-module
157
+ // require so a partial install state can still parse what's left).
158
+ function _parseEnvFile(_fs, filePath) {
159
+ try {
160
+ const text = _fs.readFileSync(filePath, 'utf8');
161
+ const out = {};
162
+ for (const raw of text.split('\n')) {
163
+ const line = raw.trim();
164
+ if (!line || line.startsWith('#')) continue;
165
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
166
+ if (!m) continue;
167
+ let v = m[2];
168
+ if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
169
+ v = v.slice(1, -1);
170
+ }
171
+ out[m[1]] = v;
172
+ }
173
+ return out;
174
+ } catch (_) { return {}; }
175
+ }
176
+
177
+ // Canonical ISO-8601 timestamp for the hook backup suffix. Format:
178
+ // `2026-05-07T22:48:00.000Z` — exactly what `Date.prototype.toISOString()`
179
+ // returns. Brief mandates ISO-8601-regex matching (T4-CODEX 18:46 ET concern);
180
+ // POSIX filesystems handle colons in filenames without issue.
181
+ function _isoStamp(_now) {
182
+ const d = (_now || (() => new Date()))();
183
+ return d.toISOString();
184
+ }
185
+
186
+ // Default interactive yes/no prompt — only invoked when neither --yes nor a
187
+ // test stub is provided. Returns a boolean.
188
+ async function _defaultPromptYesNo(question, defaultYes = false) {
189
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
190
+ const suffix = defaultYes ? '(Y/n)' : '(y/N)';
191
+ const ans = (await rl.question(` ${question} ${suffix} `)).trim().toLowerCase();
192
+ rl.close();
193
+ if (ans === '') return defaultYes;
194
+ return ans === 'y' || ans === 'yes';
195
+ }
196
+
197
+ // Default interactive "type X to confirm" prompt — only invoked for
198
+ // --purge-supabase. Returns true iff the user types the expected literal.
199
+ async function _defaultPromptInputMatching(question, expected) {
200
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
201
+ const ans = (await rl.question(` ${question} `)).trim();
202
+ rl.close();
203
+ return ans === expected;
204
+ }
205
+
206
+ // ── Detection ───────────────────────────────────────────────────────
207
+
208
+ // Pure: returns the resolved set of paths under the given fake-or-real $HOME
209
+ // for a given platform. Centralized so every step uses the same shapes and
210
+ // the test fixtures know exactly what to populate.
211
+ function _resolvePaths(home, platform) {
212
+ return {
213
+ home,
214
+ platform,
215
+ termdeckDir: path.join(home, '.termdeck'),
216
+ secretsEnv: path.join(home, '.termdeck', 'secrets.env'),
217
+ claudeJson: path.join(home, '.claude.json'),
218
+ settingsJson: path.join(home, '.claude', 'settings.json'),
219
+ hookFile: path.join(home, '.claude', 'hooks', 'memory-session-end.js'),
220
+ launchAgentsDir: path.join(home, 'Library', 'LaunchAgents'),
221
+ launchAgentGlob: 'com.jhizzard.termdeck.', // prefix match against .plist files
222
+ systemdUnit: path.join(home, '.config', 'systemd', 'user', 'termdeck.service'),
223
+ };
224
+ }
225
+
226
+ function _claudeJsonHasMnestraEntry(_fs, claudeJson) {
227
+ if (!_fs.existsSync(claudeJson)) return false;
228
+ let parsed;
229
+ try { parsed = JSON.parse(_fs.readFileSync(claudeJson, 'utf8') || '{}'); }
230
+ catch (_) { return false; }
231
+ return !!(parsed && parsed.mcpServers && parsed.mcpServers.mnestra);
232
+ }
233
+
234
+ function _settingsJsonHasOurHook(_fs, settingsJson) {
235
+ if (!_fs.existsSync(settingsJson)) return false;
236
+ let parsed;
237
+ try { parsed = JSON.parse(_fs.readFileSync(settingsJson, 'utf8') || '{}'); }
238
+ catch (_) { return false; }
239
+ if (!parsed || !parsed.hooks) return false;
240
+ for (const event of ['Stop', 'SessionEnd']) {
241
+ const arr = parsed.hooks[event];
242
+ if (!Array.isArray(arr)) continue;
243
+ for (const elem of arr) {
244
+ if (!elem) continue;
245
+ // Canonical Claude-Code group shape: { matcher, hooks: [{ type, command }] }
246
+ if (Array.isArray(elem.hooks) && elem.hooks.some(_isSessionEndHookEntry)) return true;
247
+ // Legacy / hand-edited flat shape: { type, command } directly in array.
248
+ // T3 (Sprint 61 18:50 ET) found that real-world fixtures use this
249
+ // alternative shape, and our uninstall must handle both.
250
+ if (_isSessionEndHookEntry(elem)) return true;
251
+ }
252
+ }
253
+ return false;
254
+ }
255
+
256
+ function _findLaunchAgents(_fs, dir, prefix) {
257
+ try {
258
+ return _fs.readdirSync(dir)
259
+ .filter((n) => n.startsWith(prefix) && n.endsWith('.plist'))
260
+ .map((n) => path.join(dir, n));
261
+ } catch (_) { return []; }
262
+ }
263
+
264
+ // Look for prior-uninstall residue: `memory-session-end.js.bak.*` siblings of
265
+ // the canonical hook destination. Used to distinguish "never installed" (no
266
+ // bak files, so we say "nothing to uninstall") from "already uninstalled" (at
267
+ // least one bak file from a prior run, so we say "already uninstalled"). The
268
+ // distinction is required by the T1 brief idempotency contract — T4-CODEX
269
+ // 18:46 ET concern.
270
+ function _findHookBakFiles(_fs, hookFile) {
271
+ try {
272
+ const dir = path.dirname(hookFile);
273
+ const baseName = path.basename(hookFile);
274
+ return _fs.readdirSync(dir)
275
+ .filter((n) => n.startsWith(`${baseName}.bak.`))
276
+ .map((n) => path.join(dir, n));
277
+ } catch (_) { return []; }
278
+ }
279
+
280
+ function _detectInstallState(_fs, paths) {
281
+ const launchAgents = paths.platform === 'darwin'
282
+ ? _findLaunchAgents(_fs, paths.launchAgentsDir, paths.launchAgentGlob)
283
+ : [];
284
+ const systemdActive = paths.platform === 'linux' && _fs.existsSync(paths.systemdUnit);
285
+ return {
286
+ hasTermdeckDir: _fs.existsSync(paths.termdeckDir),
287
+ hasMnestraMcpEntry: _claudeJsonHasMnestraEntry(_fs, paths.claudeJson),
288
+ hasOurHookInSettings: _settingsJsonHasOurHook(_fs, paths.settingsJson),
289
+ hasHookFile: _fs.existsSync(paths.hookFile),
290
+ hookBakFiles: _findHookBakFiles(_fs, paths.hookFile),
291
+ launchAgents,
292
+ systemdActive,
293
+ };
294
+ }
295
+
296
+ // Pre-flight validation. Runs BEFORE any destructive step. If `~/.claude.json`
297
+ // exists but is malformed, abort hard — never run --purge-supabase, never
298
+ // remove ~/.termdeck, never splice settings.json. T4-CODEX 18:48 ET concern:
299
+ // previously the malformed-claude.json fatal step was discovered only after
300
+ // destructive steps had already run, risking a partial destructive uninstall
301
+ // on exactly the path documented as abort.
302
+ function _preflightValidate(_fs, paths) {
303
+ if (_fs.existsSync(paths.claudeJson)) {
304
+ let raw;
305
+ try { raw = _fs.readFileSync(paths.claudeJson, 'utf8'); }
306
+ catch (e) {
307
+ return {
308
+ ok: false,
309
+ fatal: { name: 'claude-json-mcp', status: 'error', detail: `unreadable: ${e.message}`, fatal: true },
310
+ };
311
+ }
312
+ if (raw.trim() !== '') {
313
+ try {
314
+ const parsed = JSON.parse(raw);
315
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
316
+ throw new Error('top-level must be an object');
317
+ }
318
+ } catch (e) {
319
+ return {
320
+ ok: false,
321
+ fatal: {
322
+ name: 'claude-json-mcp',
323
+ status: 'malformed',
324
+ fatal: true,
325
+ detail: `${paths.claudeJson} is malformed (${e.message}); not modified — fix the JSON and re-run. NO destructive steps were executed.`,
326
+ },
327
+ };
328
+ }
329
+ }
330
+ }
331
+ return { ok: true };
332
+ }
333
+
334
+ function _isFullyClean(state) {
335
+ return !state.hasTermdeckDir
336
+ && !state.hasMnestraMcpEntry
337
+ && !state.hasOurHookInSettings
338
+ && !state.hasHookFile
339
+ && state.launchAgents.length === 0
340
+ && !state.systemdActive;
341
+ }
342
+
343
+ // ── Steps ───────────────────────────────────────────────────────────
344
+
345
+ // Step 2: ~/.termdeck/. Honors --keep-secrets. Idempotent — missing dir is OK.
346
+ function _stepRemoveTermdeckDir(_fs, paths, opts) {
347
+ const out = { name: 'termdeck-dir', status: 'pending', detail: '' };
348
+ if (!_fs.existsSync(paths.termdeckDir)) {
349
+ out.status = 'skipped'; out.detail = 'not present';
350
+ return out;
351
+ }
352
+ if (opts.dryRun) {
353
+ out.status = 'would-remove';
354
+ out.detail = `would remove ${paths.termdeckDir} (${_formatBytes(_approxSize(_fs, paths.termdeckDir))})`;
355
+ return out;
356
+ }
357
+ try {
358
+ if (opts.keepSecrets) {
359
+ // Snapshot any `secrets.env*` files into memory, nuke the dir, restore.
360
+ const preserved = [];
361
+ for (const entry of _fs.readdirSync(paths.termdeckDir)) {
362
+ if (entry === 'secrets.env' || entry.startsWith('secrets.env.bak.')) {
363
+ preserved.push({ name: entry, body: _fs.readFileSync(path.join(paths.termdeckDir, entry)) });
364
+ }
365
+ }
366
+ _fs.rmSync(paths.termdeckDir, { recursive: true, force: true });
367
+ _fs.mkdirSync(paths.termdeckDir, { recursive: true });
368
+ for (const f of preserved) {
369
+ _fs.writeFileSync(path.join(paths.termdeckDir, f.name), f.body, { mode: 0o600 });
370
+ }
371
+ out.status = 'preserved-secrets';
372
+ out.detail = `removed ${paths.termdeckDir}, preserved ${preserved.length} secrets file(s)`;
373
+ } else {
374
+ _fs.rmSync(paths.termdeckDir, { recursive: true, force: true });
375
+ out.status = 'removed';
376
+ out.detail = `removed ${paths.termdeckDir}`;
377
+ }
378
+ } catch (e) {
379
+ out.status = 'error'; out.detail = `${e && e.message ? e.message : e}`;
380
+ }
381
+ return out;
382
+ }
383
+
384
+ // Step 3: ~/.claude.json mnestra MCP entry. Surgical splice. Atomic write.
385
+ // Aborts hard (and signals the caller) on malformed JSON: the file is preserved
386
+ // bit-exact rather than overwritten with our best guess.
387
+ function _stepSpliceClaudeJson(_fs, paths, opts) {
388
+ const out = { name: 'claude-json-mcp', status: 'pending', detail: '', fatal: false };
389
+ if (!_fs.existsSync(paths.claudeJson)) {
390
+ out.status = 'skipped'; out.detail = 'not present';
391
+ return out;
392
+ }
393
+ let raw;
394
+ try { raw = _fs.readFileSync(paths.claudeJson, 'utf8'); }
395
+ catch (e) {
396
+ out.status = 'error'; out.detail = `unreadable: ${e.message}`;
397
+ return out;
398
+ }
399
+ let parsed;
400
+ try {
401
+ parsed = raw.trim() === '' ? {} : JSON.parse(raw);
402
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
403
+ throw new Error('top-level must be an object');
404
+ }
405
+ } catch (e) {
406
+ out.status = 'malformed';
407
+ out.fatal = true;
408
+ out.detail = `${paths.claudeJson} is malformed (${e.message}); not modified — fix the JSON and re-run.`;
409
+ return out;
410
+ }
411
+ const servers = (parsed.mcpServers && typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers))
412
+ ? parsed.mcpServers : null;
413
+ if (!servers || !servers.mnestra) {
414
+ out.status = 'skipped'; out.detail = 'no mnestra MCP entry present';
415
+ return out;
416
+ }
417
+ if (opts.dryRun) {
418
+ out.status = 'would-splice';
419
+ out.detail = `would splice mcpServers.mnestra from ${paths.claudeJson}`;
420
+ return out;
421
+ }
422
+ delete servers.mnestra;
423
+ // Preserve every other top-level key (permissions, env, anything else)
424
+ // because we wrote `parsed` not `{ mcpServers: servers }`.
425
+ parsed.mcpServers = servers;
426
+ try {
427
+ _atomicWriteJson(_fs, paths.claudeJson, parsed, 0o600);
428
+ out.status = 'spliced';
429
+ const remaining = Object.keys(servers);
430
+ out.detail = remaining.length === 0
431
+ ? 'removed mnestra (no other MCP entries remained)'
432
+ : `removed mnestra (preserved: ${remaining.join(', ')})`;
433
+ } catch (e) {
434
+ out.status = 'error'; out.detail = `write failed: ${e.message}`;
435
+ }
436
+ return out;
437
+ }
438
+
439
+ // Step 4: ~/.claude/settings.json. Splice both Stop and SessionEnd entries
440
+ // pointing at our hook. Preserve other event wirings + other entries inside
441
+ // SessionEnd/Stop. Delete keys that become empty.
442
+ function _stepSpliceSettingsJson(_fs, paths, opts) {
443
+ const out = { name: 'settings-json-hooks', status: 'pending', detail: '', fatal: false };
444
+ if (!_fs.existsSync(paths.settingsJson)) {
445
+ out.status = 'skipped'; out.detail = 'not present';
446
+ return out;
447
+ }
448
+ let raw;
449
+ try { raw = _fs.readFileSync(paths.settingsJson, 'utf8'); }
450
+ catch (e) {
451
+ out.status = 'error'; out.detail = `unreadable: ${e.message}`;
452
+ return out;
453
+ }
454
+ let parsed;
455
+ try {
456
+ parsed = raw.trim() === '' ? {} : JSON.parse(raw);
457
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
458
+ throw new Error('top-level must be an object');
459
+ }
460
+ } catch (e) {
461
+ out.status = 'malformed';
462
+ out.fatal = false; // unlike claude.json, we keep going for partial recovery
463
+ out.detail = `${paths.settingsJson} is malformed (${e.message}); not modified.`;
464
+ return out;
465
+ }
466
+ if (!parsed.hooks || typeof parsed.hooks !== 'object') {
467
+ out.status = 'skipped'; out.detail = 'no hooks section in settings.json';
468
+ return out;
469
+ }
470
+ let removedCount = 0;
471
+ for (const event of ['Stop', 'SessionEnd']) {
472
+ const arr = parsed.hooks[event];
473
+ if (!Array.isArray(arr)) continue;
474
+ // Two shapes coexist in the wild (T3 finding 2026-05-07 18:50 ET):
475
+ // 1. Canonical group shape: { matcher, hooks: [{ type, command }, ...] }
476
+ // 2. Flat shape: { type, command, timeout? } directly in arr
477
+ // We splice both. For canonical groups, filter the inner `hooks` array;
478
+ // empty groups get pruned post-pass. For flat entries, the array element
479
+ // itself is the candidate.
480
+ for (const elem of arr) {
481
+ if (elem && Array.isArray(elem.hooks)) {
482
+ const before = elem.hooks.length;
483
+ elem.hooks = elem.hooks.filter((e) => !_isSessionEndHookEntry(e));
484
+ removedCount += before - elem.hooks.length;
485
+ }
486
+ }
487
+ const next = [];
488
+ for (const elem of arr) {
489
+ if (!elem) continue;
490
+ // Drop flat entries that match our hook.
491
+ if (_isSessionEndHookEntry(elem) && !Array.isArray(elem.hooks)) {
492
+ removedCount += 1;
493
+ continue;
494
+ }
495
+ // Drop now-empty canonical groups (would otherwise stay as `{ matcher, hooks: [] }`).
496
+ if (Array.isArray(elem.hooks) && elem.hooks.length === 0) continue;
497
+ next.push(elem);
498
+ }
499
+ parsed.hooks[event] = next;
500
+ if (parsed.hooks[event].length === 0) delete parsed.hooks[event];
501
+ }
502
+ if (Object.keys(parsed.hooks).length === 0) delete parsed.hooks;
503
+ if (removedCount === 0) {
504
+ out.status = 'skipped'; out.detail = 'no entries pointed at our hook';
505
+ return out;
506
+ }
507
+ if (opts.dryRun) {
508
+ out.status = 'would-splice';
509
+ out.detail = `would remove ${removedCount} hook entr${removedCount === 1 ? 'y' : 'ies'} from ${paths.settingsJson}`;
510
+ return out;
511
+ }
512
+ try {
513
+ _atomicWriteJson(_fs, paths.settingsJson, parsed, 0o600);
514
+ out.status = 'spliced';
515
+ out.detail = `removed ${removedCount} hook entr${removedCount === 1 ? 'y' : 'ies'}; other event wirings preserved`;
516
+ } catch (e) {
517
+ out.status = 'error'; out.detail = `write failed: ${e.message}`;
518
+ }
519
+ return out;
520
+ }
521
+
522
+ // Step 5: rename the bundled hook file to .bak.<dashed-ISO>. Don't hard-delete:
523
+ // the user may have customized, and the backup is cheap.
524
+ function _stepBackupHookFile(_fs, paths, opts) {
525
+ const out = { name: 'hook-file-backup', status: 'pending', detail: '' };
526
+ if (!_fs.existsSync(paths.hookFile)) {
527
+ out.status = 'skipped'; out.detail = 'not present';
528
+ return out;
529
+ }
530
+ const stamp = _isoStamp(opts._now);
531
+ const bakPath = `${paths.hookFile}.bak.${stamp}`;
532
+ if (opts.dryRun) {
533
+ out.status = 'would-rename';
534
+ out.detail = `would rename ${paths.hookFile} → ${bakPath}`;
535
+ return out;
536
+ }
537
+ try {
538
+ _fs.renameSync(paths.hookFile, bakPath);
539
+ out.status = 'renamed';
540
+ out.detail = `${paths.hookFile} → ${bakPath}`;
541
+ } catch (e) {
542
+ out.status = 'error'; out.detail = `rename failed: ${e.message}`;
543
+ }
544
+ return out;
545
+ }
546
+
547
+ // Step 6 (darwin only): LaunchAgents — `launchctl unload` BEFORE `rm`. The
548
+ // unload call's exit code is non-fatal: the agent may not be loaded in the
549
+ // current session, especially in tests.
550
+ function _stepRemoveLaunchAgents(_fs, _spawnSync, paths, opts) {
551
+ const out = { name: 'launch-agents', status: 'pending', detail: '', actions: [] };
552
+ if (paths.platform !== 'darwin') {
553
+ out.status = 'skipped'; out.detail = `not darwin (got ${paths.platform})`;
554
+ return out;
555
+ }
556
+ const matches = _findLaunchAgents(_fs, paths.launchAgentsDir, paths.launchAgentGlob);
557
+ if (matches.length === 0) {
558
+ out.status = 'skipped'; out.detail = 'no com.jhizzard.termdeck.* plist found';
559
+ return out;
560
+ }
561
+ if (opts.dryRun) {
562
+ out.status = 'would-remove';
563
+ out.detail = `would unload + remove ${matches.length} plist(s): ${matches.map((p) => path.basename(p)).join(', ')}`;
564
+ return out;
565
+ }
566
+ let failures = 0;
567
+ for (const plist of matches) {
568
+ try {
569
+ out.actions.push({ kind: 'unload', target: plist });
570
+ _spawnSync('launchctl', ['unload', plist], { encoding: 'utf8' });
571
+ } catch (_) { /* non-fatal */ }
572
+ try {
573
+ out.actions.push({ kind: 'rm', target: plist });
574
+ _fs.unlinkSync(plist);
575
+ } catch (e) { failures++; out.actions.push({ kind: 'rm-failed', target: plist, error: e.message }); }
576
+ }
577
+ if (failures > 0) {
578
+ out.status = 'partial';
579
+ out.detail = `unloaded ${matches.length} plist(s); ${failures} could not be removed`;
580
+ } else {
581
+ out.status = 'removed';
582
+ out.detail = `unloaded + removed ${matches.length} plist(s)`;
583
+ }
584
+ return out;
585
+ }
586
+
587
+ // Step 7 (linux only): systemd user unit — disable + stop, then rm. The
588
+ // system-scope `/etc/systemd/system/termdeck.service` requires sudo; we surface
589
+ // a hint rather than attempting it.
590
+ function _stepRemoveSystemdUnit(_fs, _spawnSync, paths, opts) {
591
+ const out = { name: 'systemd-unit', status: 'pending', detail: '', actions: [] };
592
+ if (paths.platform !== 'linux') {
593
+ out.status = 'skipped'; out.detail = `not linux (got ${paths.platform})`;
594
+ return out;
595
+ }
596
+ if (!_fs.existsSync(paths.systemdUnit)) {
597
+ out.status = 'skipped'; out.detail = 'no user-scope termdeck.service unit found';
598
+ return out;
599
+ }
600
+ if (opts.dryRun) {
601
+ out.status = 'would-remove';
602
+ out.detail = `would systemctl --user disable --now + remove ${paths.systemdUnit}`;
603
+ return out;
604
+ }
605
+ try {
606
+ out.actions.push({ kind: 'systemctl-disable', target: paths.systemdUnit });
607
+ _spawnSync('systemctl', ['--user', 'disable', '--now', 'termdeck.service'], { encoding: 'utf8' });
608
+ } catch (_) { /* non-fatal */ }
609
+ try {
610
+ out.actions.push({ kind: 'rm', target: paths.systemdUnit });
611
+ _fs.unlinkSync(paths.systemdUnit);
612
+ out.status = 'removed';
613
+ out.detail = `disabled + removed ${paths.systemdUnit}`;
614
+ } catch (e) {
615
+ out.status = 'error';
616
+ out.detail = `rm failed: ${e.message}`;
617
+ }
618
+ return out;
619
+ }
620
+
621
+ // Step 8: --purge-supabase. Two-step prompt. Reads SUPABASE_DB_URL from
622
+ // secrets.env BEFORE step 2 has a chance to delete the directory (this step
623
+ // is invoked from the orchestrator BEFORE _stepRemoveTermdeckDir even though
624
+ // the spec lists supabase-purge as a later step in user-facing summary). On
625
+ // confirm: if `psql` is on PATH, run a DROP TABLE / DROP FUNCTION CASCADE on
626
+ // the Mnestra/Rumen object set; otherwise print the SQL block for the user
627
+ // to run manually via Supabase MCP / psql.
628
+ // Broader DROP block (T4-CODEX 18:46 ET concern: brief requires `rumen_*`
629
+ // tables/functions/types — implementation must enumerate ALL three kinds).
630
+ // Implemented as a PL/pgSQL DO block so a single statement covers an unbounded
631
+ // set of `rumen_*` and Mnestra-named objects. Idempotent: every drop is
632
+ // IF EXISTS / CASCADE.
633
+ function _buildPurgeSql(ref) {
634
+ return `-- TermDeck/Mnestra/Rumen purge (--purge-supabase) for project ${ref}
635
+ -- Drops every public-schema object whose name starts with mnestra_, memory_,
636
+ -- rumen_, or matches the canonical bundled-table list. Idempotent and CASCADE.
637
+
638
+ DROP TABLE IF EXISTS public.memory_relationships CASCADE;
639
+ DROP TABLE IF EXISTS public.memory_items CASCADE;
640
+ DROP TABLE IF EXISTS public.memory_sessions CASCADE;
641
+ DROP TABLE IF EXISTS public.mnestra_migrations CASCADE;
642
+
643
+ DO $$
644
+ DECLARE r RECORD;
645
+ BEGIN
646
+ -- All rumen_* tables.
647
+ FOR r IN
648
+ SELECT tablename FROM pg_tables
649
+ WHERE schemaname = 'public' AND tablename LIKE 'rumen\\_%' ESCAPE '\\'
650
+ LOOP
651
+ EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
652
+ END LOOP;
653
+
654
+ -- All rumen_* and mnestra_* and memory_* functions (covers SECURITY DEFINER
655
+ -- doctor functions, search RPCs, graph helpers).
656
+ FOR r IN
657
+ SELECT p.proname,
658
+ pg_catalog.pg_get_function_identity_arguments(p.oid) AS args
659
+ FROM pg_proc p
660
+ JOIN pg_namespace n ON n.oid = p.pronamespace
661
+ WHERE n.nspname = 'public'
662
+ AND (p.proname LIKE 'rumen\\_%' ESCAPE '\\'
663
+ OR p.proname LIKE 'mnestra\\_%' ESCAPE '\\'
664
+ OR p.proname LIKE 'memory\\_%' ESCAPE '\\')
665
+ LOOP
666
+ EXECUTE 'DROP FUNCTION IF EXISTS public.' || quote_ident(r.proname)
667
+ || '(' || r.args || ') CASCADE';
668
+ END LOOP;
669
+
670
+ -- All rumen_* and mnestra_* types (enums, composites).
671
+ FOR r IN
672
+ SELECT t.typname FROM pg_type t
673
+ JOIN pg_namespace n ON n.oid = t.typnamespace
674
+ WHERE n.nspname = 'public'
675
+ AND (t.typname LIKE 'rumen\\_%' ESCAPE '\\'
676
+ OR t.typname LIKE 'mnestra\\_%' ESCAPE '\\')
677
+ LOOP
678
+ EXECUTE 'DROP TYPE IF EXISTS public.' || quote_ident(r.typname) || ' CASCADE';
679
+ END LOOP;
680
+ END $$;
681
+ `;
682
+ }
683
+
684
+ async function _stepPurgeSupabase(_fs, _spawnSync, _promptYesNo, _promptInputMatching, paths, opts) {
685
+ const out = { name: 'purge-supabase', status: 'pending', detail: '' };
686
+ if (!opts.purgeSupabase) {
687
+ out.status = 'skipped'; out.detail = 'flag not set';
688
+ return out;
689
+ }
690
+ // Read secrets BEFORE the dir-removal step lands. Caller orchestrates the
691
+ // ordering — this function does not delete the dir itself.
692
+ const secrets = _parseEnvFile(_fs, paths.secretsEnv);
693
+ const dbUrl = secrets.SUPABASE_DB_URL || secrets.DATABASE_URL || '';
694
+ const url = secrets.SUPABASE_URL || '';
695
+ const ref = url ? (url.match(/^https?:\/\/([a-z0-9]+)\./) || [])[1] || '' : '';
696
+ if (!ref) {
697
+ out.status = 'skipped';
698
+ out.detail = 'cannot resolve project ref from secrets.env (SUPABASE_URL missing) — refusing to drop schemas blind';
699
+ return out;
700
+ }
701
+ // T4-CODEX 18:46 ET concern: dry-run must NOT prompt for destructive
702
+ // confirmation — it should just print what *would* happen. Short-circuit
703
+ // BEFORE any prompt.
704
+ if (opts.dryRun) {
705
+ out.status = 'would-purge';
706
+ out.detail = `would run DROP block against ${ref} (Mnestra + Rumen + memory_ tables/functions/types, IF EXISTS / CASCADE)`;
707
+ return out;
708
+ }
709
+ const ok1 = await _promptYesNo(
710
+ `--purge-supabase will DROP Mnestra/Rumen schemas from project ${ref}. Continue?`,
711
+ false,
712
+ );
713
+ if (!ok1) {
714
+ out.status = 'skipped'; out.detail = 'user declined first prompt';
715
+ return out;
716
+ }
717
+ const ok2 = await _promptInputMatching(
718
+ `Type the project ref (${ref}) to confirm drop:`,
719
+ ref,
720
+ );
721
+ if (!ok2) {
722
+ out.status = 'skipped'; out.detail = 'project ref did not match — aborted';
723
+ return out;
724
+ }
725
+ const sql = _buildPurgeSql(ref);
726
+ if (!dbUrl) {
727
+ out.status = 'sql-printed';
728
+ out.detail = `SUPABASE_DB_URL missing — printing SQL for you to run via Supabase SQL editor or psql:\n${sql}`;
729
+ return out;
730
+ }
731
+ // Look for psql on PATH. Zero-runtime-dep package — we cannot ship `pg`.
732
+ const psqlCheck = _spawnSync('which', ['psql'], { encoding: 'utf8' });
733
+ if (psqlCheck.status !== 0 || !(psqlCheck.stdout || '').trim()) {
734
+ out.status = 'sql-printed';
735
+ out.detail = `psql not on PATH — printing SQL for you to run via Supabase SQL editor:\n${sql}`;
736
+ return out;
737
+ }
738
+ const r = _spawnSync('psql', [dbUrl, '-v', 'ON_ERROR_STOP=1', '-c', sql], { encoding: 'utf8' });
739
+ if (r.status === 0) {
740
+ out.status = 'purged';
741
+ out.detail = `dropped Mnestra/Rumen schemas + functions + types in project ${ref}`;
742
+ } else {
743
+ out.status = 'error';
744
+ out.detail = `psql exit ${r.status}: ${r.stderr || ''}`;
745
+ }
746
+ return out;
747
+ }
748
+
749
+ // ── Orchestrator ────────────────────────────────────────────────────
750
+
751
+ function _printPreflight(out, state, paths, opts) {
752
+ const lines = [];
753
+ lines.push(`${ANSI.bold}TermDeck Stack Uninstall — pre-flight${ANSI.reset}`);
754
+ lines.push(`${ANSI.dim}─────────────────────────────────────────────${ANSI.reset}`);
755
+ if (state.hasTermdeckDir) {
756
+ const sz = _formatBytes(_approxSize(opts._fs || fs, paths.termdeckDir));
757
+ lines.push(` ${ANSI.cyan}•${ANSI.reset} ${paths.termdeckDir} ${ANSI.dim}(${sz})${ANSI.reset}${opts.keepSecrets ? ' [secrets preserved]' : ''}`);
758
+ }
759
+ if (state.hasMnestraMcpEntry) lines.push(` ${ANSI.cyan}•${ANSI.reset} mcpServers.mnestra in ${paths.claudeJson} ${ANSI.dim}(surgical splice — other entries preserved)${ANSI.reset}`);
760
+ if (state.hasOurHookInSettings) lines.push(` ${ANSI.cyan}•${ANSI.reset} hooks.{Stop,SessionEnd} entries in ${paths.settingsJson} ${ANSI.dim}(surgical splice — other entries preserved)${ANSI.reset}`);
761
+ if (state.hasHookFile) lines.push(` ${ANSI.cyan}•${ANSI.reset} ${paths.hookFile} ${ANSI.dim}(renamed to .bak.<timestamp>, not deleted)${ANSI.reset}`);
762
+ for (const p of state.launchAgents) lines.push(` ${ANSI.cyan}•${ANSI.reset} ${p} ${ANSI.dim}(launchctl unload + rm)${ANSI.reset}`);
763
+ if (state.systemdActive) lines.push(` ${ANSI.cyan}•${ANSI.reset} ${paths.systemdUnit} ${ANSI.dim}(systemctl --user disable --now + rm)${ANSI.reset}`);
764
+ if (opts.purgeSupabase) lines.push(` ${ANSI.red}•${ANSI.reset} ${ANSI.bold}--purge-supabase: will DROP Mnestra/Rumen tables in your Supabase project${ANSI.reset}`);
765
+ lines.push('');
766
+ out.write(lines.join('\n') + '\n');
767
+ }
768
+
769
+ function _printSummaryLine(out, step) {
770
+ const verbColor = {
771
+ removed: ANSI.green, spliced: ANSI.green, renamed: ANSI.green,
772
+ 'preserved-secrets': ANSI.green, purged: ANSI.green,
773
+ skipped: ANSI.dim,
774
+ 'would-remove': ANSI.yellow, 'would-splice': ANSI.yellow,
775
+ 'would-rename': ANSI.yellow, 'would-purge': ANSI.yellow,
776
+ 'sql-printed': ANSI.yellow, partial: ANSI.yellow,
777
+ error: ANSI.red, malformed: ANSI.red,
778
+ };
779
+ const color = verbColor[step.status] || ANSI.dim;
780
+ out.write(` ${color}${step.status.padEnd(20)}${ANSI.reset} ${step.name.padEnd(22)} ${ANSI.dim}${step.detail || ''}${ANSI.reset}\n`);
781
+ }
782
+
783
+ async function uninstall(opts = {}) {
784
+ const _fs = opts._fs || fs;
785
+ const _spawnSync = opts._spawnSync || child_process.spawnSync;
786
+ const _promptYesNo = opts._promptYesNo || _defaultPromptYesNo;
787
+ const _promptInputMatching = opts._promptInputMatching || _defaultPromptInputMatching;
788
+ const out = opts._stdout || process.stdout;
789
+ const home = opts.home || os.homedir();
790
+ const platform = opts.platform || process.platform;
791
+
792
+ const args = opts.argv ? parseArgs(opts.argv) : {
793
+ dryRun: !!opts.dryRun, yes: !!opts.yes,
794
+ keepSecrets: !!opts.keepSecrets, purgeSupabase: !!opts.purgeSupabase, help: false,
795
+ };
796
+ if (args.help) {
797
+ printHelp(out);
798
+ return { ok: true, exitCode: 0, summary: { steps: [], state: null }, args };
799
+ }
800
+
801
+ const paths = _resolvePaths(home, platform);
802
+ const state = _detectInstallState(_fs, paths);
803
+ const summary = { steps: [], state, paths, args, idempotencyState: null };
804
+
805
+ if (_isFullyClean(state) && !args.purgeSupabase) {
806
+ out.write(`\n${ANSI.bold}TermDeck Stack Uninstall${ANSI.reset}\n`);
807
+ out.write(`${ANSI.dim}─────────────────────────────────────────────${ANSI.reset}\n\n`);
808
+ // T4-CODEX 18:46 ET concern: distinguish "never installed" from "already
809
+ // uninstalled". Prior-uninstall residue (a `.bak.*` of our hook) is the
810
+ // signal that an uninstall has run before — message changes accordingly.
811
+ if (state.hookBakFiles && state.hookBakFiles.length > 0) {
812
+ summary.idempotencyState = 'already-uninstalled';
813
+ out.write(` ${ANSI.dim}─${ANSI.reset} ${ANSI.dim}already uninstalled (no live TermDeck state; ${state.hookBakFiles.length} prior-uninstall .bak file${state.hookBakFiles.length === 1 ? '' : 's'} retained for safety)${ANSI.reset}\n\n`);
814
+ } else {
815
+ summary.idempotencyState = 'nothing-to-uninstall';
816
+ out.write(` ${ANSI.dim}─${ANSI.reset} ${ANSI.dim}nothing to uninstall (no TermDeck-attributable state found)${ANSI.reset}\n\n`);
817
+ }
818
+ return { ok: true, exitCode: 0, summary };
819
+ }
820
+
821
+ // T4-CODEX 18:48 ET concern: pre-flight validation aborts BEFORE any
822
+ // destructive step runs. If `~/.claude.json` is malformed, we never reach
823
+ // --purge-supabase, never remove `~/.termdeck/`, never splice settings.json.
824
+ const preflight = _preflightValidate(_fs, paths);
825
+ if (!preflight.ok) {
826
+ summary.steps = [preflight.fatal];
827
+ summary.preflightAborted = true;
828
+ out.write('\n');
829
+ _printSummaryLine(out, preflight.fatal);
830
+ out.write(`\n${ANSI.red}Uninstall aborted at pre-flight (${preflight.fatal.name}): ${preflight.fatal.detail}${ANSI.reset}\n`);
831
+ out.write(`${ANSI.dim}NO destructive steps were executed; --purge-supabase, ~/.termdeck/, settings.json splice all skipped.${ANSI.reset}\n\n`);
832
+ return { ok: false, exitCode: 1, summary };
833
+ }
834
+
835
+ out.write('\n');
836
+ _printPreflight(out, state, paths, { ...args, _fs });
837
+
838
+ // Effective keep-secrets: --keep-secrets flag wins; otherwise interactive
839
+ // prompts (T4-CODEX 18:46 ET concern); otherwise --yes/CI mode removes
840
+ // secrets as the safe default per the brief.
841
+ let effectiveKeepSecrets = !!args.keepSecrets;
842
+ if (!args.keepSecrets && !args.yes && _fs.existsSync(paths.secretsEnv)) {
843
+ effectiveKeepSecrets = await _promptYesNo(
844
+ `Preserve ${paths.secretsEnv} (and any secrets.env.bak.* siblings)?`,
845
+ true,
846
+ );
847
+ }
848
+
849
+ if (!args.yes) {
850
+ const proceed = await _promptYesNo('Proceed with uninstall?', false);
851
+ if (!proceed) {
852
+ out.write(` ${ANSI.dim}Aborted by user.${ANSI.reset}\n\n`);
853
+ return { ok: true, exitCode: 0, summary, aborted: true };
854
+ }
855
+ }
856
+
857
+ out.write(`${ANSI.bold}Removing...${ANSI.reset}\n`);
858
+
859
+ // Step 8 (purge-supabase) MUST run before step 2 (termdeck-dir) so we can
860
+ // read SUPABASE_DB_URL from secrets.env. We reorder internally for that.
861
+ const stepArgs = { ...args, keepSecrets: effectiveKeepSecrets, _now: opts._now };
862
+ const steps = [];
863
+ if (args.purgeSupabase) {
864
+ const r = await _stepPurgeSupabase(_fs, _spawnSync, _promptYesNo, _promptInputMatching, paths, stepArgs);
865
+ steps.push(r); _printSummaryLine(out, r);
866
+ }
867
+ for (const fn of [
868
+ (o) => _stepRemoveTermdeckDir(_fs, paths, o),
869
+ (o) => _stepSpliceClaudeJson(_fs, paths, o),
870
+ (o) => _stepSpliceSettingsJson(_fs, paths, o),
871
+ (o) => _stepBackupHookFile(_fs, paths, o),
872
+ (o) => _stepRemoveLaunchAgents(_fs, _spawnSync, paths, o),
873
+ (o) => _stepRemoveSystemdUnit(_fs, _spawnSync, paths, o),
874
+ ]) {
875
+ let r;
876
+ try {
877
+ r = fn(stepArgs);
878
+ } catch (e) {
879
+ r = { name: 'unknown', status: 'error', detail: e && e.message ? e.message : String(e) };
880
+ }
881
+ steps.push(r);
882
+ _printSummaryLine(out, r);
883
+ }
884
+ summary.steps = steps;
885
+
886
+ // Determine exit code: any fatal step → 1. Pre-flight validation already
887
+ // covers the most-likely fatal (malformed claude.json), so this catches
888
+ // any future fatal-tagged step that lands without a separate pre-flight gate.
889
+ const fatal = steps.find((s) => s && s.fatal);
890
+ const exitCode = fatal ? 1 : 0;
891
+ const ok = !fatal;
892
+
893
+ out.write('\n');
894
+ if (fatal) {
895
+ out.write(`${ANSI.red}Uninstall encountered a fatal step (${fatal.name}): ${fatal.detail}${ANSI.reset}\n`);
896
+ } else if (args.dryRun) {
897
+ out.write(`${ANSI.yellow}(--dry-run was set; nothing was actually removed.)${ANSI.reset}\n`);
898
+ } else {
899
+ out.write(`${ANSI.green}Uninstalled.${ANSI.reset} Run \`${ANSI.bold}npm uninstall -g @jhizzard/termdeck @jhizzard/termdeck-stack${ANSI.reset}\` to remove the npm packages.\n`);
900
+ out.write(`${ANSI.dim}(The script can't safely uninstall its own bin while running.)${ANSI.reset}\n`);
901
+ }
902
+ out.write('\n');
903
+
904
+ return { ok, exitCode, summary };
905
+ }
906
+
907
+ // ── CLI ─────────────────────────────────────────────────────────────
908
+
909
+ if (require.main === module) {
910
+ uninstall({ argv: process.argv.slice(2) })
911
+ .then((r) => process.exit(r.exitCode || 0))
912
+ .catch((err) => {
913
+ process.stderr.write(`[termdeck-stack uninstall] failed: ${err && err.stack || err}\n`);
914
+ process.exit(1);
915
+ });
916
+ }
917
+
918
+ module.exports = {
919
+ uninstall,
920
+ printHelp,
921
+ parseArgs,
922
+ // Test hooks — exposed so unit tests can drive primitives without a full run.
923
+ _isSessionEndHookEntry,
924
+ _resolvePaths,
925
+ _detectInstallState,
926
+ _isFullyClean,
927
+ _preflightValidate,
928
+ _stepRemoveTermdeckDir,
929
+ _stepSpliceClaudeJson,
930
+ _stepSpliceSettingsJson,
931
+ _stepBackupHookFile,
932
+ _stepRemoveLaunchAgents,
933
+ _stepRemoveSystemdUnit,
934
+ _stepPurgeSupabase,
935
+ _claudeJsonHasMnestraEntry,
936
+ _settingsJsonHasOurHook,
937
+ _findLaunchAgents,
938
+ _findHookBakFiles,
939
+ _isoStamp,
940
+ _parseEnvFile,
941
+ _atomicWriteJson,
942
+ _buildPurgeSql,
943
+ };