backstep 0.0.0-stage → 0.1.0
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 +118 -2
- package/dist/adapters/fs.js +66 -0
- package/dist/adapters/postgres.js +220 -0
- package/dist/adapters/tree.js +171 -0
- package/dist/check.js +117 -0
- package/dist/classify.js +53 -0
- package/dist/cli.js +408 -0
- package/dist/connect.js +237 -0
- package/dist/firebase.js +137 -0
- package/dist/look.js +34 -0
- package/dist/proxy.js +285 -0
- package/dist/push.js +108 -0
- package/dist/rules.js +86 -0
- package/dist/servers.js +226 -0
- package/dist/setup.js +109 -0
- package/dist/shell.js +112 -0
- package/dist/sql.js +88 -0
- package/dist/store.js +132 -0
- package/dist/supabase.js +245 -0
- package/dist/telemetry.js +77 -0
- package/dist/ui/index.html +246 -0
- package/dist/ui/server.js +70 -0
- package/dist/undo.js +176 -0
- package/dist/watch.js +138 -0
- package/mod/.claude-plugin/marketplace.json +5 -0
- package/mod/.claude-plugin/plugin.json +14 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/register.test.tsx +432 -0
- package/mod/hooks/register.tsx +529 -0
- package/mod/types/index.d.ts +30 -0
- package/package.json +48 -4
package/dist/connect.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Connects Backstep to what a project uses, so undo covers it, without anyone
|
|
2
|
+
// having to know what to connect or where its settings live. The person asks
|
|
3
|
+
// Claude ("connect Backstep to my Supabase", "to everything I use") and the
|
|
4
|
+
// Claude Code mod runs this; `backstep connect` in a terminal does the same.
|
|
5
|
+
// - Supabase: a connection string from the project's .env files, the local
|
|
6
|
+
// database `supabase start` runs, or one the person gives.
|
|
7
|
+
// - Firebase: undo runs through Firebase's own MCP server, so there's nothing
|
|
8
|
+
// to connect once Claude can reach Firebase. When the project uses Firebase
|
|
9
|
+
// and Claude can't, this adds that server (behind Backstep).
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { claudeConfigFile, wrapServers } from "./servers.js";
|
|
15
|
+
import { connectSupabase, hasSavedToken, LOCAL, mintSupabase, projectRef, saveSupabaseToken, supabaseDismissed, supabaseProjects, supabaseToken } from "./supabase.js";
|
|
16
|
+
const ENV_FILES = [".env", ".env.local", ".env.development", ".env.development.local"];
|
|
17
|
+
const PG = /^postgres(ql)?:\/\//;
|
|
18
|
+
const FIREBASE_SERVER = ["npx", "-y", "firebase-tools@latest", "mcp"];
|
|
19
|
+
function read(file) {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(file, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function envVars(dir) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const f of ENV_FILES) {
|
|
30
|
+
for (const line of read(join(dir, f)).split("\n")) {
|
|
31
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
32
|
+
if (m)
|
|
33
|
+
out.push([m[1], m[2].replace(/^(['"])(.*)\1$/, "$2")]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function deps(dir) {
|
|
39
|
+
try {
|
|
40
|
+
const pkg = JSON.parse(read(join(dir, "package.json")));
|
|
41
|
+
return [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Every MCP server a session in `dir` can reach, from Claude Code's config:
|
|
48
|
+
// the person's, the project's own, its .mcp.json, and the claude.ai
|
|
49
|
+
// connectors that have been used.
|
|
50
|
+
export function configServers(dir) {
|
|
51
|
+
let config = {};
|
|
52
|
+
try {
|
|
53
|
+
config = JSON.parse(read(claudeConfigFile()));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// no config yet
|
|
57
|
+
}
|
|
58
|
+
const project = Object.entries(config.projects ?? {}).find(([d]) => resolve(d) === resolve(dir))?.[1];
|
|
59
|
+
let shared = {};
|
|
60
|
+
try {
|
|
61
|
+
shared = JSON.parse(read(join(dir, ".mcp.json"))).mcpServers ?? {};
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// no .mcp.json
|
|
65
|
+
}
|
|
66
|
+
const list = (all = {}) => Object.entries(all).map(([name, e]) => ({ name, target: [e.command ?? "", ...(e.args ?? []), e.url ?? ""].join(" ").trim() }));
|
|
67
|
+
const connectors = (config.claudeAiMcpEverConnected ?? []).map((name) => ({ name, target: "" }));
|
|
68
|
+
return [...list(project?.mcpServers), ...list(shared), ...list(config.mcpServers), ...connectors];
|
|
69
|
+
}
|
|
70
|
+
const servers = (dir) => configServers(dir).map((s) => `${s.name} ${s.target}`);
|
|
71
|
+
export function projectUses(dir) {
|
|
72
|
+
const d = deps(dir);
|
|
73
|
+
return {
|
|
74
|
+
supabase: d.some((x) => x.startsWith("@supabase/")) || envVars(dir).some(([k]) => k.includes("SUPABASE")) || existsSync(join(dir, "supabase", "config.toml")),
|
|
75
|
+
firebase: existsSync(join(dir, "firebase.json")) || existsSync(join(dir, ".firebaserc")) || d.some((x) => x === "firebase" || x === "firebase-admin"),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Connection strings worth trying, best first: the ones that say they're
|
|
79
|
+
// direct, then any other Postgres URL in the project's env files.
|
|
80
|
+
function candidates(dir) {
|
|
81
|
+
const urls = envVars(dir)
|
|
82
|
+
.filter(([, v]) => PG.test(v) && !v.includes("[YOUR-PASSWORD]"))
|
|
83
|
+
.sort(([a], [b]) => Number(/DIRECT|SUPABASE/.test(b)) - Number(/DIRECT|SUPABASE/.test(a)))
|
|
84
|
+
.map(([, v]) => v);
|
|
85
|
+
// `supabase start` runs a database with a fixed password on the port its config names.
|
|
86
|
+
const toml = read(join(dir, "supabase", "config.toml"));
|
|
87
|
+
if (toml) {
|
|
88
|
+
const port = toml.match(/^\[db\][^[]*?^port\s*=\s*(\d+)/m)?.[1] ?? "54322";
|
|
89
|
+
urls.push(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`);
|
|
90
|
+
}
|
|
91
|
+
return [...new Set(urls)];
|
|
92
|
+
}
|
|
93
|
+
const TOKENS = "https://supabase.com/dashboard/account/tokens";
|
|
94
|
+
const WHERE = "Supabase → your project → Connect (top of the page) → Direct connection (or Session pooler) → copy the URI and put the database password in place of [YOUR-PASSWORD]";
|
|
95
|
+
// The hosted project a folder belongs to: `supabase link` notes it, and the
|
|
96
|
+
// app's own settings name it in its API URL.
|
|
97
|
+
function linkedRef(dir) {
|
|
98
|
+
const linked = read(join(dir, "supabase", ".temp", "project-ref")).trim();
|
|
99
|
+
if (/^[a-z0-9]{20}$/.test(linked))
|
|
100
|
+
return linked;
|
|
101
|
+
for (const [, v] of envVars(dir)) {
|
|
102
|
+
const m = v.match(/^https:\/\/([a-z0-9]{20})\.supabase\.co/);
|
|
103
|
+
if (m)
|
|
104
|
+
return m[1];
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
// Signed in to Supabase: this folder's project connects now, the rest as Claude uses them.
|
|
109
|
+
async function signedIn(dir, token, ref, isNew) {
|
|
110
|
+
const project = ref ?? linkedRef(dir);
|
|
111
|
+
if (project && !supabaseProjects().includes(project)) {
|
|
112
|
+
try {
|
|
113
|
+
await mintSupabase(project, token);
|
|
114
|
+
return { service: "supabase", state: "connected", detail: `connected project ${project} with your Supabase sign-in` };
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return { service: "supabase", state: "needs", detail: `signed in, but couldn't connect project ${project}: ${err instanceof Error ? err.message : String(err)}` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return isNew ? { service: "supabase", state: "connected", detail: "signed in to Supabase, so each project connects itself as Claude uses it" } : null;
|
|
121
|
+
}
|
|
122
|
+
// Whether the project this folder uses already has its database connected.
|
|
123
|
+
function projectConnected(dir) {
|
|
124
|
+
const ref = linkedRef(dir);
|
|
125
|
+
return ref ? supabaseProjects().includes(ref) : supabaseProjects().length > 0;
|
|
126
|
+
}
|
|
127
|
+
async function supabase(dir, given, ref, interactive = false, auto = false) {
|
|
128
|
+
const label = (r) => (r === LOCAL ? "the local database" : `project ${r}`);
|
|
129
|
+
// On its own, Backstep only notices: connecting is the person's call.
|
|
130
|
+
if (auto) {
|
|
131
|
+
if (!projectUses(dir).supabase || supabaseDismissed(dir))
|
|
132
|
+
return { service: "supabase", state: "unused", detail: "nothing to ask" };
|
|
133
|
+
if (projectConnected(dir))
|
|
134
|
+
return { service: "supabase", state: "covered", detail: "already connected" };
|
|
135
|
+
return { service: "supabase", state: "ask", detail: "this project uses Supabase, but Backstep isn't connected to it" };
|
|
136
|
+
}
|
|
137
|
+
// An access token (sbp_…) rather than a connection string: one sign-in for every project.
|
|
138
|
+
if (given?.startsWith("sbp_")) {
|
|
139
|
+
try {
|
|
140
|
+
await saveSupabaseToken(given);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return { service: "supabase", state: "needs", detail: err instanceof Error ? err.message : String(err) };
|
|
144
|
+
}
|
|
145
|
+
return (await signedIn(dir, given, ref, true));
|
|
146
|
+
}
|
|
147
|
+
if (given) {
|
|
148
|
+
try {
|
|
149
|
+
return { service: "supabase", state: "connected", detail: `connected ${label(await connectSupabase(given, ref))}` };
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
return { service: "supabase", state: "needs", detail: err instanceof Error ? err.message : String(err) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const had = supabaseProjects();
|
|
156
|
+
const errors = [];
|
|
157
|
+
for (const url of candidates(dir)) {
|
|
158
|
+
const r = ref ?? projectRef(url) ?? LOCAL;
|
|
159
|
+
if (had.includes(r))
|
|
160
|
+
continue;
|
|
161
|
+
// A Postgres URL that isn't Supabase's belongs to something else, and so
|
|
162
|
+
// does a local one in a project that doesn't use Supabase.
|
|
163
|
+
if (r === LOCAL && (!/@(127\.0\.0\.1|localhost)[:/]/.test(url) || !projectUses(dir).supabase))
|
|
164
|
+
continue;
|
|
165
|
+
try {
|
|
166
|
+
await connectSupabase(url, ref);
|
|
167
|
+
return { service: "supabase", state: "connected", detail: `connected ${label(r)}, found in the project's settings` };
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Already signed in to Supabase, here or through its own CLI: keep that sign-in.
|
|
174
|
+
const token = supabaseToken(interactive);
|
|
175
|
+
if (token) {
|
|
176
|
+
const isNew = !hasSavedToken();
|
|
177
|
+
if (isNew)
|
|
178
|
+
await saveSupabaseToken(token).catch(() => null);
|
|
179
|
+
const s = await signedIn(dir, token, ref, isNew);
|
|
180
|
+
if (s)
|
|
181
|
+
return s;
|
|
182
|
+
return { service: "supabase", state: "covered", detail: "signed in to Supabase, so each project connects itself as Claude uses it" };
|
|
183
|
+
}
|
|
184
|
+
if (had.length)
|
|
185
|
+
return { service: "supabase", state: "covered", detail: `already connected: ${had.map(label).join(", ")}` };
|
|
186
|
+
const used = /supabase/i.test(servers(dir).join("\n")) || projectUses(dir).supabase;
|
|
187
|
+
if (!used && errors.length === 0)
|
|
188
|
+
return { service: "supabase", state: "unused", detail: "this project doesn't use Supabase" };
|
|
189
|
+
return {
|
|
190
|
+
service: "supabase",
|
|
191
|
+
state: "needs",
|
|
192
|
+
detail: `needs a Supabase sign-in. Easiest: an access token, made once at ${TOKENS} ("Generate new token"), which connects every project. ` +
|
|
193
|
+
`Or the database's connection string (${WHERE})${errors.length ? `. Tried the one in the project's settings: ${errors[0]}` : ""}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// firebase-tools keeps its sign-in here once someone runs `firebase login`.
|
|
197
|
+
function firebaseSignedIn() {
|
|
198
|
+
try {
|
|
199
|
+
const c = JSON.parse(read(join(homedir(), ".config", "configstore", "firebase-tools.json")));
|
|
200
|
+
return Boolean(c.tokens?.refresh_token);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// `auto`: on its own as a session starts, so a server is only added when it would work.
|
|
207
|
+
function firebase(dir, cli, auto = false) {
|
|
208
|
+
if (servers(dir).some((s) => /firebase|firestore/i.test(s))) {
|
|
209
|
+
return { service: "firebase", state: "covered", detail: "Claude reaches Firebase through its MCP server, so its changes can be undone" };
|
|
210
|
+
}
|
|
211
|
+
if (!projectUses(dir).firebase)
|
|
212
|
+
return { service: "firebase", state: "unused", detail: "this project doesn't use Firebase" };
|
|
213
|
+
if (auto && !firebaseSignedIn()) {
|
|
214
|
+
return { service: "firebase", state: "needs", detail: "sign in to Firebase once (run: npx firebase-tools login), and Backstep connects it by itself" };
|
|
215
|
+
}
|
|
216
|
+
// For this project only, like `claude mcp add` without -s.
|
|
217
|
+
const r = spawnSync("claude", ["mcp", "add", "firebase", "--", ...FIREBASE_SERVER], { cwd: dir, encoding: "utf8" });
|
|
218
|
+
if (r.status !== 0) {
|
|
219
|
+
return { service: "firebase", state: "needs", detail: `couldn't add Firebase's MCP server: ${`${r.stderr ?? ""}${r.stdout ?? ""}`.trim().split("\n")[0] || "claude mcp add failed"}` };
|
|
220
|
+
}
|
|
221
|
+
wrapServers(cli, { onlyDir: dir });
|
|
222
|
+
return {
|
|
223
|
+
service: "firebase",
|
|
224
|
+
state: "added",
|
|
225
|
+
detail: "added Firebase's MCP server for this project, behind Backstep. Restart Claude Code to use it (and run `npx firebase-tools login` first if you've never signed in to Firebase)",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// `auto`: run by the Claude Code mod as a session starts, with nobody asked.
|
|
229
|
+
// `interactive`: a person is at the terminal (macOS may ask them to share `supabase login`'s sign-in).
|
|
230
|
+
export async function connect(opts) {
|
|
231
|
+
const out = [];
|
|
232
|
+
if (opts.only !== "firebase")
|
|
233
|
+
out.push(await supabase(opts.dir, opts.url, opts.ref, opts.interactive, opts.auto));
|
|
234
|
+
if (opts.only !== "supabase")
|
|
235
|
+
out.push(firebase(opts.dir, opts.cli, opts.auto));
|
|
236
|
+
return out;
|
|
237
|
+
}
|
package/dist/firebase.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Firestore names documents projects/<p>/databases/<d>/documents/<path>.
|
|
2
|
+
const DOC_NAME = /^projects\/[^/]+\/databases\/[^/]+\/documents\/.+/;
|
|
3
|
+
function text(r) {
|
|
4
|
+
return (r.content ?? []).map((c) => (c.type === "text" ? c.text : "")).join("");
|
|
5
|
+
}
|
|
6
|
+
function data(r) {
|
|
7
|
+
if (r.structuredContent)
|
|
8
|
+
return r.structuredContent;
|
|
9
|
+
try {
|
|
10
|
+
const v = JSON.parse(text(r));
|
|
11
|
+
return v && typeof v === "object" ? v : {};
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const notFound = (r) => r.isError === true && /not[ _-]?found|404|no document/i.test(text(r));
|
|
18
|
+
class ReadFailed extends Error {
|
|
19
|
+
}
|
|
20
|
+
function readOk(r, what) {
|
|
21
|
+
if (!r)
|
|
22
|
+
throw new ReadFailed(`couldn't read ${what} first`);
|
|
23
|
+
if (r.isError && !notFound(r))
|
|
24
|
+
throw new ReadFailed(`couldn't read ${what} first: ${text(r).slice(0, 200)}`);
|
|
25
|
+
return r;
|
|
26
|
+
}
|
|
27
|
+
// Splits "firestore_update_document" into its prefix and the base tool name.
|
|
28
|
+
// Google's own Firestore server has no prefix.
|
|
29
|
+
function split(tool, bases) {
|
|
30
|
+
for (const base of bases) {
|
|
31
|
+
if (tool === base)
|
|
32
|
+
return { prefix: "", base };
|
|
33
|
+
if (tool.endsWith(`_${base}`))
|
|
34
|
+
return { prefix: tool.slice(0, -base.length), base };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function firestore(tool, args) {
|
|
39
|
+
const t = split(tool, ["update_document", "delete_document", "add_document"]);
|
|
40
|
+
if (!t || (t.prefix !== "" && t.prefix !== "firestore_"))
|
|
41
|
+
return null;
|
|
42
|
+
const p = t.prefix;
|
|
43
|
+
const restore = (name, before) => notFound(before)
|
|
44
|
+
? { tool: `${p}delete_document`, args: { name } }
|
|
45
|
+
: { tool: `${p}update_document`, args: { document: { name, fields: data(before).fields ?? {} } } };
|
|
46
|
+
if (t.base === "update_document") {
|
|
47
|
+
const name = args.document?.name;
|
|
48
|
+
if (typeof name !== "string" || !DOC_NAME.test(name))
|
|
49
|
+
return null;
|
|
50
|
+
return {
|
|
51
|
+
read: { tool: `${p}get_document`, args: { name } },
|
|
52
|
+
undo: (read) => restore(name, readOk(read, name)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (t.base === "delete_document") {
|
|
56
|
+
const name = args.name;
|
|
57
|
+
if (typeof name !== "string" || !DOC_NAME.test(name))
|
|
58
|
+
return null;
|
|
59
|
+
return {
|
|
60
|
+
read: { tool: `${p}get_document`, args: { name } },
|
|
61
|
+
undo: (read) => {
|
|
62
|
+
const before = readOk(read, name);
|
|
63
|
+
// Deleting a document that wasn't there changed nothing.
|
|
64
|
+
return notFound(before) ? null : restore(name, before);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (typeof args.parent !== "string" || typeof args.collectionId !== "string")
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
undo: (_, result) => {
|
|
72
|
+
const name = data(result).name;
|
|
73
|
+
if (typeof name !== "string")
|
|
74
|
+
throw new ReadFailed("the new document's name wasn't in the result");
|
|
75
|
+
return { tool: `${p}delete_document`, args: { name } };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function realtimeDatabase(tool, args) {
|
|
80
|
+
const t = split(tool, ["set_data"]);
|
|
81
|
+
if (!t || !/^(realtimedatabase|database)_$/.test(t.prefix) || typeof args.path !== "string")
|
|
82
|
+
return null;
|
|
83
|
+
const where = { path: args.path, ...(typeof args.databaseUrl === "string" ? { databaseUrl: args.databaseUrl } : {}) };
|
|
84
|
+
return {
|
|
85
|
+
read: { tool: `${t.prefix}get_data`, args: where },
|
|
86
|
+
undo: (read) => {
|
|
87
|
+
const before = text(readOk(read, args.path)).trim();
|
|
88
|
+
let value;
|
|
89
|
+
try {
|
|
90
|
+
value = JSON.parse(before || "null");
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new ReadFailed(`couldn't read ${args.path} first: ${before.slice(0, 200)}`);
|
|
94
|
+
}
|
|
95
|
+
// The REST API answers a refused read with {"error": "..."}.
|
|
96
|
+
if (value && typeof value === "object" && Object.keys(value).length === 1 && "error" in value) {
|
|
97
|
+
throw new ReadFailed(`couldn't read ${args.path} first: ${value.error}`);
|
|
98
|
+
}
|
|
99
|
+
// Writing null deletes the path, which is right when nothing was there.
|
|
100
|
+
return { tool: `${t.prefix}set_data`, args: { ...where, data: JSON.stringify(value) } };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function remoteConfig(tool) {
|
|
105
|
+
if (tool !== "remoteconfig_update_template")
|
|
106
|
+
return null;
|
|
107
|
+
return {
|
|
108
|
+
read: { tool: "remoteconfig_get_template", args: {} },
|
|
109
|
+
undo: (read) => {
|
|
110
|
+
const r = readOk(read, "the Remote Config template");
|
|
111
|
+
const version = Number(data(r).version?.versionNumber);
|
|
112
|
+
// The first template has nothing before it to roll back to.
|
|
113
|
+
if (notFound(r) || !Number.isFinite(version) || version <= 0)
|
|
114
|
+
return null;
|
|
115
|
+
return { tool: "remoteconfig_update_template", args: { version_number: version } };
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function auth(tool, args) {
|
|
120
|
+
// Custom claims can be set but not removed through the server, so only enable/disable is undoable.
|
|
121
|
+
if (tool !== "auth_update_user" || typeof args.uid !== "string" || typeof args.disabled !== "boolean")
|
|
122
|
+
return null;
|
|
123
|
+
const uid = args.uid;
|
|
124
|
+
return {
|
|
125
|
+
read: { tool: "auth_get_users", args: { uids: [uid] } },
|
|
126
|
+
undo: (read) => {
|
|
127
|
+
const users = data(readOk(read, `user ${uid}`)).users;
|
|
128
|
+
if (!users?.[0])
|
|
129
|
+
throw new ReadFailed(`couldn't find user ${uid} first`);
|
|
130
|
+
return { tool: "auth_update_user", args: { uid, disabled: users[0].disabled === true } };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function firebasePlan(tool, args) {
|
|
135
|
+
return firestore(tool, args) ?? realtimeDatabase(tool, args) ?? remoteConfig(tool) ?? auth(tool, args);
|
|
136
|
+
}
|
|
137
|
+
export { ReadFailed };
|
package/dist/look.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// How Backstep looks in a terminal: the mark (two rewind triangles, white over
|
|
2
|
+
// pink), pink for Backstep, white for what matters, dim for the rest. Plain
|
|
3
|
+
// text where colour isn't wanted (NO_COLOR, piped output).
|
|
4
|
+
const on = !process.env.NO_COLOR && (Boolean(process.env.FORCE_COLOR) || (process.stdout.isTTY === true && process.env.TERM !== "dumb"));
|
|
5
|
+
const truecolor = /truecolor|24bit/i.test(process.env.COLORTERM ?? "");
|
|
6
|
+
const paint = (code) => (s) => (on ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
7
|
+
// The mark's pink, #ee7bb6, or the nearest of the 256 colours.
|
|
8
|
+
export const pink = paint(truecolor ? "38;2;238;123;182" : "38;5;211");
|
|
9
|
+
export const white = paint("1;97");
|
|
10
|
+
export const dim = paint("2");
|
|
11
|
+
export const width = () => Math.min(process.stdout.columns || 80, 92);
|
|
12
|
+
// Words wrapped to `cols`, each line after the first indented by `indent`.
|
|
13
|
+
export function wrap(text, cols, indent) {
|
|
14
|
+
const lines = [];
|
|
15
|
+
let line = "";
|
|
16
|
+
for (const word of text.split(" ")) {
|
|
17
|
+
if (line && line.length + 1 + word.length > cols) {
|
|
18
|
+
lines.push(line);
|
|
19
|
+
line = word;
|
|
20
|
+
}
|
|
21
|
+
else
|
|
22
|
+
line = line ? `${line} ${word}` : word;
|
|
23
|
+
}
|
|
24
|
+
if (line)
|
|
25
|
+
lines.push(line);
|
|
26
|
+
return lines.join(`\n${" ".repeat(indent)}`);
|
|
27
|
+
}
|
|
28
|
+
// The front triangle white, the one behind it pink, as on the logo.
|
|
29
|
+
export function logo(tagline) {
|
|
30
|
+
const front = [" ▄█", " ▄███", "▄█████", " ▀███", " ▀█"];
|
|
31
|
+
const back = [" ▄█", " ▄███", "▄█████", " ▀███", " ▀█"];
|
|
32
|
+
const side = ["", white("Backstep"), dim(tagline), "", ""];
|
|
33
|
+
return "\n" + front.map((f, i) => ` ${white(f)}${pink(back[i])} ${side[i]}`.trimEnd()).join("\n");
|
|
34
|
+
}
|