backstep 0.1.1 → 0.1.3
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 +3 -0
- package/dist/adapters/dbsnap.js +265 -0
- package/dist/cli.js +53 -5
- package/dist/connect.js +18 -1
- package/dist/shell.js +15 -0
- package/dist/store.js +6 -0
- package/dist/telemetry.js +1 -1
- package/dist/undo.js +38 -5
- package/dist/update.js +67 -0
- package/mod/hooks/register.test.tsx +54 -0
- package/mod/hooks/register.tsx +40 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
|
|
|
8
8
|
- **Supabase:** `execute_sql` and `apply_migration` snapshot the tables they change, over a connection to the project's database. Backstep never connects it on its own: when Claude Code opens a project that uses Supabase and isn't connected, it asks (Connect, Not now, or Don't ask for this project). Connect uses a connection string in the project's `.env` files, the local `supabase start` database, or a Supabase sign-in (`supabase login`, `SUPABASE_ACCESS_TOKEN`, or an access token you give Backstep once), with which the project gets a temporary database login that Backstep renews as it runs out. You can also ask Claude to "connect Backstep to Supabase" any time. Deleted rows, dropped tables and migrations, including the migration's history row, all come back. Works for the claude.ai Supabase connector and a local Supabase MCP server alike.
|
|
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
|
+
- **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+z 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.
|
|
11
12
|
- **Code Claude pushed:** ctrl+z 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.
|
|
12
13
|
- **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.
|
|
13
14
|
- **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).
|
|
@@ -52,6 +53,8 @@ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the se
|
|
|
52
53
|
| `--rules <file>` | undo rules (or set `BACKSTEP_RULES`) |
|
|
53
54
|
| `--pg <url>` | the Postgres database the wrapped server writes to. Enables database undo. Without it, SQL writes are recorded but can't be undone. |
|
|
54
55
|
|
|
56
|
+
Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off).
|
|
57
|
+
|
|
55
58
|
Other commands: `backstep ui`, `backstep log`, `backstep undo <action-id>`, `backstep undo --run <run-id>`, `backstep connect [supabase|firebase]` (connects what the project uses, the same as asking Claude), `backstep disconnect supabase <ref>`.
|
|
56
59
|
|
|
57
60
|
In Claude Code you can just ask: "connect Backstep to everything I use". Supabase is connected from the project's settings (or the connection string you paste), and when the project uses Firebase but Claude can't reach it yet, Firebase's MCP server is added behind Backstep.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Database undo for shell commands (`supabase db push`, `supabase migration up`,
|
|
2
|
+
// `psql`, `prisma migrate`, seed scripts…), which change a database Backstep
|
|
3
|
+
// can't see into. Before the command runs, the whole database's own tables are
|
|
4
|
+
// noted:
|
|
5
|
+
// - every table's structure: columns, constraints, indexes, foreign keys,
|
|
6
|
+
// row level security and its policies, triggers, grants, sequence counters;
|
|
7
|
+
// - every table's rows, copied into Backstep's own schema in the same
|
|
8
|
+
// database, smallest tables first, until a size budget is spent. A very big
|
|
9
|
+
// database keeps its structure, its migration history and as many tables'
|
|
10
|
+
// rows as fit; undo names the tables whose rows it couldn't bring back.
|
|
11
|
+
// Undo, in one transaction: drops tables made since, rebuilds tables dropped
|
|
12
|
+
// or reshaped, and puts back rows wherever they changed.
|
|
13
|
+
import pg from "pg";
|
|
14
|
+
import { q } from "./postgres.js";
|
|
15
|
+
const SNAP_SCHEMA = "backstep";
|
|
16
|
+
const budget = () => Number(process.env.BACKSTEP_DB_SNAPSHOT_MB ?? 250) * 1024 * 1024;
|
|
17
|
+
// Supabase's and Postgres's own schemas: managed by them, not by the project's migrations.
|
|
18
|
+
const SYSTEM = [
|
|
19
|
+
"information_schema", "auth", "storage", "realtime", "_realtime", "extensions", "graphql", "graphql_public", "pgsodium",
|
|
20
|
+
"pgsodium_masks", "vault", "net", "supabase_functions", "cron", "pgbouncer", "_analytics", "_supavisor", "pgtle", "topology",
|
|
21
|
+
"tiger", "tiger_data",
|
|
22
|
+
];
|
|
23
|
+
const MIGRATIONS = "supabase_migrations";
|
|
24
|
+
async function withClient(url, fn) {
|
|
25
|
+
const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 10_000 });
|
|
26
|
+
await client.connect();
|
|
27
|
+
try {
|
|
28
|
+
return await fn(client);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await client.end();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function tables(c) {
|
|
35
|
+
const r = await c.query(`SELECT c.oid::int AS oid, n.nspname AS schema, c.relname AS name, pg_total_relation_size(c.oid)::bigint AS bytes
|
|
36
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
37
|
+
WHERE c.relkind = 'r' AND NOT c.relispartition
|
|
38
|
+
AND n.nspname NOT LIKE 'pg\\_%' AND n.nspname NOT LIKE 'backstep%' AND n.nspname <> ALL($1::text[])
|
|
39
|
+
ORDER BY n.nspname, c.relname`, [SYSTEM]);
|
|
40
|
+
return r.rows.map((x) => ({ oid: Number(x.oid), schema: x.schema, name: x.name, bytes: Number(x.bytes) }));
|
|
41
|
+
}
|
|
42
|
+
const full = (t) => `${q(t.schema)}.${q(t.name)}`;
|
|
43
|
+
async function shapeOf(c, oid) {
|
|
44
|
+
const r = await c.query(`SELECT string_agg(attname || ' ' || format_type(atttypid, atttypmod), ', ' ORDER BY attnum) AS s
|
|
45
|
+
FROM pg_attribute WHERE attrelid = $1 AND attnum > 0 AND NOT attisdropped`, [oid]);
|
|
46
|
+
return r.rows[0].s ?? "";
|
|
47
|
+
}
|
|
48
|
+
async function describe(c, t) {
|
|
49
|
+
const name = full(t);
|
|
50
|
+
const cols = await c.query(`SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull, a.attidentity, a.attgenerated,
|
|
51
|
+
pg_get_expr(d.adbin, d.adrelid) AS def
|
|
52
|
+
FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
53
|
+
WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [t.oid]);
|
|
54
|
+
const seqs = await c.query(`SELECT s.oid::regclass::text AS name, a.attname
|
|
55
|
+
FROM pg_depend dep JOIN pg_class s ON s.oid = dep.objid AND s.relkind = 'S'
|
|
56
|
+
JOIN pg_attribute a ON a.attrelid = dep.refobjid AND a.attnum = dep.refobjsubid
|
|
57
|
+
WHERE dep.refobjid = $1 AND dep.deptype IN ('a', 'i')`, [t.oid]);
|
|
58
|
+
const seqValues = [];
|
|
59
|
+
const create = [];
|
|
60
|
+
// Serial columns' sequences go with their table; make them first so the defaults resolve.
|
|
61
|
+
const serial = seqs.rows.filter((s) => !cols.rows.find((col) => col.attname === s.attname)?.attidentity);
|
|
62
|
+
for (const s of serial)
|
|
63
|
+
create.push(`CREATE SEQUENCE IF NOT EXISTS ${s.name}`);
|
|
64
|
+
const defs = cols.rows.map((col) => {
|
|
65
|
+
let d = `${q(col.attname)} ${col.type}`;
|
|
66
|
+
if (col.attgenerated === "s")
|
|
67
|
+
d += ` GENERATED ALWAYS AS (${col.def}) STORED`;
|
|
68
|
+
else if (col.attidentity)
|
|
69
|
+
d += ` GENERATED ${col.attidentity === "a" ? "ALWAYS" : "BY DEFAULT"} AS IDENTITY`;
|
|
70
|
+
else if (col.def !== null)
|
|
71
|
+
d += ` DEFAULT ${col.def}`;
|
|
72
|
+
if (col.attnotnull)
|
|
73
|
+
d += " NOT NULL";
|
|
74
|
+
return d;
|
|
75
|
+
});
|
|
76
|
+
create.push(`CREATE TABLE ${name} (${defs.join(", ")})`);
|
|
77
|
+
for (const s of serial)
|
|
78
|
+
create.push(`ALTER SEQUENCE ${s.name} OWNED BY ${name}.${q(s.attname)}`);
|
|
79
|
+
for (const s of seqs.rows) {
|
|
80
|
+
const v = await c.query(`SELECT last_value::text AS v, is_called FROM ${s.name}`);
|
|
81
|
+
seqValues.push({ name: s.name, value: v.rows[0].v, called: v.rows[0].is_called });
|
|
82
|
+
}
|
|
83
|
+
const cons = await c.query(`SELECT conname, contype, pg_get_constraintdef(oid) AS def FROM pg_constraint
|
|
84
|
+
WHERE conrelid = $1 AND contype IN ('p', 'u', 'c', 'x', 'f') ORDER BY contype = 'p' DESC, conname`, [t.oid]);
|
|
85
|
+
const fks = [];
|
|
86
|
+
for (const k of cons.rows) {
|
|
87
|
+
const sql = `ALTER TABLE ${name} ADD CONSTRAINT ${q(k.conname)} ${k.def}`;
|
|
88
|
+
if (k.contype === "f")
|
|
89
|
+
fks.push({ name: k.conname, sql });
|
|
90
|
+
else
|
|
91
|
+
create.push(sql);
|
|
92
|
+
}
|
|
93
|
+
const idx = await c.query(`SELECT pg_get_indexdef(i.indexrelid) AS def FROM pg_index i
|
|
94
|
+
WHERE i.indrelid = $1 AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conindid = i.indexrelid)`, [t.oid]);
|
|
95
|
+
for (const i of idx.rows)
|
|
96
|
+
create.push(i.def);
|
|
97
|
+
const rls = await c.query(`SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = $1`, [t.oid]);
|
|
98
|
+
if (rls.rows[0].relrowsecurity)
|
|
99
|
+
create.push(`ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY`);
|
|
100
|
+
if (rls.rows[0].relforcerowsecurity)
|
|
101
|
+
create.push(`ALTER TABLE ${name} FORCE ROW LEVEL SECURITY`);
|
|
102
|
+
const pols = await c.query(`SELECT policyname, permissive, cmd, roles::text[] AS roles, qual, with_check FROM pg_policies WHERE schemaname = $1 AND tablename = $2`, [t.schema, t.name]);
|
|
103
|
+
for (const p of pols.rows) {
|
|
104
|
+
const roles = p.roles.map((r) => (r === "public" ? "public" : q(r))).join(", ");
|
|
105
|
+
create.push(`CREATE POLICY ${q(p.policyname)} ON ${name} AS ${p.permissive} FOR ${p.cmd} TO ${roles}` +
|
|
106
|
+
(p.qual ? ` USING (${p.qual})` : "") +
|
|
107
|
+
(p.with_check ? ` WITH CHECK (${p.with_check})` : ""));
|
|
108
|
+
}
|
|
109
|
+
// Supabase's API reaches tables through grants to anon and authenticated.
|
|
110
|
+
const grants = await c.query(`SELECT CASE WHEN g.grantee = 0 THEN 'PUBLIC' ELSE quote_ident(pg_get_userbyid(g.grantee)) END AS who, g.privilege_type AS priv
|
|
111
|
+
FROM pg_class c, aclexplode(c.relacl) g WHERE c.oid = $1 AND g.grantee <> c.relowner`, [t.oid]);
|
|
112
|
+
for (const g of grants.rows)
|
|
113
|
+
create.push(`GRANT ${g.priv} ON ${name} TO ${g.who}`);
|
|
114
|
+
const trig = await c.query(`SELECT pg_get_triggerdef(oid) AS def FROM pg_trigger WHERE tgrelid = $1 AND NOT tgisinternal`, [t.oid]);
|
|
115
|
+
return { schema: t.schema, name: t.name, shape: await shapeOf(c, t.oid), create, triggers: trig.rows.map((x) => x.def), fks, seqs: seqValues };
|
|
116
|
+
}
|
|
117
|
+
// The same rows in any order give the same fingerprint.
|
|
118
|
+
async function fingerprint(c, table) {
|
|
119
|
+
const r = await c.query(`SELECT md5(coalesce(string_agg(h, '' ORDER BY h), '')) AS s FROM (SELECT md5(t::text) AS h FROM ${table} t) x`);
|
|
120
|
+
return r.rows[0].s;
|
|
121
|
+
}
|
|
122
|
+
export async function snapshotDb(actionId, url) {
|
|
123
|
+
return withClient(url, async (c) => {
|
|
124
|
+
const all = await tables(c);
|
|
125
|
+
await c.query(`CREATE SCHEMA IF NOT EXISTS ${q(SNAP_SCHEMA)}`);
|
|
126
|
+
// The migration history first, then the smallest tables: the most tables for the budget.
|
|
127
|
+
const order = [...all].sort((a, b) => Number(b.schema === MIGRATIONS) - Number(a.schema === MIGRATIONS) || a.bytes - b.bytes);
|
|
128
|
+
let spent = 0;
|
|
129
|
+
const copied = new Map();
|
|
130
|
+
let k = 0;
|
|
131
|
+
for (const t of order) {
|
|
132
|
+
if (t.schema !== MIGRATIONS && spent + t.bytes > budget())
|
|
133
|
+
continue;
|
|
134
|
+
spent += t.bytes;
|
|
135
|
+
const copy = `${actionId}_db${k++}`;
|
|
136
|
+
await c.query(`CREATE TABLE ${q(SNAP_SCHEMA)}.${q(copy)} AS TABLE ${full(t)}`);
|
|
137
|
+
copied.set(t.oid, { copy, sum: await fingerprint(c, full(t)) });
|
|
138
|
+
}
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const t of all)
|
|
141
|
+
out.push({ ...(await describe(c, t)), copy: copied.get(t.oid)?.copy ?? null, sum: copied.get(t.oid)?.sum ?? null });
|
|
142
|
+
return { adapter: "db", url, tables: out };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async function insertable(c, table) {
|
|
146
|
+
const r = await c.query(`SELECT string_agg(quote_ident(attname), ', ' ORDER BY attnum) AS cols FROM pg_attribute
|
|
147
|
+
WHERE attrelid = to_regclass($1) AND attnum > 0 AND NOT attisdropped AND attgenerated = ''`, [table]);
|
|
148
|
+
return r.rows[0].cols;
|
|
149
|
+
}
|
|
150
|
+
async function putRows(c, t) {
|
|
151
|
+
const cols = await insertable(c, full(t));
|
|
152
|
+
const r = await c.query(`INSERT INTO ${full(t)} (${cols}) OVERRIDING SYSTEM VALUE SELECT ${cols} FROM ${q(SNAP_SCHEMA)}.${q(t.copy)}`);
|
|
153
|
+
return r.rowCount ?? 0;
|
|
154
|
+
}
|
|
155
|
+
const label = (t) => (t.schema === "public" ? t.name : `${t.schema}.${t.name}`);
|
|
156
|
+
export async function undoDb(url, u) {
|
|
157
|
+
return withClient(url, async (c) => {
|
|
158
|
+
const log = [];
|
|
159
|
+
await c.query("BEGIN");
|
|
160
|
+
try {
|
|
161
|
+
const now = new Map((await tables(c)).map((t) => [`${t.schema}.${t.name}`, t]));
|
|
162
|
+
const was = new Map(u.tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
163
|
+
// Made since: gone again.
|
|
164
|
+
for (const [key, t] of now) {
|
|
165
|
+
if (was.has(key))
|
|
166
|
+
continue;
|
|
167
|
+
await c.query(`DROP TABLE IF EXISTS ${full(t)} CASCADE`);
|
|
168
|
+
log.push(`removed new table ${label(t)}`);
|
|
169
|
+
}
|
|
170
|
+
// Dropped or reshaped since: rebuilt as it was, rows and all.
|
|
171
|
+
const rebuilt = [];
|
|
172
|
+
const changed = [];
|
|
173
|
+
for (const [key, t] of was) {
|
|
174
|
+
const cur = now.get(key);
|
|
175
|
+
if (cur && (await shapeOf(c, cur.oid)) === t.shape) {
|
|
176
|
+
if (t.copy && (await fingerprint(c, full(t))) !== t.sum)
|
|
177
|
+
changed.push(t);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (cur)
|
|
181
|
+
await c.query(`DROP TABLE ${full(t)} CASCADE`);
|
|
182
|
+
for (const sql of t.create)
|
|
183
|
+
await c.query(sql);
|
|
184
|
+
rebuilt.push(t);
|
|
185
|
+
}
|
|
186
|
+
for (const t of rebuilt) {
|
|
187
|
+
const rows = t.copy ? await putRows(c, t) : 0;
|
|
188
|
+
log.push(t.copy
|
|
189
|
+
? `restored table ${label(t)} (${rows} rows)`
|
|
190
|
+
: `restored table ${label(t)}, but not its rows: it was too big to copy`);
|
|
191
|
+
}
|
|
192
|
+
// Rows changed in a table that's still there: put the old ones back. Tables
|
|
193
|
+
// whose rows point at these come along, so no foreign key is left dangling.
|
|
194
|
+
const set = new Map(changed.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
195
|
+
for (let grew = true; grew;) {
|
|
196
|
+
grew = false;
|
|
197
|
+
const refs = await c.query(`SELECT DISTINCT cn.nspname || '.' || cl.relname AS child, pn.nspname || '.' || pl.relname AS parent
|
|
198
|
+
FROM pg_constraint k JOIN pg_class cl ON cl.oid = k.conrelid JOIN pg_namespace cn ON cn.oid = cl.relnamespace
|
|
199
|
+
JOIN pg_class pl ON pl.oid = k.confrelid JOIN pg_namespace pn ON pn.oid = pl.relnamespace WHERE k.contype = 'f'`);
|
|
200
|
+
for (const r of refs.rows) {
|
|
201
|
+
if (!set.has(r.parent) || set.has(r.child) || r.child === r.parent)
|
|
202
|
+
continue;
|
|
203
|
+
const child = was.get(r.child);
|
|
204
|
+
if (!child?.copy)
|
|
205
|
+
throw new Error(`couldn't put back the rows of ${label(set.get(r.parent))}: ${r.child} points at them and was too big to copy`);
|
|
206
|
+
set.set(r.child, child);
|
|
207
|
+
grew = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (set.size) {
|
|
211
|
+
const list = [...set.values()];
|
|
212
|
+
for (const t of list)
|
|
213
|
+
await c.query(`ALTER TABLE ${full(t)} DISABLE TRIGGER USER`);
|
|
214
|
+
await c.query(`TRUNCATE ${list.map(full).join(", ")}`);
|
|
215
|
+
// Parents before the tables that point at them.
|
|
216
|
+
const left = new Set(set.keys());
|
|
217
|
+
const deps = await c.query(`SELECT cn.nspname || '.' || cl.relname AS child, pn.nspname || '.' || pl.relname AS parent
|
|
218
|
+
FROM pg_constraint k JOIN pg_class cl ON cl.oid = k.conrelid JOIN pg_namespace cn ON cn.oid = cl.relnamespace
|
|
219
|
+
JOIN pg_class pl ON pl.oid = k.confrelid JOIN pg_namespace pn ON pn.oid = pl.relnamespace WHERE k.contype = 'f'`);
|
|
220
|
+
while (left.size) {
|
|
221
|
+
const ready = [...left].filter((k) => !deps.rows.some((d) => d.child === k && d.parent !== k && left.has(d.parent)));
|
|
222
|
+
const batch = ready.length ? ready : [...left];
|
|
223
|
+
for (const key of batch) {
|
|
224
|
+
const t = set.get(key);
|
|
225
|
+
log.push(`restored ${await putRows(c, t)} rows in ${label(t)}`);
|
|
226
|
+
left.delete(key);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const t of list)
|
|
230
|
+
await c.query(`ALTER TABLE ${full(t)} ENABLE TRIGGER USER`);
|
|
231
|
+
}
|
|
232
|
+
// Triggers on rebuilt tables once their rows are in, then every foreign key a drop took with it.
|
|
233
|
+
for (const t of rebuilt)
|
|
234
|
+
for (const sql of t.triggers)
|
|
235
|
+
await c.query(sql);
|
|
236
|
+
for (const t of u.tables) {
|
|
237
|
+
for (const fk of t.fks) {
|
|
238
|
+
const has = await c.query(`SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass($1) AND conname = $2`, [full(t), fk.name]);
|
|
239
|
+
if (has.rowCount === 0)
|
|
240
|
+
await c.query(fk.sql);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Counters, so new rows don't collide with restored ones.
|
|
244
|
+
for (const t of [...rebuilt, ...set.values()]) {
|
|
245
|
+
for (const s of t.seqs)
|
|
246
|
+
await c.query(`SELECT setval($1, $2::bigint, $3)`, [s.name, s.value, s.called]);
|
|
247
|
+
}
|
|
248
|
+
await c.query("COMMIT");
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
await c.query("ROLLBACK");
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
await dropCopies(c, u);
|
|
255
|
+
return log.length ? log.join("\n") : "nothing had changed in the database";
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function dropCopies(c, u) {
|
|
259
|
+
for (const t of u.tables)
|
|
260
|
+
if (t.copy)
|
|
261
|
+
await c.query(`DROP TABLE IF EXISTS ${q(SNAP_SCHEMA)}.${q(t.copy)}`);
|
|
262
|
+
}
|
|
263
|
+
export async function discardDb(u) {
|
|
264
|
+
await withClient(u.url, (c) => dropCopies(c, u)).catch(() => { });
|
|
265
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,14 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { runProxy } from "./proxy.js";
|
|
5
5
|
import { startUi } from "./ui/server.js";
|
|
6
6
|
import { Store, snapshotDir } from "./store.js";
|
|
7
|
-
import { undoAction, undoRun, undoSince } from "./undo.js";
|
|
7
|
+
import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
|
|
8
8
|
import { snapshotPaths } from "./adapters/fs.js";
|
|
9
9
|
import { notePushed, snapshotTree } from "./adapters/tree.js";
|
|
10
10
|
import { revertPush } from "./push.js";
|
|
11
|
+
import { snapshotDb } from "./adapters/dbsnap.js";
|
|
12
|
+
import { projectDatabases } from "./connect.js";
|
|
11
13
|
const PUSH = /\bgit\b[^;&|]*\bpush\b/;
|
|
12
|
-
import { classifyShell } from "./shell.js";
|
|
14
|
+
import { classifyShell, supabaseTarget, touchesDatabase } from "./shell.js";
|
|
13
15
|
import { watchEnd, watchStart } from "./watch.js";
|
|
14
16
|
import { sendActive } from "./telemetry.js";
|
|
15
17
|
import { readFileSync, rmSync } from "node:fs";
|
|
@@ -20,6 +22,7 @@ import { disconnectSupabase, dismissSupabase } from "./supabase.js";
|
|
|
20
22
|
import { connect } from "./connect.js";
|
|
21
23
|
import { check, printCheck } from "./check.js";
|
|
22
24
|
import { logo } from "./look.js";
|
|
25
|
+
import { checkUpdate, runUpdate, skipVersion } from "./update.js";
|
|
23
26
|
import { askConnectionString } from "./setup.js";
|
|
24
27
|
// Header values can name env vars, so tokens don't have to sit in agent config files.
|
|
25
28
|
function parseHeaders(list) {
|
|
@@ -57,6 +60,7 @@ const HELP = `backstep: undo for everything your agents touch
|
|
|
57
60
|
Supabase from the project's .env files or local database (or the
|
|
58
61
|
connection string you give), Firebase by adding its MCP server.
|
|
59
62
|
In Claude Code you can just ask: "connect Backstep to my Supabase"
|
|
63
|
+
backstep update install the newest version (Claude Code offers this as a session starts)
|
|
60
64
|
backstep status [--json] what Backstep can and can't undo for each service Claude Code reaches,
|
|
61
65
|
and what to connect next
|
|
62
66
|
backstep disconnect supabase <ref>
|
|
@@ -208,6 +212,27 @@ async function main() {
|
|
|
208
212
|
}
|
|
209
213
|
return;
|
|
210
214
|
}
|
|
215
|
+
// Run by the Claude Code mod as a session starts: is there a newer Backstep?
|
|
216
|
+
if (cmd === "update-check") {
|
|
217
|
+
console.log(JSON.stringify(await checkUpdate()));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// `backstep update` installs the newest version; --skip <v> stops asking about that one.
|
|
221
|
+
if (cmd === "update") {
|
|
222
|
+
const { values } = parseArgs({ args: rest, options: { skip: { type: "string" }, version: { type: "string" }, json: { type: "boolean", default: false } } });
|
|
223
|
+
if (values.skip) {
|
|
224
|
+
skipVersion(values.skip);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const target = values.version ?? (await checkUpdate()).latest;
|
|
228
|
+
if (!target)
|
|
229
|
+
throw new Error("couldn't reach npm to find the newest version");
|
|
230
|
+
const out = runUpdate(target);
|
|
231
|
+
console.log(values.json ? JSON.stringify(out) : out.ok ? `Backstep ${out.detail}. Restart Claude Code to use it.` : `Update failed: ${out.detail}`);
|
|
232
|
+
if (!out.ok)
|
|
233
|
+
process.exitCode = 1;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
211
236
|
if (cmd === "disconnect") {
|
|
212
237
|
const [service, ref] = rest;
|
|
213
238
|
if (service !== "supabase" || !ref)
|
|
@@ -294,12 +319,35 @@ async function main() {
|
|
|
294
319
|
});
|
|
295
320
|
// Outbound commands still get a snapshot: whatever they change locally can come back.
|
|
296
321
|
// A push also notes where the remote branches stood, so undo can take it back there too.
|
|
297
|
-
const
|
|
322
|
+
const cwd = values.cwd ?? process.cwd();
|
|
323
|
+
const tree = snapshotTree(a.id, cwd, { pushes: PUSH.test(command) });
|
|
324
|
+
// A migration, reset, seed or raw SQL: the connected databases it could change are noted too.
|
|
325
|
+
const dbs = [];
|
|
326
|
+
const missed = [];
|
|
327
|
+
if (touchesDatabase(command)) {
|
|
328
|
+
for (const url of await projectDatabases(cwd, supabaseTarget(command))) {
|
|
329
|
+
try {
|
|
330
|
+
dbs.push(await snapshotDb(a.id, url));
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
missed.push(err instanceof Error ? err.message : String(err));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const undo = tree ? { ...tree, ...(dbs.length ? { dbs } : {}) } : dbs.length ? { adapter: "dbs", dbs } : null;
|
|
298
338
|
if (undo)
|
|
299
339
|
store.updateAction(a.id, { undo: JSON.stringify(undo) });
|
|
300
|
-
|
|
340
|
+
// With its database saved, a command that reaches it is no longer lost to undo.
|
|
341
|
+
const kind = plan.kind === "outbound" && dbs.length && plan.reason === "changes a database directly" ? "write" : plan.kind;
|
|
342
|
+
if (kind !== plan.kind)
|
|
343
|
+
store.updateAction(a.id, { kind: "reversible", reason: null });
|
|
344
|
+
if (!undo && kind !== "outbound")
|
|
301
345
|
store.updateAction(a.id, { kind: "write", reason: "project too large to snapshot" });
|
|
302
|
-
|
|
346
|
+
if (missed.length)
|
|
347
|
+
store.updateAction(a.id, { reason: `couldn't save the database first: ${missed[0]}` });
|
|
348
|
+
if (dbs.length)
|
|
349
|
+
await pruneDatabaseCopies(store).catch(() => { });
|
|
350
|
+
console.log(JSON.stringify({ kind, reason: kind === plan.kind ? plan.reason : undefined, id: a.id, undoable: undo !== null, db: dbs.length }));
|
|
303
351
|
return;
|
|
304
352
|
}
|
|
305
353
|
// After a shell command that pushed: notes what it sent (run by the Claude Code mod).
|
package/dist/connect.js
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, 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, supabaseDismissed, supabaseProjects, supabaseToken } from "./supabase.js";
|
|
15
|
+
import { connectSupabase, hasSavedToken, LOCAL, mintSupabase, projectRef, saveSupabaseToken, supabaseDb, 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"];
|
|
@@ -105,6 +105,23 @@ function linkedRef(dir) {
|
|
|
105
105
|
}
|
|
106
106
|
return undefined;
|
|
107
107
|
}
|
|
108
|
+
// The connected databases a command in this folder could change: only ones the
|
|
109
|
+
// person connected, so a database is never touched without their say.
|
|
110
|
+
export async function projectDatabases(dir, target) {
|
|
111
|
+
const out = [];
|
|
112
|
+
const ref = linkedRef(dir);
|
|
113
|
+
if (target !== "local" && ref && supabaseProjects().includes(ref)) {
|
|
114
|
+
const url = await supabaseUrl(ref);
|
|
115
|
+
if (url)
|
|
116
|
+
out.push(url);
|
|
117
|
+
}
|
|
118
|
+
if (target !== "hosted" && projectUses(dir).supabase) {
|
|
119
|
+
const local = supabaseDb(LOCAL);
|
|
120
|
+
if (local)
|
|
121
|
+
out.push(local);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
108
125
|
// Signed in to Supabase: this folder's project connects now, the rest as Claude uses them.
|
|
109
126
|
async function signedIn(dir, token, ref, isNew) {
|
|
110
127
|
const project = ref ?? linkedRef(dir);
|
package/dist/shell.js
CHANGED
|
@@ -110,3 +110,18 @@ export function classifyShell(command) {
|
|
|
110
110
|
return { kind: "read" };
|
|
111
111
|
return { kind: "write" };
|
|
112
112
|
}
|
|
113
|
+
// Commands that change a database through its own tools rather than Claude's:
|
|
114
|
+
// migrations, resets, seeds and raw SQL.
|
|
115
|
+
const DATABASE = /(^|[\s;&|(])(npx\s+|pnpm\s+(dlx\s+)?|bunx\s+|yarn\s+)?(supabase\s+(db|migration|seed)\b|psql\b|pg_restore\b|prisma\s+(migrate|db)\b|drizzle-kit\s+(push|migrate)\b|knex\s+(migrate|seed)|sequelize(-cli)?\s+db:|typeorm\s+(migration|schema)|node-pg-migrate\b|dbmate\b|goose\b|sqlx\s+(migrate|database)\b|alembic\b|(rails|rake)\s+db:|python3?\s+manage\.py\s+(migrate|flush|loaddata)\b)/;
|
|
116
|
+
export function touchesDatabase(command) {
|
|
117
|
+
return DATABASE.test(command);
|
|
118
|
+
}
|
|
119
|
+
// Which of a project's databases a Supabase command aims at: `db push` and
|
|
120
|
+
// --linked mean the hosted project, the rest default to the local one.
|
|
121
|
+
export function supabaseTarget(command) {
|
|
122
|
+
if (!/\bsupabase\s+(db|migration|seed)\b/.test(command))
|
|
123
|
+
return "both";
|
|
124
|
+
if (/--local\b/.test(command))
|
|
125
|
+
return "local";
|
|
126
|
+
return /--linked\b|--db-url\b|\bdb\s+push\b/.test(command) ? "hosted" : "local";
|
|
127
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -114,6 +114,12 @@ export class Store {
|
|
|
114
114
|
const r = this.db.prepare("UPDATE actions SET status = ? WHERE id = ? AND status = ?").run(to, id, from);
|
|
115
115
|
return r.changes === 1;
|
|
116
116
|
}
|
|
117
|
+
// Actions still holding a copy of a database, newest first.
|
|
118
|
+
databaseCopies() {
|
|
119
|
+
return this.db
|
|
120
|
+
.prepare(`SELECT * FROM actions WHERE undo LIKE '%"adapter":"db"%' AND status IN ('ok', 'error') ORDER BY created_at DESC`)
|
|
121
|
+
.all();
|
|
122
|
+
}
|
|
117
123
|
getAction(id) {
|
|
118
124
|
return this.db.prepare("SELECT * FROM actions WHERE id = ?").get(id);
|
|
119
125
|
}
|
package/dist/telemetry.js
CHANGED
|
@@ -29,7 +29,7 @@ function installId() {
|
|
|
29
29
|
writeFileSync(file, id + "\n", { mode: 0o600 });
|
|
30
30
|
return id;
|
|
31
31
|
}
|
|
32
|
-
function version() {
|
|
32
|
+
export function version() {
|
|
33
33
|
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
34
34
|
return JSON.parse(readFileSync(pkg, "utf8")).version;
|
|
35
35
|
}
|
package/dist/undo.js
CHANGED
|
@@ -3,18 +3,45 @@ import { rmSync } from "node:fs";
|
|
|
3
3
|
import { undoPaths } from "./adapters/fs.js";
|
|
4
4
|
import { undoSql } from "./adapters/postgres.js";
|
|
5
5
|
import { undoTree } from "./adapters/tree.js";
|
|
6
|
+
import { discardDb, undoDb } from "./adapters/dbsnap.js";
|
|
6
7
|
import { snapshotDir } from "./store.js";
|
|
7
8
|
import { dbKey, freshUrl } from "./supabase.js";
|
|
8
9
|
const INVERSE_TIMEOUT_MS = 20_000;
|
|
10
|
+
// Database copies sit in the person's own database, so only the newest few per
|
|
11
|
+
// database are kept; older commands keep their file undo but not their database undo.
|
|
12
|
+
const KEEP_DB_COPIES = 10;
|
|
13
|
+
export async function pruneDatabaseCopies(store) {
|
|
14
|
+
const seen = new Map();
|
|
15
|
+
for (const a of store.databaseCopies()) {
|
|
16
|
+
const u = JSON.parse(a.undo);
|
|
17
|
+
const keep = [];
|
|
18
|
+
for (const d of u.dbs ?? []) {
|
|
19
|
+
const k = dbKey(d.url);
|
|
20
|
+
const n = (seen.get(k) ?? 0) + 1;
|
|
21
|
+
seen.set(k, n);
|
|
22
|
+
if (n <= KEEP_DB_COPIES)
|
|
23
|
+
keep.push(d);
|
|
24
|
+
else
|
|
25
|
+
await discardDb({ ...d, url: await freshUrl(d.url) });
|
|
26
|
+
}
|
|
27
|
+
if (keep.length === (u.dbs ?? []).length)
|
|
28
|
+
continue;
|
|
29
|
+
const next = u.adapter === "dbs" ? (keep.length ? { ...u, dbs: keep } : null) : { ...u, dbs: keep.length ? keep : undefined };
|
|
30
|
+
store.updateAction(a.id, { undo: next ? JSON.stringify(next) : null, ...(next ? {} : { kind: "write", reason: "its database copy was cleared to save space" }) });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
9
33
|
function resources(store, a) {
|
|
10
34
|
const u = a.undo ? JSON.parse(a.undo) : null;
|
|
11
35
|
if (!u)
|
|
12
36
|
return new Set();
|
|
13
37
|
if (u.adapter === "fs")
|
|
14
38
|
return new Set(u.entries.map((e) => `fs:${e.path}`));
|
|
15
|
-
// A project snapshot rewinds every file under its folder.
|
|
39
|
+
// A project snapshot rewinds every file under its folder, and any whole database saved with it.
|
|
40
|
+
const whole = (dbs = []) => dbs.map((d) => `pgdb:${dbKey(d.url)}`);
|
|
16
41
|
if (u.adapter === "tree")
|
|
17
|
-
return new Set([`fs:${u.root}
|
|
42
|
+
return new Set([`fs:${u.root}`, ...whole(u.dbs)]);
|
|
43
|
+
if (u.adapter === "dbs")
|
|
44
|
+
return new Set(whole(u.dbs));
|
|
18
45
|
// An opposite tool call doesn't rewind shared state, so it never conflicts.
|
|
19
46
|
if (u.adapter === "inverse" || u.adapter === "engine")
|
|
20
47
|
return new Set();
|
|
@@ -22,7 +49,7 @@ function resources(store, a) {
|
|
|
22
49
|
const url = u.url ?? store.getRun(a.run_id)?.pg_url;
|
|
23
50
|
const db = url ? dbKey(url) : a.run_id;
|
|
24
51
|
const pgKey = (t) => `pg:${db}:${t.schema}.${t.name}`;
|
|
25
|
-
return new Set([...u.snapshots, ...u.trashed, ...u.created].map(pgKey));
|
|
52
|
+
return new Set([...u.snapshots, ...u.trashed, ...u.created].map(pgKey).concat(url ? [`pgdb:${dbKey(url)}`] : []));
|
|
26
53
|
}
|
|
27
54
|
// Same resource, or a file inside a folder that's rewound whole.
|
|
28
55
|
function overlaps(a, b) {
|
|
@@ -102,8 +129,14 @@ async function undoOne(store, a, opts = {}) {
|
|
|
102
129
|
if (u.adapter === "fs") {
|
|
103
130
|
detail = undoPaths(u);
|
|
104
131
|
}
|
|
105
|
-
else if (u.adapter === "tree") {
|
|
106
|
-
|
|
132
|
+
else if (u.adapter === "tree" || u.adapter === "dbs") {
|
|
133
|
+
// The databases first: their undo is one transaction each, so a failure leaves everything as it was.
|
|
134
|
+
const lines = [];
|
|
135
|
+
for (const d of u.dbs ?? [])
|
|
136
|
+
lines.push(await undoDb(await freshUrl(d.url), d));
|
|
137
|
+
if (u.adapter === "tree")
|
|
138
|
+
lines.unshift(undoTree(u, a.id));
|
|
139
|
+
detail = lines.filter((l) => !/^nothing had changed/.test(l)).join("\n") || lines[0];
|
|
107
140
|
}
|
|
108
141
|
else {
|
|
109
142
|
const url = u.url ?? store.getRun(a.run_id)?.pg_url;
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Keeping Backstep current. As each Claude Code session starts, the mod asks
|
|
2
|
+
// whether npm has a newer version; if so it asks the person, and an update
|
|
3
|
+
// runs the new version's own setup, the same command a first install runs.
|
|
4
|
+
// Off with BACKSTEP_UPDATE_CHECK=0.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homeDir } from "./store.js";
|
|
9
|
+
import { version } from "./telemetry.js";
|
|
10
|
+
const REGISTRY = process.env.BACKSTEP_REGISTRY ?? "https://registry.npmjs.org";
|
|
11
|
+
const file = () => join(homeDir(), "settings.json");
|
|
12
|
+
function settings() {
|
|
13
|
+
try {
|
|
14
|
+
return existsSync(file()) ? JSON.parse(readFileSync(file(), "utf8")) : {};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// 1.2.10 > 1.2.9; anything after a dash (a pre-release) counts as older than without.
|
|
21
|
+
export function newer(a, b) {
|
|
22
|
+
const parse = (v) => {
|
|
23
|
+
const [main, pre] = v.split("-", 2);
|
|
24
|
+
return { nums: main.split(".").map((n) => Number(n) || 0), pre: pre ?? null };
|
|
25
|
+
};
|
|
26
|
+
const x = parse(a);
|
|
27
|
+
const y = parse(b);
|
|
28
|
+
for (let i = 0; i < 3; i++)
|
|
29
|
+
if ((x.nums[i] ?? 0) !== (y.nums[i] ?? 0))
|
|
30
|
+
return (x.nums[i] ?? 0) > (y.nums[i] ?? 0);
|
|
31
|
+
return x.pre === null && y.pre !== null;
|
|
32
|
+
}
|
|
33
|
+
// Never slow: a session doesn't wait more than a few seconds on npm.
|
|
34
|
+
export async function checkUpdate() {
|
|
35
|
+
const current = version();
|
|
36
|
+
if (/^(0|false|off|no)$/i.test(process.env.BACKSTEP_UPDATE_CHECK ?? ""))
|
|
37
|
+
return { current, latest: null, available: false };
|
|
38
|
+
let latest = null;
|
|
39
|
+
try {
|
|
40
|
+
const r = await fetch(`${REGISTRY}/backstep/latest`, { signal: AbortSignal.timeout(4000) });
|
|
41
|
+
if (r.ok)
|
|
42
|
+
latest = (await r.json()).version ?? null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// offline: nothing to offer
|
|
46
|
+
}
|
|
47
|
+
const available = latest !== null && newer(latest, current) && settings().skipVersion !== latest;
|
|
48
|
+
return { current, latest, available };
|
|
49
|
+
}
|
|
50
|
+
export function skipVersion(v) {
|
|
51
|
+
mkdirSync(homeDir(), { recursive: true });
|
|
52
|
+
writeFileSync(file(), JSON.stringify({ ...settings(), skipVersion: v }, null, 2) + "\n");
|
|
53
|
+
}
|
|
54
|
+
// The new version's setup puts its own copy in place, points Claude Code's mod
|
|
55
|
+
// at it and re-wraps servers, exactly as a fresh install would.
|
|
56
|
+
export function runUpdate(v) {
|
|
57
|
+
const r = spawnSync("npx", ["-y", `backstep@${v}`, "setup"], {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout: 5 * 60 * 1000,
|
|
60
|
+
env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0" },
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
if (r.status === 0)
|
|
64
|
+
return { ok: true, detail: `updated to ${v}` };
|
|
65
|
+
const why = `${r.stderr ?? ""}${r.stdout ?? ""}`.trim().split("\n").filter(Boolean).at(-1) ?? "npx failed";
|
|
66
|
+
return { ok: false, detail: why.replace(/^backstep: /, "") };
|
|
67
|
+
}
|
|
@@ -172,16 +172,23 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
172
172
|
tools.push(e.name)
|
|
173
173
|
return { value: { tool: `mcp__backstep__${e.name}` } }
|
|
174
174
|
})
|
|
175
|
+
const logs: string[] = []
|
|
176
|
+
on('ui.log', (_, e) => {
|
|
177
|
+
logs.push(e.text)
|
|
178
|
+
return { value: undefined }
|
|
179
|
+
})
|
|
175
180
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
176
181
|
|
|
177
182
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
178
183
|
await new Promise((r) => setTimeout(r, 10))
|
|
179
184
|
expect(tools).toEqual(['connect'])
|
|
185
|
+
expect(logs[0]).toContain('Backstep connected')
|
|
180
186
|
expect(runs).toEqual([
|
|
181
187
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
|
|
182
188
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
|
|
183
189
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
|
|
184
190
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'status', '--quick', '--json', '--dir', '/repo'],
|
|
191
|
+
['node', '--disable-warning=ExperimentalWarning', CLI, 'update-check'],
|
|
185
192
|
])
|
|
186
193
|
expect(toasts.find((t) => t.includes('linear is now behind Backstep'))).toBeTruthy()
|
|
187
194
|
// Supabase gets its own question, and only in a project that uses it.
|
|
@@ -430,3 +437,50 @@ test('after undoing a turn that pushed, Backstep asks and pushes the undo', { op
|
|
|
430
437
|
expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).not.toContain("couldn't be undone")
|
|
431
438
|
await ui.unmount()
|
|
432
439
|
})
|
|
440
|
+
|
|
441
|
+
function updates(on: On, answer: string) {
|
|
442
|
+
const runs: string[][] = []
|
|
443
|
+
const toasts: string[] = []
|
|
444
|
+
const asked: string[] = []
|
|
445
|
+
on('process.run', (_, e) => {
|
|
446
|
+
runs.push([...e.argv])
|
|
447
|
+
const stdout = e.argv.includes('update-check')
|
|
448
|
+
? JSON.stringify({ current: '0.1.2', latest: '0.2.0', available: true })
|
|
449
|
+
: e.argv.includes('update') && e.argv.includes('--json')
|
|
450
|
+
? JSON.stringify({ ok: true, detail: 'updated to 0.2.0' })
|
|
451
|
+
: e.argv.includes('status')
|
|
452
|
+
? JSON.stringify({ items: [], suggest: [] })
|
|
453
|
+
: '[]'
|
|
454
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
455
|
+
})
|
|
456
|
+
on('ui.toast', (_, e) => {
|
|
457
|
+
toasts.push(e.text)
|
|
458
|
+
return { value: undefined }
|
|
459
|
+
})
|
|
460
|
+
on('ui.log', () => ({ value: undefined }))
|
|
461
|
+
on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
|
|
462
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
463
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
464
|
+
asked.push(questions[0].question)
|
|
465
|
+
return { result: { questions, answers: { [questions[0].question]: answer } } }
|
|
466
|
+
})
|
|
467
|
+
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
468
|
+
return { runs, toasts, asked }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
test('a newer version is offered as a session starts, and Update installs it', { options: { cli: CLI } }, async ($, on) => {
|
|
472
|
+
const { runs, toasts, asked } = updates(on, 'Update')
|
|
473
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
474
|
+
await settle()
|
|
475
|
+
expect(asked).toEqual(['Backstep 0.2.0 is out (you have 0.1.2). Update now?'])
|
|
476
|
+
expect(runs.some((r) => r.slice(3).join(' ') === 'update --version 0.2.0 --json')).toBe(true)
|
|
477
|
+
expect(toasts.at(-1)).toBe('Backstep updated to 0.2.0. Restart Claude Code to use it.')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('Skip this version stops asking about it, and Not now changes nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
481
|
+
const { runs } = updates(on, 'Skip this version')
|
|
482
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
483
|
+
await settle()
|
|
484
|
+
expect(runs.some((r) => r.slice(3).join(' ') === 'update --skip 0.2.0')).toBe(true)
|
|
485
|
+
expect(runs.some((r) => r.includes('--version'))).toBe(false)
|
|
486
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -298,7 +298,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
|
|
|
298
298
|
const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
|
|
299
299
|
try {
|
|
300
300
|
for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
|
|
301
|
-
if (c.service === 'supabase' && c.state === 'ask')
|
|
301
|
+
if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, cwd)
|
|
302
302
|
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.')
|
|
303
303
|
}
|
|
304
304
|
} catch {
|
|
@@ -352,6 +352,39 @@ async function askSupabase($: EngineInterface, cwd: string) {
|
|
|
352
352
|
$.ui.toast('Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.')
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
|
|
356
|
+
|
|
357
|
+
// A newer Backstep on npm: asked as a session starts, installed only on a yes.
|
|
358
|
+
async function offerUpdate($: EngineInterface) {
|
|
359
|
+
const ran = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
|
|
360
|
+
let u: { current?: string; latest?: string | null; available?: boolean } = {}
|
|
361
|
+
try {
|
|
362
|
+
u = JSON.parse(ran?.stdout || '{}') as typeof u
|
|
363
|
+
} catch {
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
if (!u.available || !u.latest) return
|
|
367
|
+
const answer = await $.ui
|
|
368
|
+
.ask(`Backstep ${u.latest} is out (you have ${u.current}). Update now?`, { header: 'Backstep', options: [UPDATE.yes, UPDATE.later, UPDATE.skip] })
|
|
369
|
+
.catch(() => null)
|
|
370
|
+
if (answer === UPDATE.skip) {
|
|
371
|
+
await $.process.run(backstep(['update', '--skip', u.latest])).catch(() => null)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (answer !== UPDATE.yes) return
|
|
375
|
+
$.ui.toast(`Updating Backstep to ${u.latest}…`)
|
|
376
|
+
const done = await $.process
|
|
377
|
+
.run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 300_000 })
|
|
378
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
379
|
+
let r: { ok: boolean; detail: string } = { ok: false, detail: (done.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
|
|
380
|
+
try {
|
|
381
|
+
r = JSON.parse(done.stdout) as typeof r
|
|
382
|
+
} catch {
|
|
383
|
+
// keep the error above
|
|
384
|
+
}
|
|
385
|
+
$.ui.toast(r.ok ? `Backstep ${r.detail}. Restart Claude Code to use it.` : `Backstep couldn't update: ${r.detail}. Try: npx backstep@latest setup`)
|
|
386
|
+
}
|
|
387
|
+
|
|
355
388
|
const CONNECT = 'mcp__backstep__connect'
|
|
356
389
|
|
|
357
390
|
const CONNECT_TOOL = {
|
|
@@ -403,9 +436,12 @@ export const register: Register = (on, options) => {
|
|
|
403
436
|
on('session.start', async ($, e, next) => {
|
|
404
437
|
await $.tool.register(CONNECT_TOOL)
|
|
405
438
|
const r = await next(e)
|
|
439
|
+
// Said first thing, so nobody has to wonder whether it's running.
|
|
440
|
+
$.ui.log("✓ Backstep connected · press ctrl+z to undo Claude's last turn")
|
|
406
441
|
// In the background: a session never waits on either.
|
|
407
442
|
void wrapNewServers($, e.cwd)
|
|
408
|
-
|
|
443
|
+
// One question at a time: Supabase first, then any update.
|
|
444
|
+
void checkCoverage($, e.cwd).then(() => offerUpdate($))
|
|
409
445
|
void $.process.run(backstep(['ping'])).catch(() => null)
|
|
410
446
|
return r
|
|
411
447
|
})
|
|
@@ -449,7 +485,8 @@ export const register: Register = (on, options) => {
|
|
|
449
485
|
const sid = await $.session.id()
|
|
450
486
|
// Never blocks the command: if Backstep can't be reached it just won't be undoable.
|
|
451
487
|
const ran = await $.process
|
|
452
|
-
|
|
488
|
+
// A database command copies the database first, which takes longer on a big one.
|
|
489
|
+
.run(backstep(['shell', '--session', sid, '--cwd', cwd, '--', command]), { timeoutMs: 180_000 })
|
|
453
490
|
.catch(() => null)
|
|
454
491
|
let plan: ShellPlan | null = null
|
|
455
492
|
try {
|