backstep 0.1.8 → 0.1.9

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.9",
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": {
@@ -43,6 +43,7 @@ test('an edit is snapshotted and the band offers to undo the turn', { options: {
43
43
 
44
44
  expect(runs).toEqual([
45
45
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'snap', '--session', 's1', '--tool', 'Edit', '--cwd', '/repo', '--', '/repo/src/a.ts'],
46
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'db-check', '--cwd', '/repo', '--', 'ls'],
46
47
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'shell', '--session', 's1', '--cwd', '/repo', '--', 'ls'],
47
48
  ])
48
49
 
@@ -566,3 +567,42 @@ test('with two keys bound the band names both', { options: { cli: CLI } }, async
566
567
  expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u / ctrl+q to undo')
567
568
  await ui.unmount()
568
569
  })
570
+
571
+ test('about to change a Supabase project that is not connected, Backstep asks first, then saves it', { options: { cli: CLI } }, async ($, on) => {
572
+ mock.clock(on, { now: 5000 })
573
+ const runs: string[][] = []
574
+ const asked: string[] = []
575
+ on('session.cwd', () => ({ value: '/work' }))
576
+ on('session.id', () => ({ value: 's1' }))
577
+ on('ui.toast', () => ({ value: undefined }))
578
+ on('process.run', (_, e) => {
579
+ runs.push([...e.argv])
580
+ const stdout = e.argv.includes('db-check')
581
+ ? JSON.stringify({ ask: '/work/taskflow' })
582
+ : e.argv.includes('connect')
583
+ ? JSON.stringify([{ service: 'supabase', state: 'connected', detail: "connected with the project's own Supabase keys" }])
584
+ : e.argv.includes('shell')
585
+ ? JSON.stringify({ kind: 'write', id: 'r1', undoable: true, db: 1 })
586
+ : '[]'
587
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
588
+ })
589
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
590
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
591
+ asked.push(questions[0].question)
592
+ return { result: { questions, answers: { [questions[0].question]: 'Connect' } } }
593
+ })
594
+ on('tool.call', () => ({ result: {} }))
595
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
596
+
597
+ await $.prompt.submit({ text: 'seed it', wait: false, origin: { kind: 'composer' } })
598
+ const command = 'cd taskflow && curl -X POST "$NEXT_PUBLIC_SUPABASE_URL/rest/v1/tasks"'
599
+ await $.tool.call({ tool: 'Bash', command })
600
+ expect(asked).toHaveLength(1)
601
+ const order = runs.map((r) => r[3])
602
+ expect(order).toEqual(['db-check', 'connect', 'shell'])
603
+ expect(runs[1].slice(3)).toEqual(['connect', 'supabase', '--dir', '/work/taskflow', '--json'])
604
+
605
+ // Asked once a session per project, whatever the answer.
606
+ await $.tool.call({ tool: 'Bash', command })
607
+ expect(asked).toHaveLength(1)
608
+ })
@@ -332,7 +332,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
332
332
  const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
333
333
  try {
334
334
  for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
335
- if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, cwd)
335
+ if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, c.dir ?? cwd)
336
336
  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
337
  }
338
338
  } catch {
@@ -357,9 +357,15 @@ const ASK = {
357
357
  never: "Don't ask for this project",
358
358
  }
359
359
 
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.
360
+ // Projects asked about this session: "Not now" means not again until the next one.
361
+ const asked = new Set<string>()
362
+
363
+ // Asked when Claude Code opens a project that uses Supabase, or Claude is about
364
+ // to change one, and Backstep isn't connected to it; the answer is the only
365
+ // thing that connects it.
362
366
  async function askSupabase($: EngineInterface, cwd: string) {
367
+ if (asked.has(cwd)) return
368
+ asked.add(cwd)
363
369
  const answer = await $.ui
364
370
  .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
371
  header: 'Backstep',
@@ -478,7 +484,7 @@ const CONNECT_TOOL = {
478
484
  },
479
485
  }
480
486
 
481
- type Connected = { service: string; state: string; detail: string }
487
+ type Connected = { service: string; state: string; detail: string; dir?: string }
482
488
 
483
489
  async function connectServices($: EngineInterface, input: { service?: string; connection_string?: string; project_ref?: string }) {
484
490
  const cwd = await $.session.cwd()
@@ -562,6 +568,15 @@ export const register: Register = (on, options) => {
562
568
  const t = await current($)
563
569
  const cwd = await $.session.cwd()
564
570
  const sid = await $.session.id()
571
+ // About to change a Supabase project Backstep isn't connected to: ask now,
572
+ // so it can be saved before the command runs.
573
+ const checked = await $.process.run(backstep(['db-check', '--cwd', cwd, '--', command])).catch(() => null)
574
+ try {
575
+ const ask = (JSON.parse(checked?.stdout || '{}') as { ask?: string | null }).ask
576
+ if (ask) await askSupabase($, ask)
577
+ } catch {
578
+ // can't tell: run it as before
579
+ }
565
580
  // Never blocks the command: if Backstep can't be reached it just won't be undoable.
566
581
  const ran = await $.process
567
582
  // A database command copies the database first, which takes longer on a big one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",