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 +4 -0
- package/dist/adapters/firestore.js +160 -0
- package/dist/adapters/tree.js +100 -3
- package/dist/cli.js +79 -17
- package/dist/connect.js +166 -8
- package/dist/github.js +111 -0
- package/dist/shell.js +9 -0
- package/dist/supabase.js +17 -3
- package/dist/undo.js +13 -5
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +172 -8
- package/mod/hooks/register.tsx +252 -77
- package/mod/types/index.d.ts +6 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/adapters/tree.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
const
|
|
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 =
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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" } } });
|