backstep 0.1.16 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/adapters/dbsnap.js +8 -0
- package/dist/cli.js +32 -0
- package/dist/connect.js +4 -14
- package/dist/keys.js +18 -0
- package/dist/setup.js +1 -0
- package/dist/telemetry.js +1 -0
- package/dist/uninstall.js +105 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +80 -11
- package/mod/hooks/register.tsx +55 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,10 +25,16 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
|
|
|
25
25
|
npx backstep setup
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
This installs Backstep, adds its Claude Code mod, binds **ctrl+u** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too.
|
|
28
|
+
This installs Backstep, adds its Claude Code mod, binds **ctrl+u** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too. Databases (Supabase, Postgres, Firebase) aren't touched at setup: the first time Claude goes to change one, Backstep asks whether to connect it. Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
|
|
29
29
|
|
|
30
30
|
Servers in a repo's shared `.mcp.json` that you've approved get a wrapped copy for that project only; the repo's file is never edited. Remote servers that Claude Code signs in to stay as they are, and the mod watches their calls instead. Servers you add later are wrapped the next time Claude Code starts. `backstep unwrap` puts every server back the way it was and stops new ones being wrapped.
|
|
31
31
|
|
|
32
|
+
## Uninstall
|
|
33
|
+
|
|
34
|
+
In Claude Code, type **/uninstall-backstep**, or just ask Claude to uninstall Backstep. From a terminal: `backstep uninstall` (or `npx backstep uninstall`).
|
|
35
|
+
|
|
36
|
+
It asks first, then takes away everything Backstep set up: your MCP servers go back to their own commands, the database copies Backstep saved are dropped (and its `backstep` schema, only if that leaves it empty), the undo key comes out of Claude Code's keybindings, the plugin is removed, and `~/.backstep` (history, snapshots, saved connections) is deleted. Nothing you undid or kept changes. Restart Claude Code to finish.
|
|
37
|
+
|
|
32
38
|
## Try it
|
|
33
39
|
|
|
34
40
|
```sh
|
|
@@ -112,7 +118,7 @@ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override
|
|
|
112
118
|
|
|
113
119
|
## Telemetry
|
|
114
120
|
|
|
115
|
-
`backstep setup` sends one anonymous `install` event,
|
|
121
|
+
`backstep setup` sends one anonymous `install` event, Claude Code with the Backstep mod sends one `active` event a day, and `backstep uninstall` sends one `uninstall` event. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
|
|
116
122
|
|
|
117
123
|
Turn it off with `BACKSTEP_TELEMETRY=0`, or the standard `DO_NOT_TRACK=1`.
|
|
118
124
|
|
package/dist/adapters/dbsnap.js
CHANGED
|
@@ -263,3 +263,11 @@ async function dropCopies(c, u) {
|
|
|
263
263
|
export async function discardDb(u) {
|
|
264
264
|
await withClient(u.url, (c) => dropCopies(c, u)).catch(() => { });
|
|
265
265
|
}
|
|
266
|
+
// On uninstall, after every copy is dropped: removes Backstep's schema, but only
|
|
267
|
+
// if it's empty (no CASCADE), so a schema of the project's own by that name is never touched.
|
|
268
|
+
export async function dropSnapSchema(url) {
|
|
269
|
+
return withClient(url, async (c) => {
|
|
270
|
+
await c.query(`DROP SCHEMA IF EXISTS ${q(SNAP_SCHEMA)}`);
|
|
271
|
+
return true;
|
|
272
|
+
}).catch(() => false);
|
|
273
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -88,6 +88,8 @@ const HELP = `backstep: undo for everything your agents touch
|
|
|
88
88
|
backstep setup install the Claude Code mod, bind ctrl+u to undo Claude's last turn,
|
|
89
89
|
and put your MCP servers behind Backstep
|
|
90
90
|
backstep unwrap take your MCP servers back out from behind Backstep
|
|
91
|
+
backstep uninstall [--yes] remove Backstep and everything it set up (in Claude Code: /uninstall-backstep,
|
|
92
|
+
or ask Claude to uninstall Backstep)
|
|
91
93
|
backstep connect [supabase|firebase] [<access-token>|<connection-string>] [--project <ref>] [--dir <folder>] [--json]
|
|
92
94
|
connect what the project uses, so Claude's changes to it can be undone:
|
|
93
95
|
Supabase from the project's .env files or local database (or the
|
|
@@ -166,6 +168,36 @@ async function main() {
|
|
|
166
168
|
await sendActive();
|
|
167
169
|
return;
|
|
168
170
|
}
|
|
171
|
+
if (cmd === "uninstall") {
|
|
172
|
+
const { values } = parseArgs({ args: rest, options: { yes: { type: "boolean", default: false }, json: { type: "boolean", default: false } } });
|
|
173
|
+
if (!values.yes) {
|
|
174
|
+
if (!process.stdin.isTTY)
|
|
175
|
+
throw new Error("run it with --yes to confirm: backstep uninstall --yes");
|
|
176
|
+
const { createInterface } = await import("node:readline/promises");
|
|
177
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
178
|
+
const answer = (await rl.question("This removes Backstep from this computer: your MCP servers go back to how they were, the undo key\n" +
|
|
179
|
+
"and the Claude Code plugin are removed, and Backstep's history and saved copies are deleted\n" +
|
|
180
|
+
"(nothing you undid or kept is changed). Remove Backstep? [y/N] ")).trim();
|
|
181
|
+
rl.close();
|
|
182
|
+
if (!/^y(es)?$/i.test(answer)) {
|
|
183
|
+
console.log("Kept Backstep.");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const { uninstall } = await import("./uninstall.js");
|
|
188
|
+
const removed = await uninstall();
|
|
189
|
+
if (values.json) {
|
|
190
|
+
console.log(JSON.stringify(removed));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
for (const r of removed)
|
|
194
|
+
console.log(` ${r.ok ? "✓" : "!"} ${r.step}: ${r.detail}`);
|
|
195
|
+
console.log(`\nBackstep is removed.${removed.every((r) => r.ok) ? "" : " The lines marked ! need a look."} Restart Claude Code to finish.`);
|
|
196
|
+
}
|
|
197
|
+
if (removed.some((r) => !r.ok))
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
169
201
|
if (cmd === "unwrap") {
|
|
170
202
|
autoWrap(false);
|
|
171
203
|
const changes = unwrapServers();
|
package/dist/connect.js
CHANGED
|
@@ -354,26 +354,16 @@ async function supabase(dir, given, ref, interactive = false, auto = false) {
|
|
|
354
354
|
`Or the database's connection string (${WHERE})${errors.length ? `. Tried the one in the project's settings: ${errors[0]}` : ""}`,
|
|
355
355
|
};
|
|
356
356
|
}
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
try {
|
|
360
|
-
const c = JSON.parse(read(join(homedir(), ".config", "configstore", "firebase-tools.json")));
|
|
361
|
-
return Boolean(c.tokens?.refresh_token);
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
364
|
-
return false;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
// `auto`: on its own as a session starts, so a server is only added when it would work.
|
|
357
|
+
// `auto`: as a session starts. Nothing is added then: like Supabase, Firebase is
|
|
358
|
+
// only set up once Claude goes to change it and the person says Connect.
|
|
368
359
|
function firebase(dir, cli, auto = false) {
|
|
369
360
|
if (servers(dir).some((s) => /firebase|firestore/i.test(s))) {
|
|
370
361
|
return { service: "firebase", state: "covered", detail: "Claude reaches Firebase through its MCP server, so its changes can be undone" };
|
|
371
362
|
}
|
|
372
363
|
if (!projectUses(dir).firebase)
|
|
373
364
|
return { service: "firebase", state: "unused", detail: "this project doesn't use Firebase" };
|
|
374
|
-
if (auto
|
|
375
|
-
return { service: "firebase", state: "
|
|
376
|
-
}
|
|
365
|
+
if (auto)
|
|
366
|
+
return { service: "firebase", state: "unused", detail: "set up the first time Claude goes to change Firebase" };
|
|
377
367
|
// For this project only, like `claude mcp add` without -s.
|
|
378
368
|
const r = spawnSync("claude", ["mcp", "add", "firebase", "--", ...FIREBASE_SERVER], { cwd: dir, encoding: "utf8" });
|
|
379
369
|
if (r.status !== 0) {
|
package/dist/keys.js
CHANGED
|
@@ -84,6 +84,24 @@ export function setUndoKey(raw) {
|
|
|
84
84
|
writeFileSync(file(), JSON.stringify(config, null, 2) + "\n");
|
|
85
85
|
return { key, keys, previous };
|
|
86
86
|
}
|
|
87
|
+
// On uninstall: takes every key bound to Backstep's undo back out. Returns the keys removed.
|
|
88
|
+
export function removeUndoKeys() {
|
|
89
|
+
if (!existsSync(file()))
|
|
90
|
+
return [];
|
|
91
|
+
const config = load();
|
|
92
|
+
const removed = new Set();
|
|
93
|
+
for (const block of config.bindings ?? []) {
|
|
94
|
+
for (const [k, a] of Object.entries(block.bindings)) {
|
|
95
|
+
if (a === UNDO_ACTION) {
|
|
96
|
+
delete block.bindings[k];
|
|
97
|
+
removed.add(k);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (removed.size)
|
|
102
|
+
writeFileSync(file(), JSON.stringify(config, null, 2) + "\n");
|
|
103
|
+
return [...removed];
|
|
104
|
+
}
|
|
87
105
|
// How a key reads on screen: cmd+z → ⌘Z.
|
|
88
106
|
export function keyLabel(k) {
|
|
89
107
|
return k.startsWith("cmd+") ? `⌘${k.slice(4).toUpperCase()}` : k;
|
package/dist/setup.js
CHANGED
|
@@ -78,6 +78,7 @@ export async function setup() {
|
|
|
78
78
|
const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
|
|
79
79
|
console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
|
|
80
80
|
console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+q"`));
|
|
81
|
+
console.log(dim(` To remove Backstep, type /uninstall-backstep in Claude Code, or ask Claude to uninstall it.`));
|
|
81
82
|
printCheck(check(process.cwd()), bs);
|
|
82
83
|
if (telemetryOn()) {
|
|
83
84
|
console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
|
package/dist/telemetry.js
CHANGED
|
@@ -63,6 +63,7 @@ async function send(event) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
export const sendInstall = () => send("install");
|
|
66
|
+
export const sendUninstall = () => send("uninstall");
|
|
66
67
|
// At most once a day, as Claude Code starts with Backstep's mod.
|
|
67
68
|
export async function sendActive() {
|
|
68
69
|
if (!telemetryOn())
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Takes Backstep off this computer: everything setup and use put in place, in
|
|
2
|
+
// the order that never leaves something pointing at a Backstep that's gone.
|
|
3
|
+
// 1. MCP servers wrapped behind Backstep get their own commands back
|
|
4
|
+
// 2. the database copies Backstep made are dropped, and its schema if empty
|
|
5
|
+
// 3. the undo key comes out of Claude Code's keybindings
|
|
6
|
+
// 4. the Claude Code plugin and its source are removed
|
|
7
|
+
// 5. ~/.backstep (history, snapshots, saved connections) is deleted
|
|
8
|
+
// 6. the global npm package, when setup installed one
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, rmSync } from "node:fs";
|
|
11
|
+
import { homeDir, Store } from "./store.js";
|
|
12
|
+
import { unwrapServers } from "./servers.js";
|
|
13
|
+
import { removeUndoKeys } from "./keys.js";
|
|
14
|
+
import { discardDb, dropSnapSchema } from "./adapters/dbsnap.js";
|
|
15
|
+
import { dbKey, freshUrl } from "./supabase.js";
|
|
16
|
+
import { sendUninstall } from "./telemetry.js";
|
|
17
|
+
const run = (cmd, args) => {
|
|
18
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
19
|
+
return { ok: r.status === 0, missing: Boolean(r.error), out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
20
|
+
};
|
|
21
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
22
|
+
async function dropDatabaseCopies() {
|
|
23
|
+
if (!existsSync(homeDir()))
|
|
24
|
+
return null;
|
|
25
|
+
const store = new Store();
|
|
26
|
+
const urls = new Map();
|
|
27
|
+
let dropped = 0;
|
|
28
|
+
for (const a of store.databaseCopies()) {
|
|
29
|
+
const u = JSON.parse(a.undo);
|
|
30
|
+
for (const d of u.dbs ?? []) {
|
|
31
|
+
const url = await freshUrl(d.url).catch(() => d.url);
|
|
32
|
+
await discardDb({ ...d, url });
|
|
33
|
+
urls.set(dbKey(d.url), url);
|
|
34
|
+
dropped++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (urls.size === 0)
|
|
38
|
+
return null;
|
|
39
|
+
let schemas = 0;
|
|
40
|
+
for (const url of urls.values())
|
|
41
|
+
if (await dropSnapSchema(url))
|
|
42
|
+
schemas++;
|
|
43
|
+
return {
|
|
44
|
+
step: "database copies",
|
|
45
|
+
ok: schemas === urls.size,
|
|
46
|
+
detail: `dropped ${plural(dropped, "saved copy")} from ${plural(urls.size, "database")}` +
|
|
47
|
+
(schemas < urls.size ? `; couldn't reach ${urls.size - schemas} of them, so a "backstep" schema may be left there to drop by hand` : ""),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function uninstall() {
|
|
51
|
+
const out = [];
|
|
52
|
+
// Counted before anything is deleted (the install id lives in ~/.backstep).
|
|
53
|
+
await sendUninstall();
|
|
54
|
+
const servers = unwrapServers();
|
|
55
|
+
const restored = servers.filter((c) => c.done).length;
|
|
56
|
+
const kept = servers.filter((c) => !c.done && !/nothing to put back/.test(c.note));
|
|
57
|
+
if (servers.length) {
|
|
58
|
+
out.push({
|
|
59
|
+
step: "MCP servers",
|
|
60
|
+
ok: kept.length === 0,
|
|
61
|
+
detail: `gave ${plural(restored, "server")} back ${restored === 1 ? "its" : "their"} own command` +
|
|
62
|
+
(kept.length ? `; left ${kept.map((c) => `${c.name} (${c.note})`).join(", ")}` : ""),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const db = await dropDatabaseCopies();
|
|
67
|
+
if (db)
|
|
68
|
+
out.push(db);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
out.push({ step: "database copies", ok: false, detail: err instanceof Error ? err.message : String(err) });
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const keys = removeUndoKeys();
|
|
75
|
+
if (keys.length)
|
|
76
|
+
out.push({ step: "undo key", ok: true, detail: `unbound ${keys.join(", ")}` });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
out.push({ step: "undo key", ok: false, detail: `couldn't edit Claude Code's keybindings: ${err instanceof Error ? err.message : String(err)}` });
|
|
80
|
+
}
|
|
81
|
+
const plugin = run("claude", ["plugin", "uninstall", "backstep@backstep"]);
|
|
82
|
+
const market = run("claude", ["plugin", "marketplace", "remove", "backstep"]);
|
|
83
|
+
if (plugin.missing) {
|
|
84
|
+
out.push({ step: "Claude Code plugin", ok: false, detail: "Claude Code isn't on the PATH: remove the plugin with /plugin inside Claude Code" });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const gone = plugin.ok || /not (installed|found)/i.test(plugin.out);
|
|
88
|
+
out.push({ step: "Claude Code plugin", ok: gone, detail: gone ? `removed${market.ok ? ", with its source" : ""}` : plugin.out.split("\n").at(-1) ?? "failed" });
|
|
89
|
+
}
|
|
90
|
+
if (existsSync(homeDir())) {
|
|
91
|
+
try {
|
|
92
|
+
rmSync(homeDir(), { recursive: true, force: true });
|
|
93
|
+
out.push({ step: "Backstep's folder", ok: true, detail: `deleted ${homeDir()} (history, snapshots, saved connections)` });
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
out.push({ step: "Backstep's folder", ok: false, detail: `couldn't delete ${homeDir()}: ${err instanceof Error ? err.message : String(err)}` });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Last: this may be the copy that's running.
|
|
100
|
+
if (run("npm", ["ls", "-g", "backstep", "--depth=0"]).ok) {
|
|
101
|
+
const npm = run("npm", ["uninstall", "-g", "backstep"]);
|
|
102
|
+
out.push({ step: "backstep command", ok: npm.ok, detail: npm.ok ? "removed the global npm package" : "run: npm uninstall -g backstep" });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
@@ -235,14 +235,13 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
235
235
|
|
|
236
236
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
237
237
|
await new Promise((r) => setTimeout(r, 10))
|
|
238
|
-
expect(tools).toEqual(['connect', 'undo_key'])
|
|
238
|
+
expect(tools).toEqual(['connect', 'undo_key', 'uninstall'])
|
|
239
239
|
expect(logs[0]).toContain('Backstep connected')
|
|
240
240
|
expect(runs).toEqual([
|
|
241
241
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'key', '--json'],
|
|
242
242
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
|
|
243
|
-
['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
|
|
244
|
-
['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
|
|
245
243
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'status', '--quick', '--json', '--dir', '/repo'],
|
|
244
|
+
['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
|
|
246
245
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'update-check'],
|
|
247
246
|
])
|
|
248
247
|
expect(toasts.find((t) => t.includes('linear is now behind Backstep'))).toBeTruthy()
|
|
@@ -386,6 +385,8 @@ test('asking Claude to connect runs backstep connect, with the connection string
|
|
|
386
385
|
expect(toasts).toHaveLength(1)
|
|
387
386
|
})
|
|
388
387
|
|
|
388
|
+
const settle = () => new Promise((r) => setTimeout(r, 20))
|
|
389
|
+
|
|
389
390
|
// A project that uses Supabase, not connected: Backstep asks, and only the answer connects it.
|
|
390
391
|
function supabaseProject(on: On, answer: string, connectResult: object) {
|
|
391
392
|
const runs: string[][] = []
|
|
@@ -393,7 +394,11 @@ function supabaseProject(on: On, answer: string, connectResult: object) {
|
|
|
393
394
|
const asked: string[] = []
|
|
394
395
|
on('process.run', (_, e) => {
|
|
395
396
|
runs.push([...e.argv])
|
|
396
|
-
const stdout = e.argv.includes('
|
|
397
|
+
const stdout = e.argv.includes('db-check')
|
|
398
|
+
? JSON.stringify({ ask: '/repo', service: 'supabase' })
|
|
399
|
+
: e.argv.includes('shell')
|
|
400
|
+
? JSON.stringify({ kind: 'write', undoable: true })
|
|
401
|
+
: e.argv.includes('--auto')
|
|
397
402
|
? JSON.stringify([{ service: 'supabase', state: 'ask', detail: "this project uses Supabase, but Backstep isn't connected to it" }])
|
|
398
403
|
: e.argv.includes('status')
|
|
399
404
|
? JSON.stringify({ items: [], suggest: [] })
|
|
@@ -413,15 +418,32 @@ function supabaseProject(on: On, answer: string, connectResult: object) {
|
|
|
413
418
|
return { result: { questions, answers: { [questions[0].question]: answer } } }
|
|
414
419
|
})
|
|
415
420
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
421
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
422
|
+
on('session.id', () => ({ value: 's1' }))
|
|
423
|
+
on('tool.call', () => ({ result: {} }))
|
|
416
424
|
return { runs, toasts, asked }
|
|
417
425
|
}
|
|
418
426
|
|
|
419
|
-
|
|
427
|
+
// Claude is about to change the database: the moment Backstep asks.
|
|
428
|
+
async function changeDatabase($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
|
|
429
|
+
on('ui.log', () => ({ value: undefined }))
|
|
430
|
+
mock.clock(on, { now: 5000 })
|
|
431
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
432
|
+
await settle()
|
|
433
|
+
await $.tool.call({ tool: 'Bash', command: 'supabase db push' })
|
|
434
|
+
await settle()
|
|
435
|
+
}
|
|
420
436
|
|
|
421
|
-
test('a
|
|
422
|
-
const {
|
|
437
|
+
test('opening a session never asks about a database, even with Supabase projects below the folder', { options: { cli: CLI } }, async ($, on) => {
|
|
438
|
+
const { asked } = supabaseProject(on, 'Connect', {})
|
|
423
439
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
424
440
|
await settle()
|
|
441
|
+
expect(asked).toHaveLength(0)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
test('a Supabase project that is not connected is asked about when Claude goes to change it, and Connect connects it', { options: { cli: CLI } }, async ($, on) => {
|
|
445
|
+
const { runs, toasts, asked } = supabaseProject(on, 'Connect', { service: 'supabase', state: 'connected', detail: 'connected project abc' })
|
|
446
|
+
await changeDatabase($, on)
|
|
425
447
|
expect(asked).toHaveLength(1)
|
|
426
448
|
expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --json')).toBe(true)
|
|
427
449
|
expect(toasts.find((t) => t.includes('connected project abc'))).toBeTruthy()
|
|
@@ -429,15 +451,13 @@ test('a Supabase project that is not connected is asked about, and Connect conne
|
|
|
429
451
|
|
|
430
452
|
test('Not now connects nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
431
453
|
const { runs } = supabaseProject(on, 'Not now', {})
|
|
432
|
-
await
|
|
433
|
-
await settle()
|
|
454
|
+
await changeDatabase($, on)
|
|
434
455
|
expect(runs.filter((r) => r.includes('connect') && !r.includes('--auto'))).toHaveLength(0)
|
|
435
456
|
})
|
|
436
457
|
|
|
437
458
|
test("Don't ask for this project remembers it", { options: { cli: CLI } }, async ($, on) => {
|
|
438
459
|
const { runs } = supabaseProject(on, "Don't ask for this project", {})
|
|
439
|
-
await
|
|
440
|
-
await settle()
|
|
460
|
+
await changeDatabase($, on)
|
|
441
461
|
expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --dismiss')).toBe(true)
|
|
442
462
|
})
|
|
443
463
|
|
|
@@ -524,9 +544,22 @@ function updates(on: On, answer: string) {
|
|
|
524
544
|
return { result: { questions, answers: { [questions[0].question]: answer } } }
|
|
525
545
|
})
|
|
526
546
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
547
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
548
|
+
on('session.id', () => ({ value: 's1' }))
|
|
549
|
+
on('tool.call', () => ({ result: {} }))
|
|
527
550
|
return { runs, toasts, asked }
|
|
528
551
|
}
|
|
529
552
|
|
|
553
|
+
// Claude is about to change the database: the moment Backstep asks.
|
|
554
|
+
async function changeDatabase($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
|
|
555
|
+
on('ui.log', () => ({ value: undefined }))
|
|
556
|
+
mock.clock(on, { now: 5000 })
|
|
557
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
558
|
+
await settle()
|
|
559
|
+
await $.tool.call({ tool: 'Bash', command: 'supabase db push' })
|
|
560
|
+
await settle()
|
|
561
|
+
}
|
|
562
|
+
|
|
530
563
|
test('a newer version is offered as a session starts, and Update installs it', { options: { cli: CLI } }, async ($, on) => {
|
|
531
564
|
const { runs, toasts, asked } = updates(on, 'Update')
|
|
532
565
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
@@ -826,3 +859,39 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
|
|
|
826
859
|
expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes GitHub')
|
|
827
860
|
await ui.unmount()
|
|
828
861
|
})
|
|
862
|
+
|
|
863
|
+
// Uninstalling: asked first, and only the answer removes anything.
|
|
864
|
+
function uninstallSetup(on: On, answer: string) {
|
|
865
|
+
const runs: string[][] = []
|
|
866
|
+
const asked: string[] = []
|
|
867
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
868
|
+
on('session.id', () => ({ value: 's1' }))
|
|
869
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
870
|
+
on('process.run', (_, e) => {
|
|
871
|
+
runs.push([...e.argv])
|
|
872
|
+
const stdout = e.argv.includes('uninstall') ? JSON.stringify([{ step: 'Claude Code plugin', ok: true, detail: 'removed' }]) : ''
|
|
873
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
874
|
+
})
|
|
875
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
876
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
877
|
+
asked.push(questions[0].question)
|
|
878
|
+
return { result: { questions, answers: { [questions[0].question]: answer } } }
|
|
879
|
+
})
|
|
880
|
+
return { runs, asked }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
test('/uninstall-backstep asks, then removes Backstep', { options: { cli: CLI } }, async ($, on) => {
|
|
884
|
+
const { runs, asked } = uninstallSetup(on, 'Remove Backstep')
|
|
885
|
+
const r = await $.command.run({ command: 'uninstall-backstep' } as never)
|
|
886
|
+
expect(asked).toHaveLength(1)
|
|
887
|
+
expect(runs.at(-1)?.slice(3)).toEqual(['uninstall', '--yes', '--json'])
|
|
888
|
+
expect(JSON.stringify(r)).toContain('Backstep is removed')
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
test('asking Claude to uninstall asks too, and Keep it removes nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
892
|
+
const { runs, asked } = uninstallSetup(on, 'Keep it')
|
|
893
|
+
const r = await $.tool.call({ tool: 'mcp__backstep__uninstall' } as never)
|
|
894
|
+
expect(asked).toHaveLength(1)
|
|
895
|
+
expect(runs.filter((a) => a.includes('uninstall'))).toHaveLength(0)
|
|
896
|
+
expect(r.result).toBe('Kept Backstep: nothing was removed.')
|
|
897
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -489,17 +489,9 @@ async function wrapNewServers($: EngineInterface, cwd: string) {
|
|
|
489
489
|
// says what Claude reaches that Backstep could undo once connected, so nobody
|
|
490
490
|
// presses undo expecting it to come back.
|
|
491
491
|
async function checkCoverage($: EngineInterface, cwd: string) {
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
try {
|
|
496
|
-
for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
|
|
497
|
-
if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
|
|
498
|
-
if (c.state === 'added') $.ui.toast('Backstep added Firebase for this project, so Claude can reach it and its changes can be undone. Restart Claude Code to use it.')
|
|
499
|
-
}
|
|
500
|
-
} catch {
|
|
501
|
-
// Backstep couldn't be reached: nothing to say.
|
|
502
|
-
}
|
|
492
|
+
// Nothing is asked or set up here: a database (Supabase, Postgres, Firebase)
|
|
493
|
+
// is only asked about once Claude is about to change it (see the Bash hook),
|
|
494
|
+
// not just because a project in or below this folder happens to use one.
|
|
503
495
|
const ran = await $.process.run(backstep(['status', '--quick', '--json', '--dir', cwd])).catch(() => null)
|
|
504
496
|
let items: { name: string; level: string; detail: string }[] = []
|
|
505
497
|
try {
|
|
@@ -525,9 +517,9 @@ const asked = new Set<string>()
|
|
|
525
517
|
// How each database is named in the question, and what the answer connects.
|
|
526
518
|
const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
|
|
527
519
|
|
|
528
|
-
// Asked when Claude
|
|
529
|
-
//
|
|
530
|
-
//
|
|
520
|
+
// Asked when Claude is about to change a database (Supabase, other Postgres,
|
|
521
|
+
// Firebase) that Backstep isn't connected to; never just because a project uses
|
|
522
|
+
// one. The answer is the only thing that connects it.
|
|
531
523
|
async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
|
|
532
524
|
const key = `${service}:${cwd}`
|
|
533
525
|
if (asked.has(key)) return
|
|
@@ -676,12 +668,56 @@ async function connectServices($: EngineInterface, input: { service?: string; co
|
|
|
676
668
|
return out.map((s) => `${s.service === 'supabase' ? 'Supabase' : 'Firebase'} (${s.state}): ${s.detail}`).join('\n')
|
|
677
669
|
}
|
|
678
670
|
|
|
671
|
+
const UNINSTALL = 'mcp__backstep__uninstall'
|
|
672
|
+
|
|
673
|
+
const UNINSTALL_TOOL = {
|
|
674
|
+
name: 'uninstall',
|
|
675
|
+
description:
|
|
676
|
+
'Remove Backstep (the undo for this session) from this computer: puts MCP servers back as they were, drops its saved database copies, ' +
|
|
677
|
+
"removes the undo key and the Claude Code plugin, and deletes Backstep's history. Use it only when the user asks to uninstall, remove or delete Backstep. " +
|
|
678
|
+
'The user is asked to confirm first; nothing else is needed.',
|
|
679
|
+
inputSchema: { type: 'object', properties: {} },
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const UNINSTALL_ASK = { yes: 'Remove Backstep', no: 'Keep it' }
|
|
683
|
+
|
|
684
|
+
// Asked first, every time: the answer is the only thing that removes it.
|
|
685
|
+
async function uninstallBackstep($: EngineInterface): Promise<string> {
|
|
686
|
+
const answer = await $.ui
|
|
687
|
+
.ask(
|
|
688
|
+
"Remove Backstep from this computer? Your MCP servers go back to how they were, and the undo key, the plugin and Backstep's history are removed. " +
|
|
689
|
+
"Nothing you've undone or kept changes, but Claude's turns can't be undone after this.",
|
|
690
|
+
{
|
|
691
|
+
header: 'Backstep',
|
|
692
|
+
options: [UNINSTALL_ASK.yes, UNINSTALL_ASK.no],
|
|
693
|
+
},
|
|
694
|
+
)
|
|
695
|
+
.catch(() => null)
|
|
696
|
+
if (answer !== UNINSTALL_ASK.yes) return 'Kept Backstep: nothing was removed.'
|
|
697
|
+
const ran = await $.process
|
|
698
|
+
.run(backstep(['uninstall', '--yes', '--json']), { timeoutMs: 180_000 })
|
|
699
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
700
|
+
let removed: { step: string; ok: boolean; detail: string }[] = []
|
|
701
|
+
try {
|
|
702
|
+
removed = JSON.parse(ran.stdout || '[]') as typeof removed
|
|
703
|
+
} catch {
|
|
704
|
+
removed = []
|
|
705
|
+
}
|
|
706
|
+
if (removed.length === 0) return `Backstep couldn't uninstall itself: ${(ran.stderr || ran.stdout).trim().split('\n').at(-1) || 'it failed'}. Run: backstep uninstall`
|
|
707
|
+
const lines = removed.map((r) => `${r.ok ? '✓' : '!'} ${r.step}: ${r.detail}`).join('\n')
|
|
708
|
+
$.ui.toast('Backstep is removed. Restart Claude Code to finish.')
|
|
709
|
+
return `Backstep is removed. Restart Claude Code to finish.\n${lines}`
|
|
710
|
+
}
|
|
711
|
+
|
|
679
712
|
export const register: Register = (on, options) => {
|
|
680
713
|
cli = String(options.cli ?? 'backstep')
|
|
681
714
|
|
|
682
715
|
on('session.start', async ($, e, next) => {
|
|
683
716
|
await $.tool.register(CONNECT_TOOL)
|
|
684
717
|
await $.tool.register(KEY_TOOL)
|
|
718
|
+
await $.tool.register(UNINSTALL_TOOL)
|
|
719
|
+
// A command that can't be added never holds up the session: asking Claude still uninstalls.
|
|
720
|
+
await $.command.register({ name: 'uninstall-backstep', description: 'Remove Backstep and everything it set up from this computer' }).catch(() => null)
|
|
685
721
|
const r = await next(e)
|
|
686
722
|
const boundOut = await $.process.run(backstep(['key', '--json'])).catch(() => null)
|
|
687
723
|
try {
|
|
@@ -719,9 +755,13 @@ export const register: Register = (on, options) => {
|
|
|
719
755
|
|
|
720
756
|
on('tool.call', { tool: CONNECT }, async ($, e) => ({ result: await connectServices($, e as { service?: string; connection_string?: string; project_ref?: string }) }))
|
|
721
757
|
|
|
758
|
+
on('tool.call', { tool: UNINSTALL }, async ($) => ({ result: await uninstallBackstep($) }))
|
|
759
|
+
|
|
760
|
+
on('command.run', { command: 'uninstall-backstep' }, async ($) => ({ text: await uninstallBackstep($) }))
|
|
761
|
+
|
|
722
762
|
on('tool.call', async ($, e, next) => {
|
|
723
763
|
const tool = String(e.tool)
|
|
724
|
-
if (tool === CONNECT || tool === KEY) return next(e)
|
|
764
|
+
if (tool === CONNECT || tool === KEY || tool === UNINSTALL) return next(e)
|
|
725
765
|
if (EDIT_TOOLS.has(tool)) {
|
|
726
766
|
const input = e as { file_path?: string; notebook_path?: string }
|
|
727
767
|
const path = input.file_path ?? input.notebook_path
|