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/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,111 @@
|
|
|
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).
|
|
84
|
+
export function grantScope(scope) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
// gh would open the page too; Backstep already does, so gh's browser is told to do nothing.
|
|
87
|
+
const env = { ...process.env, NO_COLOR: "1", GH_BROWSER: process.platform === "win32" ? "cmd /c exit" : "true" };
|
|
88
|
+
const child = spawn("gh", ["auth", "refresh", "-h", "github.com", "-s", scope], { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
89
|
+
let out = "";
|
|
90
|
+
let shown = false;
|
|
91
|
+
const timer = setTimeout(() => child.kill(), 9 * 60 * 1000);
|
|
92
|
+
const read = (chunk) => {
|
|
93
|
+
out += chunk.toString();
|
|
94
|
+
const code = out.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/)?.[1];
|
|
95
|
+
if (code && !shown) {
|
|
96
|
+
shown = true;
|
|
97
|
+
copy(code);
|
|
98
|
+
openPage(DEVICE);
|
|
99
|
+
// gh waits for Enter before it would open the page itself; it's already open.
|
|
100
|
+
child.stdin.write("\n");
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
child.stdout.on("data", read);
|
|
104
|
+
child.stderr.on("data", read);
|
|
105
|
+
child.on("error", () => resolve({ ok: false, detail: "gh isn't installed" }));
|
|
106
|
+
child.on("close", (status) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
resolve(status === 0 ? { ok: true, detail: "GitHub approved it" } : { ok: false, detail: out.split("\n").filter(Boolean).at(-1) ?? "GitHub didn't approve it" });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
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);
|