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 +1 -0
- package/dist/adapters/rest.js +170 -0
- package/dist/check.js +4 -3
- package/dist/cli.js +41 -9
- package/dist/connect.js +70 -10
- package/dist/shell.js +16 -0
- package/dist/supabase.js +6 -0
- package/dist/undo.js +11 -3
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +131 -3
- package/mod/hooks/register.tsx +157 -42
- package/mod/types/index.d.ts +6 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
return { service: "supabase", state: "
|
|
152
|
-
|
|
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];
|
|
@@ -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(
|
|
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
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
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
|
|
189
|
-
: `Nothing in
|
|
208
|
+
? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
|
|
209
|
+
: `Nothing in ${which} could be undone.${shell}`,
|
|
190
210
|
}))
|
|
191
|
-
await bump($,
|
|
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 ?
|
|
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)
|
|
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
|
-
//
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
const
|
|
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
|
-
{
|
|
622
|
-
<Text key={`
|
|
623
|
-
{' '}
|
|
624
|
-
{i.
|
|
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 =
|
|
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>
|
package/mod/types/index.d.ts
CHANGED
|
@@ -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 }
|