backstep 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
9
9
  - **Firebase:** before a change, Backstep reads what's there through the Firebase MCP server itself, and undoes through it too, so there's nothing to set up. Firestore documents updated, deleted or added; Realtime Database writes; Remote Config publishes (rolled back to the version before); and users enabled or disabled.
10
10
  - **claude.ai connectors and servers Claude Code signs in to:** Backstep can't sit in front of these, so the Claude Code mod watches their calls instead. Calls with an undo rule are undone through Claude Code's own connection.
11
11
  - **Database commands Claude runs** (`supabase db push`, `supabase migration up`, `supabase db reset`, `psql`, `prisma migrate`, `drizzle-kit push`, seed scripts…): in a project whose Supabase database is connected, the whole database is noted before the command runs, and ctrl+u puts it back in one transaction: rows, new tables dropped, dropped or altered tables rebuilt with their constraints, indexes, foreign keys, row level security policies, triggers and grants, sequence counters, and the migration history. Copies sit in a `backstep` schema in the same database; the newest 10 per database are kept. On a very big database it keeps every table's structure and the migration history, and rows smallest-table-first up to a budget (`BACKSTEP_DB_SNAPSHOT_MB`, default 250); undo names any table whose rows it couldn't bring back. Functions, views, types and extensions aren't restored.
12
+ - **Supabase through its web API** (curl to `/rest/v1`, seed and app scripts using supabase-js): Backstep finds Supabase projects in the folder Claude Code opened or a level or two below, and the first time Claude is about to change one that isn't connected, it asks before the command runs. Connect uses the project's own `SUPABASE_URL` and service role key from its `.env`, no password: every table's rows are saved on this machine before each such command (smallest first, up to the size budget), and ctrl+u removes rows added since and writes back the old ones. Table changes need the database itself connected (password or access token).
12
13
  - **Code Claude pushed:** ctrl+u rewinds the commits locally, then asks whether to undo them on GitHub (or any remote) too. Yes pushes one new commit that undoes Claude's, made in a temporary worktree: no force push, no rewritten history, so it works on protected branches and for teammates who already pulled. Newer commits that clash, or a rewritten branch, stop it. A branch Claude created is deleted instead, if nobody has added to it. It uses the project's own git sign-in, so there's nothing to connect.
13
14
  - **Things that can't be undone** (sending email, payments, deploys, `ALTER TABLE`…) run as normal and are marked in the journal, so you know what an undo didn't cover.
14
15
  - **Anything else, by rule:** a rules file maps a tool to its opposite (`create_issue` → close the issue, `send_message` → retract it), filled in from the call and its result. See [Undo rules](#undo-rules).
@@ -0,0 +1,170 @@
1
+ // Supabase undo through its own web API (PostgREST), for a project Backstep
2
+ // reaches with the keys in its .env rather than a database password. Before a
3
+ // command that could change it (a curl to /rest/v1, a seed script, an app
4
+ // script using supabase-js), every table's rows are read and kept on this
5
+ // machine, smallest tables first up to a budget. Undo deletes rows added
6
+ // since and writes the old rows back. Rows only: the web API can't drop or
7
+ // rebuild tables, so table changes need the database itself connected.
8
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { snapshotDir } from "../store.js";
11
+ const PAGE = 1000;
12
+ const budget = () => Number(process.env.BACKSTEP_DB_SNAPSHOT_MB ?? 250) * 1024 * 1024;
13
+ async function call(url, key, path, init = {}) {
14
+ const r = await fetch(`${url.replace(/\/$/, "")}/rest/v1/${path}`, {
15
+ ...init,
16
+ headers: { apikey: key, authorization: `Bearer ${key}`, "content-type": "application/json", ...init.headers },
17
+ signal: AbortSignal.timeout(30_000),
18
+ });
19
+ if (!r.ok)
20
+ throw new Error(`Supabase said ${r.status}: ${(await r.text()).slice(0, 200)}`);
21
+ return r;
22
+ }
23
+ // The tables the API can write, and each one's primary key, from its OpenAPI description.
24
+ async function describe(url, key) {
25
+ const spec = (await (await call(url, key, "", { headers: { accept: "application/openapi+json" } })).json());
26
+ const out = [];
27
+ for (const [name, def] of Object.entries(spec.definitions ?? {})) {
28
+ if (!spec.paths?.[`/${name}`]?.post)
29
+ continue; // a view or a read-only table
30
+ const pk = Object.entries(def.properties ?? {})
31
+ .filter(([, p]) => /<pk\/>|primary key/i.test(p.description ?? ""))
32
+ .map(([col]) => col);
33
+ out.push({ name, pk });
34
+ }
35
+ return out;
36
+ }
37
+ async function count(url, key, table) {
38
+ const r = await call(url, key, `${encodeURIComponent(table)}?select=*`, { method: "HEAD", headers: { prefer: "count=exact", range: "0-0", "range-unit": "items" } });
39
+ return Number(r.headers.get("content-range")?.split("/")[1] ?? 0) || 0;
40
+ }
41
+ async function rows(url, key, table) {
42
+ const out = [];
43
+ for (let from = 0;; from += PAGE) {
44
+ const r = await call(url, key, `${encodeURIComponent(table)}?select=*`, { headers: { range: `${from}-${from + PAGE - 1}`, "range-unit": "items" } });
45
+ const page = (await r.json());
46
+ out.push(...page);
47
+ if (page.length < PAGE)
48
+ return out;
49
+ }
50
+ }
51
+ export async function snapshotRest(actionId, url, key) {
52
+ const tables = await describe(url, key);
53
+ const sized = await Promise.all(tables.map(async (t) => ({ ...t, n: await count(url, key, t.name) })));
54
+ const dir = join(snapshotDir(actionId), "rest");
55
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
56
+ let spent = 0;
57
+ const out = [];
58
+ // Smallest first: the most tables for the budget.
59
+ for (const t of sized.sort((a, b) => a.n - b.n)) {
60
+ if (t.pk.length === 0) {
61
+ out.push({ name: t.name, pk: [], file: null, skipped: "it has no primary key" });
62
+ continue;
63
+ }
64
+ const data = JSON.stringify(await rows(url, key, t.name));
65
+ if (spent + data.length > budget()) {
66
+ out.push({ name: t.name, pk: t.pk, file: null, skipped: "it was too big to copy" });
67
+ continue;
68
+ }
69
+ spent += data.length;
70
+ const file = join(dir, `${out.length}.json`);
71
+ writeFileSync(file, data, { mode: 0o600 });
72
+ out.push({ name: t.name, pk: t.pk, file });
73
+ }
74
+ return { adapter: "rest", url, tables: out };
75
+ }
76
+ const keyOf = (pk, r) => JSON.stringify(pk.map((c) => r[c]));
77
+ const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
78
+ // PostgREST's filter syntax for one row's key.
79
+ function match(pk, r) {
80
+ return pk.map((c) => `${encodeURIComponent(c)}=eq.${encodeURIComponent(String(r[c]))}`).join("&");
81
+ }
82
+ export async function undoRest(u, key) {
83
+ const log = [];
84
+ const plans = [];
85
+ const live = new Set((await describe(u.url, key)).map((t) => t.name));
86
+ for (const t of u.tables) {
87
+ if (!t.file) {
88
+ if (t.skipped === "it was too big to copy")
89
+ log.push(`couldn't put back the rows of ${t.name}: ${t.skipped}`);
90
+ continue;
91
+ }
92
+ if (!live.has(t.name)) {
93
+ log.push(`couldn't put back ${t.name}: the table is gone, and bringing back a table needs the database connected`);
94
+ continue;
95
+ }
96
+ const was = JSON.parse(readFileSync(t.file, "utf8"));
97
+ const now = await rows(u.url, key, t.name);
98
+ const before = new Map(was.map((r) => [keyOf(t.pk, r), r]));
99
+ const after = new Map(now.map((r) => [keyOf(t.pk, r), r]));
100
+ const del = now.filter((r) => !before.has(keyOf(t.pk, r)));
101
+ const put = was.filter((r) => {
102
+ const cur = after.get(keyOf(t.pk, r));
103
+ return !cur || !same(cur, r);
104
+ });
105
+ if (del.length || put.length)
106
+ plans.push({ t, del, put });
107
+ }
108
+ // Foreign keys decide the order, which the API doesn't say: deletes and
109
+ // writes that fail are tried again after the others, until nothing moves.
110
+ const deleted = new Map();
111
+ for (let left = plans.flatMap((p) => p.del.map((r) => ({ p, r }))), pass = 0; left.length && pass < 6; pass++) {
112
+ const failed = [];
113
+ for (const { p, r } of left) {
114
+ try {
115
+ await call(u.url, key, `${encodeURIComponent(p.t.name)}?${match(p.t.pk, r)}`, { method: "DELETE" });
116
+ deleted.set(p.t.name, (deleted.get(p.t.name) ?? 0) + 1);
117
+ }
118
+ catch {
119
+ failed.push({ p, r });
120
+ }
121
+ }
122
+ if (failed.length === left.length)
123
+ throw new Error(`couldn't delete the rows added to ${failed[0].p.t.name}`);
124
+ left = failed;
125
+ }
126
+ const restored = new Map();
127
+ for (let left = plans.filter((p) => p.put.length), pass = 0; left.length && pass < 6; pass++) {
128
+ const failed = [];
129
+ for (const p of left) {
130
+ try {
131
+ await upsert(u.url, key, p.t, p.put);
132
+ restored.set(p.t.name, p.put.length);
133
+ }
134
+ catch {
135
+ failed.push(p);
136
+ }
137
+ }
138
+ if (failed.length === left.length)
139
+ throw new Error(`couldn't write back the rows of ${failed[0].t.name}`);
140
+ left = failed;
141
+ }
142
+ for (const p of plans) {
143
+ const parts = [restored.get(p.t.name) ? `put back ${restored.get(p.t.name)} rows` : "", deleted.get(p.t.name) ? `removed ${deleted.get(p.t.name)} new rows` : ""].filter(Boolean);
144
+ if (parts.length)
145
+ log.unshift(`${p.t.name}: ${parts.join(", ")}`);
146
+ }
147
+ return log.length ? log.join("\n") : "nothing had changed in Supabase";
148
+ }
149
+ // Writes rows back over whatever has their key. Columns the database fills in
150
+ // itself (generated ones) are dropped and tried again when it refuses them.
151
+ async function upsert(url, key, t, put) {
152
+ let body = put;
153
+ for (let i = 0; i < 5; i++) {
154
+ try {
155
+ await call(url, key, `${encodeURIComponent(t.name)}?on_conflict=${t.pk.map(encodeURIComponent).join(",")}`, {
156
+ method: "POST",
157
+ headers: { prefer: "resolution=merge-duplicates,return=minimal" },
158
+ body: JSON.stringify(body),
159
+ });
160
+ return;
161
+ }
162
+ catch (err) {
163
+ const col = String(err).match(/column "([^"]+)" can only be updated to DEFAULT|cannot insert a non-DEFAULT value into column "([^"]+)"/);
164
+ const name = col?.[1] ?? col?.[2];
165
+ if (!name)
166
+ throw err;
167
+ body = body.map(({ [name]: _, ...rest }) => rest);
168
+ }
169
+ }
170
+ }
package/dist/check.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Claude Code mod checks it as a session starts, so nobody assumes a service
4
4
  // is covered when it isn't.
5
5
  import { spawnSync } from "node:child_process";
6
- import { configServers, projectUses } from "./connect.js";
6
+ import { configServers, projectConnected, projectUses, supabaseDirs } from "./connect.js";
7
7
  import { supabaseProjects } from "./supabase.js";
8
8
  import { watchRules } from "./watch.js";
9
9
  import { dim, pink, white, width, wrap } from "./look.js";
@@ -60,12 +60,13 @@ function classify(s, supabaseConnected, ruled) {
60
60
  // which starts every server: for a check as a session starts.
61
61
  export function check(dir, opts = {}) {
62
62
  const found = (opts.quick ? null : listed(dir)) ?? configServers(dir);
63
- const connected = supabaseProjects().length > 0;
63
+ // A database connection, or the project's own API keys.
64
+ const connected = supabaseProjects().length > 0 || supabaseDirs(dir).some(projectConnected);
64
65
  const ruled = new Set(watchRules().map((r) => r.server ?? "").filter(Boolean));
65
66
  const items = found.map((s) => classify(s, connected, ruled));
66
67
  const uses = projectUses(dir);
67
68
  const suggest = [];
68
- const supabaseHere = items.some((i) => SUPABASE.test(i.name)) || uses.supabase;
69
+ const supabaseHere = items.some((i) => SUPABASE.test(i.name)) || uses.supabase || supabaseDirs(dir).length > 0;
69
70
  if (!connected && supabaseHere) {
70
71
  suggest.push({ title: "Connect Supabase", why: "so Claude's database changes can be undone", how: 'Ask Claude: "connect Backstep to Supabase"' });
71
72
  }
package/dist/cli.js CHANGED
@@ -9,16 +9,23 @@ import { snapshotPaths } from "./adapters/fs.js";
9
9
  import { notePushed, snapshotTree } from "./adapters/tree.js";
10
10
  import { revertPush } from "./push.js";
11
11
  import { snapshotDb } from "./adapters/dbsnap.js";
12
- import { projectDatabases } from "./connect.js";
12
+ import { projectConnected, projectDatabases, restKeys, supabaseDirs } from "./connect.js";
13
+ import { snapshotRest } from "./adapters/rest.js";
13
14
  const PUSH = /\bgit\b[^;&|]*\bpush\b/;
14
- import { classifyShell, supabaseTarget, touchesDatabase } from "./shell.js";
15
+ import { classifyShell, commandDir, supabaseTarget, touchesDatabase, touchesSupabase } from "./shell.js";
15
16
  import { watchEnd, watchStart } from "./watch.js";
16
17
  import { sendActive } from "./telemetry.js";
17
18
  import { readFileSync, rmSync } from "node:fs";
18
19
  import { loadRules } from "./rules.js";
19
20
  import { setup } from "./setup.js";
20
21
  import { autoWrap, printChanges, unwrapServers, wrapServers } from "./servers.js";
21
- import { disconnectSupabase, dismissSupabase } from "./supabase.js";
22
+ import { disconnectSupabase, dismissSupabase, restKey, supabaseDismissed } from "./supabase.js";
23
+ // The Supabase projects a shell command could change: those in the folder it
24
+ // works in (or a level or two below), when the command is one that reaches a database.
25
+ function reachedProjects(command, cwd) {
26
+ const dirs = supabaseDirs(commandDir(command, cwd));
27
+ return touchesDatabase(command) || touchesSupabase(command, dirs.length > 0) ? dirs : [];
28
+ }
22
29
  import { connect } from "./connect.js";
23
30
  import { check, printCheck } from "./check.js";
24
31
  import { logo } from "./look.js";
@@ -343,11 +350,15 @@ async function main() {
343
350
  // A push also notes where the remote branches stood, so undo can take it back there too.
344
351
  const cwd = values.cwd ?? process.cwd();
345
352
  const tree = snapshotTree(a.id, cwd, { pushes: PUSH.test(command) });
346
- // A migration, reset, seed or raw SQL: the connected databases it could change are noted too.
353
+ // A migration, reset, seed, raw SQL, or anything reaching Supabase's API: the
354
+ // connected databases it could change are noted too, or their rows through
355
+ // the API where only the project's keys connect it.
347
356
  const dbs = [];
357
+ const rests = [];
348
358
  const missed = [];
349
- if (touchesDatabase(command)) {
350
- for (const url of await projectDatabases(cwd, supabaseTarget(command))) {
359
+ for (const d of reachedProjects(command, cwd)) {
360
+ const urls = await projectDatabases(d, supabaseTarget(command));
361
+ for (const url of urls) {
351
362
  try {
352
363
  dbs.push(await snapshotDb(a.id, url));
353
364
  }
@@ -355,12 +366,23 @@ async function main() {
355
366
  missed.push(err instanceof Error ? err.message : String(err));
356
367
  }
357
368
  }
369
+ const rest = restKeys(d);
370
+ if (!urls.length && rest && restKey(rest.url)) {
371
+ try {
372
+ rests.push(await snapshotRest(a.id, rest.url, restKey(rest.url)));
373
+ }
374
+ catch (err) {
375
+ missed.push(err instanceof Error ? err.message : String(err));
376
+ }
377
+ }
358
378
  }
359
- const undo = tree ? { ...tree, ...(dbs.length ? { dbs } : {}) } : dbs.length ? { adapter: "dbs", dbs } : null;
379
+ const extra = { ...(dbs.length ? { dbs } : {}), ...(rests.length ? { rests } : {}) };
380
+ const undo = tree ? { ...tree, ...extra } : dbs.length || rests.length ? { adapter: "dbs", dbs, rests } : null;
360
381
  if (undo)
361
382
  store.updateAction(a.id, { undo: JSON.stringify(undo) });
362
383
  // With its database saved, a command that reaches it is no longer lost to undo.
363
- const kind = plan.kind === "outbound" && dbs.length && plan.reason === "changes a database directly" ? "write" : plan.kind;
384
+ const saved = dbs.length + rests.length;
385
+ const kind = plan.kind === "outbound" && saved && /database|request/.test(plan.reason ?? "") ? "write" : plan.kind;
364
386
  if (kind !== plan.kind)
365
387
  store.updateAction(a.id, { kind: "reversible", reason: null });
366
388
  if (!undo && kind !== "outbound")
@@ -369,7 +391,17 @@ async function main() {
369
391
  store.updateAction(a.id, { reason: `couldn't save the database first: ${missed[0]}` });
370
392
  if (dbs.length)
371
393
  await pruneDatabaseCopies(store).catch(() => { });
372
- console.log(JSON.stringify({ kind, reason: kind === plan.kind ? plan.reason : undefined, id: a.id, undoable: undo !== null, db: dbs.length }));
394
+ console.log(JSON.stringify({ kind, reason: kind === plan.kind ? plan.reason : undefined, id: a.id, undoable: undo !== null, db: saved }));
395
+ return;
396
+ }
397
+ // Before a shell command, from the Claude Code mod: does it reach a Supabase project Backstep
398
+ // isn't connected to? Then the mod asks first, so the project can be saved before it changes.
399
+ if (cmd === "db-check") {
400
+ const sep = rest.indexOf("--");
401
+ const { values } = parseArgs({ args: sep === -1 ? rest : rest.slice(0, sep), options: { cwd: { type: "string" } } });
402
+ const command = sep === -1 ? "" : rest.slice(sep + 1).join(" ");
403
+ const ask = reachedProjects(command, values.cwd ?? process.cwd()).find((d) => !projectConnected(d) && !supabaseDismissed(d));
404
+ console.log(JSON.stringify({ ask: ask ?? null }));
373
405
  return;
374
406
  }
375
407
  // After a shell command that pushed: notes what it sent (run by the Claude Code mod).
package/dist/connect.js CHANGED
@@ -8,11 +8,11 @@
8
8
  // to connect once Claude can reach Firebase. When the project uses Firebase
9
9
  // and Claude can't, this adds that server (behind Backstep).
10
10
  import { spawnSync } from "node:child_process";
11
- import { existsSync, readFileSync } from "node:fs";
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { join, resolve } from "node:path";
14
14
  import { claudeConfigFile, wrapServers } from "./servers.js";
15
- import { connectSupabase, hasSavedToken, LOCAL, mintSupabase, projectRef, saveSupabaseToken, supabaseDb, supabaseDismissed, supabaseProjects, supabaseToken, supabaseUrl, } from "./supabase.js";
15
+ import { connectSupabase, hasSavedToken, LOCAL, mintSupabase, projectRef, saveSupabaseToken, supabaseDb, restKey, saveRest, supabaseDismissed, supabaseProjects, supabaseToken, supabaseUrl, } from "./supabase.js";
16
16
  const ENV_FILES = [".env", ".env.local", ".env.development", ".env.development.local"];
17
17
  const PG = /^postgres(ql)?:\/\//;
18
18
  const FIREBASE_SERVER = ["npx", "-y", "firebase-tools@latest", "mcp"];
@@ -75,6 +75,41 @@ export function projectUses(dir) {
75
75
  firebase: existsSync(join(dir, "firebase.json")) || existsSync(join(dir, ".firebaserc")) || d.some((x) => x === "firebase" || x === "firebase-admin"),
76
76
  };
77
77
  }
78
+ // Folders a session in `dir` works in that use Supabase: the folder itself, or
79
+ // its projects a level or two down (Claude Code opened in a parent folder).
80
+ export function supabaseDirs(dir) {
81
+ if (projectUses(dir).supabase)
82
+ return [dir];
83
+ const out = [];
84
+ const walk = (d, depth) => {
85
+ if (depth === 0)
86
+ return;
87
+ let names = [];
88
+ try {
89
+ names = readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !/^(\.|node_modules$|dist$|build$|out$|coverage$)/.test(e.name)).map((e) => e.name);
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ for (const n of names) {
95
+ const child = join(d, n);
96
+ if (projectUses(child).supabase)
97
+ out.push(child);
98
+ else
99
+ walk(child, depth - 1);
100
+ }
101
+ };
102
+ walk(dir, 2);
103
+ return out;
104
+ }
105
+ // The project's API URL and service key, from its .env files: enough to read
106
+ // and write its rows through Supabase's own API, with no database password.
107
+ export function restKeys(dir) {
108
+ const vars = envVars(dir);
109
+ const url = vars.find(([k, v]) => /SUPABASE_URL$/.test(k) && /^https?:\/\//.test(v))?.[1];
110
+ const key = vars.find(([k, v]) => /SERVICE_ROLE|SERVICE_KEY|SECRET_KEY/.test(k) && (/SUPABASE/.test(k) || v.startsWith("sb_secret_")) && v.length > 20)?.[1];
111
+ return url && key ? { url: url.replace(/\/$/, ""), key } : null;
112
+ }
78
113
  // Connection strings worth trying, best first: the ones that say they're
79
114
  // direct, then any other Postgres URL in the project's env files.
80
115
  function candidates(dir) {
@@ -94,7 +129,7 @@ const TOKENS = "https://supabase.com/dashboard/account/tokens";
94
129
  const WHERE = "Supabase → your project → Connect (top of the page) → Direct connection (or Session pooler) → copy the URI and put the database password in place of [YOUR-PASSWORD]";
95
130
  // The hosted project a folder belongs to: `supabase link` notes it, and the
96
131
  // app's own settings name it in its API URL.
97
- function linkedRef(dir) {
132
+ export function linkedRef(dir) {
98
133
  const linked = read(join(dir, "supabase", ".temp", "project-ref")).trim();
99
134
  if (/^[a-z0-9]{20}$/.test(linked))
100
135
  return linked;
@@ -136,8 +171,11 @@ async function signedIn(dir, token, ref, isNew) {
136
171
  }
137
172
  return isNew ? { service: "supabase", state: "connected", detail: "signed in to Supabase, so each project connects itself as Claude uses it" } : null;
138
173
  }
139
- // Whether the project this folder uses already has its database connected.
140
- function projectConnected(dir) {
174
+ // Whether the project this folder uses is connected: its database, or its API through the project's keys.
175
+ export function projectConnected(dir) {
176
+ const rest = restKeys(dir);
177
+ if (rest && restKey(rest.url))
178
+ return true;
141
179
  const ref = linkedRef(dir);
142
180
  return ref ? supabaseProjects().includes(ref) : supabaseProjects().length > 0;
143
181
  }
@@ -145,12 +183,16 @@ async function supabase(dir, given, ref, interactive = false, auto = false) {
145
183
  const label = (r) => (r === LOCAL ? "the local database" : `project ${r}`);
146
184
  // On its own, Backstep only notices: connecting is the person's call.
147
185
  if (auto) {
148
- if (!projectUses(dir).supabase || supabaseDismissed(dir))
149
- return { service: "supabase", state: "unused", detail: "nothing to ask" };
150
- if (projectConnected(dir))
151
- return { service: "supabase", state: "covered", detail: "already connected" };
152
- return { service: "supabase", state: "ask", detail: "this project uses Supabase, but Backstep isn't connected to it" };
186
+ const dirs = supabaseDirs(dir);
187
+ const unconnected = dirs.find((d) => !supabaseDismissed(d) && !projectConnected(d));
188
+ if (unconnected)
189
+ return { service: "supabase", state: "ask", detail: "this project uses Supabase, but Backstep isn't connected to it", dir: unconnected };
190
+ // Connected, or the person said not to ask.
191
+ return dirs.some(projectConnected) ? { service: "supabase", state: "covered", detail: "already connected" } : { service: "supabase", state: "unused", detail: "nothing to ask" };
153
192
  }
193
+ // Opened in a parent folder: connect the Supabase project inside it.
194
+ if (!projectUses(dir).supabase)
195
+ dir = supabaseDirs(dir)[0] ?? dir;
154
196
  // An access token (sbp_…) rather than a connection string: one sign-in for every project.
155
197
  if (given?.startsWith("sbp_")) {
156
198
  try {
@@ -200,6 +242,24 @@ async function supabase(dir, given, ref, interactive = false, auto = false) {
200
242
  }
201
243
  if (had.length)
202
244
  return { service: "supabase", state: "covered", detail: `already connected: ${had.map(label).join(", ")}` };
245
+ // No database to reach, but the project's own API keys: rows can be undone through them.
246
+ const rest = restKeys(dir);
247
+ if (rest) {
248
+ try {
249
+ const r = await fetch(`${rest.url}/rest/v1/`, { headers: { apikey: rest.key, authorization: `Bearer ${rest.key}` }, signal: AbortSignal.timeout(15_000) });
250
+ if (!r.ok)
251
+ throw new Error(`Supabase said ${r.status}`);
252
+ saveRest(rest.url, rest.key);
253
+ return {
254
+ service: "supabase",
255
+ state: "connected",
256
+ detail: "connected with the project's own Supabase keys: rows Claude adds, changes or deletes can be undone (undoing changes to tables themselves needs the database password or an access token)",
257
+ };
258
+ }
259
+ catch (err) {
260
+ errors.push(`the project's Supabase keys didn't work: ${err instanceof Error ? err.message : String(err)}`);
261
+ }
262
+ }
203
263
  const used = /supabase/i.test(servers(dir).join("\n")) || projectUses(dir).supabase;
204
264
  if (!used && errors.length === 0)
205
265
  return { service: "supabase", state: "unused", detail: "this project doesn't use Supabase" };
package/dist/shell.js CHANGED
@@ -125,3 +125,19 @@ export function supabaseTarget(command) {
125
125
  return "local";
126
126
  return /--linked\b|--db-url\b|\bdb\s+push\b/.test(command) ? "hosted" : "local";
127
127
  }
128
+ // Commands that reach a Supabase project some other way: its web API (curl to
129
+ // /rest/v1, its URL or keys from .env), or a script or package script that
130
+ // might use supabase-js, judged together with whether the project uses Supabase.
131
+ const SUPABASE_API = /supabase\.co\b|\/rest\/v1\b|SUPABASE_[A-Z_]*(URL|KEY)|(localhost|127\.0\.0\.1):54321\b/;
132
+ const SCRIPT = /(^|[\s;&|(])((node|tsx|ts-node|bun|deno|python3?)\s+(run\s+)?[^\s;&|]+\.(m?[jt]s|cjs|py)\b|(npm|pnpm|yarn|bun)\s+(run\s+)?(seed|db|migrate|reset)[\w:-]*)/;
133
+ export function touchesSupabase(command, projectUsesSupabase) {
134
+ return SUPABASE_API.test(command) || (projectUsesSupabase && SCRIPT.test(command));
135
+ }
136
+ // The folder a command works in: where its leading `cd` goes, else where it runs.
137
+ export function commandDir(command, cwd) {
138
+ const m = command.match(/^\s*cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*(&&|;|$)/);
139
+ const to = m?.[1] ?? m?.[2] ?? m?.[3];
140
+ if (!to)
141
+ return cwd;
142
+ return to.startsWith("/") ? to : to.startsWith("~") ? to.replace(/^~/, process.env.HOME ?? "~") : `${cwd.replace(/\/$/, "")}/${to}`;
143
+ }
package/dist/supabase.js CHANGED
@@ -159,6 +159,12 @@ async function api(token, method, path, body) {
159
159
  return (await r.json());
160
160
  }
161
161
  export const hasSavedToken = () => Boolean(load().token);
162
+ export function saveRest(url, key) {
163
+ const c = load();
164
+ c.rest = { ...c.rest, [url.replace(/\/$/, "")]: key };
165
+ save(c);
166
+ }
167
+ export const restKey = (url) => load().rest?.[url.replace(/\/$/, "")] ?? null;
162
168
  export function dismissSupabase(dir) {
163
169
  const c = load();
164
170
  c.dismissed = [...new Set([...(c.dismissed ?? []), dir])];
package/dist/undo.js CHANGED
@@ -5,7 +5,8 @@ import { undoSql } from "./adapters/postgres.js";
5
5
  import { undoTree } from "./adapters/tree.js";
6
6
  import { discardDb, undoDb } from "./adapters/dbsnap.js";
7
7
  import { snapshotDir } from "./store.js";
8
- import { dbKey, freshUrl } from "./supabase.js";
8
+ import { dbKey, freshUrl, restKey } from "./supabase.js";
9
+ import { undoRest } from "./adapters/rest.js";
9
10
  const INVERSE_TIMEOUT_MS = 20_000;
10
11
  // Database copies sit in the person's own database, so only the newest few per
11
12
  // database are kept; older commands keep their file undo but not their database undo.
@@ -38,10 +39,11 @@ function resources(store, a) {
38
39
  return new Set(u.entries.map((e) => `fs:${e.path}`));
39
40
  // A project snapshot rewinds every file under its folder, and any whole database saved with it.
40
41
  const whole = (dbs = []) => dbs.map((d) => `pgdb:${dbKey(d.url)}`);
42
+ const apis = (rests = []) => rests.map((r) => `pgdb:${r.url}`);
41
43
  if (u.adapter === "tree")
42
- return new Set([`fs:${u.root}`, ...whole(u.dbs)]);
44
+ return new Set([`fs:${u.root}`, ...whole(u.dbs), ...apis(u.rests)]);
43
45
  if (u.adapter === "dbs")
44
- return new Set(whole(u.dbs));
46
+ return new Set([...whole(u.dbs), ...apis(u.rests)]);
45
47
  // An opposite tool call doesn't rewind shared state, so it never conflicts.
46
48
  if (u.adapter === "inverse" || u.adapter === "engine")
47
49
  return new Set();
@@ -134,6 +136,12 @@ async function undoOne(store, a, opts = {}) {
134
136
  const lines = [];
135
137
  for (const d of u.dbs ?? [])
136
138
  lines.push(await undoDb(await freshUrl(d.url), d));
139
+ for (const r of u.rests ?? []) {
140
+ const key = restKey(r.url);
141
+ if (!key)
142
+ throw new Error(`Backstep isn't connected to ${r.url} any more`);
143
+ lines.push(await undoRest(r, key));
144
+ }
137
145
  if (u.adapter === "tree")
138
146
  lines.unshift(undoTree(u, a.id));
139
147
  detail = lines.filter((l) => !/^nothing had changed/.test(l)).join("\n") || lines[0];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Press ctrl+z to rewind everything Claude did in its last turn: file edits, shell commands, and database and tool changes.",
5
5
  "types": "./types/index.d.ts",
6
6
  "userConfig": {
@@ -13,9 +13,14 @@ const CLI = '/opt/backstep/cli.js'
13
13
  // `undo` answers with `undo`. Every argv the mod runs is recorded.
14
14
  function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }) {
15
15
  const runs: string[][] = []
16
+ const logs: string[] = []
16
17
  const prompts: { text: string; context?: readonly string[] }[] = []
17
18
  on('session.cwd', () => ({ value: '/repo' }))
18
19
  on('session.id', () => ({ value: 's1' }))
20
+ on('ui.log', (_, e) => {
21
+ logs.push(e.text)
22
+ return { value: undefined }
23
+ })
19
24
  on('process.run', (_, e) => {
20
25
  runs.push([...e.argv])
21
26
  const r = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? { exitCode: 0, stdout: JSON.stringify(shell) } : { exitCode: 0, stdout: '' }
@@ -30,7 +35,7 @@ function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: strin
30
35
  const { Text } = $.ui.resolve(e)
31
36
  return <Text>engine</Text>
32
37
  })
33
- return { runs, prompts }
38
+ return { runs, prompts, logs }
34
39
  }
35
40
 
36
41
  test('an edit is snapshotted and the band offers to undo the turn', { options: { cli: CLI } }, async ($, on) => {
@@ -43,6 +48,7 @@ test('an edit is snapshotted and the band offers to undo the turn', { options: {
43
48
 
44
49
  expect(runs).toEqual([
45
50
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'snap', '--session', 's1', '--tool', 'Edit', '--cwd', '/repo', '--', '/repo/src/a.ts'],
51
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'db-check', '--cwd', '/repo', '--', 'ls'],
46
52
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'shell', '--session', 's1', '--cwd', '/repo', '--', 'ls'],
47
53
  ])
48
54
 
@@ -109,11 +115,12 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
109
115
  const pressed = ui.press({ key: 'undo' })
110
116
  await clock.advance(1000)
111
117
  await pressed
118
+ await clock.advance(500)
112
119
  expect((await ui.find({ type: 'Text', text: /Stopped/ }))?.text).toContain('Stopped after 1 change')
113
120
  expect((await ui.find({ type: 'Text', text: /row changed since/ }))).toBeTruthy()
114
121
 
115
122
  // Still undoable once the band folds, since the turn was not fully rewound.
116
- await clock.advance(10000)
123
+ await clock.advance(13000)
117
124
  expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
118
125
  await ui.unmount()
119
126
  expect(prompts).toHaveLength(1)
@@ -218,6 +225,7 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
218
225
  toasts.push(e.text)
219
226
  return { value: undefined }
220
227
  })
228
+ on('ui.log', () => ({ value: undefined }))
221
229
  on('session.cwd', () => ({ value: '/repo' }))
222
230
  on('session.id', () => ({ value: 's1' }))
223
231
  on('process.run', (_, e) => {
@@ -529,7 +537,7 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
529
537
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
530
538
 
531
539
  await ui.press({ key: 'undo' })
532
- expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn\?/ }))).toBeTruthy()
540
+ expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn \("fix it"\)\?/ }))).toBeTruthy()
533
541
  expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo')
534
542
  await clock.advance(6000)
535
543
  expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
@@ -566,3 +574,123 @@ test('with two keys bound the band names both', { options: { cli: CLI } }, async
566
574
  expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u / ctrl+q to undo')
567
575
  await ui.unmount()
568
576
  })
577
+
578
+ test('about to change a Supabase project that is not connected, Backstep asks first, then saves it', { options: { cli: CLI } }, async ($, on) => {
579
+ mock.clock(on, { now: 5000 })
580
+ const runs: string[][] = []
581
+ const asked: string[] = []
582
+ on('session.cwd', () => ({ value: '/work' }))
583
+ on('session.id', () => ({ value: 's1' }))
584
+ on('ui.toast', () => ({ value: undefined }))
585
+ on('process.run', (_, e) => {
586
+ runs.push([...e.argv])
587
+ const stdout = e.argv.includes('db-check')
588
+ ? JSON.stringify({ ask: '/work/taskflow' })
589
+ : e.argv.includes('connect')
590
+ ? JSON.stringify([{ service: 'supabase', state: 'connected', detail: "connected with the project's own Supabase keys" }])
591
+ : e.argv.includes('shell')
592
+ ? JSON.stringify({ kind: 'write', id: 'r1', undoable: true, db: 1 })
593
+ : '[]'
594
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
595
+ })
596
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
597
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
598
+ asked.push(questions[0].question)
599
+ return { result: { questions, answers: { [questions[0].question]: 'Connect' } } }
600
+ })
601
+ on('tool.call', () => ({ result: {} }))
602
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
603
+
604
+ await $.prompt.submit({ text: 'seed it', wait: false, origin: { kind: 'composer' } })
605
+ const command = 'cd taskflow && curl -X POST "$NEXT_PUBLIC_SUPABASE_URL/rest/v1/tasks"'
606
+ await $.tool.call({ tool: 'Bash', command })
607
+ expect(asked).toHaveLength(1)
608
+ const order = runs.map((r) => r[3])
609
+ expect(order).toEqual(['db-check', 'connect', 'shell'])
610
+ expect(runs[1].slice(3)).toEqual(['connect', 'supabase', '--dir', '/work/taskflow', '--json'])
611
+
612
+ // Asked once a session per project, whatever the answer.
613
+ await $.tool.call({ tool: 'Bash', command })
614
+ expect(asked).toHaveLength(1)
615
+ })
616
+
617
+ // An engine where each shell command's plan is given by a test, and undo answers as told.
618
+ function shellEngine(on: On, plans: (command: string) => object, undo: object[]) {
619
+ const runs: string[][] = []
620
+ const logs: string[] = []
621
+ on('session.cwd', () => ({ value: '/repo' }))
622
+ on('session.id', () => ({ value: 's1' }))
623
+ on('ui.toast', () => ({ value: undefined }))
624
+ on('ui.log', (_, e) => {
625
+ logs.push(e.text)
626
+ return { value: undefined }
627
+ })
628
+ on('process.run', (_, e) => {
629
+ runs.push([...e.argv])
630
+ const command = e.argv.at(-1) ?? ''
631
+ const out = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? plans(command) : e.argv.includes('db-check') ? { ask: null } : null
632
+ return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
633
+ })
634
+ on('tool.call', { tool: 'AskUserQuestion' }, () => ({ result: { questions: [], answers: {} } }))
635
+ on('tool.call', () => ({ result: {} }))
636
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
637
+ on('ui.render', ($, e) => {
638
+ const { Text } = $.ui.resolve(e)
639
+ return <Text>engine</Text>
640
+ })
641
+ return { runs, logs }
642
+ }
643
+
644
+ test('when the last turn only committed and pushed, undo offers to take back the work it saved too', { options: { cli: CLI } }, async ($, on) => {
645
+ const clock = mock.clock(on, { now: 5000 })
646
+ const { runs } = shellEngine(on, () => ({ kind: 'outbound', reason: 'pushes to a remote', id: 'p1', undoable: true }), [])
647
+
648
+ await $.prompt.submit({ text: 'add the kanban board', wait: false, origin: { kind: 'composer' } })
649
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/src/board.tsx', old_string: 'a', new_string: 'b' })
650
+ await clock.advance(1000)
651
+ await $.prompt.submit({ text: 'commit and push it', wait: false, origin: { kind: 'composer' } })
652
+ await $.tool.call({ tool: 'Bash', command: 'git add -A && git commit -m "board" && git push' })
653
+
654
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
655
+ expect((await ui.find({ type: 'Text', text: /Last turn \("commit and push it"\)/ }))).toBeTruthy()
656
+ await ui.press({ key: 'undo' })
657
+ expect((await ui.find({ type: 'Text', text: /only saved and pushed to git/ }))).toBeTruthy()
658
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo it and the turn before ("add the kanban board")')
659
+ expect(await ui.find({ type: 'Button', key: 'just' })).toBeTruthy()
660
+
661
+ const pressed = ui.press({ key: 'undo' })
662
+ await clock.advance(1000)
663
+ await pressed
664
+ // Both turns: everything since the first one started.
665
+ expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--json', '--via-claude-code'])
666
+ await ui.unmount()
667
+ })
668
+
669
+ test('after an undo, everything undone and everything that was not is listed, in the band and the conversation', { options: { cli: CLI } }, async ($, on) => {
670
+ const clock = mock.clock(on, { now: 5000 })
671
+ const { logs } = shellEngine(
672
+ on,
673
+ (command) => (command.startsWith('curl') ? { kind: 'outbound', reason: 'sends a request', id: 'r1', undoable: true } : { kind: 'write', id: 'w1', undoable: true }),
674
+ [{ id: 'w1', tool: 'Bash', ok: true, detail: 'restored /repo/src/a.ts\nremoved /repo/src/b.ts' }],
675
+ )
676
+
677
+ await $.prompt.submit({ text: 'tidy and notify', wait: false, origin: { kind: 'composer' } })
678
+ await $.tool.call({ tool: 'Bash', command: 'rm src/a.ts' })
679
+ await $.tool.call({ tool: 'Bash', command: 'curl -X POST https://hooks.example.com/notify -d done' })
680
+
681
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
682
+ await ui.press({ key: 'undo' })
683
+ const pressed = ui.press({ key: 'undo' })
684
+ await clock.advance(1000)
685
+ await pressed
686
+ await clock.advance(500)
687
+ expect((await ui.find({ type: 'Text', text: /src\/a\.ts restored/ }))).toBeTruthy()
688
+ expect((await ui.find({ type: 'Text', text: /Not undone/ }))).toBeTruthy()
689
+ expect((await ui.find({ type: 'Text', text: /curl -X POST https:\/\/hooks\.example\.com\/notify -d done: sends a request/ }))).toBeTruthy()
690
+
691
+ const report = logs.find((l) => l.includes('Backstep undid'))!
692
+ expect(report).toContain('undid 2 changes from the last turn ("tidy and notify")')
693
+ expect(report).toContain('◀ src/b.ts removed')
694
+ expect(report).toContain('Not undone:\n ✗ curl -X POST')
695
+ await ui.unmount()
696
+ })
@@ -19,6 +19,8 @@ const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as st
19
19
  // Backstep's mark, as on the logo: the front rewind triangle in the terminal's
20
20
  // own black or white, the one behind it in the logo's pink.
21
21
  const PINK = '#ee7bb6'
22
+ // The mark where only plain text goes (a transcript line).
23
+ const MARK_TEXT = '◀◀'
22
24
  // How long the "press again to undo" question waits before it lets go.
23
25
  const CONFIRM_MS = 5000
24
26
 
@@ -71,7 +73,10 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
71
73
  ? `Claude pushed a new branch, ${p.branch}, to ${p.host}. Delete it there too?`
72
74
  : `Claude pushed ${p.commits === 1 ? '1 commit' : `${p.commits} commits`} to ${p.branch} on ${p.host}. Undo ${p.commits === 1 ? 'it' : 'them'} there too?`
73
75
  const answer = await $.ui.ask(what, { header: 'Backstep', options: [p.before === null ? 'Delete the branch' : PUSH_ASK.yes, PUSH_ASK.no] }).catch(() => null)
74
- if (!answer || answer === PUSH_ASK.no) continue
76
+ if (!answer || answer === PUSH_ASK.no) {
77
+ $.ui.log(`${MARK_TEXT} Not undone on ${p.host}: Claude's ${p.before === null ? `branch ${p.branch}` : p.commits === 1 ? 'commit' : `${p.commits} commits`} stay there (you chose to leave it).`)
78
+ continue
79
+ }
75
80
  const ran = await $.process
76
81
  .run(backstep(['revert-push', '--root', o.pushed.root]), { stdin: JSON.stringify(p), timeoutMs: 180_000 })
77
82
  .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
@@ -82,6 +87,7 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
82
87
  // keep the error above
83
88
  }
84
89
  $.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}. Nothing was changed on ${p.host}.`)
90
+ $.ui.log(r.ok ? `${MARK_TEXT} On ${p.host}: ${r.detail}.` : `${MARK_TEXT} Not undone on ${p.host}: ${r.detail}. Nothing was changed there.`)
85
91
  await update($, note, (n) =>
86
92
  n ? `${n}\n${r.ok ? `On ${p.host}: ${r.detail}.` : `${p.host} was left as it was: ${r.detail}.`}` : n,
87
93
  )
@@ -103,7 +109,8 @@ async function pressUndo($: EngineInterface) {
103
109
  const now = await $.clock.now()
104
110
  if (at !== null && now - at < CONFIRM_MS) {
105
111
  await update($, armed, () => null)
106
- return undoLastTurn($)
112
+ const { before } = await undoTargets($)
113
+ return undoLastTurn($, before ? 2 : 1)
107
114
  }
108
115
  await update($, armed, () => now)
109
116
  $.clock.after(CONFIRM_MS, () => {
@@ -111,9 +118,11 @@ async function pressUndo($: EngineInterface) {
111
118
  })
112
119
  }
113
120
 
114
- async function undoLastTurn($: EngineInterface) {
121
+ // Undoes the last `count` turns that changed something, newest first.
122
+ async function undoLastTurn($: EngineInterface, count = 1) {
115
123
  if (await read($, rewind)) return
116
- const target = (await read($, turns)).findLast(hasWork)
124
+ const taken = (await read($, turns)).filter(hasWork).slice(-count)
125
+ const target = taken[0]
117
126
  if (!target) return
118
127
 
119
128
  await update($, rewind, (): Rewind => ({ phase: 'running', frame: 0, shown: 0, items: [], message: '' }))
@@ -165,17 +174,28 @@ async function undoLastTurn($: EngineInterface) {
165
174
  const cut = outcomes.findIndex((o) => !o.ok)
166
175
  if (cut !== -1) outcomes = outcomes.slice(0, cut + 1)
167
176
 
168
- const items: RewindItem[] = outcomes.map((o) => {
169
- const first = o.detail.split('\n')[0] ?? ''
170
- const what = first.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`)
171
- return { label: o.ok ? what || o.tool : `${o.tool}: ${first}`, ok: o.ok }
172
- })
173
- const done = items.filter((i) => i.ok).length
174
- const failed = items.some((i) => !i.ok)
175
- // A push counted as lost when it ran, but undo can now offer to take it back there.
176
- const offered = outcomes.filter((o) => o.ok && o.pushed).length
177
- const lost = Math.max(0, (target.lost ?? 0) - offered)
178
- const shell = lost ? ` ${plural(lost, 'action')} that reached outside couldn't be undone.` : ''
177
+ // Every change undone, one per line, and every one that wasn't, with why.
178
+ const undone: RewindItem[] = outcomes
179
+ .filter((o) => o.ok)
180
+ .flatMap((o) =>
181
+ (o.detail || o.tool)
182
+ .split('\n')
183
+ .filter(Boolean)
184
+ .map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
185
+ )
186
+ const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
187
+ // A push is offered back on GitHub below, so it isn't listed as lost.
188
+ const offered = outcomes.some((o) => o.ok && o.pushed)
189
+ const kept = taken
190
+ .flatMap((t) => t.lostItems ?? [])
191
+ .filter((l) => !(offered && /pushes to a remote/.test(l)))
192
+ .map((label) => ({ label, ok: false }))
193
+ const items: RewindItem[] = [...undone, ...stopped, ...kept]
194
+ const done = undone.length
195
+ const failed = stopped.length > 0
196
+ const lost = kept.length
197
+ const which = taken.length > 1 ? `the last ${taken.length} turns` : 'the last turn'
198
+ const shell = lost ? ` ${plural(lost, 'thing')} couldn't be undone: listed below.` : ''
179
199
 
180
200
  await update($, rewind, (): Rewind => ({
181
201
  phase: failed ? 'failed' : items.length ? 'done' : 'empty',
@@ -185,16 +205,24 @@ async function undoLastTurn($: EngineInterface) {
185
205
  message: failed
186
206
  ? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
187
207
  : items.length
188
- ? `Rewound ${plural(done, 'change')} from the last turn.${shell}`
189
- : `Nothing in the last turn could be undone.${shell}`,
208
+ ? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
209
+ : `Nothing in ${which} could be undone.${shell}`,
190
210
  }))
191
- await bump($, target.id, (t) => ({ ...t, undone: !failed }))
211
+ for (const t of taken) await bump($, t.id, (x) => ({ ...x, undone: !failed }))
212
+
213
+ // The whole account, kept in the conversation, where the band's few rows fold away.
214
+ const report = [
215
+ `${MARK_TEXT} Backstep undid ${plural(done, 'change')} from ${which}${taken.length === 1 ? named(target) : ''}${done ? ':' : '.'}`,
216
+ ...undone.map((i) => ` ◀ ${i.label}`),
217
+ ...(stopped.length || kept.length ? ["Not undone:", ...[...stopped, ...kept].map((i) => ` ✗ ${i.label}`)] : []),
218
+ ]
219
+ $.ui.log(report.join('\n'))
192
220
 
193
221
  if (done) {
194
222
  const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
195
223
  const key = await keysLabel($)
196
224
  await update($, note, () =>
197
- `[Backstep] The user pressed undo (${key}) on your previous turn. These changes were reverted:\n${list}\n` +
225
+ `[Backstep] The user pressed undo (${key}) on your ${taken.length > 1 ? `last ${taken.length} turns` : 'previous turn'}. These changes were reverted:\n${list}\n` +
198
226
  (lost ? `${plural(lost, 'action')} from that turn couldn't be undone (they reached outside this machine with no way back, or the project was too big to snapshot).\n` : '') +
199
227
  `Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
200
228
  )
@@ -207,7 +235,7 @@ async function undoLastTurn($: EngineInterface) {
207
235
  const reveal = $.clock.every(90, () => {
208
236
  void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
209
237
  })
210
- $.clock.after(failed ? 9000 : 4200, () => {
238
+ $.clock.after(failed || lost ? 12_000 : 4200, () => {
211
239
  reveal.cancel()
212
240
  void update($, rewind, () => null)
213
241
  })
@@ -257,7 +285,7 @@ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Ne
257
285
  hint($, plan)
258
286
  // Behind Backstep, or Backstep couldn't be reached: count it as before.
259
287
  if (!plan || plan.kind === 'wrapped' || !plan.id) {
260
- if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, mcp: x.mcp + 1 }))
288
+ if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, gitOnly: false, mcp: x.mcp + 1 }))
261
289
  return next(e)
262
290
  }
263
291
  const r = await next(plan.args ? { ...e, ...plan.args } : e)
@@ -274,7 +302,9 @@ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Ne
274
302
  } catch {
275
303
  undoable = false
276
304
  }
277
- if (!r.isError) await bump($, t.id, (x) => (undoable ? { ...x, mcp: x.mcp + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
305
+ if (!r.isError) {
306
+ await bump($, t.id, (x) => (undoable ? { ...x, gitOnly: false, mcp: x.mcp + 1 } : lostOne({ ...x, gitOnly: false }, `${tool.replace(/^mcp__/, '').replace('__', ' ')}: ${plan.reason ?? 'there is no way to undo it'}`)))
307
+ }
278
308
  return r
279
309
  }
280
310
 
@@ -289,8 +319,40 @@ function backstep(args: string[]): string[] {
289
319
  return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
290
320
  }
291
321
 
292
- function newTurn(startedAt: number): Turn {
293
- return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false }
322
+ function newTurn(startedAt: number, prompt?: string): Turn {
323
+ return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false, ...(prompt ? { prompt } : {}) }
324
+ }
325
+
326
+ // A command as it reads in a list: without its leading `cd`, cut short.
327
+ const brief = (command: string) => {
328
+ const c = command.replace(/^\s*cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*&&\s*/, '').replace(/\s+/g, ' ').trim()
329
+ return c.length > 60 ? `${c.slice(0, 59)}…` : c
330
+ }
331
+
332
+ // Something in a turn that can't be undone, named, so undo can list it.
333
+ const lostOne = (x: Turn, what: string): Turn => ({ ...x, lost: (x.lost ?? 0) + 1, lostItems: [...(x.lostItems ?? []), what].slice(-20) })
334
+
335
+ // A turn named by what the person asked: ("commit and push it").
336
+ const named = (t: Turn) => {
337
+ const p = (t.prompt ?? '').replace(/\s+/g, ' ').trim()
338
+ return p ? ` ("${p.length > 40 ? `${p.slice(0, 39)}…` : p}")` : ''
339
+ }
340
+
341
+ // Each part only saves to git (add, commit, push and friends), or only moves around.
342
+ const GIT_SAVE = /^(git\s+(add|commit|push|status|log|diff|show|branch|remote|fetch|rev-parse|config)\b|cd\s|echo\s|true$)/
343
+ const onlySavesToGit = (command: string) =>
344
+ command
345
+ .split(/&&|\|\||;|\n/)
346
+ .map((p) => p.trim())
347
+ .filter(Boolean)
348
+ .every((p) => GIT_SAVE.test(p))
349
+
350
+ // What one press would take back: the last turn, and, when that turn only
351
+ // committed and pushed, the turn whose work it saved.
352
+ async function undoTargets($: EngineInterface): Promise<{ target: Turn | undefined; before: Turn | undefined }> {
353
+ const list = (await read($, turns)).filter(hasWork)
354
+ const target = list.at(-1)
355
+ return { target, before: target?.gitOnly ? list.at(-2) : undefined }
294
356
  }
295
357
 
296
358
  // The turn tool calls belong to; one opens if none is (a call before any prompt).
@@ -332,7 +394,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
332
394
  const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
333
395
  try {
334
396
  for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
335
- if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, cwd)
397
+ if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, c.dir ?? cwd)
336
398
  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.')
337
399
  }
338
400
  } catch {
@@ -357,9 +419,15 @@ const ASK = {
357
419
  never: "Don't ask for this project",
358
420
  }
359
421
 
360
- // Asked when Claude Code opens a project that uses Supabase and Backstep isn't
361
- // connected to it; the answer is the only thing that connects it.
422
+ // Projects asked about this session: "Not now" means not again until the next one.
423
+ const asked = new Set<string>()
424
+
425
+ // Asked when Claude Code opens a project that uses Supabase, or Claude is about
426
+ // to change one, and Backstep isn't connected to it; the answer is the only
427
+ // thing that connects it.
362
428
  async function askSupabase($: EngineInterface, cwd: string) {
429
+ if (asked.has(cwd)) return
430
+ asked.add(cwd)
363
431
  const answer = await $.ui
364
432
  .ask("This project uses Supabase, but Backstep isn't connected to it, so Claude's changes to its database can't be undone. Connect it?", {
365
433
  header: 'Backstep',
@@ -478,7 +546,7 @@ const CONNECT_TOOL = {
478
546
  },
479
547
  }
480
548
 
481
- type Connected = { service: string; state: string; detail: string }
549
+ type Connected = { service: string; state: string; detail: string; dir?: string }
482
550
 
483
551
  async function connectServices($: EngineInterface, input: { service?: string; connection_string?: string; project_ref?: string }) {
484
552
  const cwd = await $.session.cwd()
@@ -528,7 +596,7 @@ export const register: Register = (on, options) => {
528
596
  on('prompt.submit', async ($, e, next) => {
529
597
  if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
530
598
  const startedAt = await $.clock.now()
531
- await update($, turns, (l) => [...(l ?? []), newTurn(startedAt)].slice(-KEEP_TURNS))
599
+ await update($, turns, (l) => [...(l ?? []), newTurn(startedAt, e.text)].slice(-KEEP_TURNS))
532
600
  const pending = await read($, note)
533
601
  if (!pending) return next(e)
534
602
  await update($, note, () => null)
@@ -554,7 +622,9 @@ export const register: Register = (on, options) => {
554
622
  .run(backstep(['snap', '--session', sid, '--tool', tool, '--cwd', cwd, '--', path]))
555
623
  .catch(() => null)
556
624
  if (snap?.exitCode === 0) {
557
- await bump($, t.id, (x) => ({ ...x, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
625
+ await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
626
+ } else {
627
+ await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split('/').pop()}: Backstep couldn't save a copy first`))
558
628
  }
559
629
  }
560
630
  } else if (tool === 'Bash') {
@@ -562,6 +632,15 @@ export const register: Register = (on, options) => {
562
632
  const t = await current($)
563
633
  const cwd = await $.session.cwd()
564
634
  const sid = await $.session.id()
635
+ // About to change a Supabase project Backstep isn't connected to: ask now,
636
+ // so it can be saved before the command runs.
637
+ const checked = await $.process.run(backstep(['db-check', '--cwd', cwd, '--', command])).catch(() => null)
638
+ try {
639
+ const ask = (JSON.parse(checked?.stdout || '{}') as { ask?: string | null }).ask
640
+ if (ask) await askSupabase($, ask)
641
+ } catch {
642
+ // can't tell: run it as before
643
+ }
565
644
  // Never blocks the command: if Backstep can't be reached it just won't be undoable.
566
645
  const ran = await $.process
567
646
  // A database command copies the database first, which takes longer on a big one.
@@ -575,9 +654,15 @@ export const register: Register = (on, options) => {
575
654
  }
576
655
  if (plan?.kind === 'outbound') {
577
656
  // What it changed here comes back with the project snapshot; what it sent out doesn't (a push is offered back after undo).
578
- await bump($, t.id, (x) => ({ ...x, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0), lost: (x.lost ?? 0) + 1 }))
657
+ const git = onlySavesToGit(command)
658
+ await bump($, t.id, (x) =>
659
+ lostOne({ ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0) }, `${brief(command)}: ${plan.reason ?? 'it reached outside this computer'}`),
660
+ )
579
661
  } else if (plan?.kind === 'write') {
580
- await bump($, t.id, (x) => (plan.undoable ? { ...x, shell: (x.shell ?? 0) + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
662
+ const git = onlySavesToGit(command)
663
+ await bump($, t.id, (x) =>
664
+ plan.undoable ? { ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + 1 } : lostOne({ ...x, gitOnly: false }, `${brief(command)}: ${plan.reason ?? "Backstep couldn't save a copy first"}`),
665
+ )
581
666
  }
582
667
  // A push: once it's run, note what it sent, so undo can offer to take it back there.
583
668
  if (plan?.id && PUSH.test(command)) {
@@ -611,33 +696,63 @@ export const register: Register = (on, options) => {
611
696
  if (r) {
612
697
  const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
613
698
  const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
614
- const visible = r.items.slice(0, Math.min(r.shown, 6))
615
- const more = r.items.length - 6
699
+ // What came back (the first few; the conversation has them all), then everything that didn't.
700
+ const shown = r.items.slice(0, r.shown)
701
+ const back = shown.filter((i) => i.ok)
702
+ const kept = shown.filter((i) => !i.ok)
703
+ const more = r.items.filter((i) => i.ok).length - 5
616
704
  return (
617
705
  <Box flexDirection="column">
618
706
  <Text color={tone} bold>
619
707
  {glyph} {r.message}
620
708
  </Text>
621
- {visible.map((i, n) => (
622
- <Text key={`i${n}`} color={i.ok ? undefined : 'error'} dimColor={i.ok}>
623
- {' '}
624
- {i.ok ? '◀ ' : '✗ '}
709
+ {back.slice(0, 5).map((i, n) => (
710
+ <Text key={`b${n}`} dimColor>
711
+ {' ◀ '}
712
+ {i.label}
713
+ </Text>
714
+ ))}
715
+ {back.length >= 5 && more > 0 ? <Text dimColor>{` and ${more} more (all listed in the conversation)`}</Text> : null}
716
+ {kept.length ? <Text bold>{' Not undone:'}</Text> : null}
717
+ {kept.slice(0, 6).map((i, n) => (
718
+ <Text key={`k${n}`} color="error">
719
+ {' ✗ '}
625
720
  {i.label}
626
721
  </Text>
627
722
  ))}
628
- {r.shown >= 6 && more > 0 ? <Text dimColor>{` and ${more} more`}</Text> : null}
629
723
  </Box>
630
724
  )
631
725
  }
632
726
 
633
- const target = (await read($, turns)).findLast(hasWork)
727
+ const { target, before } = await undoTargets($)
634
728
  const label = (await read($, keys)).map(keyLabel).join(' / ')
635
729
  if (target && (await read($, armed)) !== null) {
730
+ // The last turn only committed and pushed: the work it saved is what the person most likely wants back.
731
+ if (before) {
732
+ return (
733
+ <Box>
734
+ <Text bold>◀</Text>
735
+ <Text color={PINK} bold>◀</Text>
736
+ <Text color="warning" bold> That turn only saved and pushed to git. </Text>
737
+ <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
738
+ {`${label} again to undo it and the turn before${named(before)}`}
739
+ </Button>
740
+ <Text dimColor> · </Text>
741
+ <Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
742
+ just the push
743
+ </Button>
744
+ <Text dimColor> · </Text>
745
+ <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
746
+ cancel
747
+ </Button>
748
+ </Box>
749
+ )
750
+ }
636
751
  return (
637
752
  <Box>
638
753
  <Text bold>◀</Text>
639
754
  <Text color={PINK} bold>◀</Text>
640
- <Text color="warning" bold> Undo Claude's last turn? </Text>
755
+ <Text color="warning" bold>{` Undo Claude's last turn${named(target)}? `}</Text>
641
756
  <Text dimColor>{summary(target)} </Text>
642
757
  <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
643
758
  {`${label} again to undo`}
@@ -658,7 +773,7 @@ export const register: Register = (on, options) => {
658
773
  <Text bold>◀</Text>
659
774
  <Text color={PINK} bold>◀</Text>
660
775
  <Text> </Text>
661
- <Text dimColor>{target ? `Last turn: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
776
+ <Text dimColor>{target ? `Last turn${named(target)}: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
662
777
  <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
663
778
  {`${label} to undo`}
664
779
  </Button>
@@ -10,6 +10,12 @@ export type Turn = {
10
10
  // Shell commands and tool calls whose effects can't be taken back: they reached outside the machine with no undo rule, or the project was too big to snapshot.
11
11
  lost: number
12
12
  undone: boolean
13
+ // What the person asked for, so undo can name the turn it would take back.
14
+ prompt?: string
15
+ // Every change was a git commit or push (saving the turn before's work), so undo offers to take both.
16
+ gitOnly?: boolean
17
+ // What in this turn can't be undone, and why, so undo can say so by name.
18
+ lostItems?: string[]
13
19
  }
14
20
 
15
21
  export type RewindItem = { label: string; ok: boolean }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "scripts": {
6
6
  "dev": "node src/cli.ts",