backstep 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # Backstep
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Undo for everything your agents touch. Backstep sits between your agent (Claude Code, Cursor, any MCP client) and the MCP servers it uses. Before a tool changes something, Backstep saves a way back. When an agent goes wrong, you undo the action, or the whole run, from a dashboard.
4
+
5
+ - **Files:** every path a tool is about to touch is copied first. Writes, edits, deletes, creates and moves all roll back.
6
+ - **Postgres:** `INSERT`/`UPDATE`/`DELETE`/`TRUNCATE` snapshot the table first. `DROP TABLE` never really drops: the table is moved to a `backstep_trash` schema with its indexes, constraints and sequences, so undo brings it back exactly. `CREATE TABLE` is undone by dropping the new table.
7
+ - **Shell commands Claude runs:** before each one, the project folder is saved into a hidden git history under `~/.backstep/trees`, along with the project's own branches, HEAD and staged files. `rm`, `git reset --hard`, scripts that rewrite files and new branches all roll back. Commands that only read are skipped.
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
+ - **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
+ - **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
+ - **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
+ - **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
+ - **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).
14
+ - **Conflicts:** undoing an action first undoes any later action that touched the same file or table, so nothing gets silently thrown away.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npx backstep setup
20
+ ```
21
+
22
+ This installs Backstep, adds its Claude Code mod, binds **ctrl+z** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too. If you use Supabase, it asks for your database's connection string (Enter skips; you can ask Claude to connect it later). Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
23
+
24
+ Servers in a repo's shared `.mcp.json` that you've approved get a wrapped copy for that project only; the repo's file is never edited. Remote servers that Claude Code signs in to stay as they are, and the mod watches their calls instead. Servers you add later are wrapped the next time Claude Code starts. `backstep unwrap` puts every server back the way it was and stops new ones being wrapped.
25
+
26
+ ## Try it
27
+
28
+ ```sh
29
+ npm install
30
+ npm run demo # throwaway Postgres on :5544 with a customers table; prints the claude mcp add line
31
+ npm run ui # dashboard at http://localhost:4747
32
+ ```
33
+
34
+ Then ask Claude to drop the customers table, and press **Undo** in the dashboard.
35
+
36
+ ## Wrap any MCP server
37
+
38
+ ```sh
39
+ backstep wrap --name files -- npx -y @modelcontextprotocol/server-filesystem ~/project
40
+ backstep wrap --name db --pg "$DATABASE_URL" -- npx -y some-postgres-mcp-server
41
+ backstep wrap --name github --url https://api.githubcopilot.com/mcp/ \
42
+ --header 'Authorization: Bearer ${GITHUB_TOKEN}' --rules examples/rules.json
43
+ ```
44
+
45
+ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the server command>`.
46
+
47
+ | Option | |
48
+ |---|---|
49
+ | `--name` | label in the dashboard |
50
+ | `--url <url>` | wrap a remote MCP server (Streamable HTTP, falls back to SSE) |
51
+ | `--header "K: V"` | header for the remote server, repeatable. `${VAR}` is read from the environment so tokens stay out of config files |
52
+ | `--rules <file>` | undo rules (or set `BACKSTEP_RULES`) |
53
+ | `--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
+ 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
+
57
+ 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.
58
+
59
+ During development, run `node src/cli.ts …` (Node 24 runs the TypeScript directly). `npm run build` compiles to `dist/`.
60
+
61
+ ## Undo rules
62
+
63
+ For tools Backstep can't snapshot, a rule names the tool that undoes them. `{{args.x}}` comes from the original call; `{{result.x}}` comes from its result (structured content, or the text parsed as JSON).
64
+
65
+ ```json
66
+ { "rules": [
67
+ { "server": "github", "tool": "create_issue",
68
+ "inverse": { "tool": "update_issue",
69
+ "args": { "owner": "{{args.owner}}", "repo": "{{args.repo}}",
70
+ "issue_number": "{{result.number}}", "state": "closed" } } }
71
+ ] }
72
+ ```
73
+
74
+ `server` is optional and matches `--name`. For claude.ai connectors and servers Claude Code signs in to, put rules in `~/.backstep/rules.json`; `server` is the name as it appears in tool names (`claude_ai_Notion`), and pressing ctrl+z runs the opposite call through Claude Code. A rule overrides the built-in guess, so a `send_message` with a retract rule is undoable. The undo runs through a live `backstep wrap` of that server; if none is running, the dashboard tells you to start it, and any running copy with the same `--name` picks up the request. `examples/rules.json` has GitHub rules as a starting point. Check the tool and argument names against your server's version.
75
+
76
+ ## How it decides
77
+
78
+ Each call is classified before it runs (`src/classify.ts`):
79
+
80
+ 0. **Undo rules** match first, then **Firebase** tools.
81
+ 1. **SQL** (an argument like `sql`/`query` that reads like SQL): judged by the statements, not the tool name. Reads pass through.
82
+ 2. **Reads:** `readOnlyHint`, or names like `get_*`, `list_*`, `read_*`, `search_*`.
83
+ 3. **Irreversible:** names like `send_*`, `pay`, `deploy`, `execute_command`, or `destructiveHint`/`openWorldHint` with nothing to snapshot. These run and are marked, not undone.
84
+ 4. **Paths:** arguments named `path`, `source`, `destination`… get file snapshots.
85
+ 5. Anything else is recorded as a write with no undo.
86
+
87
+ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override with `BACKSTEP_HOME`). File snapshots live in `~/.backstep/snapshots/`, Postgres snapshots in the `backstep` schema of your database.
88
+
89
+ ## Known limits (MVP)
90
+
91
+ - `ALTER TABLE`, writing CTEs, `TRUNCATE … CASCADE`, functions and other DDL run but aren't undone.
92
+ - Table snapshots are full copies, capped at ~1M rows; changes to bigger tables aren't undoable. Files are capped at 50 MB per call.
93
+ - Supabase changes need the database connected. Without it they run as normal and the Claude Code mod says once to ask Claude to connect it. The connection string is kept in `~/.backstep/connections.json` (mode 0600).
94
+ - Firebase undo needs the Firebase server running behind Backstep (it is after setup). Custom claims on users, Storage, Cloud Messaging and deploys aren't undone. Firestore undo puts the whole document back, so a change someone else made to it in between is lost too.
95
+ - Google's own Firestore MCP server, connected with sign-in, is watched rather than wrapped, so its changes aren't undone yet.
96
+ - Undoing a table restore runs `DELETE` and then re-inserts rows, so foreign keys from other tables pointing at deleted rows can block it. The undo then fails cleanly inside a transaction.
97
+ - `--pg` must point at the same database the wrapped server uses.
98
+ - The dashboard only accepts requests from this machine. The journal stores the `--pg` URL so undo works later.
99
+ - Remote servers that need an OAuth login can't be wrapped; they're watched from Claude Code instead, so their undo works with ctrl+z but not from the dashboard.
100
+ - Shell snapshots cover the project folder (the git repository, or the folder Claude Code runs in). Files outside it, and ignored folders like `node_modules`, aren't saved: undo restores `package.json` and the lockfile, and you re-run the install.
101
+ - Shell commands are judged before they run by what they look like, so a script that pushes or deploys from inside is snapshotted, not held.
102
+
103
+ ## Telemetry
104
+
105
+ `backstep setup` sends one anonymous `install` event, and Claude Code with the Backstep mod sends one `active` event a day. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
106
+
107
+ Turn it off with `BACKSTEP_TELEMETRY=0`, or the standard `DO_NOT_TRACK=1`.
108
+
109
+ ## Tests
110
+
111
+ ```sh
112
+ npm test
113
+ ```
114
+
115
+ Runs the official filesystem MCP server and a SQL MCP server on PGlite through the real proxy, plus a remote HTTP server that requires an API key. Then it checks undo, conflict chains, undo rules and dashboard request checks. Supabase is checked against a real Postgres (PGlite), through both the connector path and the proxy, and Firebase against a stand-in server with the real tool names.
116
+
117
+ ## Landing page
118
+
119
+ `site/index.html` is a static page with no build step. Open it directly, or deploy the folder with `vercel deploy site`. Set `ACCESS_EMAIL` at the top of its script before it goes live; until then the early-access form says access opens soon.
@@ -0,0 +1,66 @@
1
+ // File undo: copy every path a tool is about to touch, then put it all back.
2
+ // Restoring "the state each path was in" handles writes, edits, deletes,
3
+ // creates and moves with one rule.
4
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { snapshotDir } from "../store.js";
8
+ const MAX_BYTES = 50 * 1024 * 1024;
9
+ export function resolvePath(p, cwd) {
10
+ if (p === "~" || p.startsWith("~/"))
11
+ return join(homedir(), p.slice(1));
12
+ return resolve(cwd, p);
13
+ }
14
+ function sizeOf(p, budget) {
15
+ const st = lstatSync(p);
16
+ if (!st.isDirectory())
17
+ return st.size;
18
+ let total = 0;
19
+ for (const name of readdirSync(p)) {
20
+ total += sizeOf(join(p, name), budget - total);
21
+ if (total > budget)
22
+ return total;
23
+ }
24
+ return total;
25
+ }
26
+ // Returns null when a path is too large to copy; the caller then can't promise undo.
27
+ export function snapshotPaths(actionId, paths, cwd) {
28
+ const dir = snapshotDir(actionId);
29
+ const seen = new Set();
30
+ const entries = [];
31
+ let budget = MAX_BYTES;
32
+ for (const raw of paths) {
33
+ const path = resolvePath(raw, cwd);
34
+ if (seen.has(path))
35
+ continue;
36
+ seen.add(path);
37
+ if (!existsSync(path)) {
38
+ entries.push({ path, existed: false, snapshot: null });
39
+ continue;
40
+ }
41
+ const size = sizeOf(path, budget);
42
+ if (size > budget)
43
+ return null;
44
+ budget -= size;
45
+ const snapshot = join(dir, String(entries.length));
46
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ cpSync(path, snapshot, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
48
+ entries.push({ path, existed: true, snapshot });
49
+ }
50
+ return { adapter: "fs", entries };
51
+ }
52
+ export function undoPaths(undo) {
53
+ const restored = [];
54
+ for (const e of undo.entries) {
55
+ rmSync(e.path, { recursive: true, force: true });
56
+ if (e.existed && e.snapshot) {
57
+ mkdirSync(dirname(e.path), { recursive: true });
58
+ cpSync(e.snapshot, e.path, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
59
+ restored.push(`restored ${e.path}`);
60
+ }
61
+ else {
62
+ restored.push(`removed ${e.path}`);
63
+ }
64
+ }
65
+ return restored.join("\n");
66
+ }
@@ -0,0 +1,220 @@
1
+ // Postgres undo.
2
+ // - INSERT / UPDATE / DELETE / TRUNCATE: copy the table into the `backstep`
3
+ // schema first; undo puts the rows back.
4
+ // - DROP TABLE: never actually dropped. Backstep runs the call itself and moves
5
+ // the table (with its indexes, constraints and sequences) into `backstep_trash`.
6
+ // - CREATE TABLE: undo drops the new table.
7
+ // Where Backstep can't run SQL in place of the server (Supabase runs it
8
+ // through its own API), the DROP is rewritten into that move instead.
9
+ // Anything else (ALTER, CTE writes, TRUNCATE ... CASCADE, functions) is reported
10
+ // as irreversible instead of pretending.
11
+ import pg from "pg";
12
+ import { parseSql } from "../sql.js";
13
+ const MAX_ROWS = 1_000_000;
14
+ const SNAP_SCHEMA = "backstep";
15
+ const TRASH_SCHEMA = "backstep_trash";
16
+ export function q(ident) {
17
+ return `"${ident.replaceAll('"', '""')}"`;
18
+ }
19
+ async function withClient(url, fn) {
20
+ const client = new pg.Client({ connectionString: url });
21
+ await client.connect();
22
+ try {
23
+ return await fn(client);
24
+ }
25
+ finally {
26
+ await client.end();
27
+ }
28
+ }
29
+ async function resolveTable(c, name) {
30
+ const r = await c.query(`SELECT n.nspname AS schema, c.relname AS name, c.reltuples::bigint AS rows
31
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
32
+ WHERE c.oid = to_regclass($1) AND c.relkind IN ('r', 'p')`, [name]);
33
+ if (r.rowCount === 0)
34
+ return null;
35
+ return { schema: r.rows[0].schema, name: r.rows[0].name, rows: Number(r.rows[0].rows) };
36
+ }
37
+ function key(t) {
38
+ return `${t.schema}.${t.name}`;
39
+ }
40
+ const UNSUPPORTED = {
41
+ alter: "ALTER TABLE changes structure, which Backstep can't snapshot yet",
42
+ unknown: "it contains a statement Backstep can't snapshot",
43
+ };
44
+ export async function planSql(url, actionId, sql, opts = {}) {
45
+ const statements = parseSql(sql);
46
+ for (const s of statements) {
47
+ const why = UNSUPPORTED[s.op];
48
+ if (why)
49
+ return { mode: "irreversible", reason: `${why}: ${s.text.slice(0, 80)}` };
50
+ }
51
+ const hasDrop = statements.some((s) => s.op === "drop");
52
+ return withClient(url, async (c) => {
53
+ const undo = { adapter: "pg", snapshots: [], trashed: [], created: [] };
54
+ const snapped = new Set();
55
+ const createdHere = new Set();
56
+ const snapshot = async (raw) => {
57
+ const t = await resolveTable(c, raw);
58
+ if (!t || snapped.has(key(t)) || createdHere.has(key(t)))
59
+ return;
60
+ if (t.rows > MAX_ROWS)
61
+ throw new TooBig(`${key(t)} has about ${t.rows} rows, over the ${MAX_ROWS} snapshot limit`);
62
+ const snap = `${actionId}_${undo.snapshots.length}`;
63
+ await c.query(`CREATE SCHEMA IF NOT EXISTS ${q(SNAP_SCHEMA)}`);
64
+ await c.query(`CREATE TABLE ${q(SNAP_SCHEMA)}.${q(snap)} AS TABLE ${q(t.schema)}.${q(t.name)}`);
65
+ snapped.add(key(t));
66
+ undo.snapshots.push({ schema: t.schema, name: t.name, snapshot: snap });
67
+ };
68
+ const noteCreate = async (raw) => {
69
+ const exists = await resolveTable(c, raw);
70
+ if (exists)
71
+ return;
72
+ // The table doesn't exist yet, so resolve its future name by hand.
73
+ const parts = raw.split(".").map((p) => p.replace(/^"|"$/g, ""));
74
+ const [schema, name] = parts.length === 2 ? parts : [(await c.query("SELECT current_schema() AS s")).rows[0].s, parts[0]];
75
+ const n = /^".*"$/.test(raw.split(".").at(-1)) ? name : name.toLowerCase();
76
+ createdHere.add(`${schema}.${n}`);
77
+ undo.created.push({ schema, name: n });
78
+ };
79
+ try {
80
+ for (const t of opts.alsoSnapshot ?? [])
81
+ await snapshot(t);
82
+ if (!hasDrop) {
83
+ for (const s of statements) {
84
+ if (s.op === "create")
85
+ await Promise.all(s.tables.map(noteCreate));
86
+ else if (s.op !== "read")
87
+ for (const t of s.tables)
88
+ await snapshot(t);
89
+ }
90
+ return { mode: "forward", undo };
91
+ }
92
+ if (opts.rewrite) {
93
+ // The server runs the call as one transaction, so the moves only stick if it all does.
94
+ const out = [];
95
+ for (const s of statements) {
96
+ if (s.op !== "drop") {
97
+ if (s.op === "create")
98
+ for (const t of s.tables)
99
+ await noteCreate(t);
100
+ else if (s.op !== "read")
101
+ for (const t of s.tables)
102
+ await snapshot(t);
103
+ out.push(s.text);
104
+ continue;
105
+ }
106
+ for (const raw of s.tables) {
107
+ const t = await resolveTable(c, raw);
108
+ // Missing: let the server say so. Made in this same call: nothing to keep.
109
+ if (!t || createdHere.has(key(t))) {
110
+ out.push(`DROP TABLE ${s.ifExists ? "IF EXISTS " : ""}${raw}`);
111
+ continue;
112
+ }
113
+ const trash = `${actionId}_${undo.trashed.length}_${t.name}`.slice(0, 63);
114
+ out.push(`CREATE SCHEMA IF NOT EXISTS ${q(TRASH_SCHEMA)}`, `ALTER TABLE ${q(t.schema)}.${q(t.name)} RENAME TO ${q(trash)}`, `ALTER TABLE ${q(t.schema)}.${q(trash)} SET SCHEMA ${q(TRASH_SCHEMA)}`);
115
+ undo.trashed.push({ schema: t.schema, name: t.name, trash });
116
+ }
117
+ }
118
+ return { mode: "rewrite", undo, sql: out.join(";\n") + ";" };
119
+ }
120
+ // A DROP is in there, so run the whole call here inside one transaction.
121
+ const done = [];
122
+ await c.query("BEGIN");
123
+ try {
124
+ for (const s of statements) {
125
+ if (s.op === "drop") {
126
+ for (const raw of s.tables) {
127
+ const t = await resolveTable(c, raw);
128
+ if (!t) {
129
+ if (s.ifExists)
130
+ continue;
131
+ throw new Error(`table "${raw}" does not exist`);
132
+ }
133
+ if (createdHere.has(key(t))) {
134
+ await c.query(`DROP TABLE ${q(t.schema)}.${q(t.name)}`);
135
+ continue;
136
+ }
137
+ const trash = `${actionId}_${undo.trashed.length}_${t.name}`.slice(0, 63);
138
+ await c.query(`CREATE SCHEMA IF NOT EXISTS ${q(TRASH_SCHEMA)}`);
139
+ await c.query(`ALTER TABLE ${q(t.schema)}.${q(t.name)} RENAME TO ${q(trash)}`);
140
+ await c.query(`ALTER TABLE ${q(t.schema)}.${q(trash)} SET SCHEMA ${q(TRASH_SCHEMA)}`);
141
+ undo.trashed.push({ schema: t.schema, name: t.name, trash });
142
+ }
143
+ done.push(`DROP TABLE (moved to ${TRASH_SCHEMA})`);
144
+ continue;
145
+ }
146
+ if (s.op === "create")
147
+ for (const t of s.tables)
148
+ await noteCreate(t);
149
+ else if (s.op !== "read")
150
+ for (const t of s.tables)
151
+ await snapshot(t);
152
+ const r = await c.query(s.text);
153
+ done.push(`${r.command ?? s.op.toUpperCase()}${r.rowCount != null ? ` ${r.rowCount}` : ""}`);
154
+ }
155
+ await c.query("COMMIT");
156
+ }
157
+ catch (err) {
158
+ await c.query("ROLLBACK");
159
+ throw err;
160
+ }
161
+ return { mode: "self", undo, summary: done.join("\n") };
162
+ }
163
+ catch (err) {
164
+ if (err instanceof TooBig)
165
+ return { mode: "irreversible", reason: err.message };
166
+ throw err;
167
+ }
168
+ });
169
+ }
170
+ class TooBig extends Error {
171
+ }
172
+ async function insertableColumns(c, schema, name) {
173
+ const r = await c.query(`SELECT a.attname FROM pg_attribute a
174
+ WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped AND a.attgenerated = ''
175
+ ORDER BY a.attnum`, [`${q(schema)}.${q(name)}`]);
176
+ return r.rows.map((row) => row.attname);
177
+ }
178
+ export async function undoSql(url, undo) {
179
+ return withClient(url, async (c) => {
180
+ const log = [];
181
+ await c.query("BEGIN");
182
+ try {
183
+ for (const t of [...undo.created].reverse()) {
184
+ await c.query(`DROP TABLE IF EXISTS ${q(t.schema)}.${q(t.name)}`);
185
+ log.push(`dropped new table ${t.schema}.${t.name}`);
186
+ }
187
+ for (const t of [...undo.trashed].reverse()) {
188
+ if (await resolveTable(c, `${q(t.schema)}.${q(t.name)}`)) {
189
+ throw new Error(`can't bring back ${t.schema}.${t.name}: a table with that name exists again`);
190
+ }
191
+ await c.query(`ALTER TABLE ${q(TRASH_SCHEMA)}.${q(t.trash)} SET SCHEMA ${q(t.schema)}`);
192
+ await c.query(`ALTER TABLE ${q(t.schema)}.${q(t.trash)} RENAME TO ${q(t.name)}`);
193
+ log.push(`restored dropped table ${t.schema}.${t.name}`);
194
+ }
195
+ for (const s of undo.snapshots) {
196
+ const cols = (await insertableColumns(c, s.schema, s.name)).map(q).join(", ");
197
+ const target = `${q(s.schema)}.${q(s.name)}`;
198
+ await c.query(`DELETE FROM ${target}`);
199
+ const r = await c.query(`INSERT INTO ${target} (${cols}) OVERRIDING SYSTEM VALUE SELECT ${cols} FROM ${q(SNAP_SCHEMA)}.${q(s.snapshot)}`);
200
+ log.push(`restored ${r.rowCount} rows in ${s.schema}.${s.name}`);
201
+ }
202
+ await c.query("COMMIT");
203
+ }
204
+ catch (err) {
205
+ await c.query("ROLLBACK");
206
+ throw err;
207
+ }
208
+ for (const s of undo.snapshots)
209
+ await c.query(`DROP TABLE IF EXISTS ${q(SNAP_SCHEMA)}.${q(s.snapshot)}`);
210
+ return log.join("\n");
211
+ });
212
+ }
213
+ export async function discardSnapshots(url, undo) {
214
+ if (undo.snapshots.length === 0)
215
+ return;
216
+ await withClient(url, async (c) => {
217
+ for (const s of undo.snapshots)
218
+ await c.query(`DROP TABLE IF EXISTS ${q(SNAP_SCHEMA)}.${q(s.snapshot)}`);
219
+ });
220
+ }
@@ -0,0 +1,171 @@
1
+ // Project undo for shell commands: before a command runs, the whole project
2
+ // folder is committed into a hidden git repository Backstep keeps for it under
3
+ // ~/.backstep/trees, and the project's own git state (branches, HEAD, index)
4
+ // is noted. Git stores only what changed, so this stays fast on big projects.
5
+ // Undo puts the folder and the git state back the way they were.
6
+ import { createHash } from "node:crypto";
7
+ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, resolve } from "node:path";
10
+ import { execFileSync } from "node:child_process";
11
+ import { homeDir, snapshotDir } from "../store.js";
12
+ const TIMEOUT_MS = 20_000;
13
+ // Too big to copy and rebuilt by a command anyway. A project's own .gitignore is honoured too.
14
+ const EXCLUDE = ["node_modules/", ".venv/", "venv/", "__pycache__/", ".next/", ".nuxt/", ".turbo/", ".cache/", ".DS_Store"];
15
+ const ENV = {
16
+ ...process.env,
17
+ GIT_AUTHOR_NAME: "backstep",
18
+ GIT_AUTHOR_EMAIL: "backstep@localhost",
19
+ GIT_COMMITTER_NAME: "backstep",
20
+ GIT_COMMITTER_EMAIL: "backstep@localhost",
21
+ GIT_TERMINAL_PROMPT: "0",
22
+ };
23
+ function git(args, opts = {}) {
24
+ const pre = ["-c", "core.autocrlf=false", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"];
25
+ if (opts.gitDir)
26
+ pre.push("--git-dir", opts.gitDir);
27
+ if (opts.workTree)
28
+ pre.push("--work-tree", opts.workTree);
29
+ return execFileSync("git", [...pre, ...args], {
30
+ cwd: opts.cwd ?? opts.workTree,
31
+ env: ENV,
32
+ encoding: "utf8",
33
+ timeout: TIMEOUT_MS,
34
+ maxBuffer: 64 * 1024 * 1024,
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ }).trim();
37
+ }
38
+ function tryGit(args, cwd) {
39
+ try {
40
+ return git(args, { cwd });
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ // The project a command runs in: its git repository, or the folder itself.
47
+ export function projectRoot(cwd) {
48
+ return tryGit(["rev-parse", "--show-toplevel"], cwd) ?? resolve(cwd);
49
+ }
50
+ function shadowFor(root) {
51
+ const gitDir = join(homeDir(), "trees", createHash("sha256").update(root).digest("hex").slice(0, 16) + ".git");
52
+ if (!existsSync(join(gitDir, "HEAD"))) {
53
+ mkdirSync(gitDir, { recursive: true, mode: 0o700 });
54
+ git(["init", "--bare", "--quiet", gitDir], { cwd: root });
55
+ mkdirSync(join(gitDir, "info"), { recursive: true });
56
+ writeFileSync(join(gitDir, "info", "exclude"), EXCLUDE.join("\n") + "\n");
57
+ writeFileSync(join(gitDir, "description"), `Backstep snapshots of ${root}\n`);
58
+ }
59
+ return gitDir;
60
+ }
61
+ // Stages the folder as it is now in the hidden repository's index and returns its tree.
62
+ function stage(gitDir, root) {
63
+ git(["add", "--all", "--ignore-errors", "."], { gitDir, workTree: root });
64
+ return git(["write-tree"], { gitDir, workTree: root });
65
+ }
66
+ function refsUnder(root, prefix) {
67
+ const refs = {};
68
+ for (const line of (tryGit(["for-each-ref", "--format=%(refname) %(objectname)", prefix], root) ?? "").split("\n")) {
69
+ const [name, sha] = line.split(" ");
70
+ if (name && sha && !name.endsWith("/HEAD"))
71
+ refs[name] = sha;
72
+ }
73
+ return refs;
74
+ }
75
+ function projectGit(root, actionId, pushes) {
76
+ const dotGit = tryGit(["rev-parse", "--absolute-git-dir"], root);
77
+ if (!dotGit)
78
+ return null;
79
+ const head = tryGit(["symbolic-ref", "-q", "HEAD"], root) ?? tryGit(["rev-parse", "HEAD"], root) ?? "";
80
+ const refs = {};
81
+ for (const line of (tryGit(["for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags"], root) ?? "").split("\n")) {
82
+ const [name, sha] = line.split(" ");
83
+ if (name && sha)
84
+ refs[name] = sha;
85
+ }
86
+ let index = null;
87
+ if (existsSync(join(dotGit, "index"))) {
88
+ const dir = snapshotDir(actionId);
89
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
90
+ index = join(dir, "index");
91
+ copyFileSync(join(dotGit, "index"), index);
92
+ }
93
+ return { head, refs, index, ...(pushes ? { remotes: refsUnder(root, "refs/remotes") } : {}) };
94
+ }
95
+ // Returns null when the folder can't be snapshotted: the home folder or the
96
+ // whole disk, or a folder too big to stage in time.
97
+ export function snapshotTree(actionId, cwd, opts = {}) {
98
+ const root = projectRoot(cwd);
99
+ if (root === "/" || root === homedir() || root === resolve(homeDir()))
100
+ return null;
101
+ try {
102
+ const gitDir = shadowFor(root);
103
+ const tree = stage(gitDir, root);
104
+ const commit = git(["commit-tree", tree, "-m", actionId], { gitDir, workTree: root });
105
+ // A ref per snapshot keeps it from being garbage collected until it's undone.
106
+ git(["update-ref", `refs/backstep/${actionId}`, commit], { gitDir, workTree: root });
107
+ return { adapter: "tree", root, gitDir, commit, git: projectGit(root, actionId, opts.pushes === true) };
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ // After a command that pushes: each remote branch that moved, and how far.
114
+ export function notePushed(u) {
115
+ const before = u.git?.remotes;
116
+ if (!before)
117
+ return [];
118
+ const out = [];
119
+ for (const [ref, after] of Object.entries(refsUnder(u.root, "refs/remotes"))) {
120
+ const was = before[ref] ?? null;
121
+ if (was === after)
122
+ continue;
123
+ const [remote, ...rest] = ref.replace(/^refs\/remotes\//, "").split("/");
124
+ const commits = was ? Number(tryGit(["rev-list", "--count", `${was}..${after}`], u.root) ?? "0") : Number(tryGit(["rev-list", "--count", after], u.root) ?? "0");
125
+ out.push({ remote, branch: rest.join("/"), before: was, after, commits, host: remoteHost(u.root, remote) });
126
+ }
127
+ return out;
128
+ }
129
+ function remoteHost(root, remote) {
130
+ const url = tryGit(["remote", "get-url", remote], root) ?? "";
131
+ const host = url.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)/i)?.[1] ?? "";
132
+ return /github\.com$/i.test(host) ? "GitHub" : /gitlab/i.test(host) ? "GitLab" : /bitbucket/i.test(host) ? "Bitbucket" : remote;
133
+ }
134
+ export function undoTree(u, actionId) {
135
+ const { root, gitDir } = u;
136
+ // Stage what's there now, then move the index and the folder to the snapshot:
137
+ // changed and deleted files come back, files made since are removed, and
138
+ // ignored files are left alone.
139
+ stage(gitDir, root);
140
+ const before = git(["diff", "--cached", "--name-status", u.commit], { gitDir, workTree: root });
141
+ git(["read-tree", "--reset", "-u", u.commit], { gitDir, workTree: root });
142
+ git(["update-ref", "-d", `refs/backstep/${actionId}`], { gitDir, workTree: root });
143
+ const lines = [];
144
+ for (const row of before.split("\n").filter(Boolean)) {
145
+ const [status, path] = row.split("\t");
146
+ // The diff reads from the snapshot to now: A was made since, so it's removed.
147
+ lines.push(`${status === "A" ? "removed" : "restored"} ${join(root, path)}`);
148
+ }
149
+ if (u.git) {
150
+ const now = tryGit(["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags"], root) ?? "";
151
+ for (const name of now.split("\n").filter(Boolean)) {
152
+ if (!(name in u.git.refs)) {
153
+ git(["update-ref", "-d", name], { cwd: root });
154
+ lines.push(`removed ${name.replace(/^refs\/(heads|tags)\//, "")}`);
155
+ }
156
+ }
157
+ for (const [name, sha] of Object.entries(u.git.refs)) {
158
+ if (tryGit(["rev-parse", "-q", "--verify", name], root) !== sha) {
159
+ git(["update-ref", name, sha], { cwd: root });
160
+ lines.push(`restored ${name.replace(/^refs\/(heads|tags)\//, "")}`);
161
+ }
162
+ }
163
+ if (u.git.head.startsWith("refs/"))
164
+ git(["symbolic-ref", "HEAD", u.git.head], { cwd: root });
165
+ else if (u.git.head)
166
+ git(["update-ref", "--no-deref", "HEAD", u.git.head], { cwd: root });
167
+ if (u.git.index)
168
+ copyFileSync(u.git.index, join(git(["rev-parse", "--absolute-git-dir"], { cwd: root }), "index"));
169
+ }
170
+ return lines.length ? lines.join("\n") : `nothing had changed in ${root}`;
171
+ }