backstep 0.1.10 → 0.1.14

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/dist/connect.js CHANGED
@@ -8,11 +8,13 @@
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, readdirSync, readFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
- import { join, resolve } from "node:path";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import pg from "pg";
15
+ import { accessToken, loadCreds } from "./adapters/firestore.js";
14
16
  import { claudeConfigFile, wrapServers } from "./servers.js";
15
- import { connectSupabase, hasSavedToken, LOCAL, mintSupabase, projectRef, saveSupabaseToken, supabaseDb, restKey, saveRest, supabaseDismissed, supabaseProjects, supabaseToken, supabaseUrl, } from "./supabase.js";
17
+ import { connectSupabase, hasSavedToken, LOCAL, mintSupabase, projectRef, saveSupabaseToken, supabaseDb, restKey, firebaseConn, postgresUrl, saveFirebase, savePostgres, saveRest, supabaseDismissed, supabaseProjects, supabaseToken, supabaseUrl, } from "./supabase.js";
16
18
  const ENV_FILES = [".env", ".env.local", ".env.development", ".env.development.local"];
17
19
  const PG = /^postgres(ql)?:\/\//;
18
20
  const FIREBASE_SERVER = ["npx", "-y", "firebase-tools@latest", "mcp"];
@@ -71,14 +73,82 @@ const servers = (dir) => configServers(dir).map((s) => `${s.name} ${s.target}`);
71
73
  export function projectUses(dir) {
72
74
  const d = deps(dir);
73
75
  return {
76
+ postgres: postgresUrls(dir).length > 0,
74
77
  supabase: d.some((x) => x.startsWith("@supabase/")) || envVars(dir).some(([k]) => k.includes("SUPABASE")) || existsSync(join(dir, "supabase", "config.toml")),
75
78
  firebase: existsSync(join(dir, "firebase.json")) || existsSync(join(dir, ".firebaserc")) || d.some((x) => x === "firebase" || x === "firebase-admin"),
76
79
  };
77
80
  }
81
+ // Postgres databases in the project's .env that aren't Supabase's: Neon, Railway, Render, AWS, a local one.
82
+ export function postgresUrls(dir) {
83
+ const supa = existsSync(join(dir, "supabase", "config.toml")) || deps(dir).some((x) => x.startsWith("@supabase/"));
84
+ return [
85
+ ...new Set(envVars(dir)
86
+ .filter(([, v]) => PG.test(v) && !v.includes("[YOUR-PASSWORD]"))
87
+ .map(([, v]) => v)
88
+ .filter((v) => !projectRef(v) && !/supabase\.(co|com)\b/.test(v) && !(supa && /@(127\.0\.0\.1|localhost):5432[0-9]\b/.test(v)))),
89
+ ];
90
+ }
91
+ // The Firebase project a folder uses, from .firebaserc or its settings.
92
+ export function firebaseProject(dir) {
93
+ try {
94
+ const rc = JSON.parse(read(join(dir, ".firebaserc")));
95
+ if (rc.projects?.default)
96
+ return rc.projects.default;
97
+ }
98
+ catch {
99
+ // no .firebaserc
100
+ }
101
+ return envVars(dir).find(([k]) => /FIREBASE_PROJECT_ID$|GCLOUD_PROJECT$|GOOGLE_CLOUD_PROJECT$/.test(k))?.[1];
102
+ }
103
+ // Credentials that can read and write the project's Firestore: a service account
104
+ // key in the project or named by GOOGLE_APPLICATION_CREDENTIALS, one pasted into
105
+ // an env var, or gcloud's application default login.
106
+ export function firebaseCreds(dir) {
107
+ const vars = envVars(dir);
108
+ const named = vars.find(([k]) => k === "GOOGLE_APPLICATION_CREDENTIALS")?.[1] ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
109
+ if (named) {
110
+ const f = named.startsWith("/") ? named : join(dir, named);
111
+ if (existsSync(f))
112
+ return f;
113
+ }
114
+ for (const sub of [dir, join(dir, "config"), join(dir, "secrets")]) {
115
+ let names = [];
116
+ try {
117
+ names = readdirSync(sub);
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ const key = names.find((n) => /^(service-?account.*|.*firebase-adminsdk.*)\.json$/i.test(n) || /^serviceAccountKey\.json$/i.test(n));
123
+ if (key)
124
+ return join(sub, key);
125
+ }
126
+ // A key pasted whole into an env var: kept in Backstep's own folder, readable only by the person.
127
+ const inline = vars.find(([k, v]) => /FIREBASE.*(SERVICE_ACCOUNT|ADMIN).*|GOOGLE_CREDENTIALS/.test(k) && v.trim().startsWith("{"))?.[1];
128
+ if (inline) {
129
+ const f = join(homedir(), ".backstep", `firebase-${firebaseProject(dir) ?? "project"}.json`);
130
+ mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
131
+ writeFileSync(f, inline, { mode: 0o600 });
132
+ return f;
133
+ }
134
+ const adc = join(homedir(), ".config", "gcloud", "application_default_credentials.json");
135
+ return existsSync(adc) ? adc : null;
136
+ }
137
+ const rtdbUrl = (dir) => envVars(dir).find(([k, v]) => /DATABASE_URL$/.test(k) && /firebaseio\.com|firebasedatabase\.app/.test(v))?.[1];
78
138
  // Folders a session in `dir` works in that use Supabase: the folder itself, or
79
139
  // its projects a level or two down (Claude Code opened in a parent folder).
80
140
  export function supabaseDirs(dir) {
81
- if (projectUses(dir).supabase)
141
+ return projectDirs(dir, (d) => projectUses(d).supabase);
142
+ }
143
+ // Folders that use any database Backstep connects: Supabase, other Postgres, Firebase.
144
+ export function dbDirs(dir) {
145
+ return projectDirs(dir, (d) => {
146
+ const u = projectUses(d);
147
+ return u.supabase || u.postgres || u.firebase;
148
+ });
149
+ }
150
+ export function projectDirs(dir, uses) {
151
+ if (uses(dir))
82
152
  return [dir];
83
153
  const out = [];
84
154
  const walk = (d, depth) => {
@@ -93,7 +163,7 @@ export function supabaseDirs(dir) {
93
163
  }
94
164
  for (const n of names) {
95
165
  const child = join(d, n);
96
- if (projectUses(child).supabase)
166
+ if (uses(child))
97
167
  out.push(child);
98
168
  else
99
169
  walk(child, depth - 1);
@@ -171,6 +241,20 @@ async function signedIn(dir, token, ref, isNew) {
171
241
  }
172
242
  return isNew ? { service: "supabase", state: "connected", detail: "signed in to Supabase, so each project connects itself as Claude uses it" } : null;
173
243
  }
244
+ // Whether one of a folder's databases is connected to Backstep.
245
+ export function connectedFor(dir, service) {
246
+ if (service === "supabase")
247
+ return projectConnected(dir);
248
+ if (service === "postgres")
249
+ return postgresUrls(dir).every((u) => postgresUrl(u) !== null);
250
+ const project = firebaseProject(dir);
251
+ return project !== undefined && firebaseConn(project) !== null;
252
+ }
253
+ // A folder's databases that aren't connected yet, and that the person hasn't said not to ask about.
254
+ export function unconnected(dir) {
255
+ const u = projectUses(dir);
256
+ return ["supabase", "postgres", "firebase"].filter((s) => u[s] && !connectedFor(dir, s) && !supabaseDismissed(dir, s));
257
+ }
174
258
  // Whether the project this folder uses is connected: its database, or its API through the project's keys.
175
259
  export function projectConnected(dir) {
176
260
  const rest = restKeys(dir);
@@ -304,11 +388,85 @@ function firebase(dir, cli, auto = false) {
304
388
  }
305
389
  // `auto`: run by the Claude Code mod as a session starts, with nobody asked.
306
390
  // `interactive`: a person is at the terminal (macOS may ask them to share `supabase login`'s sign-in).
391
+ async function postgres(dir, given, auto = false) {
392
+ if (auto) {
393
+ const d = dbDirs(dir).find((x) => unconnected(x).includes("postgres"));
394
+ return d
395
+ ? { service: "postgres", state: "ask", detail: "this project uses a Postgres database, but Backstep isn't connected to it", dir: d }
396
+ : { service: "postgres", state: dbDirs(dir).some((x) => projectUses(x).postgres) ? "covered" : "unused", detail: "nothing to ask" };
397
+ }
398
+ if (!projectUses(dir).postgres && !given)
399
+ dir = projectDirs(dir, (d) => projectUses(d).postgres)[0] ?? dir;
400
+ const urls = given ? [given] : postgresUrls(dir);
401
+ if (urls.length === 0)
402
+ return { service: "postgres", state: "unused", detail: "this project has no Postgres database in its settings" };
403
+ const errors = [];
404
+ for (const url of urls) {
405
+ const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: 10_000 });
406
+ try {
407
+ await client.connect();
408
+ await client.query("SELECT 1");
409
+ savePostgres(url);
410
+ }
411
+ catch (err) {
412
+ errors.push(err instanceof Error ? err.message : String(err));
413
+ }
414
+ finally {
415
+ await client.end().catch(() => { });
416
+ }
417
+ }
418
+ if (errors.length === urls.length)
419
+ return { service: "postgres", state: "needs", detail: `couldn't reach the database in the project's settings: ${errors[0]}` };
420
+ const host = (u) => {
421
+ try {
422
+ return new URL(u).hostname;
423
+ }
424
+ catch {
425
+ return "the database";
426
+ }
427
+ };
428
+ return { service: "postgres", state: "connected", detail: `connected ${urls.map(host).join(", ")}, from the project's settings` };
429
+ }
430
+ const SERVICE_KEY_HOW = "Firebase console → your project → ⚙ Project settings → Service accounts → Generate new private key, then save the file in the project as serviceAccountKey.json (and keep it out of git)";
431
+ // Firebase's data (Firestore, Realtime Database), signed in with the project's
432
+ // service account or gcloud's login. Separate from its MCP server, which the
433
+ // firebase() step above adds.
434
+ async function firebaseData(dir, auto = false) {
435
+ if (auto) {
436
+ const d = dbDirs(dir).find((x) => unconnected(x).includes("firebase"));
437
+ return d ? { service: "firebase", state: "ask", detail: "this project uses Firebase, but Backstep isn't connected to its data", dir: d } : { service: "firebase", state: "unused", detail: "nothing to ask" };
438
+ }
439
+ if (!projectUses(dir).firebase)
440
+ dir = projectDirs(dir, (d) => projectUses(d).firebase)[0] ?? dir;
441
+ const credsFile = firebaseCreds(dir);
442
+ let project = firebaseProject(dir);
443
+ if (!credsFile)
444
+ return { service: "firebase", state: "needs", detail: `needs the project's service account key: ${SERVICE_KEY_HOW}` };
445
+ try {
446
+ const creds = loadCreds(credsFile);
447
+ if (!project && creds.type === "service_account")
448
+ project = creds.project_id;
449
+ if (!project)
450
+ return { service: "firebase", state: "needs", detail: "couldn't tell which Firebase project this is: add a .firebaserc (run: npx firebase-tools use --add)" };
451
+ await accessToken(creds);
452
+ saveFirebase({ project, credsFile, ...(rtdbUrl(dir) ? { rtdb: rtdbUrl(dir) } : {}) });
453
+ return { service: "firebase", state: "connected", detail: `connected Firebase project ${project}: Firestore${rtdbUrl(dir) ? " and the Realtime Database" : ""} can be undone` };
454
+ }
455
+ catch (err) {
456
+ return { service: "firebase", state: "needs", detail: `the Firebase sign-in didn't work (${err instanceof Error ? err.message : String(err)}). ${SERVICE_KEY_HOW}` };
457
+ }
458
+ }
307
459
  export async function connect(opts) {
308
460
  const out = [];
309
- if (opts.only !== "firebase")
461
+ const want = (s) => !opts.only || opts.only === s;
462
+ if (want("supabase"))
310
463
  out.push(await supabase(opts.dir, opts.url, opts.ref, opts.interactive, opts.auto));
311
- if (opts.only !== "supabase")
312
- out.push(firebase(opts.dir, opts.cli, opts.auto));
464
+ if (want("postgres"))
465
+ out.push(await postgres(opts.dir, opts.only === "postgres" ? opts.url : undefined, opts.auto));
466
+ if (want("firebase")) {
467
+ const data = await firebaseData(opts.dir, opts.auto);
468
+ // Asked about its data first; its MCP server is added as before once that's settled.
469
+ out.push(data.state === "ask" ? data : firebase(opts.dir, opts.cli, opts.auto), ...(data.state === "ask" || data.state === "unused" ? [] : [data]));
470
+ }
313
471
  return out;
314
472
  }
package/dist/github.js ADDED
@@ -0,0 +1,113 @@
1
+ // Things Claude makes on GitHub with the gh command line (a repository, an
2
+ // issue, a pull request, a release), noted from what gh printed, so undo can
3
+ // offer to take them away again with gh itself, signed in as the person.
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ const CREATES = /\bgh\s+(repo|issue|pr|release)\s+create\b/g;
6
+ const URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?(?:\/(issues|pull)\/(\d+)|\/releases\/tag\/([^\s)]+))?(?=[\s)'"]|$)/g;
7
+ // What a gh command made, from the command and what it printed.
8
+ export function ghMade(command, output) {
9
+ const kinds = new Set([...command.matchAll(CREATES)].map((m) => m[1]));
10
+ if (kinds.size === 0)
11
+ return [];
12
+ const out = [];
13
+ for (const m of output.matchAll(URL)) {
14
+ const [url, repo, part, num, tag] = m;
15
+ if (part === "issues" && kinds.has("issue"))
16
+ out.push({ kind: "issue", repo, number: Number(num), url });
17
+ else if (part === "pull" && kinds.has("pr"))
18
+ out.push({ kind: "pr", repo, number: Number(num), url });
19
+ else if (tag && kinds.has("release"))
20
+ out.push({ kind: "release", repo, tag, url });
21
+ else if (!part && !tag && kinds.has("repo"))
22
+ out.push({ kind: "repo", repo, url });
23
+ }
24
+ // `gh repo create` also says "Created repository owner/name on GitHub".
25
+ for (const m of output.matchAll(/Created repository ([\w.-]+\/[\w.-]+)/g))
26
+ out.push({ kind: "repo", repo: m[1], url: `https://github.com/${m[1]}` });
27
+ const seen = new Set();
28
+ return out.filter((g) => {
29
+ const k = `${g.kind}:${g.repo}:${g.number ?? g.tag ?? ""}`;
30
+ return seen.has(k) ? false : (seen.add(k), true);
31
+ });
32
+ }
33
+ export const describeGh = (g) => g.kind === "repo" ? `the repository ${g.repo}` : g.kind === "release" ? `the release ${g.tag} in ${g.repo}` : `${g.kind === "pr" ? "pull request" : "issue"} #${g.number} in ${g.repo}`;
34
+ function gh(args) {
35
+ const r = spawnSync("gh", args, { encoding: "utf8", timeout: 60_000, env: { ...process.env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" } });
36
+ return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
37
+ }
38
+ export function undoGh(g) {
39
+ const what = describeGh(g);
40
+ let r;
41
+ if (g.kind === "repo") {
42
+ r = gh(["repo", "delete", g.repo, "--yes"]);
43
+ // gh's sign-in can't delete repositories until the person allows it: the mod gets that OK and tries again.
44
+ if (!r.ok && /delete_repo/.test(r.out))
45
+ return { ok: false, needs: "delete_repo", detail: `GitHub needs your OK before ${g.repo} can be deleted` };
46
+ }
47
+ else if (g.kind === "issue") {
48
+ // Deleting needs admin rights; closing is the fallback anyone can do.
49
+ r = gh(["issue", "delete", String(g.number), "-R", g.repo, "--yes"]);
50
+ if (!r.ok) {
51
+ r = gh(["issue", "close", String(g.number), "-R", g.repo, "-c", "Undone with Backstep."]);
52
+ if (r.ok)
53
+ return { ok: true, detail: `closed ${what} (deleting it needs admin rights)` };
54
+ }
55
+ }
56
+ else if (g.kind === "pr") {
57
+ r = gh(["pr", "close", String(g.number), "-R", g.repo, "--delete-branch", "-c", "Undone with Backstep."]);
58
+ if (r.ok)
59
+ return { ok: true, detail: `closed ${what} and deleted its branch` };
60
+ }
61
+ else {
62
+ r = gh(["release", "delete", g.tag, "-R", g.repo, "--yes", "--cleanup-tag"]);
63
+ }
64
+ if (r.ok)
65
+ return { ok: true, detail: `deleted ${what}` };
66
+ return { ok: false, detail: `couldn't undo ${what}: ${r.out.split("\n").filter(Boolean).at(-1) ?? "gh failed"}` };
67
+ }
68
+ const DEVICE = "https://github.com/login/device";
69
+ function copy(text) {
70
+ const tries = process.platform === "darwin" ? [["pbcopy", []]] : process.platform === "win32" ? [["clip", []]] : [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["-b", "-i"]]];
71
+ for (const [cmd, args] of tries)
72
+ if (spawnSync(cmd, args, { input: text }).status === 0)
73
+ return true;
74
+ return false;
75
+ }
76
+ function openPage(url) {
77
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
78
+ spawnSync(cmd, args, { stdio: "ignore" });
79
+ }
80
+ // Gives gh's sign-in another permission (like delete_repo) with GitHub's own
81
+ // approval page: the one-time code is put on the clipboard and the page opened,
82
+ // so the person only pastes it and approves. Resolves once GitHub says yes,
83
+ // or no, or nine minutes pass (Claude Code gives a command ten). `onCode` hears
84
+ // the code as soon as gh prints it, so it can be shown while GitHub waits.
85
+ export function grantScope(scope, onCode) {
86
+ return new Promise((resolve) => {
87
+ // gh would open the page too; Backstep already does, so gh's browser is told to do nothing.
88
+ const env = { ...process.env, NO_COLOR: "1", GH_BROWSER: process.platform === "win32" ? "cmd /c exit" : "true" };
89
+ const child = spawn("gh", ["auth", "refresh", "-h", "github.com", "-s", scope], { env, stdio: ["pipe", "pipe", "pipe"] });
90
+ let out = "";
91
+ let shown = false;
92
+ const timer = setTimeout(() => child.kill(), 9 * 60 * 1000);
93
+ const read = (chunk) => {
94
+ out += chunk.toString();
95
+ const code = out.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/)?.[1];
96
+ if (code && !shown) {
97
+ shown = true;
98
+ copy(code);
99
+ openPage(DEVICE);
100
+ onCode?.(code);
101
+ // gh waits for Enter before it would open the page itself; it's already open.
102
+ child.stdin.write("\n");
103
+ }
104
+ };
105
+ child.stdout.on("data", read);
106
+ child.stderr.on("data", read);
107
+ child.on("error", () => resolve({ ok: false, detail: "gh isn't installed" }));
108
+ child.on("close", (status) => {
109
+ clearTimeout(timer);
110
+ resolve(status === 0 ? { ok: true, detail: "GitHub approved it" } : { ok: false, detail: out.split("\n").filter(Boolean).at(-1) ?? "GitHub didn't approve it" });
111
+ });
112
+ });
113
+ }
package/dist/shell.js CHANGED
@@ -141,3 +141,12 @@ export function commandDir(command, cwd) {
141
141
  return cwd;
142
142
  return to.startsWith("/") ? to : to.startsWith("~") ? to.replace(/^~/, process.env.HOME ?? "~") : `${cwd.replace(/\/$/, "")}/${to}`;
143
143
  }
144
+ // Commands that change Firebase's data some other way than its MCP server.
145
+ const FIREBASE_DATA = /\bfirebase(-tools)?\s+(firestore:|database:)|firestore\.googleapis\.com|firebaseio\.com|firebasedatabase\.app/;
146
+ export function touchesFirebase(command, projectUsesFirebase) {
147
+ return FIREBASE_DATA.test(command) || (projectUsesFirebase && SCRIPT.test(command));
148
+ }
149
+ // A command that could change a SQLite file in the project.
150
+ export function touchesSqlite(command) {
151
+ return DATABASE.test(command) || SCRIPT.test(command) || /\bsqlite3?\b|\.(db|sqlite3?|db3)\b/.test(command);
152
+ }
package/dist/supabase.js CHANGED
@@ -165,12 +165,26 @@ export function saveRest(url, key) {
165
165
  save(c);
166
166
  }
167
167
  export const restKey = (url) => load().rest?.[url.replace(/\/$/, "")] ?? null;
168
- export function dismissSupabase(dir) {
168
+ export function savePostgres(url) {
169
169
  const c = load();
170
- c.dismissed = [...new Set([...(c.dismissed ?? []), dir])];
170
+ c.postgres = { ...c.postgres, [dbKey(url)]: url };
171
171
  save(c);
172
172
  }
173
- export const supabaseDismissed = (dir) => (load().dismissed ?? []).includes(dir);
173
+ export const postgresUrl = (url) => load().postgres?.[dbKey(url)] ?? null;
174
+ export function saveFirebase(conn) {
175
+ const c = load();
176
+ c.firebase = { ...c.firebase, [conn.project]: conn };
177
+ save(c);
178
+ }
179
+ export const firebaseConn = (project) => load().firebase?.[project] ?? null;
180
+ // "Don't ask for this project": Supabase keeps its old key (the folder), other databases add theirs.
181
+ const dismissKey = (dir, service = "supabase") => (service === "supabase" ? dir : `${service}:${dir}`);
182
+ export function dismissSupabase(dir, service = "supabase") {
183
+ const c = load();
184
+ c.dismissed = [...new Set([...(c.dismissed ?? []), dismissKey(dir, service)])];
185
+ save(c);
186
+ }
187
+ export const supabaseDismissed = (dir, service = "supabase") => (load().dismissed ?? []).includes(dismissKey(dir, service));
174
188
  // Checks the token works before keeping it.
175
189
  export async function saveSupabaseToken(token) {
176
190
  const projects = await api(token, "GET", "/v1/projects");
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, restKey } from "./supabase.js";
8
+ import { dbKey, firebaseConn, freshUrl, restKey } from "./supabase.js";
9
+ import { undoFirebase } from "./adapters/firestore.js";
9
10
  import { undoRest } from "./adapters/rest.js";
10
11
  const INVERSE_TIMEOUT_MS = 20_000;
11
12
  // Database copies sit in the person's own database, so only the newest few per
@@ -39,11 +40,11 @@ function resources(store, a) {
39
40
  return new Set(u.entries.map((e) => `fs:${e.path}`));
40
41
  // A project snapshot rewinds every file under its folder, and any whole database saved with it.
41
42
  const whole = (dbs = []) => dbs.map((d) => `pgdb:${dbKey(d.url)}`);
42
- const apis = (rests = []) => rests.map((r) => `pgdb:${r.url}`);
43
+ const apis = (rests = [], fbs = []) => [...rests.map((r) => `pgdb:${r.url}`), ...fbs.map((f) => `firebase:${f.project}`)];
43
44
  if (u.adapter === "tree")
44
- return new Set([`fs:${u.root}`, ...whole(u.dbs), ...apis(u.rests)]);
45
+ return new Set([`fs:${u.root}`, ...whole(u.dbs), ...apis(u.rests, u.fbs)]);
45
46
  if (u.adapter === "dbs")
46
- return new Set([...whole(u.dbs), ...apis(u.rests)]);
47
+ return new Set([...whole(u.dbs), ...apis(u.rests, u.fbs)]);
47
48
  // An opposite tool call doesn't rewind shared state, so it never conflicts.
48
49
  if (u.adapter === "inverse" || u.adapter === "engine")
49
50
  return new Set();
@@ -142,6 +143,12 @@ async function undoOne(store, a, opts = {}) {
142
143
  throw new Error(`Backstep isn't connected to ${r.url} any more`);
143
144
  lines.push(await undoRest(r, key));
144
145
  }
146
+ for (const f of u.fbs ?? []) {
147
+ const conn = firebaseConn(f.project);
148
+ if (!conn)
149
+ throw new Error(`Backstep isn't connected to the Firebase project ${f.project} any more`);
150
+ lines.push(await undoFirebase(f, conn.credsFile));
151
+ }
145
152
  if (u.adapter === "tree")
146
153
  lines.unshift(undoTree(u, a.id));
147
154
  detail = lines.filter((l) => !/^nothing had changed/.test(l)).join("\n") || lines[0];
@@ -157,7 +164,8 @@ async function undoOne(store, a, opts = {}) {
157
164
  if (u.adapter === "fs" || u.adapter === "tree")
158
165
  rmSync(snapshotDir(a.id), { recursive: true, force: true });
159
166
  const pushed = u.adapter === "tree" && u.pushed?.length ? { root: u.root, pushes: u.pushed } : undefined;
160
- return { id: a.id, tool: a.tool, ok: true, detail, ...(pushed ? { pushed } : {}) };
167
+ const github = u.adapter === "tree" && u.github?.length ? u.github : undefined;
168
+ return { id: a.id, tool: a.tool, ok: true, detail, ...(pushed ? { pushed } : {}), ...(github ? { github } : {}) };
161
169
  }
162
170
  catch (err) {
163
171
  const detail = err instanceof Error ? err.message : String(err);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.10",
3
+ "version": "0.1.14",
4
4
  "description": "Press ctrl+z to rewind everything Claude did in its last turn: file edits, shell commands, and database and tool changes.",
5
5
  "types": "./types/index.d.ts",
6
6
  "userConfig": {
@@ -79,15 +79,18 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
79
79
 
80
80
  await clock.advance(1000)
81
81
  await pressed
82
+ await clock.advance(500)
82
83
  expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change from the last turn.')
83
84
  expect((await ui.find({ type: 'Text', text: /src\/a\.ts restored/ }))).toBeTruthy()
84
85
 
85
- // The band folds away, and with the turn undone nothing is left to offer.
86
- await clock.advance(5000)
87
- expect(await ui.find({ type: 'Text', text: /nothing to undo yet/ })).toBeTruthy()
88
- await ui.unmount()
86
+ // The list stays up, however long, until the next message.
87
+ await clock.advance(60_000)
88
+ expect((await ui.find({ type: 'Text', text: /Rewound/ }))).toBeTruthy()
89
89
 
90
90
  await $.prompt.submit({ text: 'try again', wait: false, origin: { kind: 'composer' } })
91
+ // Then the band goes back to the undo line; with the turn undone, nothing is left to offer.
92
+ expect(await ui.find({ type: 'Text', text: /Rewound/ })).toBeFalsy()
93
+ await ui.unmount()
91
94
  const note = prompts.at(-1)?.context?.at(-1) ?? ''
92
95
  expect(note).toContain('These changes were reverted:\n- src/a.ts restored')
93
96
 
@@ -119,11 +122,10 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
119
122
  expect((await ui.find({ type: 'Text', text: /Stopped/ }))?.text).toContain('Stopped after 1 change')
120
123
  expect((await ui.find({ type: 'Text', text: /row changed since/ }))).toBeTruthy()
121
124
 
122
- // Still undoable once the band folds, since the turn was not fully rewound.
123
- await clock.advance(13000)
125
+ // Still undoable after the next message, since the turn was not fully rewound.
126
+ await $.prompt.submit({ text: 'hmm', wait: false, origin: { kind: 'composer' } })
124
127
  expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
125
128
  await ui.unmount()
126
- expect(prompts).toHaveLength(1)
127
129
  })
128
130
 
129
131
  test('with nothing to undo the band still holds the key, and a press says there is nothing to undo', async ($, on) => {
@@ -537,7 +539,8 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
537
539
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
538
540
 
539
541
  await ui.press({ key: 'undo' })
540
- expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn \("fix it"\)\?/ }))).toBeTruthy()
542
+ expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn\?/ }))).toBeTruthy()
543
+ expect((await ui.find({ type: 'Text', text: /"fix it" · 1 edit to 1 file/ }))).toBeTruthy()
541
544
  expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo')
542
545
  await clock.advance(6000)
543
546
  expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
@@ -652,10 +655,11 @@ test('when the last turn only committed and pushed, undo offers to take back the
652
655
  await $.tool.call({ tool: 'Bash', command: 'git add -A && git commit -m "board" && git push' })
653
656
 
654
657
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
655
- expect((await ui.find({ type: 'Text', text: /Last turn \("commit and push it"\)/ }))).toBeTruthy()
658
+ expect((await ui.find({ type: 'Text', text: /Last turn: "commit and push it"/ }))).toBeTruthy()
656
659
  await ui.press({ key: 'undo' })
657
660
  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")')
661
+ expect((await ui.find({ type: 'Text', text: /Undo it and the turn before it "add the kanban board"\?/ }))).toBeTruthy()
662
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo both')
659
663
  expect(await ui.find({ type: 'Button', key: 'just' })).toBeTruthy()
660
664
 
661
665
  const pressed = ui.press({ key: 'undo' })
@@ -694,3 +698,97 @@ test('after an undo, everything undone and everything that was not is listed, in
694
698
  expect(report).toContain('Not undone:\n ✗ curl -X POST')
695
699
  await ui.unmount()
696
700
  })
701
+
702
+ test('a GitHub repository Claude created is offered for deletion after undo, and its push needs no undo of its own', { options: { cli: CLI } }, async ($, on) => {
703
+ const clock = mock.clock(on, { now: 5000 })
704
+ const repo = { kind: 'repo', repo: 'bzells11/taskflow', url: 'https://github.com/bzells11/taskflow' }
705
+ const runs: { argv: string[]; stdin?: string }[] = []
706
+ const asked: string[] = []
707
+ const logs: string[] = []
708
+ on('session.cwd', () => ({ value: '/repo' }))
709
+ on('session.id', () => ({ value: 's1' }))
710
+ on('ui.toast', () => ({ value: undefined }))
711
+ on('ui.log', (_, e) => {
712
+ logs.push(e.text)
713
+ return { value: undefined }
714
+ })
715
+ on('process.run', (_, e) => {
716
+ runs.push({ argv: [...e.argv], stdin: e.init?.stdin })
717
+ const out = e.argv.includes('shell')
718
+ ? { kind: 'outbound', reason: 'changes GitHub', id: 'g1', undoable: true }
719
+ : e.argv.includes('undo')
720
+ ? [{ id: 'g1', tool: 'Bash', ok: true, detail: 'restored main', github: [repo], pushed: { root: '/repo', pushes: [{ remote: 'origin', branch: 'main', before: null, after: 'b', commits: 1, host: 'GitHub' }] } }]
721
+ : e.argv.includes('gh-undo')
722
+ ? // The first try finds gh's sign-in can't delete repositories yet.
723
+ runs.filter((r) => r.argv.includes('gh-undo')).length === 1
724
+ ? { ok: false, needs: 'delete_repo', detail: 'GitHub needs your OK before bzells11/taskflow can be deleted' }
725
+ : { ok: true, detail: 'deleted the repository bzells11/taskflow' }
726
+ : e.argv.includes('db-check')
727
+ ? { ask: null }
728
+ : null
729
+ return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
730
+ })
731
+ // GitHub's approval: the code comes first, then the answer once the person approves (when `approve` is called).
732
+ let approve = () => {}
733
+ const approved = new Promise<void>((resolve) => (approve = resolve))
734
+ let codeShown: Promise<void> = Promise.resolve()
735
+ let gotCode = () => {}
736
+ codeShown = new Promise<void>((resolve) => (gotCode = resolve))
737
+ on('process.spawn', async function* (_, e) {
738
+ runs.push({ argv: [...e.argv] })
739
+ yield { stream: 'stdout' as const, text: '{"code":"ABCD-1234","url":"https://github.com/login/device"}\n' }
740
+ gotCode()
741
+ await approved
742
+ yield { stream: 'stdout' as const, text: '{"ok":true,"detail":"GitHub approved it"}\n' }
743
+ return { value: { code: 0, signal: null } }
744
+ })
745
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
746
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
747
+ asked.push(questions[0].question)
748
+ return { result: { questions, answers: { [questions[0].question]: 'Delete it' } } }
749
+ })
750
+ on('tool.call', () => ({ result: {}, text: '✓ Created repository bzells11/taskflow on GitHub' }))
751
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
752
+ on('ui.render', ($, e) => {
753
+ const { Text } = $.ui.resolve(e)
754
+ return <Text>engine</Text>
755
+ })
756
+
757
+ await $.prompt.submit({ text: 'make a github repo', wait: false, origin: { kind: 'composer' } })
758
+ await $.tool.call({ tool: 'Bash', command: 'gh repo create taskflow --public --source . --push' })
759
+ // What gh printed is noted once it has run.
760
+ const end = runs.find((r) => r.argv.includes('shell-end'))
761
+ expect(end?.stdin).toContain('Created repository bzells11/taskflow')
762
+
763
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
764
+ await ui.press({ key: 'undo' })
765
+ const pressed = ui.press({ key: 'undo' })
766
+ await clock.advance(1000)
767
+ await pressed
768
+ await codeShown
769
+ await clock.settle()
770
+ // While GitHub waits, the band says it's a step to take and shows the code, not a failure.
771
+ expect(await ui.find({ type: 'Text', text: /One more step to delete the GitHub repository bzells11\/taskflow/ })).toBeTruthy()
772
+ expect(await ui.find({ type: 'Text', text: 'ABCD-1234' })).toBeTruthy()
773
+ // The push went to the repository being deleted, so it isn't listed as not undone.
774
+ expect(await ui.find({ type: 'Text', text: /Not undone/ })).toBeFalsy()
775
+ approve()
776
+ await clock.settle()
777
+ expect(await ui.find({ type: 'Text', text: 'ABCD-1234' })).toBeFalsy()
778
+ expect(await ui.find({ type: 'Text', text: /on GitHub: deleted the repository bzells11\/taskflow/ })).toBeTruthy()
779
+ expect(asked).toEqual(['Claude created the GitHub repository bzells11/taskflow. Delete it on GitHub too?'])
780
+ expect(JSON.parse(runs.find((r) => r.argv.includes('gh-undo'))?.stdin ?? '{}')).toEqual(repo)
781
+ // Missing permission: GitHub's OK is fetched by Backstep itself, then the delete is tried again.
782
+ expect(runs.filter((r) => r.argv.includes('gh-undo') || r.argv.includes('gh-auth')).map((r) => r.argv.slice(3).join(' '))).toEqual([
783
+ 'gh-undo',
784
+ 'gh-auth delete_repo',
785
+ 'gh-undo',
786
+ ])
787
+ expect(logs.find((l) => l.includes('paste the code ABCD-1234 on the GitHub page'))).toBeTruthy()
788
+ expect(logs.find((l) => l.includes('On GitHub: deleted the repository'))).toBeTruthy()
789
+ // The repository is gone, so its push isn't asked about.
790
+ expect(runs.some((r) => r.argv.includes('revert-push'))).toBe(false)
791
+ // And "changes GitHub" isn't listed as not undone, since it was offered.
792
+ expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes GitHub')
793
+ await ui.unmount()
794
+ })