backstep 0.1.9 → 0.1.13

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
@@ -10,7 +10,11 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
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
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).
13
+ - **Any Postgres database** (Neon, Railway, Render, AWS, a local one) named in the project's `.env` (`DATABASE_URL` and the like): Backstep asks once, then saves the whole database before Claude's database commands (`psql`, `prisma migrate`, `drizzle-kit push`, seed and app scripts) and puts it back on undo, the same way as Supabase.
14
+ - **SQLite files** in the project (`dev.db`, `*.sqlite`), git-ignored ones included: copied before commands that could change them and restored on undo; a database file made since is removed.
15
+ - **Firebase data changed outside its MCP server** (`firebase firestore:delete`, `firebase database:set`, firebase-admin scripts, curl to Firestore): Backstep asks once and connects with the project's service account key (or gcloud's application default login). Before such a command, Firestore's documents (top-level collections first, then subcollections, up to the size budget) and the Realtime Database are saved; undo deletes documents added since, writes back changed or deleted ones, and puts the Realtime Database back.
13
16
  - **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.
17
+ - **Things Claude makes on GitHub with `gh`** (a repository, an issue, a pull request, a release): after undo, Backstep asks whether to take each one away there too (delete the repository or release, close the pull request, delete or close the issue), using your own gh sign-in. Deleting a repository needs gh's `delete_repo` permission; if it's missing, Backstep says how to grant it.
14
18
  - **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.
15
19
  - **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).
16
20
  - **Conflicts:** undoing an action first undoes any later action that touched the same file or table, so nothing gets silently thrown away.
@@ -0,0 +1,160 @@
1
+ // Firebase undo for shell commands (`firebase firestore:delete`, `firebase
2
+ // database:set`, scripts using firebase-admin, curl to Firestore), signed in
3
+ // with the project's service account or Google's application default login.
4
+ // Before such a command, Firestore's documents (top-level collections first,
5
+ // then subcollections, up to the size budget) and the Realtime Database's tree
6
+ // are kept on this machine; undo deletes documents added since and writes the
7
+ // old ones back, and puts the Realtime Database back as it was.
8
+ import { createSign } from "node:crypto";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { snapshotDir } from "../store.js";
12
+ const FIRESTORE = () => process.env.BACKSTEP_FIRESTORE_API ?? "https://firestore.googleapis.com/v1";
13
+ const TOKEN_URL = () => process.env.BACKSTEP_GOOGLE_TOKEN_URL ?? "https://oauth2.googleapis.com/token";
14
+ const SCOPES = "https://www.googleapis.com/auth/datastore https://www.googleapis.com/auth/firebase.database https://www.googleapis.com/auth/userinfo.email";
15
+ const budget = () => Number(process.env.BACKSTEP_DB_SNAPSHOT_MB ?? 250) * 1024 * 1024;
16
+ const MAX_DOCS = 50_000;
17
+ const b64 = (s) => Buffer.from(s).toString("base64url");
18
+ export async function accessToken(creds) {
19
+ let body;
20
+ if (creds.type === "service_account") {
21
+ const now = Math.floor(Date.now() / 1000);
22
+ const head = b64(JSON.stringify({ alg: "RS256", typ: "JWT" }));
23
+ const claims = b64(JSON.stringify({ iss: creds.client_email, scope: SCOPES, aud: TOKEN_URL(), iat: now, exp: now + 3600 }));
24
+ const sig = createSign("RSA-SHA256").update(`${head}.${claims}`).sign(creds.private_key);
25
+ body = new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${head}.${claims}.${b64(sig)}` });
26
+ }
27
+ else {
28
+ body = new URLSearchParams({ grant_type: "refresh_token", client_id: creds.client_id, client_secret: creds.client_secret, refresh_token: creds.refresh_token });
29
+ }
30
+ const r = await fetch(TOKEN_URL(), { method: "POST", body, signal: AbortSignal.timeout(20_000) });
31
+ if (!r.ok)
32
+ throw new Error(`Google refused the Firebase sign-in (${r.status})`);
33
+ return (await r.json()).access_token;
34
+ }
35
+ export const loadCreds = (file) => JSON.parse(readFileSync(file, "utf8"));
36
+ async function call(token, url, init = {}) {
37
+ const r = await fetch(url, {
38
+ ...init,
39
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers },
40
+ signal: AbortSignal.timeout(30_000),
41
+ });
42
+ if (!r.ok && r.status !== 404)
43
+ throw new Error(`Firebase said ${r.status}: ${(await r.text()).slice(0, 200)}`);
44
+ return r;
45
+ }
46
+ const root = (project) => `${FIRESTORE()}/projects/${project}/databases/(default)/documents`;
47
+ async function collectionIds(token, parent) {
48
+ const out = [];
49
+ let pageToken;
50
+ do {
51
+ const r = await call(token, `${parent}:listCollectionIds`, { method: "POST", body: JSON.stringify({ pageSize: 300, ...(pageToken ? { pageToken } : {}) }) });
52
+ if (r.status === 404)
53
+ return out;
54
+ const j = (await r.json());
55
+ out.push(...(j.collectionIds ?? []));
56
+ pageToken = j.nextPageToken;
57
+ } while (pageToken);
58
+ return out;
59
+ }
60
+ async function documents(token, project, path) {
61
+ const out = [];
62
+ let pageToken;
63
+ do {
64
+ const r = await call(token, `${root(project)}/${path}?pageSize=300${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`);
65
+ if (r.status === 404)
66
+ return out;
67
+ const j = (await r.json());
68
+ out.push(...(j.documents ?? []).map((d) => ({ name: d.name, fields: d.fields ?? {} })));
69
+ pageToken = j.nextPageToken;
70
+ } while (pageToken);
71
+ return out;
72
+ }
73
+ const docPath = (project, name) => name.slice(name.indexOf("/documents/") + 11);
74
+ export async function snapshotFirebase(actionId, conn) {
75
+ const token = await accessToken(loadCreds(conn.credsFile));
76
+ const dir = join(snapshotDir(actionId), "firebase");
77
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
78
+ const out = { adapter: "firebase", project: conn.project, collections: [], skipped: [], rtdb: null };
79
+ let spent = 0;
80
+ let count = 0;
81
+ // Top-level collections first, then deeper ones, so a big database keeps the most important first.
82
+ const queue = (await collectionIds(token, root(conn.project))).map((c) => c);
83
+ while (queue.length) {
84
+ const path = queue.shift();
85
+ const docs = await documents(token, conn.project, path);
86
+ const data = JSON.stringify(docs);
87
+ if (spent + data.length > budget() || count + docs.length > MAX_DOCS) {
88
+ out.skipped.push(path);
89
+ continue;
90
+ }
91
+ spent += data.length;
92
+ count += docs.length;
93
+ const file = join(dir, `${out.collections.length}.json`);
94
+ writeFileSync(file, data, { mode: 0o600 });
95
+ out.collections.push({ path, file });
96
+ for (const d of docs)
97
+ for (const sub of await collectionIds(token, d.name.startsWith("http") ? d.name : `${FIRESTORE()}/${d.name}`))
98
+ queue.push(`${docPath(conn.project, d.name)}/${sub}`);
99
+ }
100
+ if (conn.rtdb) {
101
+ const r = await call(token, `${conn.rtdb.replace(/\/$/, "")}/.json`);
102
+ if (r.ok) {
103
+ const tree = await r.text();
104
+ if (spent + tree.length <= budget()) {
105
+ const file = join(dir, "rtdb.json");
106
+ writeFileSync(file, tree, { mode: 0o600 });
107
+ out.rtdb = { url: conn.rtdb, file };
108
+ }
109
+ else
110
+ out.skipped.push("the Realtime Database");
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ const same = (a, b) => JSON.stringify(a.fields ?? {}) === JSON.stringify(b.fields ?? {});
116
+ export async function undoFirebase(u, credsFile) {
117
+ const token = await accessToken(loadCreds(credsFile));
118
+ const writes = [];
119
+ const log = [];
120
+ const kept = new Set(u.collections.map((c) => c.path));
121
+ // Top-level collections Claude made since: their documents go.
122
+ for (const c of await collectionIds(token, root(u.project))) {
123
+ if (kept.has(c) || u.skipped.includes(c))
124
+ continue;
125
+ const docs = await documents(token, u.project, c);
126
+ for (const d of docs)
127
+ writes.push({ delete: d.name });
128
+ if (docs.length)
129
+ log.push(`removed ${docs.length} documents from the new collection ${c}`);
130
+ }
131
+ for (const c of u.collections) {
132
+ const was = JSON.parse(readFileSync(c.file, "utf8"));
133
+ const now = await documents(token, u.project, c.path);
134
+ const before = new Map(was.map((d) => [d.name, d]));
135
+ const after = new Map(now.map((d) => [d.name, d]));
136
+ const del = now.filter((d) => !before.has(d.name));
137
+ const put = was.filter((d) => !after.has(d.name) || !same(after.get(d.name), d));
138
+ for (const d of del)
139
+ writes.push({ delete: d.name });
140
+ for (const d of put)
141
+ writes.push({ update: { name: d.name, fields: d.fields ?? {} } });
142
+ const parts = [put.length ? `put back ${put.length} documents` : "", del.length ? `removed ${del.length} new documents` : ""].filter(Boolean);
143
+ if (parts.length)
144
+ log.push(`${c.path}: ${parts.join(", ")}`);
145
+ }
146
+ for (let i = 0; i < writes.length; i += 500) {
147
+ await call(token, `${root(u.project)}:commit`, { method: "POST", body: JSON.stringify({ writes: writes.slice(i, i + 500) }) });
148
+ }
149
+ if (u.rtdb && existsSync(u.rtdb.file)) {
150
+ const was = readFileSync(u.rtdb.file, "utf8");
151
+ const now = await (await call(token, `${u.rtdb.url.replace(/\/$/, "")}/.json`)).text();
152
+ if (now !== was) {
153
+ await call(token, `${u.rtdb.url.replace(/\/$/, "")}/.json`, { method: "PUT", body: was });
154
+ log.push("put the Realtime Database back as it was");
155
+ }
156
+ }
157
+ for (const s of u.skipped)
158
+ log.push(`couldn't put back ${s}: it was too big to copy`);
159
+ return log.length ? log.join("\n") : "nothing had changed in Firebase";
160
+ }
@@ -4,11 +4,103 @@
4
4
  // is noted. Git stores only what changed, so this stays fast on big projects.
5
5
  // Undo puts the folder and the git state back the way they were.
6
6
  import { createHash } from "node:crypto";
7
- import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { join, resolve } from "node:path";
9
+ import { isAbsolute, join, relative, resolve } from "node:path";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { homeDir, snapshotDir } from "../store.js";
12
+ const SQLITE_BUDGET = () => Number(process.env.BACKSTEP_DB_SNAPSHOT_MB ?? 250) * 1024 * 1024;
13
+ const SKIP_DIRS = /^(node_modules|\.git|\.next|\.nuxt|\.turbo|\.cache|dist|build|out|coverage|\.venv|venv|__pycache__)$/;
14
+ function isSqlite(path) {
15
+ try {
16
+ const fd = openSync(path, "r");
17
+ const head = Buffer.alloc(16);
18
+ readSync(fd, head, 0, 16, 0);
19
+ closeSync(fd);
20
+ return head.toString("latin1") === "SQLite format 3\0";
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ // SQLite database files in a project, four folders deep at most.
27
+ export function sqliteFiles(root) {
28
+ const out = [];
29
+ const walk = (dir, depth) => {
30
+ let entries = [];
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ for (const e of entries) {
38
+ const p = join(dir, e.name);
39
+ if (e.isDirectory()) {
40
+ if (depth > 0 && !SKIP_DIRS.test(e.name))
41
+ walk(p, depth - 1);
42
+ }
43
+ else if (/\.(db|sqlite3?|db3)$/i.test(e.name) && isSqlite(p))
44
+ out.push(p);
45
+ }
46
+ };
47
+ walk(root, 4);
48
+ return out;
49
+ }
50
+ export function snapshotSqlite(actionId, root) {
51
+ const dir = join(snapshotDir(actionId), "sqlite");
52
+ const files = [];
53
+ const skipped = [];
54
+ let spent = 0;
55
+ for (const path of sqliteFiles(root)) {
56
+ // A database's write-ahead log goes with it.
57
+ for (const p of [path, `${path}-wal`, `${path}-shm`]) {
58
+ if (!existsSync(p)) {
59
+ if (p === path)
60
+ files.push({ path, copy: null });
61
+ continue;
62
+ }
63
+ const size = statSync(p).size;
64
+ if (spent + size > SQLITE_BUDGET()) {
65
+ if (p === path)
66
+ skipped.push(path);
67
+ continue;
68
+ }
69
+ spent += size;
70
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ const copy = join(dir, `${files.length}`);
72
+ copyFileSync(p, copy);
73
+ files.push({ path: p, copy });
74
+ }
75
+ }
76
+ return { files, ...(skipped.length ? { budgetSkipped: skipped } : {}) };
77
+ }
78
+ function undoSqlite(u, root) {
79
+ const lines = [];
80
+ const had = new Set(u.files.map((f) => f.path));
81
+ for (const f of u.files) {
82
+ if (f.copy) {
83
+ copyFileSync(f.copy, f.path);
84
+ if (!f.path.endsWith("-wal") && !f.path.endsWith("-shm"))
85
+ lines.push(`restored the database ${f.path}`);
86
+ }
87
+ }
88
+ // Write-ahead logs made since would replay newer writes over the restored file.
89
+ for (const f of u.files) {
90
+ for (const side of ["-wal", "-shm"])
91
+ if (!f.path.endsWith(side) && !had.has(f.path + side) && existsSync(f.path + side))
92
+ rmSync(f.path + side);
93
+ }
94
+ for (const p of sqliteFiles(root)) {
95
+ if (had.has(p))
96
+ continue;
97
+ rmSync(p);
98
+ lines.push(`removed the new database ${p}`);
99
+ }
100
+ for (const p of u.budgetSkipped ?? [])
101
+ lines.push(`couldn't put back the database ${p}: it was too big to copy`);
102
+ return lines;
103
+ }
12
104
  const TIMEOUT_MS = 20_000;
13
105
  // Too big to copy and rebuilt by a command anyway. A project's own .gitignore is honoured too.
14
106
  const EXCLUDE = ["node_modules/", ".venv/", "venv/", "__pycache__/", ".next/", ".nuxt/", ".turbo/", ".cache/", ".DS_Store"];
@@ -53,7 +145,10 @@ function shadowFor(root) {
53
145
  mkdirSync(gitDir, { recursive: true, mode: 0o700 });
54
146
  git(["init", "--bare", "--quiet", gitDir], { cwd: root });
55
147
  mkdirSync(join(gitDir, "info"), { recursive: true });
56
- writeFileSync(join(gitDir, "info", "exclude"), EXCLUDE.join("\n") + "\n");
148
+ // Backstep's own folder, when it sits inside the project, holds the live journal: never snapshotted.
149
+ const own = relative(root, resolve(homeDir()));
150
+ const skipOwn = own && !own.startsWith("..") && !isAbsolute(own) ? [`/${own}/`] : [];
151
+ writeFileSync(join(gitDir, "info", "exclude"), [...EXCLUDE, ...skipOwn].join("\n") + "\n");
57
152
  writeFileSync(join(gitDir, "description"), `Backstep snapshots of ${root}\n`);
58
153
  }
59
154
  return gitDir;
@@ -146,6 +241,8 @@ export function undoTree(u, actionId) {
146
241
  // The diff reads from the snapshot to now: A was made since, so it's removed.
147
242
  lines.push(`${status === "A" ? "removed" : "restored"} ${join(root, path)}`);
148
243
  }
244
+ if (u.sqlite)
245
+ lines.push(...undoSqlite(u.sqlite, root));
149
246
  if (u.git) {
150
247
  const now = tryGit(["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags"], root) ?? "";
151
248
  for (const name of now.split("\n").filter(Boolean)) {
package/dist/cli.js CHANGED
@@ -6,26 +6,42 @@ import { startUi } from "./ui/server.js";
6
6
  import { Store, snapshotDir } from "./store.js";
7
7
  import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
8
8
  import { snapshotPaths } from "./adapters/fs.js";
9
- import { notePushed, snapshotTree } from "./adapters/tree.js";
9
+ import { notePushed, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
10
10
  import { revertPush } from "./push.js";
11
+ import { ghMade, grantScope, undoGh } from "./github.js";
11
12
  import { snapshotDb } from "./adapters/dbsnap.js";
12
- import { projectConnected, projectDatabases, restKeys, supabaseDirs } from "./connect.js";
13
+ import { dbDirs, firebaseProject, postgresUrls, projectDatabases, projectUses, restKeys, supabaseDirs, unconnected } from "./connect.js";
14
+ import { snapshotFirebase } from "./adapters/firestore.js";
13
15
  import { snapshotRest } from "./adapters/rest.js";
14
16
  const PUSH = /\bgit\b[^;&|]*\bpush\b/;
15
- import { classifyShell, commandDir, supabaseTarget, touchesDatabase, touchesSupabase } from "./shell.js";
17
+ import { classifyShell, commandDir, supabaseTarget, touchesDatabase, touchesFirebase, touchesSqlite, touchesSupabase } from "./shell.js";
16
18
  import { watchEnd, watchStart } from "./watch.js";
17
19
  import { sendActive } from "./telemetry.js";
18
20
  import { readFileSync, rmSync } from "node:fs";
19
21
  import { loadRules } from "./rules.js";
20
22
  import { setup } from "./setup.js";
21
23
  import { autoWrap, printChanges, unwrapServers, wrapServers } from "./servers.js";
22
- import { disconnectSupabase, dismissSupabase, restKey, supabaseDismissed } from "./supabase.js";
24
+ import { disconnectSupabase, dismissSupabase, firebaseConn, postgresUrl, restKey } from "./supabase.js";
23
25
  // The Supabase projects a shell command could change: those in the folder it
24
26
  // works in (or a level or two below), when the command is one that reaches a database.
25
27
  function reachedProjects(command, cwd) {
26
28
  const dirs = supabaseDirs(commandDir(command, cwd));
27
29
  return touchesDatabase(command) || touchesSupabase(command, dirs.length > 0) ? dirs : [];
28
30
  }
31
+ // Every database a shell command could change, by folder: Supabase, other Postgres, Firebase.
32
+ function reachedDatabases(command, cwd) {
33
+ const out = [];
34
+ for (const d of reachedProjects(command, cwd))
35
+ out.push({ dir: d, service: "supabase" });
36
+ for (const d of dbDirs(commandDir(command, cwd))) {
37
+ const u = projectUses(d);
38
+ if (u.postgres && (touchesDatabase(command) || touchesSupabase(command, true)))
39
+ out.push({ dir: d, service: "postgres" });
40
+ if (u.firebase && touchesFirebase(command, true))
41
+ out.push({ dir: d, service: "firebase" });
42
+ }
43
+ return out;
44
+ }
29
45
  import { connect } from "./connect.js";
30
46
  import { check, printCheck } from "./check.js";
31
47
  import { logo } from "./look.js";
@@ -178,11 +194,12 @@ async function main() {
178
194
  },
179
195
  });
180
196
  const [service, given] = positionals;
181
- if (service && service !== "supabase" && service !== "firebase")
182
- throw new Error(`can't connect "${service}": Backstep connects supabase and firebase`);
197
+ if (service && service !== "supabase" && service !== "firebase" && service !== "postgres") {
198
+ throw new Error(`can't connect "${service}": Backstep connects supabase, postgres and firebase`);
199
+ }
183
200
  // "Don't ask for this project", from the question the Claude Code mod asks.
184
201
  if (values.dismiss) {
185
- dismissSupabase(values.dir ?? process.cwd());
202
+ dismissSupabase(values.dir ?? process.cwd(), service ?? "supabase");
186
203
  return;
187
204
  }
188
205
  const opts = {
@@ -356,7 +373,31 @@ async function main() {
356
373
  const dbs = [];
357
374
  const rests = [];
358
375
  const missed = [];
359
- for (const d of reachedProjects(command, cwd)) {
376
+ const fbs = [];
377
+ for (const { dir: d, service } of reachedDatabases(command, cwd)) {
378
+ if (service === "postgres") {
379
+ for (const url of postgresUrls(d).filter((u) => postgresUrl(u))) {
380
+ try {
381
+ dbs.push(await snapshotDb(a.id, url));
382
+ }
383
+ catch (err) {
384
+ missed.push(err instanceof Error ? err.message : String(err));
385
+ }
386
+ }
387
+ continue;
388
+ }
389
+ if (service === "firebase") {
390
+ const conn = firebaseConn(firebaseProject(d) ?? "");
391
+ if (conn) {
392
+ try {
393
+ fbs.push(await snapshotFirebase(a.id, conn));
394
+ }
395
+ catch (err) {
396
+ missed.push(err instanceof Error ? err.message : String(err));
397
+ }
398
+ }
399
+ continue;
400
+ }
360
401
  const urls = await projectDatabases(d, supabaseTarget(command));
361
402
  for (const url of urls) {
362
403
  try {
@@ -376,12 +417,14 @@ async function main() {
376
417
  }
377
418
  }
378
419
  }
379
- const extra = { ...(dbs.length ? { dbs } : {}), ...(rests.length ? { rests } : {}) };
380
- const undo = tree ? { ...tree, ...extra } : dbs.length || rests.length ? { adapter: "dbs", dbs, rests } : null;
420
+ // SQLite files in the project, even git-ignored ones, which the project snapshot leaves out.
421
+ const sqlite = tree && touchesSqlite(command) ? snapshotSqlite(a.id, tree.root) : null;
422
+ const extra = { ...(dbs.length ? { dbs } : {}), ...(rests.length ? { rests } : {}), ...(fbs.length ? { fbs } : {}), ...(sqlite?.files.length ? { sqlite } : {}) };
423
+ const undo = tree ? { ...tree, ...extra } : dbs.length || rests.length || fbs.length ? { adapter: "dbs", dbs, rests, fbs } : null;
381
424
  if (undo)
382
425
  store.updateAction(a.id, { undo: JSON.stringify(undo) });
383
426
  // With its database saved, a command that reaches it is no longer lost to undo.
384
- const saved = dbs.length + rests.length;
427
+ const saved = dbs.length + rests.length + fbs.length;
385
428
  const kind = plan.kind === "outbound" && saved && /database|request/.test(plan.reason ?? "") ? "write" : plan.kind;
386
429
  if (kind !== plan.kind)
387
430
  store.updateAction(a.id, { kind: "reversible", reason: null });
@@ -400,22 +443,41 @@ async function main() {
400
443
  const sep = rest.indexOf("--");
401
444
  const { values } = parseArgs({ args: sep === -1 ? rest : rest.slice(0, sep), options: { cwd: { type: "string" } } });
402
445
  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 }));
446
+ const ask = reachedDatabases(command, values.cwd ?? process.cwd()).find((r) => unconnected(r.dir).includes(r.service));
447
+ console.log(JSON.stringify({ ask: ask?.dir ?? null, service: ask?.service ?? null }));
405
448
  return;
406
449
  }
407
450
  // After a shell command that pushed: notes what it sent (run by the Claude Code mod).
451
+ // Also notes what a gh command made on GitHub, from its output on stdin.
408
452
  if (cmd === "shell-end") {
409
453
  const store = new Store();
410
454
  const a = store.getAction(rest[0] ?? "");
411
455
  const u = a?.undo ? JSON.parse(a.undo) : null;
412
- if (a && u?.adapter === "tree" && u.git?.remotes) {
413
- const pushed = notePushed(u);
414
- if (pushed.length)
415
- store.updateAction(a.id, { undo: JSON.stringify({ ...u, pushed }) });
456
+ const output = process.stdin.isTTY ? "" : readFileSync(0, "utf8");
457
+ if (a && u?.adapter === "tree") {
458
+ const pushed = u.git?.remotes ? notePushed(u) : [];
459
+ const github = ghMade(JSON.parse(a.args).command ?? "", output);
460
+ if (pushed.length || github.length)
461
+ store.updateAction(a.id, { undo: JSON.stringify({ ...u, ...(pushed.length ? { pushed } : {}), ...(github.length ? { github } : {}) }) });
416
462
  }
417
463
  return;
418
464
  }
465
+ // Takes away something Claude made on GitHub (the thing, as undo reported it, on stdin). The mod runs this after asking.
466
+ // Gets the person's OK on GitHub for another gh permission: code on the clipboard, approval page opened.
467
+ if (cmd === "gh-auth") {
468
+ const out = await grantScope(rest[0] ?? "");
469
+ console.log(JSON.stringify(out));
470
+ if (!out.ok)
471
+ process.exitCode = 1;
472
+ return;
473
+ }
474
+ if (cmd === "gh-undo") {
475
+ const out = undoGh(JSON.parse(readFileSync(0, "utf8")));
476
+ console.log(JSON.stringify(out));
477
+ if (!out.ok)
478
+ process.exitCode = 1;
479
+ return;
480
+ }
419
481
  // Puts an undo of code Claude pushed on the remote, as a new commit. The push, as undo reported it, comes on stdin.
420
482
  if (cmd === "revert-push") {
421
483
  const { values } = parseArgs({ args: rest, options: { root: { type: "string" } } });