backstep 0.0.0-stage → 0.1.1
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 +116 -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/servers.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { homeDir } from "./store.js";
|
|
6
|
+
const TAG = "BACKSTEP_WRAPPED";
|
|
7
|
+
const PG = /^postgres(ql)?:\/\//;
|
|
8
|
+
export function claudeConfigFile() {
|
|
9
|
+
const dir = process.env.CLAUDE_CONFIG_DIR;
|
|
10
|
+
return dir ? join(dir, ".claude.json") : join(homedir(), ".claude.json");
|
|
11
|
+
}
|
|
12
|
+
const savedFile = () => join(homeDir(), "wrapped.json");
|
|
13
|
+
const settingsFile = () => join(homeDir(), "settings.json");
|
|
14
|
+
// Claude Code may store an entry's keys in another order than they were given.
|
|
15
|
+
const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, canonical(x)])) : v;
|
|
16
|
+
const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
17
|
+
const key = (s) => `${s.scope}\0${s.dir ?? ""}\0${s.name}`;
|
|
18
|
+
function readJson(file, fallback) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeJson(file, value) {
|
|
27
|
+
mkdirSync(homeDir(), { recursive: true });
|
|
28
|
+
const tmp = `${file}.backstep-tmp`;
|
|
29
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
30
|
+
renameSync(tmp, file);
|
|
31
|
+
}
|
|
32
|
+
// `backstep unwrap` turns this off so a new session doesn't wrap everything again; setup turns it back on.
|
|
33
|
+
export function autoWrap(on) {
|
|
34
|
+
const s = readJson(settingsFile(), {});
|
|
35
|
+
if (on === undefined)
|
|
36
|
+
return s.autoWrap !== false;
|
|
37
|
+
writeJson(settingsFile(), { ...s, autoWrap: on });
|
|
38
|
+
return on;
|
|
39
|
+
}
|
|
40
|
+
// Tagged by us, or wrapped by hand: `backstep wrap …`, `node …/cli.js wrap …`.
|
|
41
|
+
function isWrapped(e) {
|
|
42
|
+
if (e.env?.[TAG])
|
|
43
|
+
return true;
|
|
44
|
+
const words = [e.command ?? "", ...(e.args ?? [])];
|
|
45
|
+
return words.some((w, i) => /(^|\/)(backstep|cli\.[jt]s)$/.test(w) && words[i + 1] === "wrap");
|
|
46
|
+
}
|
|
47
|
+
// Whether calls to a server, by the name its tools carry, go through Backstep's
|
|
48
|
+
// proxy. Claude Code spells a server's name in tool names with _ for anything
|
|
49
|
+
// but letters, digits, _ and -.
|
|
50
|
+
export function isWrappedServer(toolServer, cwd) {
|
|
51
|
+
const config = readJson(claudeConfigFile(), {});
|
|
52
|
+
const spell = (n) => n.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
53
|
+
const project = Object.entries(config.projects ?? {}).find(([dir]) => resolve(dir) === resolve(cwd))?.[1];
|
|
54
|
+
const shared = readJson(join(cwd, ".mcp.json"), {}).mcpServers ?? {};
|
|
55
|
+
// A project's own server shadows a shared one, which shadows the person's.
|
|
56
|
+
for (const servers of [project?.mcpServers ?? {}, shared, config.mcpServers ?? {}]) {
|
|
57
|
+
const hit = Object.entries(servers).find(([n]) => spell(n) === toolServer);
|
|
58
|
+
if (hit)
|
|
59
|
+
return isWrapped(hit[1]);
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
export function wrapEntry(name, e, cli) {
|
|
64
|
+
if (isWrapped(e))
|
|
65
|
+
return { skip: "already behind Backstep" };
|
|
66
|
+
const base = ["--disable-warning=ExperimentalWarning", cli, "wrap", "--name", name];
|
|
67
|
+
const env = { [TAG]: "1" };
|
|
68
|
+
if (e.command && (e.type ?? "stdio") === "stdio") {
|
|
69
|
+
const args = e.args ?? [];
|
|
70
|
+
const pg = [...Object.values(e.env ?? {}), ...args].find((v) => PG.test(v));
|
|
71
|
+
if (pg)
|
|
72
|
+
env.BACKSTEP_PG_URL = pg;
|
|
73
|
+
return {
|
|
74
|
+
entry: { ...e, type: "stdio", command: "node", args: [...base, "--", e.command, ...args], env: { ...e.env, ...env } },
|
|
75
|
+
note: pg ? "file and database changes can be undone" : "changes can be undone",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (e.url && (e.type === "http" || e.type === "sse")) {
|
|
79
|
+
// No headers usually means Claude Code signs in for it, and we can't borrow that sign-in.
|
|
80
|
+
const headers = Object.entries(e.headers ?? {});
|
|
81
|
+
if (headers.length === 0 || e.headersHelper || e.oauth)
|
|
82
|
+
return { skip: "uses sign-in, so Backstep watches its calls from inside Claude Code instead" };
|
|
83
|
+
// Header values travel in env, not args, so tokens don't show up in the process list.
|
|
84
|
+
headers.forEach(([k, v], i) => (env[`BACKSTEP_HEADER_${i}`] = `${k}: ${v}`));
|
|
85
|
+
const { url, headers: _, ...rest } = e;
|
|
86
|
+
return { entry: { ...rest, type: "stdio", command: "node", args: [...base, "--url", url], env }, note: "changes can be undone" };
|
|
87
|
+
}
|
|
88
|
+
return { skip: `${e.type ?? "this"} servers aren't supported yet` };
|
|
89
|
+
}
|
|
90
|
+
// Approved in Claude Code's prompt, or every server of the project allowed in its settings.
|
|
91
|
+
function approved(dir, p, name) {
|
|
92
|
+
if (p.disabledMcpjsonServers?.includes(name))
|
|
93
|
+
return false;
|
|
94
|
+
if (p.enabledMcpjsonServers?.includes(name))
|
|
95
|
+
return true;
|
|
96
|
+
for (const f of ["settings.local.json", "settings.json"]) {
|
|
97
|
+
const s = readJson(join(dir, ".claude", f), {});
|
|
98
|
+
if (s.enableAllProjectMcpServers || s.enabledMcpjsonServers?.includes(name))
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
// Every server Claude Code would start, with what it would start for each. A
|
|
104
|
+
// project's own server shadows a shared one of the same name, so that's the one
|
|
105
|
+
// listed, unless it's the wrapped copy Backstep made of the shared one.
|
|
106
|
+
function findServers(config, saved, onlyDir) {
|
|
107
|
+
const found = [];
|
|
108
|
+
for (const [name, entry] of Object.entries(config.mcpServers ?? {}))
|
|
109
|
+
found.push({ scope: "user", name, entry });
|
|
110
|
+
for (const [dir, p] of Object.entries(config.projects ?? {})) {
|
|
111
|
+
if (onlyDir && resolve(dir) !== resolve(onlyDir))
|
|
112
|
+
continue;
|
|
113
|
+
const own = p.mcpServers ?? {};
|
|
114
|
+
const shared = readJson(join(dir, ".mcp.json"), {}).mcpServers ?? {};
|
|
115
|
+
const copied = (name) => saved.some((s) => s.scope === "shared" && s.dir === dir && s.name === name);
|
|
116
|
+
for (const [name, entry] of Object.entries(own)) {
|
|
117
|
+
if (!(copied(name) && name in shared))
|
|
118
|
+
found.push({ scope: "local", dir, name, entry });
|
|
119
|
+
}
|
|
120
|
+
for (const [name, entry] of Object.entries(shared)) {
|
|
121
|
+
if ((!(name in own) || copied(name)) && approved(dir, p, name))
|
|
122
|
+
found.push({ scope: "shared", dir, name, entry });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
function claude(args, dir) {
|
|
128
|
+
const r = spawnSync("claude", args, { cwd: dir && existsSync(dir) ? dir : homedir(), encoding: "utf8", timeout: 30_000 });
|
|
129
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
130
|
+
}
|
|
131
|
+
// The scope Claude Code knows a server by. A shared server's wrapped copy lives in the project's own.
|
|
132
|
+
const cliScope = (s) => (s === "user" ? "user" : "local");
|
|
133
|
+
// Replaces a server's entry, putting the old one back if the new one is refused.
|
|
134
|
+
function replace(scope, dir, name, next, prev) {
|
|
135
|
+
const s = cliScope(scope);
|
|
136
|
+
if (prev) {
|
|
137
|
+
const r = claude(["mcp", "remove", "-s", s, name], dir);
|
|
138
|
+
if (!r.ok)
|
|
139
|
+
return r.out.split("\n")[0] || "claude mcp remove failed";
|
|
140
|
+
}
|
|
141
|
+
if (!next)
|
|
142
|
+
return null;
|
|
143
|
+
const r = claude(["mcp", "add-json", "-s", s, name, JSON.stringify(next)], dir);
|
|
144
|
+
if (r.ok)
|
|
145
|
+
return null;
|
|
146
|
+
if (prev)
|
|
147
|
+
claude(["mcp", "add-json", "-s", s, name, JSON.stringify(prev)], dir);
|
|
148
|
+
return r.out.split("\n")[0] || "claude mcp add-json failed";
|
|
149
|
+
}
|
|
150
|
+
// Wraps every server not yet behind Backstep. With `onlyDir`, just the servers a
|
|
151
|
+
// session in that folder would start: the person's own and that project's.
|
|
152
|
+
export function wrapServers(cli, opts = {}) {
|
|
153
|
+
const file = claudeConfigFile();
|
|
154
|
+
if (!existsSync(file))
|
|
155
|
+
return [];
|
|
156
|
+
const config = readJson(file, {});
|
|
157
|
+
const saved = readJson(savedFile(), []);
|
|
158
|
+
const changes = [];
|
|
159
|
+
for (const f of findServers(config, saved, opts.onlyDir)) {
|
|
160
|
+
const where = { scope: f.scope, dir: f.dir, name: f.name };
|
|
161
|
+
if (f.dir && !existsSync(f.dir))
|
|
162
|
+
continue;
|
|
163
|
+
// Wrapped before: wrap the original again, so it points at this copy of Backstep,
|
|
164
|
+
// or at a shared server's new settings if the repo changed them.
|
|
165
|
+
const prev = saved.find((s) => key(s) === key(where));
|
|
166
|
+
const current = f.scope === "shared" ? config.projects?.[f.dir]?.mcpServers?.[f.name] ?? null : f.entry;
|
|
167
|
+
if (prev && current && !same(prev.wrapped, current))
|
|
168
|
+
continue; // changed by hand since; leave it
|
|
169
|
+
const original = f.scope === "shared" ? f.entry : prev?.original ?? f.entry;
|
|
170
|
+
const r = wrapEntry(f.name, original, cli);
|
|
171
|
+
if ("skip" in r) {
|
|
172
|
+
if (!prev)
|
|
173
|
+
changes.push({ ...where, done: false, note: r.skip });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (prev && same(prev.wrapped, r.entry))
|
|
177
|
+
continue;
|
|
178
|
+
const err = replace(f.scope, f.dir, f.name, r.entry, f.scope === "shared" ? (prev ? current : null) : f.entry);
|
|
179
|
+
if (err) {
|
|
180
|
+
changes.push({ ...where, done: false, note: err });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (prev)
|
|
184
|
+
Object.assign(prev, { original, wrapped: r.entry });
|
|
185
|
+
else
|
|
186
|
+
saved.push({ ...where, original, wrapped: r.entry });
|
|
187
|
+
changes.push({ ...where, done: true, note: f.scope === "shared" ? `${r.note} (the repo's .mcp.json is unchanged)` : r.note });
|
|
188
|
+
writeJson(savedFile(), saved);
|
|
189
|
+
}
|
|
190
|
+
return changes;
|
|
191
|
+
}
|
|
192
|
+
export function unwrapServers() {
|
|
193
|
+
const saved = readJson(savedFile(), []);
|
|
194
|
+
const config = readJson(claudeConfigFile(), {});
|
|
195
|
+
const changes = [];
|
|
196
|
+
const left = [];
|
|
197
|
+
for (const s of saved) {
|
|
198
|
+
const where = { scope: s.scope, dir: s.dir, name: s.name };
|
|
199
|
+
const current = s.scope === "user" ? config.mcpServers?.[s.name] : config.projects?.[s.dir]?.mcpServers?.[s.name];
|
|
200
|
+
if (!current) {
|
|
201
|
+
changes.push({ ...where, done: false, note: "removed since, nothing to put back" });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!same(current, s.wrapped)) {
|
|
205
|
+
changes.push({ ...where, done: false, note: "changed since setup, left as it is" });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// A shared server's copy is just removed, and the repo's own entry takes over again.
|
|
209
|
+
const err = replace(s.scope, s.dir, s.name, s.scope === "shared" ? null : s.original, current);
|
|
210
|
+
if (err) {
|
|
211
|
+
changes.push({ ...where, done: false, note: err });
|
|
212
|
+
left.push(s);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
changes.push({ ...where, done: true, note: "restored" });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
writeJson(savedFile(), left);
|
|
219
|
+
return changes;
|
|
220
|
+
}
|
|
221
|
+
export function printChanges(changes) {
|
|
222
|
+
for (const c of changes) {
|
|
223
|
+
const where = c.dir ? ` (${c.dir.replace(homedir(), "~")})` : "";
|
|
224
|
+
console.log(` ${c.done ? "✓" : "–"} ${c.name}: ${c.note}${where}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { homeDir } from "./store.js";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { autoWrap, wrapServers } from "./servers.js";
|
|
9
|
+
import { check, printCheck } from "./check.js";
|
|
10
|
+
import { dim, logo, pink, white, width, wrap } from "./look.js";
|
|
11
|
+
import { sendInstall, telemetryOn } from "./telemetry.js";
|
|
12
|
+
// Both src/ and dist/ sit one level below the package root, next to mod/.
|
|
13
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
const UNDO_KEY = "ctrl+z";
|
|
15
|
+
// The mod's undo button borrows this action; see mod/hooks/register.tsx.
|
|
16
|
+
const UNDO_ACTION = "app:cycleDiffBase";
|
|
17
|
+
function run(cmd, args) {
|
|
18
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
19
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim(), missing: r.error !== undefined };
|
|
20
|
+
}
|
|
21
|
+
// npx runs us from a cache that gets cleaned, so the mod needs a lasting copy.
|
|
22
|
+
// A global install also puts `backstep` on the PATH; where npm's global folder
|
|
23
|
+
// isn't writable (often root-owned on macOS), keep this npx copy under ~/.backstep.
|
|
24
|
+
function keepCopy() {
|
|
25
|
+
const { version } = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
|
26
|
+
console.log(dim(` Installing backstep@${version}…`));
|
|
27
|
+
if (run("npm", ["install", "-g", `backstep@${version}`]).ok) {
|
|
28
|
+
return { root: join(run("npm", ["root", "-g"]).out, "backstep"), onPath: true };
|
|
29
|
+
}
|
|
30
|
+
const modules = join(homeDir(), "app", "node_modules");
|
|
31
|
+
rmSync(modules, { recursive: true, force: true });
|
|
32
|
+
cpSync(dirname(ROOT), modules, { recursive: true });
|
|
33
|
+
return { root: join(modules, "backstep"), onPath: false };
|
|
34
|
+
}
|
|
35
|
+
function bindUndoKey() {
|
|
36
|
+
const dir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
37
|
+
const file = join(dir, "keybindings.json");
|
|
38
|
+
let config = {
|
|
39
|
+
$schema: "https://www.schemastore.org/claude-code-keybindings.json",
|
|
40
|
+
$docs: "https://code.claude.com/docs/en/keybindings",
|
|
41
|
+
};
|
|
42
|
+
if (existsSync(file)) {
|
|
43
|
+
try {
|
|
44
|
+
config = JSON.parse(readFileSync(file, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return `couldn't read ${file}, so ${UNDO_KEY} isn't bound. Add "${UNDO_KEY}": "${UNDO_ACTION}" under Global and Chat.`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
config.bindings ??= [];
|
|
51
|
+
for (const context of ["Global", "Chat"]) {
|
|
52
|
+
let block = config.bindings.find((b) => b.context === context);
|
|
53
|
+
if (!block)
|
|
54
|
+
config.bindings.push((block = { context, bindings: {} }));
|
|
55
|
+
const taken = block.bindings[UNDO_KEY];
|
|
56
|
+
if (taken && taken !== UNDO_ACTION)
|
|
57
|
+
return `${UNDO_KEY} is already bound to ${taken} in ${file}; left it alone.`;
|
|
58
|
+
block.bindings[UNDO_KEY] = UNDO_ACTION;
|
|
59
|
+
}
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
const WHERE = `In Supabase, open your project, press Connect at the top, and copy the URI under
|
|
65
|
+
"Direct connection" (or "Session pooler" if your network has no IPv6). Put your
|
|
66
|
+
database password in place of [YOUR-PASSWORD].`;
|
|
67
|
+
export async function askConnectionString() {
|
|
68
|
+
if (!process.stdin.isTTY)
|
|
69
|
+
throw new Error("give the connection string: backstep connect supabase <postgresql://…>");
|
|
70
|
+
console.log(` ${wrap(WHERE.replaceAll("\n", " "), width() - 4, 2)}\n`);
|
|
71
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
72
|
+
try {
|
|
73
|
+
const url = (await rl.question("Connection string: ")).trim();
|
|
74
|
+
if (!url)
|
|
75
|
+
throw new Error("no connection string given");
|
|
76
|
+
return url;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
rl.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function setup() {
|
|
83
|
+
console.log(logo("undo for everything Claude does"));
|
|
84
|
+
console.log(dim("\n Setting up…"));
|
|
85
|
+
if (run("claude", ["--version"]).missing) {
|
|
86
|
+
throw new Error("Claude Code isn't installed (no `claude` on PATH). Install it from https://claude.com/claude-code, then run this again.");
|
|
87
|
+
}
|
|
88
|
+
const { root, onPath } = ROOT.includes("/_npx/") ? keepCopy() : { root: ROOT, onPath: true };
|
|
89
|
+
const cli = join(root, "dist", "cli.js");
|
|
90
|
+
// Without `backstep` on the PATH, the commands printed below go through npx.
|
|
91
|
+
const bs = onPath ? "backstep" : "npx -y backstep";
|
|
92
|
+
const install = () => run("claude", ["plugin", "install", "backstep", "--marketplace", join(root, "mod"), "--config", `cli=${cli}`]);
|
|
93
|
+
let r = install();
|
|
94
|
+
// Installed before from another copy (the file a person was given, or ~/.backstep before npm worked):
|
|
95
|
+
// Claude Code keeps one source per name, so swap the old one for this.
|
|
96
|
+
if (!r.ok && /already added from a different source/i.test(r.out)) {
|
|
97
|
+
run("claude", ["plugin", "marketplace", "remove", "backstep"]);
|
|
98
|
+
r = install();
|
|
99
|
+
}
|
|
100
|
+
if (!r.ok)
|
|
101
|
+
throw new Error(`claude plugin install failed:\n${r.out}`);
|
|
102
|
+
// Only a problem is worth a line here; the summary below says the rest.
|
|
103
|
+
const bound = bindUndoKey();
|
|
104
|
+
if (bound)
|
|
105
|
+
console.log(` ${white("!")} ${bound}`);
|
|
106
|
+
// Wrap last, so a failed install never leaves servers pointing at a Backstep that isn't there.
|
|
107
|
+
autoWrap(true);
|
|
108
|
+
wrapServers(cli);
|
|
109
|
+
// What's covered and what isn't, said once here and kept up to date by `backstep status`.
|
|
110
|
+
console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(UNDO_KEY)} after any turn to undo it.`);
|
|
111
|
+
printCheck(check(process.cwd()), bs);
|
|
112
|
+
if (telemetryOn()) {
|
|
113
|
+
console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
|
|
114
|
+
await sendInstall();
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/shell.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Decides what a shell command Claude is about to run could do, before it runs.
|
|
2
|
+
// Reads pass straight through; commands that reach outside the machine can't be
|
|
3
|
+
// taken back, so they're held; everything else gets the project snapshotted.
|
|
4
|
+
// Programs that only look. A pipeline or chain is a read only if every part is.
|
|
5
|
+
const READ_PROGRAMS = new Set([
|
|
6
|
+
"ls", "ll", "la", "cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "ag", "fd", "find", "tree",
|
|
7
|
+
"wc", "du", "df", "pwd", "echo", "printf", "which", "whereis", "type", "file", "stat", "diff", "cmp", "sort", "uniq",
|
|
8
|
+
"cut", "tr", "column", "jq", "yq", "date", "whoami", "id", "uname", "env", "printenv", "ps", "top", "uptime",
|
|
9
|
+
"basename", "dirname", "realpath", "readlink", "md5", "md5sum", "shasum", "sha256sum", "true", "false", "test", "[",
|
|
10
|
+
"sleep", "man", "tldr", "history", "lsof", "hostname", "cal", "nproc",
|
|
11
|
+
]);
|
|
12
|
+
const READ_GIT = new Set([
|
|
13
|
+
"status", "log", "diff", "show", "blame", "grep", "ls-files", "ls-tree", "rev-parse", "describe", "shortlog",
|
|
14
|
+
"reflog", "cat-file", "whatchanged", "count-objects", "check-ignore", "merge-base", "name-rev", "for-each-ref",
|
|
15
|
+
]);
|
|
16
|
+
// `git branch` and friends only read when they're just listing.
|
|
17
|
+
const LISTING_GIT = new Set(["branch", "tag", "remote", "stash", "worktree", "config"]);
|
|
18
|
+
const READ_VERSION = /^(-v|--version|-h|--help|version|help)$/;
|
|
19
|
+
// Commands that act on something outside this machine.
|
|
20
|
+
const OUTBOUND = [
|
|
21
|
+
[/^git (push|send-email|svn dcommit)\b/, "pushes to a remote"],
|
|
22
|
+
[/^(npm|pnpm|yarn|bun) (publish|unpublish|deprecate|dist-tag)\b/, "publishes a package"],
|
|
23
|
+
[/^(cargo|gem|twine|poetry|flit) (publish|push|upload)\b/, "publishes a package"],
|
|
24
|
+
[/^gh (pr (create|merge|close|comment|review|edit)|issue (create|close|comment|edit|delete)|release (create|delete|upload)|repo (create|delete|edit)|secret|workflow run|api)\b/, "changes GitHub"],
|
|
25
|
+
[/^(vercel|netlify|fly|flyctl|heroku|firebase|wrangler|railway|surge)\b(?!.*\s(--version|-v|--help|login|whoami|logs|ls|list)(\s|$))/, "deploys"],
|
|
26
|
+
[/^(kubectl (apply|delete|create|patch|replace|scale|rollout)|helm (install|upgrade|uninstall|rollback)|terraform (apply|destroy)|pulumi (up|destroy)|aws |gcloud |az )/, "changes cloud resources"],
|
|
27
|
+
[/^docker (push|login)\b/, "pushes an image"],
|
|
28
|
+
[/^(ssh|scp|sftp|rsync .*:\S)/, "runs on another machine"],
|
|
29
|
+
[/^(curl|wget|http|xh)\b.*(\s-X\s*(POST|PUT|PATCH|DELETE)|\s--request\s+(POST|PUT|PATCH|DELETE)|\s(-d|--data\S*|-F|--form|-T|--upload-file)\b|\s(POST|PUT|PATCH|DELETE)\s)/i, "sends a request"],
|
|
30
|
+
[/^(sendmail|mail|mutt)\b/, "sends email"],
|
|
31
|
+
[/^(psql|mysql|mongosh|redis-cli)\b/, "changes a database directly"],
|
|
32
|
+
];
|
|
33
|
+
// Splits on ; && || | and newlines, outside quotes. Good enough to judge each part.
|
|
34
|
+
function parts(cmd) {
|
|
35
|
+
const out = [];
|
|
36
|
+
let cur = "";
|
|
37
|
+
let quote = null;
|
|
38
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
39
|
+
const c = cmd[i];
|
|
40
|
+
if (quote) {
|
|
41
|
+
if (c === quote)
|
|
42
|
+
quote = null;
|
|
43
|
+
cur += c;
|
|
44
|
+
}
|
|
45
|
+
else if (c === "'" || c === '"') {
|
|
46
|
+
quote = c;
|
|
47
|
+
cur += c;
|
|
48
|
+
}
|
|
49
|
+
else if ((c === ";" || c === "\n" || c === "|" || c === "&") && !(c === "&" && (cmd[i - 1] === ">" || cmd[i + 1] === ">"))) {
|
|
50
|
+
out.push(cur);
|
|
51
|
+
cur = "";
|
|
52
|
+
if (cmd[i + 1] === c)
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
cur += c;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
out.push(cur);
|
|
60
|
+
return out.map((p) => p.trim()).filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
// Drops leading VAR=value assignments and wrappers like sudo, time, env.
|
|
63
|
+
function strip(part) {
|
|
64
|
+
let p = part.replace(/^\(+/, "").trim();
|
|
65
|
+
for (;;) {
|
|
66
|
+
const next = p
|
|
67
|
+
.replace(/^[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|\S*)\s+/, "")
|
|
68
|
+
.replace(/^(sudo|time|nice|nohup|command|exec|env)\s+/, "")
|
|
69
|
+
.replace(/^npx\s+(-y\s+|--yes\s+)?/, "");
|
|
70
|
+
if (next === p)
|
|
71
|
+
return p;
|
|
72
|
+
p = next;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function isRead(part) {
|
|
76
|
+
// Writing to a file through redirection is a write, whatever the program.
|
|
77
|
+
if (/(^|[^0-9&])>{1,2}\s*(?!&|\/dev\/null)/.test(part.replace(/'[^']*'|"[^"]*"/g, "")))
|
|
78
|
+
return false;
|
|
79
|
+
const words = part.split(/\s+/);
|
|
80
|
+
const [prog, sub, ...rest] = words;
|
|
81
|
+
if (READ_PROGRAMS.has(prog)) {
|
|
82
|
+
if (prog === "find" && /\s-(delete|exec|execdir|ok|fprint)\b/.test(part))
|
|
83
|
+
return false;
|
|
84
|
+
if ((prog === "sort" || prog === "tree") && /\s-o\b/.test(part))
|
|
85
|
+
return false;
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (sub !== undefined && READ_VERSION.test(sub))
|
|
89
|
+
return true;
|
|
90
|
+
if (prog === "git" && sub !== undefined) {
|
|
91
|
+
if (READ_GIT.has(sub))
|
|
92
|
+
return true;
|
|
93
|
+
if (LISTING_GIT.has(sub)) {
|
|
94
|
+
const args = rest.filter((w) => w !== "-v" && w !== "-vv" && w !== "-a" && w !== "-r" && w !== "--list" && w !== "-l" && w !== "list" && w !== "--all");
|
|
95
|
+
return args.length === 0 || (sub === "config" && (args[0] === "--get" || args[0] === "--get-all"));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if ((prog === "npm" || prog === "pnpm" || prog === "yarn") && /^(ls|list|view|info|outdated|why|audit)$/.test(sub ?? ""))
|
|
99
|
+
return true;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
export function classifyShell(command) {
|
|
103
|
+
const ps = parts(command).map(strip);
|
|
104
|
+
for (const p of ps) {
|
|
105
|
+
for (const [re, reason] of OUTBOUND)
|
|
106
|
+
if (re.test(p))
|
|
107
|
+
return { kind: "outbound", reason };
|
|
108
|
+
}
|
|
109
|
+
if (ps.length > 0 && ps.every(isRead))
|
|
110
|
+
return { kind: "read" };
|
|
111
|
+
return { kind: "write" };
|
|
112
|
+
}
|
package/dist/sql.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Just enough SQL understanding to know which tables a statement will change.
|
|
2
|
+
// This is not a parser: anything it can't place is reported as "unknown" so
|
|
3
|
+
// the caller treats it as irreversible instead of guessing.
|
|
4
|
+
const IDENT = String.raw `(?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?`;
|
|
5
|
+
const LIST = String.raw `${IDENT}(?:\s*,\s*${IDENT})*`;
|
|
6
|
+
// Splits on semicolons outside quotes and comments.
|
|
7
|
+
export function splitStatements(sql) {
|
|
8
|
+
const out = [];
|
|
9
|
+
let cur = "";
|
|
10
|
+
let quote = null;
|
|
11
|
+
for (let i = 0; i < sql.length; i++) {
|
|
12
|
+
const c = sql[i];
|
|
13
|
+
if (quote) {
|
|
14
|
+
cur += c;
|
|
15
|
+
if (c === quote)
|
|
16
|
+
quote = null;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (c === "'" || c === '"') {
|
|
20
|
+
quote = c;
|
|
21
|
+
cur += c;
|
|
22
|
+
}
|
|
23
|
+
else if (c === "-" && sql[i + 1] === "-") {
|
|
24
|
+
const end = sql.indexOf("\n", i);
|
|
25
|
+
i = end === -1 ? sql.length : end;
|
|
26
|
+
cur += " ";
|
|
27
|
+
}
|
|
28
|
+
else if (c === "/" && sql[i + 1] === "*") {
|
|
29
|
+
const end = sql.indexOf("*/", i + 2);
|
|
30
|
+
i = end === -1 ? sql.length : end + 1;
|
|
31
|
+
cur += " ";
|
|
32
|
+
}
|
|
33
|
+
else if (c === ";") {
|
|
34
|
+
if (cur.trim())
|
|
35
|
+
out.push(cur.trim());
|
|
36
|
+
cur = "";
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
cur += c;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (cur.trim())
|
|
43
|
+
out.push(cur.trim());
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function names(list) {
|
|
47
|
+
const re = new RegExp(IDENT, "g");
|
|
48
|
+
return [...list.matchAll(re)].map((m) => m[0].replace(/\s+/g, ""));
|
|
49
|
+
}
|
|
50
|
+
export function classifyStatement(text) {
|
|
51
|
+
const s = text.replace(/\s+/g, " ").trim();
|
|
52
|
+
const base = { text, tables: [], ifExists: false };
|
|
53
|
+
let m;
|
|
54
|
+
if (/^(select|show|explain|values|table)\b/i.test(s))
|
|
55
|
+
return { ...base, op: "read" };
|
|
56
|
+
if (/^with\b/i.test(s)) {
|
|
57
|
+
// A CTE that writes anywhere is too tangled to snapshot reliably.
|
|
58
|
+
return { ...base, op: /\b(insert|update|delete|merge)\b/i.test(s) ? "unknown" : "read" };
|
|
59
|
+
}
|
|
60
|
+
if ((m = s.match(new RegExp(String.raw `^insert\s+into\s+(${IDENT})`, "i")))) {
|
|
61
|
+
return { ...base, op: "insert", tables: names(m[1]) };
|
|
62
|
+
}
|
|
63
|
+
if ((m = s.match(new RegExp(String.raw `^update\s+(?:only\s+)?(${IDENT})`, "i")))) {
|
|
64
|
+
return { ...base, op: "update", tables: names(m[1]) };
|
|
65
|
+
}
|
|
66
|
+
if ((m = s.match(new RegExp(String.raw `^delete\s+from\s+(?:only\s+)?(${IDENT})`, "i")))) {
|
|
67
|
+
return { ...base, op: "delete", tables: names(m[1]) };
|
|
68
|
+
}
|
|
69
|
+
if ((m = s.match(new RegExp(String.raw `^truncate\s+(?:table\s+)?(?:only\s+)?(${LIST})`, "i")))) {
|
|
70
|
+
// CASCADE empties tables we can't see from here.
|
|
71
|
+
if (/\bcascade\b/i.test(s))
|
|
72
|
+
return { ...base, op: "unknown" };
|
|
73
|
+
return { ...base, op: "truncate", tables: names(m[1]) };
|
|
74
|
+
}
|
|
75
|
+
if ((m = s.match(new RegExp(String.raw `^drop\s+table\s+(if\s+exists\s+)?(${LIST})`, "i")))) {
|
|
76
|
+
return { ...base, op: "drop", tables: names(m[2]), ifExists: Boolean(m[1]) };
|
|
77
|
+
}
|
|
78
|
+
if ((m = s.match(new RegExp(String.raw `^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?(${IDENT})`, "i")))) {
|
|
79
|
+
return { ...base, op: "alter", tables: names(m[1]) };
|
|
80
|
+
}
|
|
81
|
+
if ((m = s.match(new RegExp(String.raw `^create\s+(?:unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?(${IDENT})`, "i")))) {
|
|
82
|
+
return { ...base, op: "create", tables: names(m[1]) };
|
|
83
|
+
}
|
|
84
|
+
return { ...base, op: "unknown" };
|
|
85
|
+
}
|
|
86
|
+
export function parseSql(sql) {
|
|
87
|
+
return splitStatements(sql).map(classifyStatement);
|
|
88
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The journal: every run and every tool call an agent makes through Backstep.
|
|
2
|
+
// SQLite in WAL mode so the proxy and the dashboard can share it across processes.
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
export function homeDir() {
|
|
9
|
+
return process.env.BACKSTEP_HOME ?? join(homedir(), ".backstep");
|
|
10
|
+
}
|
|
11
|
+
export function snapshotDir(actionId) {
|
|
12
|
+
return join(homeDir(), "snapshots", actionId);
|
|
13
|
+
}
|
|
14
|
+
export function shortId() {
|
|
15
|
+
return randomUUID().replaceAll("-", "").slice(0, 12);
|
|
16
|
+
}
|
|
17
|
+
export class Store {
|
|
18
|
+
db;
|
|
19
|
+
constructor(dir = homeDir()) {
|
|
20
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
const file = join(dir, "journal.db");
|
|
22
|
+
this.db = new DatabaseSync(file);
|
|
23
|
+
// The journal can hold database URLs, so keep it private to this user.
|
|
24
|
+
chmodSync(file, 0o600);
|
|
25
|
+
this.db.exec(`
|
|
26
|
+
PRAGMA journal_mode = WAL;
|
|
27
|
+
PRAGMA busy_timeout = 5000;
|
|
28
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
server TEXT NOT NULL,
|
|
31
|
+
command TEXT NOT NULL,
|
|
32
|
+
pg_url TEXT,
|
|
33
|
+
started_at INTEGER NOT NULL,
|
|
34
|
+
ended_at INTEGER
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS actions (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
39
|
+
seq INTEGER NOT NULL,
|
|
40
|
+
tool TEXT NOT NULL,
|
|
41
|
+
args TEXT NOT NULL,
|
|
42
|
+
kind TEXT NOT NULL,
|
|
43
|
+
status TEXT NOT NULL,
|
|
44
|
+
reason TEXT,
|
|
45
|
+
undo TEXT,
|
|
46
|
+
result TEXT,
|
|
47
|
+
error TEXT,
|
|
48
|
+
created_at INTEGER NOT NULL,
|
|
49
|
+
release_at INTEGER,
|
|
50
|
+
undone_at INTEGER
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS actions_run ON actions(run_id, seq);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS actions_created ON actions(created_at);
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
createRun(server, command, pgUrl) {
|
|
57
|
+
const run = {
|
|
58
|
+
id: shortId(),
|
|
59
|
+
server,
|
|
60
|
+
command,
|
|
61
|
+
pg_url: pgUrl,
|
|
62
|
+
started_at: Date.now(),
|
|
63
|
+
ended_at: null,
|
|
64
|
+
};
|
|
65
|
+
this.db
|
|
66
|
+
.prepare("INSERT INTO runs (id, server, command, pg_url, started_at) VALUES (?, ?, ?, ?, ?)")
|
|
67
|
+
.run(run.id, run.server, run.command, run.pg_url, run.started_at);
|
|
68
|
+
return run;
|
|
69
|
+
}
|
|
70
|
+
// Claude Code's own tool calls have no proxy to open a run, so each session
|
|
71
|
+
// gets one, found again by its command on every call.
|
|
72
|
+
sessionRun(server, command) {
|
|
73
|
+
const found = this.db
|
|
74
|
+
.prepare("SELECT * FROM runs WHERE server = ? AND command = ? ORDER BY started_at DESC LIMIT 1")
|
|
75
|
+
.get(server, command);
|
|
76
|
+
return found ?? this.createRun(server, command, null);
|
|
77
|
+
}
|
|
78
|
+
endRun(id) {
|
|
79
|
+
this.db.prepare("UPDATE runs SET ended_at = ? WHERE id = ?").run(Date.now(), id);
|
|
80
|
+
}
|
|
81
|
+
getRun(id) {
|
|
82
|
+
return this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
|
|
83
|
+
}
|
|
84
|
+
listRuns(limit = 50) {
|
|
85
|
+
return this.db
|
|
86
|
+
.prepare(`SELECT r.*,
|
|
87
|
+
(SELECT COUNT(*) FROM actions a WHERE a.run_id = r.id) AS actions,
|
|
88
|
+
(SELECT COUNT(*) FROM actions a WHERE a.run_id = r.id AND a.undo IS NOT NULL AND a.status IN ('ok', 'error')) AS undoable
|
|
89
|
+
FROM runs r ORDER BY r.started_at DESC LIMIT ?`)
|
|
90
|
+
.all(limit);
|
|
91
|
+
}
|
|
92
|
+
addAction(a) {
|
|
93
|
+
const id = shortId();
|
|
94
|
+
const seq = (this.db.prepare("SELECT MAX(seq) AS m FROM actions WHERE run_id = ?").get(a.run_id)
|
|
95
|
+
.m ?? 0) + 1;
|
|
96
|
+
const created_at = Date.now();
|
|
97
|
+
this.db
|
|
98
|
+
.prepare(`INSERT INTO actions (id, run_id, seq, tool, args, kind, status, reason, undo, result, error, created_at, release_at)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
100
|
+
.run(id, a.run_id, seq, a.tool, a.args, a.kind, a.status, a.reason, a.undo, a.result, a.error, created_at, a.release_at);
|
|
101
|
+
return { ...a, id, seq, created_at, undone_at: null };
|
|
102
|
+
}
|
|
103
|
+
updateAction(id, fields) {
|
|
104
|
+
const keys = Object.keys(fields);
|
|
105
|
+
if (keys.length === 0)
|
|
106
|
+
return;
|
|
107
|
+
const set = keys.map((k) => `${k} = ?`).join(", ");
|
|
108
|
+
const values = keys.map((k) => fields[k] ?? null);
|
|
109
|
+
this.db.prepare(`UPDATE actions SET ${set} WHERE id = ?`).run(...values, id);
|
|
110
|
+
}
|
|
111
|
+
// Moves an action between states only if it is still in the expected one,
|
|
112
|
+
// so the proxy releasing a hold and a user cancelling it can't both win.
|
|
113
|
+
transition(id, from, to) {
|
|
114
|
+
const r = this.db.prepare("UPDATE actions SET status = ? WHERE id = ? AND status = ?").run(to, id, from);
|
|
115
|
+
return r.changes === 1;
|
|
116
|
+
}
|
|
117
|
+
getAction(id) {
|
|
118
|
+
return this.db.prepare("SELECT * FROM actions WHERE id = ?").get(id);
|
|
119
|
+
}
|
|
120
|
+
listActions(runId) {
|
|
121
|
+
return this.db.prepare("SELECT * FROM actions WHERE run_id = ? ORDER BY seq").all(runId);
|
|
122
|
+
}
|
|
123
|
+
feed(limit = 200) {
|
|
124
|
+
return this.db
|
|
125
|
+
.prepare(`SELECT a.*, r.server FROM actions a JOIN runs r ON r.id = a.run_id
|
|
126
|
+
ORDER BY a.created_at DESC LIMIT ?`)
|
|
127
|
+
.all(limit);
|
|
128
|
+
}
|
|
129
|
+
close() {
|
|
130
|
+
this.db.close();
|
|
131
|
+
}
|
|
132
|
+
}
|