@jhizzard/termdeck-stack 0.6.14 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/src/index.js +9 -1
- package/src/uninstall.js +943 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ underlying packages (`@jhizzard/termdeck`, `@jhizzard/mnestra`,
|
|
|
5
5
|
`@jhizzard/rumen`) ship on their own cadences and have their own
|
|
6
6
|
changelogs — see the root `CHANGELOG.md` for `@jhizzard/termdeck`.
|
|
7
7
|
|
|
8
|
+
## [1.1.1] — 2026-05-08
|
|
9
|
+
|
|
10
|
+
### Documentation
|
|
11
|
+
- Audit-trail update: validated against `@jhizzard/termdeck@1.1.1`, the Sprint 62 close-out ship. Wave bundles (a) Brad-reported `RealtimeClient` Node-20 WebSocket polyfill in `@jhizzard/mnestra@0.4.9`, (b) cross-agent `session_summary` writer fence tests + bundled migration mirrors `021_project_tag_canonicalize_claimguard.sql` + `022_source_agent_backfill.sql` + `MIGRATION_PROBES` entries in `@jhizzard/termdeck@1.1.1`, (c) document-level capture-phase image-paste handler closing the post-v1.1.0 paste regression, (d) **2026-05-11 pre-publish fold-ins** — Mnestra `webhook-server` EADDRINUSE singleton-collision catch (Brad's 42K-crash 5-day log accumulation) + TermDeck SQLite-init ABI-mismatch fail-fast (Brad's Node 20→22 `apt upgrade` cascade into a null-handle storm). Closes Investigation 1 of `docs/CRITICAL-READ-FIRST-2026-05-07.md` (cross-agent Mnestra capture on close, empirically confirmed at 27% coverage during ClaimGuard Sprint 8.0 audit). No installer-side code changes — this is an audit-trail-only bump aligning the stack-installer's published trail with the underlying `@jhizzard/termdeck@1.1.1`.
|
|
12
|
+
|
|
8
13
|
## [0.3.3] — 2026-04-27
|
|
9
14
|
|
|
10
15
|
### Documentation
|
package/package.json
CHANGED
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') {
|
package/src/uninstall.js
ADDED
|
@@ -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
|
+
};
|