backstep 0.1.14 → 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/adapters/fs.js +7 -0
- package/dist/cli.js +55 -6
- 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/undo.js +13 -1
- package/dist/uninstall.js +105 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +117 -14
- package/mod/hooks/register.tsx +99 -37
- 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/adapters/fs.js
CHANGED
|
@@ -24,6 +24,13 @@ function sizeOf(p, budget) {
|
|
|
24
24
|
return total;
|
|
25
25
|
}
|
|
26
26
|
// Returns null when a path is too large to copy; the caller then can't promise undo.
|
|
27
|
+
// Whether a path sits in the folder Claude Code has open. Edits elsewhere belong to
|
|
28
|
+
// other projects: Backstep never records them, so undo here can never touch them.
|
|
29
|
+
export function insideDir(path, dir) {
|
|
30
|
+
const p = resolvePath(path, dir);
|
|
31
|
+
const d = resolve(dir).replace(/\/$/, "");
|
|
32
|
+
return p === d || p.startsWith(d + "/");
|
|
33
|
+
}
|
|
27
34
|
export function snapshotPaths(actionId, paths, cwd) {
|
|
28
35
|
const dir = snapshotDir(actionId);
|
|
29
36
|
const seen = new Set();
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { runProxy } from "./proxy.js";
|
|
|
5
5
|
import { startUi } from "./ui/server.js";
|
|
6
6
|
import { Store, snapshotDir } from "./store.js";
|
|
7
7
|
import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
|
|
8
|
-
import { snapshotPaths } from "./adapters/fs.js";
|
|
8
|
+
import { insideDir, snapshotPaths } from "./adapters/fs.js";
|
|
9
9
|
import { notePushed, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
|
|
10
10
|
import { revertPush } from "./push.js";
|
|
11
11
|
import { ghMade, grantScope, undoGh } from "./github.js";
|
|
@@ -24,8 +24,16 @@ import { autoWrap, printChanges, unwrapServers, wrapServers } from "./servers.js
|
|
|
24
24
|
import { disconnectSupabase, dismissSupabase, firebaseConn, postgresUrl, restKey } from "./supabase.js";
|
|
25
25
|
// The Supabase projects a shell command could change: those in the folder it
|
|
26
26
|
// works in (or a level or two below), when the command is one that reaches a database.
|
|
27
|
+
// A command that `cd`s into another project is out of scope: only the open folder is tracked.
|
|
28
|
+
function scopedDir(command, cwd) {
|
|
29
|
+
const d = commandDir(command, cwd);
|
|
30
|
+
return insideDir(d, cwd) ? d : null;
|
|
31
|
+
}
|
|
27
32
|
function reachedProjects(command, cwd) {
|
|
28
|
-
const
|
|
33
|
+
const at = scopedDir(command, cwd);
|
|
34
|
+
if (!at)
|
|
35
|
+
return [];
|
|
36
|
+
const dirs = supabaseDirs(at);
|
|
29
37
|
return touchesDatabase(command) || touchesSupabase(command, dirs.length > 0) ? dirs : [];
|
|
30
38
|
}
|
|
31
39
|
// Every database a shell command could change, by folder: Supabase, other Postgres, Firebase.
|
|
@@ -33,7 +41,8 @@ function reachedDatabases(command, cwd) {
|
|
|
33
41
|
const out = [];
|
|
34
42
|
for (const d of reachedProjects(command, cwd))
|
|
35
43
|
out.push({ dir: d, service: "supabase" });
|
|
36
|
-
|
|
44
|
+
const at = scopedDir(command, cwd);
|
|
45
|
+
for (const d of at ? dbDirs(at) : []) {
|
|
37
46
|
const u = projectUses(d);
|
|
38
47
|
if (u.postgres && (touchesDatabase(command) || touchesSupabase(command, true)))
|
|
39
48
|
out.push({ dir: d, service: "postgres" });
|
|
@@ -79,6 +88,8 @@ const HELP = `backstep: undo for everything your agents touch
|
|
|
79
88
|
backstep setup install the Claude Code mod, bind ctrl+u to undo Claude's last turn,
|
|
80
89
|
and put your MCP servers behind Backstep
|
|
81
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)
|
|
82
93
|
backstep connect [supabase|firebase] [<access-token>|<connection-string>] [--project <ref>] [--dir <folder>] [--json]
|
|
83
94
|
connect what the project uses, so Claude's changes to it can be undone:
|
|
84
95
|
Supabase from the project's .env files or local database (or the
|
|
@@ -157,6 +168,36 @@ async function main() {
|
|
|
157
168
|
await sendActive();
|
|
158
169
|
return;
|
|
159
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
|
+
}
|
|
160
201
|
if (cmd === "unwrap") {
|
|
161
202
|
autoWrap(false);
|
|
162
203
|
const changes = unwrapServers();
|
|
@@ -307,10 +348,17 @@ async function main() {
|
|
|
307
348
|
args: sep === -1 ? rest : rest.slice(0, sep),
|
|
308
349
|
options: { session: { type: "string" }, tool: { type: "string" }, cwd: { type: "string" } },
|
|
309
350
|
});
|
|
310
|
-
const
|
|
311
|
-
if (!values.session || !values.tool ||
|
|
351
|
+
const asked = sep === -1 ? [] : rest.slice(sep + 1);
|
|
352
|
+
if (!values.session || !values.tool || asked.length === 0) {
|
|
312
353
|
throw new Error("usage: backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>");
|
|
313
354
|
}
|
|
355
|
+
// Claude Code names the folder it has open: a file in another project is left out entirely.
|
|
356
|
+
const scope = values.cwd;
|
|
357
|
+
const paths = scope ? asked.filter((p) => insideDir(p, scope)) : asked;
|
|
358
|
+
if (paths.length === 0) {
|
|
359
|
+
console.log(JSON.stringify({ outside: true, undoable: false }));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
314
362
|
const store = new Store();
|
|
315
363
|
const run = store.sessionRun("claude-code", `session:${values.session}`);
|
|
316
364
|
const a = store.addAction({
|
|
@@ -541,11 +589,12 @@ async function main() {
|
|
|
541
589
|
until: { type: "string" },
|
|
542
590
|
json: { type: "boolean", default: false },
|
|
543
591
|
"via-claude-code": { type: "boolean", default: false },
|
|
592
|
+
session: { type: "string" },
|
|
544
593
|
},
|
|
545
594
|
});
|
|
546
595
|
const store = new Store();
|
|
547
596
|
const outcomes = values.since
|
|
548
|
-
? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"] })
|
|
597
|
+
? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"], session: values.session })
|
|
549
598
|
: values.run
|
|
550
599
|
? await undoRun(store, values.run)
|
|
551
600
|
: await undoAction(store, positionals[0] ?? "");
|
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())
|
package/dist/undo.js
CHANGED
|
@@ -201,9 +201,21 @@ export async function undoRun(store, runId) {
|
|
|
201
201
|
// Everything any agent did in a window of time: what one turn of a chat did,
|
|
202
202
|
// across Claude's own edits and every server wrapped by Backstep.
|
|
203
203
|
export async function undoSince(store, since, until = Date.now(), opts = {}) {
|
|
204
|
-
|
|
204
|
+
let inWindow = store.db
|
|
205
205
|
.prepare("SELECT * FROM actions WHERE created_at >= ? AND created_at <= ? ORDER BY created_at, seq")
|
|
206
206
|
.all(since, until);
|
|
207
|
+
if (opts.session) {
|
|
208
|
+
const mine = `session:${opts.session}`;
|
|
209
|
+
const commands = new Map();
|
|
210
|
+
inWindow = inWindow.filter((a) => {
|
|
211
|
+
if (!commands.has(a.run_id))
|
|
212
|
+
commands.set(a.run_id, store.getRun(a.run_id)?.command);
|
|
213
|
+
const command = commands.get(a.run_id);
|
|
214
|
+
// Another session's run: never ours to undo. Servers wrapped by `backstep wrap`
|
|
215
|
+
// record under their own runs and stay included, as before.
|
|
216
|
+
return !(command?.startsWith("session:") && command !== mine);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
207
219
|
return undoAll(store, inWindow.filter(undoable), opts);
|
|
208
220
|
}
|
|
209
221
|
async function undoAll(store, actions, opts = {}) {
|
|
@@ -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
|
+
}
|
|
@@ -60,6 +60,39 @@ test('an edit is snapshotted and the band offers to undo the turn', { options: {
|
|
|
60
60
|
}
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
test('edits outside the open folder are never tracked', { options: { cli: CLI } }, async ($, on) => {
|
|
64
|
+
mock.clock(on, { now: 5000 })
|
|
65
|
+
const { runs } = engine(on, { exitCode: 0, stdout: '[]' })
|
|
66
|
+
|
|
67
|
+
await $.prompt.submit({ text: 'fix the other project', wait: false, origin: { kind: 'composer' } })
|
|
68
|
+
await $.tool.call({ tool: 'Write', file_path: '/other-project/src/new.ts', content: 'x' })
|
|
69
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo-sibling/a.ts', old_string: 'a', new_string: 'b' })
|
|
70
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/../other-project/a.ts', old_string: 'a', new_string: 'b' })
|
|
71
|
+
|
|
72
|
+
expect(runs.filter((r) => r.includes('snap'))).toEqual([])
|
|
73
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
74
|
+
expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))?.text).toBeTruthy()
|
|
75
|
+
await ui.unmount()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a long last turn stays on one line with a fixed gap after the logo', { options: { cli: CLI } }, async ($, on) => {
|
|
79
|
+
mock.clock(on, { now: 5000 })
|
|
80
|
+
engine(on, { exitCode: 0, stdout: '[]' }, { kind: 'write', undoable: true })
|
|
81
|
+
|
|
82
|
+
await $.prompt.submit({ text: 'scrap that, lets try making the whole thing much longer than fits', wait: false, origin: { kind: 'composer' } })
|
|
83
|
+
for (const f of ['a', 'b', 'c']) await $.tool.call({ tool: 'Edit', file_path: `/repo/src/${f}.ts`, old_string: 'a', new_string: 'b' })
|
|
84
|
+
for (let i = 0; i < 8; i++) await $.tool.call({ tool: 'Bash', command: `touch x${i}` })
|
|
85
|
+
|
|
86
|
+
const ui = await $.ui.mount({ ...BAND, props: { ...BAND.props, bodyColumns: 60 }, surface: 'terminal' })
|
|
87
|
+
const line = await ui.find({ type: 'Text', text: /Last turn/ })
|
|
88
|
+
// No leading space to be trimmed on a wrap: the gap is the logo box's own margin.
|
|
89
|
+
expect(line?.text).toMatch(/^Last turn:/)
|
|
90
|
+
expect(line?.props.wrap).toBe('truncate-end')
|
|
91
|
+
expect(await ui.find({ type: 'Box', props: { flexShrink: 0, marginRight: 1 } })).toBeTruthy()
|
|
92
|
+
expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
|
|
93
|
+
await ui.unmount()
|
|
94
|
+
})
|
|
95
|
+
|
|
63
96
|
test('pressing undo rewinds the turn and tells Claude on the next prompt', { options: { cli: CLI } }, async ($, on) => {
|
|
64
97
|
const clock = mock.clock(on, { now: 5000 })
|
|
65
98
|
const { runs, prompts } = engine(on, {
|
|
@@ -75,7 +108,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
|
|
|
75
108
|
const pressed = ui.press({ key: 'undo' })
|
|
76
109
|
await clock.settle()
|
|
77
110
|
expect((await ui.find({ type: 'Text', text: /Rewinding/ }))?.text).toBeTruthy()
|
|
78
|
-
expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--json', '--via-claude-code'])
|
|
111
|
+
expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
|
|
79
112
|
|
|
80
113
|
await clock.advance(1000)
|
|
81
114
|
await pressed
|
|
@@ -141,7 +174,8 @@ test('with nothing to undo the band still holds the key, and a press says there
|
|
|
141
174
|
|
|
142
175
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
143
176
|
expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))).toBeTruthy()
|
|
144
|
-
// The
|
|
177
|
+
// The gap after the mark is its own fixed margin, so a long line can't squeeze it away.
|
|
178
|
+
expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))?.text).toMatch(/^Backstep is on/) // The mark: the front triangle in the terminal's own color, the one behind in the logo's pink.
|
|
145
179
|
expect((await ui.find({ type: 'Text', text: '◀', props: { color: '#ee7bb6' } }))).toBeTruthy()
|
|
146
180
|
await ui.press({ key: 'undo' })
|
|
147
181
|
expect(toasts[0]).toContain('Nothing to undo yet')
|
|
@@ -201,14 +235,13 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
201
235
|
|
|
202
236
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
203
237
|
await new Promise((r) => setTimeout(r, 10))
|
|
204
|
-
expect(tools).toEqual(['connect', 'undo_key'])
|
|
238
|
+
expect(tools).toEqual(['connect', 'undo_key', 'uninstall'])
|
|
205
239
|
expect(logs[0]).toContain('Backstep connected')
|
|
206
240
|
expect(runs).toEqual([
|
|
207
241
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'key', '--json'],
|
|
208
242
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
|
|
209
|
-
['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
|
|
210
|
-
['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
|
|
211
243
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'status', '--quick', '--json', '--dir', '/repo'],
|
|
244
|
+
['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
|
|
212
245
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'update-check'],
|
|
213
246
|
])
|
|
214
247
|
expect(toasts.find((t) => t.includes('linear is now behind Backstep'))).toBeTruthy()
|
|
@@ -352,6 +385,8 @@ test('asking Claude to connect runs backstep connect, with the connection string
|
|
|
352
385
|
expect(toasts).toHaveLength(1)
|
|
353
386
|
})
|
|
354
387
|
|
|
388
|
+
const settle = () => new Promise((r) => setTimeout(r, 20))
|
|
389
|
+
|
|
355
390
|
// A project that uses Supabase, not connected: Backstep asks, and only the answer connects it.
|
|
356
391
|
function supabaseProject(on: On, answer: string, connectResult: object) {
|
|
357
392
|
const runs: string[][] = []
|
|
@@ -359,7 +394,11 @@ function supabaseProject(on: On, answer: string, connectResult: object) {
|
|
|
359
394
|
const asked: string[] = []
|
|
360
395
|
on('process.run', (_, e) => {
|
|
361
396
|
runs.push([...e.argv])
|
|
362
|
-
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')
|
|
363
402
|
? JSON.stringify([{ service: 'supabase', state: 'ask', detail: "this project uses Supabase, but Backstep isn't connected to it" }])
|
|
364
403
|
: e.argv.includes('status')
|
|
365
404
|
? JSON.stringify({ items: [], suggest: [] })
|
|
@@ -379,15 +418,32 @@ function supabaseProject(on: On, answer: string, connectResult: object) {
|
|
|
379
418
|
return { result: { questions, answers: { [questions[0].question]: answer } } }
|
|
380
419
|
})
|
|
381
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: {} }))
|
|
382
424
|
return { runs, toasts, asked }
|
|
383
425
|
}
|
|
384
426
|
|
|
385
|
-
|
|
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
|
+
}
|
|
386
436
|
|
|
387
|
-
test('a
|
|
388
|
-
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', {})
|
|
389
439
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
390
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)
|
|
391
447
|
expect(asked).toHaveLength(1)
|
|
392
448
|
expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --json')).toBe(true)
|
|
393
449
|
expect(toasts.find((t) => t.includes('connected project abc'))).toBeTruthy()
|
|
@@ -395,15 +451,13 @@ test('a Supabase project that is not connected is asked about, and Connect conne
|
|
|
395
451
|
|
|
396
452
|
test('Not now connects nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
397
453
|
const { runs } = supabaseProject(on, 'Not now', {})
|
|
398
|
-
await
|
|
399
|
-
await settle()
|
|
454
|
+
await changeDatabase($, on)
|
|
400
455
|
expect(runs.filter((r) => r.includes('connect') && !r.includes('--auto'))).toHaveLength(0)
|
|
401
456
|
})
|
|
402
457
|
|
|
403
458
|
test("Don't ask for this project remembers it", { options: { cli: CLI } }, async ($, on) => {
|
|
404
459
|
const { runs } = supabaseProject(on, "Don't ask for this project", {})
|
|
405
|
-
await
|
|
406
|
-
await settle()
|
|
460
|
+
await changeDatabase($, on)
|
|
407
461
|
expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --dismiss')).toBe(true)
|
|
408
462
|
})
|
|
409
463
|
|
|
@@ -490,9 +544,22 @@ function updates(on: On, answer: string) {
|
|
|
490
544
|
return { result: { questions, answers: { [questions[0].question]: answer } } }
|
|
491
545
|
})
|
|
492
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: {} }))
|
|
493
550
|
return { runs, toasts, asked }
|
|
494
551
|
}
|
|
495
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
|
+
|
|
496
563
|
test('a newer version is offered as a session starts, and Update installs it', { options: { cli: CLI } }, async ($, on) => {
|
|
497
564
|
const { runs, toasts, asked } = updates(on, 'Update')
|
|
498
565
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
@@ -666,7 +733,7 @@ test('when the last turn only committed and pushed, undo offers to take back the
|
|
|
666
733
|
await clock.advance(1000)
|
|
667
734
|
await pressed
|
|
668
735
|
// Both turns: everything since the first one started.
|
|
669
|
-
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--json', '--via-claude-code'])
|
|
736
|
+
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
|
|
670
737
|
await ui.unmount()
|
|
671
738
|
})
|
|
672
739
|
|
|
@@ -792,3 +859,39 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
|
|
|
792
859
|
expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes GitHub')
|
|
793
860
|
await ui.unmount()
|
|
794
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
|
@@ -183,6 +183,19 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone =
|
|
|
183
183
|
// The first press asks, right there above the prompt; a second press within a
|
|
184
184
|
// few seconds undoes. A dialog would take the band off screen, and with it the
|
|
185
185
|
// key, which would then suspend Claude Code in a terminal.
|
|
186
|
+
// Whether a file path is inside the folder this session has open.
|
|
187
|
+
function within(path: string, cwd: string): boolean {
|
|
188
|
+
const abs = path.startsWith('/') ? path : `${cwd}/${path}`
|
|
189
|
+
const parts: string[] = []
|
|
190
|
+
for (const seg of abs.split('/')) {
|
|
191
|
+
if (seg === '..') parts.pop()
|
|
192
|
+
else if (seg && seg !== '.') parts.push(seg)
|
|
193
|
+
}
|
|
194
|
+
const p = '/' + parts.join('/')
|
|
195
|
+
const d = cwd.replace(/\/+$/, '')
|
|
196
|
+
return p === d || p.startsWith(d + '/')
|
|
197
|
+
}
|
|
198
|
+
|
|
186
199
|
async function pressUndo($: EngineInterface) {
|
|
187
200
|
if (await read($, rewind)) return
|
|
188
201
|
const target = (await read($, turns)).findLast(hasWork)
|
|
@@ -218,7 +231,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
218
231
|
const cwd = await $.session.cwd()
|
|
219
232
|
const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
|
|
220
233
|
const [ran] = await Promise.all([
|
|
221
|
-
$.process.run(backstep(['undo', '--since', String(target.startedAt), '--json', '--via-claude-code'])).catch((err: unknown) => ({
|
|
234
|
+
$.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', await $.session.id(), '--json', '--via-claude-code'])).catch((err: unknown) => ({
|
|
222
235
|
exitCode: 1,
|
|
223
236
|
stdout: '',
|
|
224
237
|
stderr: err instanceof Error ? err.message : String(err),
|
|
@@ -476,17 +489,9 @@ async function wrapNewServers($: EngineInterface, cwd: string) {
|
|
|
476
489
|
// says what Claude reaches that Backstep could undo once connected, so nobody
|
|
477
490
|
// presses undo expecting it to come back.
|
|
478
491
|
async function checkCoverage($: EngineInterface, cwd: string) {
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
try {
|
|
483
|
-
for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
|
|
484
|
-
if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
|
|
485
|
-
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.')
|
|
486
|
-
}
|
|
487
|
-
} catch {
|
|
488
|
-
// Backstep couldn't be reached: nothing to say.
|
|
489
|
-
}
|
|
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.
|
|
490
495
|
const ran = await $.process.run(backstep(['status', '--quick', '--json', '--dir', cwd])).catch(() => null)
|
|
491
496
|
let items: { name: string; level: string; detail: string }[] = []
|
|
492
497
|
try {
|
|
@@ -512,9 +517,9 @@ const asked = new Set<string>()
|
|
|
512
517
|
// How each database is named in the question, and what the answer connects.
|
|
513
518
|
const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
|
|
514
519
|
|
|
515
|
-
// Asked when Claude
|
|
516
|
-
//
|
|
517
|
-
//
|
|
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.
|
|
518
523
|
async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
|
|
519
524
|
const key = `${service}:${cwd}`
|
|
520
525
|
if (asked.has(key)) return
|
|
@@ -663,12 +668,56 @@ async function connectServices($: EngineInterface, input: { service?: string; co
|
|
|
663
668
|
return out.map((s) => `${s.service === 'supabase' ? 'Supabase' : 'Firebase'} (${s.state}): ${s.detail}`).join('\n')
|
|
664
669
|
}
|
|
665
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
|
+
|
|
666
712
|
export const register: Register = (on, options) => {
|
|
667
713
|
cli = String(options.cli ?? 'backstep')
|
|
668
714
|
|
|
669
715
|
on('session.start', async ($, e, next) => {
|
|
670
716
|
await $.tool.register(CONNECT_TOOL)
|
|
671
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)
|
|
672
721
|
const r = await next(e)
|
|
673
722
|
const boundOut = await $.process.run(backstep(['key', '--json'])).catch(() => null)
|
|
674
723
|
try {
|
|
@@ -706,15 +755,21 @@ export const register: Register = (on, options) => {
|
|
|
706
755
|
|
|
707
756
|
on('tool.call', { tool: CONNECT }, async ($, e) => ({ result: await connectServices($, e as { service?: string; connection_string?: string; project_ref?: string }) }))
|
|
708
757
|
|
|
758
|
+
on('tool.call', { tool: UNINSTALL }, async ($) => ({ result: await uninstallBackstep($) }))
|
|
759
|
+
|
|
760
|
+
on('command.run', { command: 'uninstall-backstep' }, async ($) => ({ text: await uninstallBackstep($) }))
|
|
761
|
+
|
|
709
762
|
on('tool.call', async ($, e, next) => {
|
|
710
763
|
const tool = String(e.tool)
|
|
711
|
-
if (tool === CONNECT || tool === KEY) return next(e)
|
|
764
|
+
if (tool === CONNECT || tool === KEY || tool === UNINSTALL) return next(e)
|
|
712
765
|
if (EDIT_TOOLS.has(tool)) {
|
|
713
766
|
const input = e as { file_path?: string; notebook_path?: string }
|
|
714
767
|
const path = input.file_path ?? input.notebook_path
|
|
715
|
-
|
|
768
|
+
const cwd = await $.session.cwd()
|
|
769
|
+
// Only the open folder is tracked: an edit in another project is never
|
|
770
|
+
// recorded, so undoing here can never delete or roll back files there.
|
|
771
|
+
if (path && within(path, cwd)) {
|
|
716
772
|
const t = await current($)
|
|
717
|
-
const cwd = await $.session.cwd()
|
|
718
773
|
const sid = await $.session.id()
|
|
719
774
|
// A failed snapshot never blocks the edit; it just won't be undoable.
|
|
720
775
|
const snap = await $.process
|
|
@@ -778,14 +833,21 @@ export const register: Register = (on, options) => {
|
|
|
778
833
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
779
834
|
if (e.props.hasSurvey) return next(e)
|
|
780
835
|
const { Box, Button, Text } = $.ui.resolve(e)
|
|
836
|
+
// The logo keeps its own box with a fixed gap after it: a space inside the
|
|
837
|
+
// text next to it gets trimmed when a long line wraps, crushing the two together.
|
|
838
|
+
const mark = () => (
|
|
839
|
+
<Box flexShrink={0} marginRight={1}>
|
|
840
|
+
<Text bold>◀</Text>
|
|
841
|
+
<Text color={PINK} bold>◀</Text>
|
|
842
|
+
</Box>
|
|
843
|
+
)
|
|
781
844
|
const r = await read($, rewind)
|
|
782
845
|
|
|
783
846
|
if (r?.phase === 'running') {
|
|
784
847
|
return (
|
|
785
848
|
<Box>
|
|
786
|
-
|
|
787
|
-
<Text
|
|
788
|
-
<Text bold> Rewinding </Text>
|
|
849
|
+
{mark()}
|
|
850
|
+
<Text bold>Rewinding </Text>
|
|
789
851
|
<Text>{bar(r.frame)}</Text>
|
|
790
852
|
<Text dimColor> undoing the last turn…</Text>
|
|
791
853
|
</Box>
|
|
@@ -797,9 +859,8 @@ export const register: Register = (on, options) => {
|
|
|
797
859
|
const step = waiting ? (
|
|
798
860
|
<Box flexDirection="column" marginTop={r ? 1 : 0}>
|
|
799
861
|
<Box>
|
|
800
|
-
|
|
801
|
-
<Text color={
|
|
802
|
-
<Text color="warning" bold>{` One more step to ${waiting.what}:`}</Text>
|
|
862
|
+
{mark()}
|
|
863
|
+
<Text color="warning" bold>{`One more step to ${waiting.what}:`}</Text>
|
|
803
864
|
</Box>
|
|
804
865
|
<Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
|
|
805
866
|
<Box>
|
|
@@ -858,9 +919,8 @@ export const register: Register = (on, options) => {
|
|
|
858
919
|
return (
|
|
859
920
|
<Box flexDirection="column">
|
|
860
921
|
<Box>
|
|
861
|
-
|
|
862
|
-
<Text color={
|
|
863
|
-
<Text color="warning" bold>{' That turn only saved and pushed to git.'}</Text>
|
|
922
|
+
{mark()}
|
|
923
|
+
<Text color="warning" bold>{'That turn only saved and pushed to git.'}</Text>
|
|
864
924
|
</Box>
|
|
865
925
|
<Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
|
|
866
926
|
<Box>
|
|
@@ -881,9 +941,8 @@ export const register: Register = (on, options) => {
|
|
|
881
941
|
return (
|
|
882
942
|
<Box flexDirection="column">
|
|
883
943
|
<Box>
|
|
884
|
-
|
|
885
|
-
<Text color={
|
|
886
|
-
<Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
|
|
944
|
+
{mark()}
|
|
945
|
+
<Text color="warning" bold>{"Undo Claude's last turn?"}</Text>
|
|
887
946
|
</Box>
|
|
888
947
|
<Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
|
|
889
948
|
<Box>
|
|
@@ -903,13 +962,16 @@ export const register: Register = (on, options) => {
|
|
|
903
962
|
// with nothing to undo, a press says so rather than doing nothing.
|
|
904
963
|
return (
|
|
905
964
|
<Box>
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
<
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
965
|
+
{mark()}
|
|
966
|
+
{/* One line: a long summary is cut short so the key to press always shows. */}
|
|
967
|
+
<Box flexShrink={1} minWidth={0}>
|
|
968
|
+
<Text dimColor wrap="truncate-end">{target ? `Last turn:${quoted(target, 28)} · ${summary(target)}` : 'Backstep is on · nothing to undo yet'}</Text>
|
|
969
|
+
</Box>
|
|
970
|
+
<Box flexShrink={0} marginLeft={2}>
|
|
971
|
+
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
|
|
972
|
+
{`${label} to undo`}
|
|
973
|
+
</Button>
|
|
974
|
+
</Box>
|
|
913
975
|
</Box>
|
|
914
976
|
)
|
|
915
977
|
})
|