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/README.md +4 -0
- package/dist/adapters/firestore.js +160 -0
- package/dist/adapters/tree.js +100 -3
- package/dist/cli.js +80 -17
- package/dist/connect.js +166 -8
- package/dist/github.js +113 -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 +108 -10
- package/mod/hooks/register.tsx +194 -61
- package/mod/types/index.d.ts +5 -1
- package/package.json +1 -1
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
312
|
-
out.push(
|
|
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
|
|
168
|
+
export function savePostgres(url) {
|
|
169
169
|
const c = load();
|
|
170
|
-
c.
|
|
170
|
+
c.postgres = { ...c.postgres, [dbKey(url)]: url };
|
|
171
171
|
save(c);
|
|
172
172
|
}
|
|
173
|
-
export const
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
|
86
|
-
await clock.advance(
|
|
87
|
-
expect(await ui.find({ type: 'Text', text: /
|
|
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
|
|
123
|
-
await
|
|
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
|
|
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
|
|
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: '
|
|
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
|
+
})
|