backstep 0.1.19 → 0.1.21
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 +1 -0
- package/dist/adapters/fs.js +4 -8
- package/dist/adapters/tree.js +10 -5
- package/dist/check.js +5 -1
- package/dist/cli.js +10 -3
- package/dist/connect.js +2 -2
- package/dist/github.js +1 -1
- package/dist/notion.js +191 -0
- package/dist/paths.js +31 -0
- package/dist/servers.js +1 -1
- package/dist/setup.js +2 -2
- package/dist/shell.js +2 -1
- package/dist/spawn.js +6 -0
- package/dist/undo.js +9 -2
- package/dist/uninstall.js +5 -2
- package/dist/update.js +1 -1
- package/dist/watch.js +45 -2
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +57 -4
- package/mod/hooks/register.tsx +67 -19
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
|
|
|
8
8
|
- **Supabase:** `execute_sql` and `apply_migration` snapshot the tables they change, over a connection to the project's database. Backstep never connects it on its own: when Claude Code opens a project that uses Supabase and isn't connected, it asks (Connect, Not now, or Don't ask for this project). Connect uses a connection string in the project's `.env` files, the local `supabase start` database, or a Supabase sign-in (`supabase login`, `SUPABASE_ACCESS_TOKEN`, or an access token you give Backstep once), with which the project gets a temporary database login that Backstep renews as it runs out. You can also ask Claude to "connect Backstep to Supabase" any time. Deleted rows, dropped tables and migrations, including the migration's history row, all come back. Works for the claude.ai Supabase connector and a local Supabase MCP server alike.
|
|
9
9
|
- **Firebase:** before a change, Backstep reads what's there through the Firebase MCP server itself, and undoes through it too, so there's nothing to set up. Firestore documents updated, deleted or added; Realtime Database writes; Remote Config publishes (rolled back to the version before); and users enabled or disabled.
|
|
10
10
|
- **claude.ai connectors and servers Claude Code signs in to:** Backstep can't sit in front of these, so the Claude Code mod watches their calls instead. Calls with an undo rule are undone through Claude Code's own connection.
|
|
11
|
+
- **Notion** (the claude.ai connector, or Notion's own MCP server): built in, nothing to set up. Pages and rows Claude creates, duplicates or databases it makes go to Notion's Trash on undo; pages it deletes come back from Trash; an edit (content, properties, icon, cover) is undone by writing back the page as the mod read it just before; moved pages go back where each came from. A page too big to read whole, or a result Backstep can't read the new pages from, is recorded and marked not undoable rather than guessed at.
|
|
11
12
|
- **Database commands Claude runs** (`supabase db push`, `supabase migration up`, `supabase db reset`, `psql`, `prisma migrate`, `drizzle-kit push`, seed scripts…): in a project whose Supabase database is connected, the whole database is noted before the command runs, and ctrl+u puts it back in one transaction: rows, new tables dropped, dropped or altered tables rebuilt with their constraints, indexes, foreign keys, row level security policies, triggers and grants, sequence counters, and the migration history. Copies sit in a `backstep` schema in the same database; the newest 10 per database are kept. On a very big database it keeps every table's structure and the migration history, and rows smallest-table-first up to a budget (`BACKSTEP_DB_SNAPSHOT_MB`, default 250); undo names any table whose rows it couldn't bring back. Functions, views, types and extensions aren't restored.
|
|
12
13
|
- **Supabase through its web API** (curl to `/rest/v1`, seed and app scripts using supabase-js): Backstep finds Supabase projects in the folder Claude Code opened or a level or two below, and the first time Claude is about to change one that isn't connected, it asks before the command runs. Connect uses the project's own `SUPABASE_URL` and service role key from its `.env`, no password: every table's rows are saved on this machine before each such command (smallest first, up to the size budget), and ctrl+u removes rows added since and writes back the old ones. Table changes need the database itself connected (password or access token).
|
|
13
14
|
- **Any Postgres database** (Neon, Railway, Render, AWS, a local one) named in the project's `.env` (`DATABASE_URL` and the like): Backstep asks once, then saves the whole database before Claude's database commands (`psql`, `prisma migrate`, `drizzle-kit push`, seed and app scripts) and puts it back on undo, the same way as Supabase.
|
package/dist/adapters/fs.js
CHANGED
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
// Restoring "the state each path was in" handles writes, edits, deletes,
|
|
3
3
|
// creates and moves with one rule.
|
|
4
4
|
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
5
|
-
import {
|
|
6
|
-
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
7
6
|
import { snapshotDir } from "../store.js";
|
|
7
|
+
import { insidePath, resolveIn } from "../paths.js";
|
|
8
8
|
const MAX_BYTES = 50 * 1024 * 1024;
|
|
9
9
|
export function resolvePath(p, cwd) {
|
|
10
|
-
|
|
11
|
-
return join(homedir(), p.slice(1));
|
|
12
|
-
return resolve(cwd, p);
|
|
10
|
+
return resolveIn(p, cwd);
|
|
13
11
|
}
|
|
14
12
|
function sizeOf(p, budget) {
|
|
15
13
|
const st = lstatSync(p);
|
|
@@ -27,9 +25,7 @@ function sizeOf(p, budget) {
|
|
|
27
25
|
// Whether a path sits in the folder Claude Code has open. Edits elsewhere belong to
|
|
28
26
|
// other projects: Backstep never records them, so undo here can never touch them.
|
|
29
27
|
export function insideDir(path, dir) {
|
|
30
|
-
|
|
31
|
-
const d = resolve(dir).replace(/\/$/, "");
|
|
32
|
-
return p === d || p.startsWith(d + "/");
|
|
28
|
+
return insidePath(resolvePath(path, dir), dir);
|
|
33
29
|
}
|
|
34
30
|
export function snapshotPaths(actionId, paths, cwd) {
|
|
35
31
|
const dir = snapshotDir(actionId);
|
package/dist/adapters/tree.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { isDiskRoot } from "../paths.js";
|
|
10
11
|
import { execFileSync } from "node:child_process";
|
|
11
12
|
import { homeDir, snapshotDir } from "../store.js";
|
|
12
13
|
const SQLITE_BUDGET = () => Number(process.env.BACKSTEP_DB_SNAPSHOT_MB ?? 250) * 1024 * 1024;
|
|
@@ -26,6 +27,8 @@ function isSqlite(path) {
|
|
|
26
27
|
// SQLite database files in a project, four folders deep at most.
|
|
27
28
|
export function sqliteFiles(root) {
|
|
28
29
|
const out = [];
|
|
30
|
+
// Backstep's own folder holds its live history: never saved or put back as a project's database.
|
|
31
|
+
const own = resolve(homeDir());
|
|
29
32
|
const walk = (dir, depth) => {
|
|
30
33
|
let entries = [];
|
|
31
34
|
try {
|
|
@@ -37,7 +40,7 @@ export function sqliteFiles(root) {
|
|
|
37
40
|
for (const e of entries) {
|
|
38
41
|
const p = join(dir, e.name);
|
|
39
42
|
if (e.isDirectory()) {
|
|
40
|
-
if (depth > 0 && !SKIP_DIRS.test(e.name))
|
|
43
|
+
if (depth > 0 && !SKIP_DIRS.test(e.name) && resolve(p) !== own)
|
|
41
44
|
walk(p, depth - 1);
|
|
42
45
|
}
|
|
43
46
|
else if (/\.(db|sqlite3?|db3)$/i.test(e.name) && isSqlite(p))
|
|
@@ -137,7 +140,9 @@ function tryGit(args, cwd) {
|
|
|
137
140
|
}
|
|
138
141
|
// The project a command runs in: its git repository, or the folder itself.
|
|
139
142
|
export function projectRoot(cwd) {
|
|
140
|
-
|
|
143
|
+
// git answers C:/Users/me/app on Windows; resolve gives the system's own spelling.
|
|
144
|
+
const top = tryGit(["rev-parse", "--show-toplevel"], cwd);
|
|
145
|
+
return resolve(top ?? cwd);
|
|
141
146
|
}
|
|
142
147
|
function shadowFor(root) {
|
|
143
148
|
const gitDir = join(homeDir(), "trees", createHash("sha256").update(root).digest("hex").slice(0, 16) + ".git");
|
|
@@ -147,7 +152,7 @@ function shadowFor(root) {
|
|
|
147
152
|
mkdirSync(join(gitDir, "info"), { recursive: true });
|
|
148
153
|
// Backstep's own folder, when it sits inside the project, holds the live journal: never snapshotted.
|
|
149
154
|
const own = relative(root, resolve(homeDir()));
|
|
150
|
-
const skipOwn = own && !own.startsWith("..") && !isAbsolute(own) ? [`/${own}/`] : [];
|
|
155
|
+
const skipOwn = own && !own.startsWith("..") && !isAbsolute(own) ? [`/${own.split(sep).join("/")}/`] : [];
|
|
151
156
|
writeFileSync(join(gitDir, "info", "exclude"), [...EXCLUDE, ...skipOwn].join("\n") + "\n");
|
|
152
157
|
writeFileSync(join(gitDir, "description"), `Backstep snapshots of ${root}\n`);
|
|
153
158
|
}
|
|
@@ -191,7 +196,7 @@ function projectGit(root, actionId, pushes) {
|
|
|
191
196
|
// whole disk, or a folder too big to stage in time.
|
|
192
197
|
export function snapshotTree(actionId, cwd, opts = {}) {
|
|
193
198
|
const root = projectRoot(cwd);
|
|
194
|
-
if (root
|
|
199
|
+
if (isDiskRoot(root) || root === homedir() || root === resolve(homeDir()))
|
|
195
200
|
return null;
|
|
196
201
|
try {
|
|
197
202
|
const gitDir = shadowFor(root);
|
package/dist/check.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// what to connect next. Setup prints it, `backstep status` prints it, and the
|
|
3
3
|
// Claude Code mod checks it as a session starts, so nobody assumes a service
|
|
4
4
|
// is covered when it isn't.
|
|
5
|
-
import { spawnSync } from "
|
|
5
|
+
import { spawnSync } from "./spawn.js";
|
|
6
6
|
import { configServers, projectConnected, projectUses, supabaseDirs } from "./connect.js";
|
|
7
7
|
import { supabaseProjects } from "./supabase.js";
|
|
8
8
|
import { watchRules } from "./watch.js";
|
|
@@ -11,6 +11,7 @@ import { DEFAULT_KEY, keyLabel, undoKeys } from "./keys.js";
|
|
|
11
11
|
const WRAPPED = /(^|[/\s])(backstep|cli\.[jt]s) wrap\b/;
|
|
12
12
|
const SUPABASE = /supabase/i;
|
|
13
13
|
const FIREBASE = /firebase|firestore/i;
|
|
14
|
+
const NOTION = /notion/i;
|
|
14
15
|
// `claude mcp list` sees everything a session would, plugins and claude.ai
|
|
15
16
|
// connectors included, and whether each works; it prints "name: target - ✔ Connected".
|
|
16
17
|
function listed(dir) {
|
|
@@ -47,6 +48,9 @@ function classify(s, supabaseConnected, ruled) {
|
|
|
47
48
|
if (wrappable)
|
|
48
49
|
return { name, level: "connect", detail: "undoable from the next Claude Code session", signIn };
|
|
49
50
|
}
|
|
51
|
+
// Notion's own opposite calls are built in (src/notion.ts), through the connector or its own server.
|
|
52
|
+
if (NOTION.test(s.name))
|
|
53
|
+
return { name, level: "undoable", detail: "pages, edits, moves and deletes", signIn };
|
|
50
54
|
if (ruled.has(s.name.replace(/[^a-zA-Z0-9_-]/g, "_")))
|
|
51
55
|
return { name, level: "undoable", detail: "the tools your undo rules cover", signIn };
|
|
52
56
|
if (wrapped)
|
package/dist/cli.js
CHANGED
|
@@ -542,12 +542,19 @@ async function main() {
|
|
|
542
542
|
// arguments, and later its result, come as JSON on stdin.
|
|
543
543
|
if (cmd === "watch-start") {
|
|
544
544
|
const sep = rest.indexOf("--");
|
|
545
|
-
const { values } = parseArgs({
|
|
545
|
+
const { values } = parseArgs({
|
|
546
|
+
args: sep === -1 ? rest : rest.slice(0, sep),
|
|
547
|
+
options: { session: { type: "string" }, cwd: { type: "string" }, before: { type: "boolean", default: false } },
|
|
548
|
+
});
|
|
546
549
|
const tool = sep === -1 ? "" : rest[sep + 1];
|
|
547
550
|
if (!values.session || !tool)
|
|
548
|
-
throw new Error("usage: backstep watch-start --session <id> [--cwd <dir>] -- <mcp__server__tool> (arguments as JSON on stdin)");
|
|
551
|
+
throw new Error("usage: backstep watch-start --session <id> [--cwd <dir>] [--before] -- <mcp__server__tool> (arguments as JSON on stdin)");
|
|
549
552
|
const input = readFileSync(0, "utf8").trim();
|
|
550
|
-
|
|
553
|
+
const parsed = input ? JSON.parse(input) : {};
|
|
554
|
+
// --before: stdin is { args, before }, with the pages the mod read just before the call.
|
|
555
|
+
const args = values.before ? (parsed.args ?? {}) : parsed;
|
|
556
|
+
const before = values.before ? (parsed.before ?? {}) : {};
|
|
557
|
+
console.log(JSON.stringify(await watchStart(new Store(), values.session, values.cwd ?? process.cwd(), tool, args, before)));
|
|
551
558
|
return;
|
|
552
559
|
}
|
|
553
560
|
if (cmd === "watch-end") {
|
package/dist/connect.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// - Firebase: undo runs through Firebase's own MCP server, so there's nothing
|
|
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
|
-
import { spawnSync } from "
|
|
10
|
+
import { spawnSync } from "./spawn.js";
|
|
11
11
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -107,7 +107,7 @@ export function firebaseCreds(dir) {
|
|
|
107
107
|
const vars = envVars(dir);
|
|
108
108
|
const named = vars.find(([k]) => k === "GOOGLE_APPLICATION_CREDENTIALS")?.[1] ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
109
109
|
if (named) {
|
|
110
|
-
const f =
|
|
110
|
+
const f = resolve(dir, named);
|
|
111
111
|
if (existsSync(f))
|
|
112
112
|
return f;
|
|
113
113
|
}
|
package/dist/github.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Things Claude makes on GitHub with the gh command line (a repository, an
|
|
2
2
|
// issue, a pull request, a release), noted from what gh printed, so undo can
|
|
3
3
|
// offer to take them away again with gh itself, signed in as the person.
|
|
4
|
-
import { spawn, spawnSync } from "
|
|
4
|
+
import { spawn, spawnSync } from "./spawn.js";
|
|
5
5
|
const CREATES = /\bgh\s+(repo|issue|pr|release)\s+create\b/g;
|
|
6
6
|
const URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?(?:\/(issues|pull)\/(\d+)|\/releases\/tag\/([^\s)]+))?(?=[\s)'"]|$)/g;
|
|
7
7
|
// What a gh command made, from the command and what it printed.
|
package/dist/notion.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Notion, through Claude Code's connector (or Notion's own MCP server): Backstep
|
|
2
|
+
// can't copy Notion, so each change is undone with Notion's opposite call.
|
|
3
|
+
//
|
|
4
|
+
// create pages or rows, duplicate a page → move what was made to Trash
|
|
5
|
+
// create a database → move it to Trash
|
|
6
|
+
// delete pages or databases → restore them from Trash
|
|
7
|
+
// edit a page → write back the page as read just before
|
|
8
|
+
// move pages → move them back to where they were
|
|
9
|
+
//
|
|
10
|
+
// Edits and moves need the page as it was: the Claude Code mod reads it with
|
|
11
|
+
// Notion's fetch right before the call and hands it over as `before`.
|
|
12
|
+
// Tools that only read, never change anything.
|
|
13
|
+
const READS = /^notion-(fetch|search|get-|list-|query-|view-|download-)/;
|
|
14
|
+
// Notion's own ids: 32 hex characters, with or without dashes.
|
|
15
|
+
const ID = /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;
|
|
16
|
+
const bare = (id) => id.replace(/-/g, "").toLowerCase();
|
|
17
|
+
export const isNotionTool = (tool) => /^notion-/.test(tool);
|
|
18
|
+
// Which pages the mod should read before the call runs.
|
|
19
|
+
export function notionReadsFirst(tool, args) {
|
|
20
|
+
if (tool === "notion-update-page" && typeof args.page_id === "string")
|
|
21
|
+
return [args.page_id];
|
|
22
|
+
if (tool === "notion-move-pages" && Array.isArray(args.page_or_database_ids))
|
|
23
|
+
return args.page_or_database_ids.filter((x) => typeof x === "string").slice(0, 20);
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
// Arguments to change before the call: page creation must answer with the new pages' ids.
|
|
27
|
+
export function notionArgs(tool, args) {
|
|
28
|
+
if (tool === "notion-create-pages" && args.allow_async !== false)
|
|
29
|
+
return { allow_async: false };
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
function parseRead(raw) {
|
|
33
|
+
let outer = {};
|
|
34
|
+
try {
|
|
35
|
+
outer = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
outer = { text: raw };
|
|
39
|
+
}
|
|
40
|
+
const text = typeof outer.text === "string" ? outer.text : raw;
|
|
41
|
+
if (!/<page[\s>]/.test(text) && !/<database[\s>]/.test(text))
|
|
42
|
+
return null;
|
|
43
|
+
const between = (tag) => {
|
|
44
|
+
const m = text.match(new RegExp(`<${tag}>\\n?([\\s\\S]*?)\\n?</${tag}>`));
|
|
45
|
+
return m ? m[1] : null;
|
|
46
|
+
};
|
|
47
|
+
let properties = null;
|
|
48
|
+
try {
|
|
49
|
+
const p = between("properties");
|
|
50
|
+
properties = p ? JSON.parse(p) : null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
properties = null;
|
|
54
|
+
}
|
|
55
|
+
const icon = text.match(/<page [^>]*\bicon="([^"]*)"/)?.[1] ?? null;
|
|
56
|
+
const c = outer.cover;
|
|
57
|
+
const cover = c === null ? null : c?.type === "external" && c.external?.url ? c.external.url : undefined;
|
|
58
|
+
// The first entry of the ancestor path is the direct parent; none means the workspace.
|
|
59
|
+
const path = between("ancestor-path");
|
|
60
|
+
let parent = null;
|
|
61
|
+
const first = path?.match(/<(parent-[a-z-]+)\s+url="([^"]+)"/);
|
|
62
|
+
if (!path || !first)
|
|
63
|
+
parent = { type: "workspace" };
|
|
64
|
+
else if (first[1] === "parent-data-source" && first[2].startsWith("collection://"))
|
|
65
|
+
parent = { type: "data_source_id", data_source_id: first[2].slice(13) };
|
|
66
|
+
else if (first[1] === "parent-page")
|
|
67
|
+
parent = { type: "page_id", page_id: bare(first[2].match(ID)?.at(-1) ?? "") };
|
|
68
|
+
else if (first[1] === "parent-database")
|
|
69
|
+
parent = { type: "database_id", database_id: bare(first[2].match(ID)?.at(-1) ?? "") };
|
|
70
|
+
if (parent && Object.values(parent).some((v) => v === ""))
|
|
71
|
+
parent = null;
|
|
72
|
+
// A page fetched in part can't be written back whole.
|
|
73
|
+
const complete = !outer.truncated && !(typeof outer.unknown_block_count === "number" && outer.unknown_block_count > 0) && !/"truncated":\s*true/.test(text);
|
|
74
|
+
return { text, properties, content: between("content"), icon, cover, parent, complete };
|
|
75
|
+
}
|
|
76
|
+
function editUndo(args, raw) {
|
|
77
|
+
const page = typeof args.page_id === "string" ? args.page_id : "";
|
|
78
|
+
const read = raw ? parseRead(raw) : null;
|
|
79
|
+
if (!page || !read)
|
|
80
|
+
return { kind: "never", reason: "Backstep couldn't read the page before it changed" };
|
|
81
|
+
const undo = { page_id: page };
|
|
82
|
+
// An icon or cover can ride along with any command.
|
|
83
|
+
if ("icon" in args)
|
|
84
|
+
undo.icon = read.icon ?? "none";
|
|
85
|
+
if ("cover" in args) {
|
|
86
|
+
if (read.cover === undefined)
|
|
87
|
+
return { kind: "never", reason: "the page's old cover was an upload Notion can't set back" };
|
|
88
|
+
undo.cover = read.cover ?? "none";
|
|
89
|
+
}
|
|
90
|
+
const command = args.command;
|
|
91
|
+
if (command === "update_properties") {
|
|
92
|
+
const changed = args.properties && typeof args.properties === "object" ? Object.keys(args.properties) : [];
|
|
93
|
+
if (changed.length && !read.properties)
|
|
94
|
+
return { kind: "never", reason: "Backstep couldn't read the page's properties before they changed" };
|
|
95
|
+
if (changed.length)
|
|
96
|
+
undo.properties = Object.fromEntries(changed.map((k) => [k, read.properties[k] ?? null]));
|
|
97
|
+
return { kind: "now", calls: [{ tool: "notion-update-page", args: { ...undo, command: "update_properties" } }], label: "put back a page's properties" };
|
|
98
|
+
}
|
|
99
|
+
if (command === "update_content" || command === "replace_content" || command === "insert_content" || command === "apply_template") {
|
|
100
|
+
if (read.content === null || !read.complete)
|
|
101
|
+
return { kind: "never", reason: "the page was too big for Backstep to read whole before it changed" };
|
|
102
|
+
return {
|
|
103
|
+
kind: "now",
|
|
104
|
+
calls: [{ tool: "notion-update-page", args: { ...undo, command: "replace_content", new_str: read.content, allow_deleting_content: true } }],
|
|
105
|
+
label: "put back a page's content",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { kind: "never", reason: `Notion's ${String(command)} can't be taken back` };
|
|
109
|
+
}
|
|
110
|
+
function moveUndo(args, before) {
|
|
111
|
+
const ids = Array.isArray(args.page_or_database_ids) ? args.page_or_database_ids : [];
|
|
112
|
+
if (!ids.length || ids.length > 20)
|
|
113
|
+
return { kind: "never", reason: "too many pages moved at once for Backstep to note where each came from" };
|
|
114
|
+
// One call per old parent: pages that came from the same place go back together.
|
|
115
|
+
const groups = new Map();
|
|
116
|
+
for (const id of ids) {
|
|
117
|
+
const parent = before[id] ? parseRead(before[id])?.parent : null;
|
|
118
|
+
if (!parent)
|
|
119
|
+
return { kind: "never", reason: "Backstep couldn't tell where a moved page came from" };
|
|
120
|
+
const key = JSON.stringify(parent);
|
|
121
|
+
if (!groups.has(key))
|
|
122
|
+
groups.set(key, { parent, ids: [] });
|
|
123
|
+
groups.get(key).ids.push(id);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
kind: "now",
|
|
127
|
+
calls: [...groups.values()].map((g) => ({ tool: "notion-move-pages", args: { page_or_database_ids: g.ids, new_parent: g.parent } })),
|
|
128
|
+
label: `moved ${ids.length === 1 ? "a page" : `${ids.length} pages`} back`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function notionPlan(tool, args, before = {}) {
|
|
132
|
+
if (READS.test(tool))
|
|
133
|
+
return { kind: "read" };
|
|
134
|
+
const ids = (k) => (Array.isArray(args[k]) ? args[k].filter((x) => typeof x === "string") : []);
|
|
135
|
+
switch (tool) {
|
|
136
|
+
case "notion-create-pages":
|
|
137
|
+
return { kind: "after", made: "pages", label: "moved the pages Claude made to Trash" };
|
|
138
|
+
case "notion-duplicate-page":
|
|
139
|
+
return { kind: "after", made: "page", label: "moved the copy Claude made to Trash" };
|
|
140
|
+
case "notion-create-database":
|
|
141
|
+
return { kind: "after", made: "database", label: "moved the database Claude made to Trash" };
|
|
142
|
+
case "notion-delete-pages":
|
|
143
|
+
case "notion-delete-databases": {
|
|
144
|
+
const gone = ids(tool === "notion-delete-pages" ? "page_ids" : "database_ids");
|
|
145
|
+
if (!gone.length)
|
|
146
|
+
return { kind: "never", reason: "nothing to restore" };
|
|
147
|
+
return { kind: "now", calls: [{ tool: "notion-restore-pages", args: { page_ids: gone } }], label: `restored ${gone.length === 1 ? "it" : `${gone.length} of them`} from Trash` };
|
|
148
|
+
}
|
|
149
|
+
case "notion-restore-pages": {
|
|
150
|
+
const back = ids("page_ids");
|
|
151
|
+
return back.length ? { kind: "now", calls: [{ tool: "notion-delete-pages", args: { page_ids: back, user_confirmed: true } }], label: "moved them back to Trash" } : { kind: "never", reason: "nothing to put back" };
|
|
152
|
+
}
|
|
153
|
+
case "notion-update-page":
|
|
154
|
+
return editUndo(args, typeof args.page_id === "string" ? before[args.page_id] : undefined);
|
|
155
|
+
case "notion-move-pages":
|
|
156
|
+
return moveUndo(args, before);
|
|
157
|
+
}
|
|
158
|
+
return { kind: "never", reason: "Notion has no way to take this one back" };
|
|
159
|
+
}
|
|
160
|
+
// The ids of what a create call made, from its result, never the parent it was made under.
|
|
161
|
+
export function notionMade(made, args, text, want) {
|
|
162
|
+
const known = new Set((JSON.stringify(args).match(ID) ?? []).map(bare));
|
|
163
|
+
// The data source and views a new database comes with have ids of their own, written collection:// and view://.
|
|
164
|
+
const sources = new Set([...text.matchAll(/(?:collection|view):\/\/([0-9a-f-]{32,36})/gi)].map((m) => bare(m[1])));
|
|
165
|
+
let found = [];
|
|
166
|
+
try {
|
|
167
|
+
const r = JSON.parse(text);
|
|
168
|
+
if (Array.isArray(r.pages))
|
|
169
|
+
found = r.pages.map((p) => p.id ?? "").filter(Boolean);
|
|
170
|
+
else if (typeof r.id === "string")
|
|
171
|
+
found = [r.id];
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// not JSON: read the ids from the text
|
|
175
|
+
}
|
|
176
|
+
if (!found.length)
|
|
177
|
+
found = text.match(ID) ?? [];
|
|
178
|
+
const ids = [...new Set(found.map(bare))].filter((id) => !known.has(id) && !sources.has(id));
|
|
179
|
+
return ids.length === want ? ids : null;
|
|
180
|
+
}
|
|
181
|
+
export function notionUndoAfter(made, args, text) {
|
|
182
|
+
const want = made === "pages" ? (Array.isArray(args.pages) ? args.pages.length : 0) : 1;
|
|
183
|
+
if (!want || want > 20)
|
|
184
|
+
return null;
|
|
185
|
+
const ids = notionMade(made, args, text, want);
|
|
186
|
+
if (!ids)
|
|
187
|
+
return null;
|
|
188
|
+
return made === "database"
|
|
189
|
+
? { tool: "notion-delete-databases", args: { database_ids: ids, user_confirmed: true } }
|
|
190
|
+
: { tool: "notion-delete-pages", args: { page_ids: ids, user_confirmed: true } };
|
|
191
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Paths on every system Claude Code runs on. Windows writes C:\Users\me\app,
|
|
2
|
+
// and its Bash (Git Bash) writes the same folder as /c/Users/me/app; macOS and
|
|
3
|
+
// Linux write /Users/me/app. Each check here takes the path module to use, so
|
|
4
|
+
// tests can ask the Windows question on any machine.
|
|
5
|
+
import nodePath from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
const native = nodePath;
|
|
8
|
+
const isWin = (p) => p.sep === "\\";
|
|
9
|
+
// A path as Git Bash on Windows writes it (/c/Users/me), turned into Windows' own (C:\Users\me).
|
|
10
|
+
export function fromShellPath(raw, p = native) {
|
|
11
|
+
if (!isWin(p))
|
|
12
|
+
return raw;
|
|
13
|
+
const m = raw.match(/^\/([a-zA-Z])(\/.*)?$/);
|
|
14
|
+
return m ? p.normalize(`${m[1].toUpperCase()}:${m[2] ?? "\\"}`) : raw;
|
|
15
|
+
}
|
|
16
|
+
// A path a tool or command named, made absolute against the folder it ran in. `~` is the home folder.
|
|
17
|
+
export function resolveIn(raw, cwd, p = native, home = homedir()) {
|
|
18
|
+
if (raw === "~" || raw.startsWith("~/") || (isWin(p) && raw.startsWith("~\\")))
|
|
19
|
+
return p.join(home, raw.slice(1));
|
|
20
|
+
return p.resolve(fromShellPath(cwd, p), fromShellPath(raw, p));
|
|
21
|
+
}
|
|
22
|
+
// Whether `path` is `dir` or inside it. Windows ignores letter case in paths.
|
|
23
|
+
export function insidePath(path, dir, p = native) {
|
|
24
|
+
const rel = p.relative(p.resolve(fromShellPath(dir, p)), p.resolve(fromShellPath(path, p)));
|
|
25
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(".." + p.sep) && !p.isAbsolute(rel));
|
|
26
|
+
}
|
|
27
|
+
// The root of a disk: / on macOS and Linux, C:\ on Windows.
|
|
28
|
+
export function isDiskRoot(dir, p = native) {
|
|
29
|
+
const abs = p.resolve(dir);
|
|
30
|
+
return p.parse(abs).root === abs;
|
|
31
|
+
}
|
package/dist/servers.js
CHANGED
package/dist/setup.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnSync } from "
|
|
1
|
+
import { spawnSync } from "./spawn.js";
|
|
2
2
|
import { cpSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -53,7 +53,7 @@ export async function setup() {
|
|
|
53
53
|
if (run("claude", ["--version"]).missing) {
|
|
54
54
|
throw new Error("Claude Code isn't installed (no `claude` on PATH). Install it from https://claude.com/claude-code, then run this again.");
|
|
55
55
|
}
|
|
56
|
-
const { root, onPath } =
|
|
56
|
+
const { root, onPath } = /[\\/]_npx[\\/]/.test(ROOT) ? keepCopy() : { root: ROOT, onPath: true };
|
|
57
57
|
const cli = join(root, "dist", "cli.js");
|
|
58
58
|
// Without `backstep` on the PATH, the commands printed below go through npx.
|
|
59
59
|
const bs = onPath ? "backstep" : "npx -y backstep";
|
package/dist/shell.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Decides what a shell command Claude is about to run could do, before it runs.
|
|
2
2
|
// Reads pass straight through; commands that reach outside the machine can't be
|
|
3
3
|
// taken back, so they're held; everything else gets the project snapshotted.
|
|
4
|
+
import { resolveIn } from "./paths.js";
|
|
4
5
|
// Programs that only look. A pipeline or chain is a read only if every part is.
|
|
5
6
|
const READ_PROGRAMS = new Set([
|
|
6
7
|
"ls", "ll", "la", "cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "ag", "fd", "find", "tree",
|
|
@@ -139,7 +140,7 @@ export function commandDir(command, cwd) {
|
|
|
139
140
|
const to = m?.[1] ?? m?.[2] ?? m?.[3];
|
|
140
141
|
if (!to)
|
|
141
142
|
return cwd;
|
|
142
|
-
return
|
|
143
|
+
return resolveIn(to, cwd);
|
|
143
144
|
}
|
|
144
145
|
// Commands that change Firebase's data some other way than its MCP server.
|
|
145
146
|
const FIREBASE_DATA = /\bfirebase(-tools)?\s+(firestore:|database:)|firestore\.googleapis\.com|firebaseio\.com|firebasedatabase\.app/;
|
package/dist/spawn.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Starting other programs (claude, npm, npx, gh) the same way on every system.
|
|
2
|
+
// On Windows most of them are .cmd scripts, which Node's own spawn can't start
|
|
3
|
+
// without a shell; cross-spawn finds them and quotes their arguments safely.
|
|
4
|
+
import crossSpawn from "cross-spawn";
|
|
5
|
+
export const spawnSync = crossSpawn.sync;
|
|
6
|
+
export const spawn = crossSpawn.spawn;
|
package/dist/undo.js
CHANGED
|
@@ -8,6 +8,7 @@ import { snapshotDir } from "./store.js";
|
|
|
8
8
|
import { dbKey, firebaseConn, freshUrl, restKey } from "./supabase.js";
|
|
9
9
|
import { undoFirebase } from "./adapters/firestore.js";
|
|
10
10
|
import { undoRest } from "./adapters/rest.js";
|
|
11
|
+
import { insidePath } from "./paths.js";
|
|
11
12
|
const INVERSE_TIMEOUT_MS = 20_000;
|
|
12
13
|
// Database copies sit in the person's own database, so only the newest few per
|
|
13
14
|
// database are kept; older commands keep their file undo but not their database undo.
|
|
@@ -60,7 +61,7 @@ function overlaps(a, b) {
|
|
|
60
61
|
return true;
|
|
61
62
|
if (!a.startsWith("fs:") || !b.startsWith("fs:"))
|
|
62
63
|
return false;
|
|
63
|
-
return a.
|
|
64
|
+
return insidePath(a.slice(3), b.slice(3)) || insidePath(b.slice(3), a.slice(3));
|
|
64
65
|
}
|
|
65
66
|
// Failed calls stay undoable: a tool can change things before it errors out.
|
|
66
67
|
function undoable(a) {
|
|
@@ -119,7 +120,13 @@ function undoEngine(store, a, u, opts) {
|
|
|
119
120
|
if (!store.transition(a.id, a.status, "undo_requested")) {
|
|
120
121
|
return { id: a.id, tool: a.tool, ok: false, detail: "this action changed while undoing; try again" };
|
|
121
122
|
}
|
|
122
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
id: a.id,
|
|
125
|
+
tool: a.tool,
|
|
126
|
+
ok: true,
|
|
127
|
+
detail: u.label ?? `runs ${u.tool}`,
|
|
128
|
+
run: { server: u.server, tool: u.tool, args: u.args, ...(u.more?.length ? { more: u.more } : {}), ...(u.label ? { label: u.label } : {}) },
|
|
129
|
+
};
|
|
123
130
|
}
|
|
124
131
|
async function undoOne(store, a, opts = {}) {
|
|
125
132
|
const u = JSON.parse(a.undo);
|
package/dist/uninstall.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 4. the Claude Code plugin and its source are removed
|
|
7
7
|
// 5. ~/.backstep (history, snapshots, saved connections) is deleted
|
|
8
8
|
// 6. the global npm package, when setup installed one
|
|
9
|
-
import { spawnSync } from "
|
|
9
|
+
import { spawnSync } from "./spawn.js";
|
|
10
10
|
import { existsSync, rmSync } from "node:fs";
|
|
11
11
|
import { homeDir, Store } from "./store.js";
|
|
12
12
|
import { unwrapServers } from "./servers.js";
|
|
@@ -23,9 +23,12 @@ async function dropDatabaseCopies() {
|
|
|
23
23
|
if (!existsSync(homeDir()))
|
|
24
24
|
return null;
|
|
25
25
|
const store = new Store();
|
|
26
|
+
// Closed right after reading: Windows won't delete a file that's still open.
|
|
27
|
+
const copies = store.databaseCopies();
|
|
28
|
+
store.close();
|
|
26
29
|
const urls = new Map();
|
|
27
30
|
let dropped = 0;
|
|
28
|
-
for (const a of
|
|
31
|
+
for (const a of copies) {
|
|
29
32
|
const u = JSON.parse(a.undo);
|
|
30
33
|
for (const d of u.dbs ?? []) {
|
|
31
34
|
const url = await freshUrl(d.url).catch(() => d.url);
|
package/dist/update.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// whether npm has a newer version; if so it asks the person, and an update
|
|
3
3
|
// runs the new version's own setup, the same command a first install runs.
|
|
4
4
|
// Off with BACKSTEP_UPDATE_CHECK=0.
|
|
5
|
-
import { spawnSync } from "
|
|
5
|
+
import { spawnSync } from "./spawn.js";
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { homeDir } from "./store.js";
|
package/dist/watch.js
CHANGED
|
@@ -12,6 +12,7 @@ import { buildInverse, findRule, loadRules } from "./rules.js";
|
|
|
12
12
|
import { isWrappedServer } from "./servers.js";
|
|
13
13
|
import { homeDir } from "./store.js";
|
|
14
14
|
import { CONNECT_HINT, isSupabaseSql, supabaseDb, supabasePlanOptions, supabaseUrl } from "./supabase.js";
|
|
15
|
+
import { isNotionTool, notionArgs, notionPlan, notionUndoAfter } from "./notion.js";
|
|
15
16
|
const DELETES = /(^|_|-)(delete|remove|trash|destroy|drop|purge|erase|archive)(_|-|$)/i;
|
|
16
17
|
const MAX_RESULT = 20_000;
|
|
17
18
|
// The engine spells MCP tools mcp__<server>__<tool>.
|
|
@@ -34,7 +35,8 @@ function unconnected(server, tool, args) {
|
|
|
34
35
|
const plan = planCall(undefined, tool, args);
|
|
35
36
|
return plan.kind === "sql" && !supabaseDb(typeof args.project_id === "string" ? args.project_id : undefined);
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
+
// `before`: pages the mod read right before the call (Notion edits and moves), by id.
|
|
39
|
+
export async function watchStart(store, session, cwd, name, args, before = {}) {
|
|
38
40
|
const t = splitTool(name);
|
|
39
41
|
if (!t)
|
|
40
42
|
return { kind: "read" };
|
|
@@ -54,6 +56,22 @@ export async function watchStart(store, session, cwd, name, args) {
|
|
|
54
56
|
release_at: null,
|
|
55
57
|
});
|
|
56
58
|
const rule = findRule(watchRules(), t.server, t.tool);
|
|
59
|
+
// Notion: its own opposite calls, built in. A rule the person wrote still wins.
|
|
60
|
+
if (!rule && isNotionTool(t.tool)) {
|
|
61
|
+
const n = notionPlan(t.tool, args, before);
|
|
62
|
+
if (n.kind === "read")
|
|
63
|
+
return { kind: "read" };
|
|
64
|
+
if (n.kind === "never")
|
|
65
|
+
return { kind: "write", id: record("write", n.reason).id, reason: n.reason };
|
|
66
|
+
const a = record("reversible", null);
|
|
67
|
+
if (n.kind === "now") {
|
|
68
|
+
const [first, ...more] = n.calls;
|
|
69
|
+
const undo = { adapter: "engine", server: t.server, tool: first.tool, args: first.args, ...(more.length ? { more } : {}), label: `Notion: ${n.label}` };
|
|
70
|
+
store.updateAction(a.id, { undo: JSON.stringify(undo) });
|
|
71
|
+
}
|
|
72
|
+
const rewrite = notionArgs(t.tool, args);
|
|
73
|
+
return rewrite ? { kind: "reversible", id: a.id, args: rewrite } : { kind: "reversible", id: a.id };
|
|
74
|
+
}
|
|
57
75
|
const plan = rule ? null : planCall(undefined, t.tool, args);
|
|
58
76
|
if (plan?.kind === "read")
|
|
59
77
|
return { kind: "read" };
|
|
@@ -92,6 +110,14 @@ export async function watchStart(store, session, cwd, name, args) {
|
|
|
92
110
|
reason = "no undo rule for this tool";
|
|
93
111
|
return { kind, id: record(kind, reason ?? null).id, reason };
|
|
94
112
|
}
|
|
113
|
+
// All the text in a result: what the model read, else the result's text parts.
|
|
114
|
+
function resultText(result, text) {
|
|
115
|
+
if (text)
|
|
116
|
+
return text;
|
|
117
|
+
if (result.structuredContent)
|
|
118
|
+
return JSON.stringify(result.structuredContent);
|
|
119
|
+
return (result.content ?? []).map((c) => (c.type === "text" ? c.text : "")).join("\n");
|
|
120
|
+
}
|
|
95
121
|
// The engine hands the mod the tool's record and the text the model read.
|
|
96
122
|
function asResult(r) {
|
|
97
123
|
const rec = r.result;
|
|
@@ -121,7 +147,24 @@ export async function watchEnd(store, id, r) {
|
|
|
121
147
|
return { undoable: true };
|
|
122
148
|
}
|
|
123
149
|
if (r.isError) {
|
|
124
|
-
|
|
150
|
+
// An opposite call kept ahead (a Notion restore or rewrite) is dropped: the call changed nothing to take back.
|
|
151
|
+
store.updateAction(id, { status: "error", error: text, ...(kept?.adapter === "engine" ? { undo: null } : {}) });
|
|
152
|
+
return { undoable: false };
|
|
153
|
+
}
|
|
154
|
+
// Notion: kept ahead from the page as it was, or read now from what the call made.
|
|
155
|
+
if (isNotionTool(a.tool) && !findRule(watchRules(), server, a.tool)) {
|
|
156
|
+
if (kept?.adapter === "engine") {
|
|
157
|
+
store.updateAction(id, { status: "ok", result: text });
|
|
158
|
+
return { undoable: true };
|
|
159
|
+
}
|
|
160
|
+
const n = notionPlan(a.tool, args);
|
|
161
|
+
const call = n.kind === "after" ? notionUndoAfter(n.made, args, resultText(result, r.text)) : null;
|
|
162
|
+
if (n.kind === "after" && call) {
|
|
163
|
+
const undo = { adapter: "engine", server, tool: call.tool, args: call.args, label: `Notion: ${n.label}` };
|
|
164
|
+
store.updateAction(id, { status: "ok", result: text, undo: JSON.stringify(undo) });
|
|
165
|
+
return { undoable: true };
|
|
166
|
+
}
|
|
167
|
+
store.updateAction(id, { status: "ok", result: text, kind: "write", reason: "Backstep couldn't tell what Notion made from its answer" });
|
|
125
168
|
return { undoable: false };
|
|
126
169
|
}
|
|
127
170
|
let undo = null;
|
|
@@ -11,11 +11,11 @@ const CLI = '/opt/backstep/cli.js'
|
|
|
11
11
|
|
|
12
12
|
// The engine beneath the mod: a repo at /repo, and a Backstep CLI whose
|
|
13
13
|
// `undo` answers with `undo`. Every argv the mod runs is recorded.
|
|
14
|
-
function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }) {
|
|
14
|
+
function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }, cwd = '/repo') {
|
|
15
15
|
const runs: string[][] = []
|
|
16
16
|
const logs: string[] = []
|
|
17
17
|
const prompts: { text: string; context?: readonly string[] }[] = []
|
|
18
|
-
on('session.cwd', () => ({ value:
|
|
18
|
+
on('session.cwd', () => ({ value: cwd }))
|
|
19
19
|
on('session.id', () => ({ value: 's1' }))
|
|
20
20
|
on('ui.log', (_, e) => {
|
|
21
21
|
logs.push(e.text)
|
|
@@ -75,6 +75,25 @@ test('edits outside the open folder are never tracked', { options: { cli: CLI }
|
|
|
75
75
|
await ui.unmount()
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
+
test('on Windows, edits in the open folder are tracked however the path is written, and others never are', { options: { cli: CLI } }, async ($, on) => {
|
|
79
|
+
mock.clock(on, { now: 5000 })
|
|
80
|
+
const { runs } = engine(on, { exitCode: 0, stdout: '[]' }, undefined, 'C:\\Users\\me\\app')
|
|
81
|
+
|
|
82
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
83
|
+
await $.tool.call({ tool: 'Edit', file_path: 'C:\\Users\\me\\app\\src\\a.ts', old_string: 'a', new_string: 'b' })
|
|
84
|
+
await $.tool.call({ tool: 'Edit', file_path: 'c:/users/me/APP/src/b.ts', old_string: 'a', new_string: 'b' })
|
|
85
|
+
await $.tool.call({ tool: 'Write', file_path: '/c/Users/me/app/src/c.ts', content: 'x' })
|
|
86
|
+
await $.tool.call({ tool: 'Write', file_path: 'C:\\Users\\me\\app-old\\x.ts', content: 'x' })
|
|
87
|
+
await $.tool.call({ tool: 'Write', file_path: 'C:\\Users\\me\\app\\..\\other\\x.ts', content: 'x' })
|
|
88
|
+
await $.tool.call({ tool: 'Write', file_path: 'D:\\Users\\me\\app\\x.ts', content: 'x' })
|
|
89
|
+
|
|
90
|
+
expect(runs.filter((r) => r.includes('snap')).map((r) => r.at(-1))).toEqual([
|
|
91
|
+
'C:\\Users\\me\\app\\src\\a.ts',
|
|
92
|
+
'c:/users/me/APP/src/b.ts',
|
|
93
|
+
'/c/Users/me/app/src/c.ts',
|
|
94
|
+
])
|
|
95
|
+
})
|
|
96
|
+
|
|
78
97
|
test('a long last turn stays on one line with a fixed gap after the logo', { options: { cli: CLI } }, async ($, on) => {
|
|
79
98
|
mock.clock(on, { now: 5000 })
|
|
80
99
|
engine(on, { exitCode: 0, stdout: '[]' }, { kind: 'write', undoable: true })
|
|
@@ -251,7 +270,7 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
251
270
|
|
|
252
271
|
// A claude.ai connector: Backstep can't sit in front of it, so the mod reports
|
|
253
272
|
// the call and, on undo, runs the rule's opposite call through Claude Code.
|
|
254
|
-
function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean }) {
|
|
273
|
+
function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?: boolean; undo?: object }) {
|
|
255
274
|
const runs: { argv: string[]; stdin?: string }[] = []
|
|
256
275
|
const calls: { server: string; tool: string; args: Record<string, unknown> }[] = []
|
|
257
276
|
const ran: Record<string, unknown>[] = []
|
|
@@ -270,12 +289,13 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
|
|
|
270
289
|
: e.argv.includes('watch-end')
|
|
271
290
|
? { undoable: opts.undoable }
|
|
272
291
|
: e.argv.includes('undo')
|
|
273
|
-
? [{ id: 'w1', tool: 'create-page', ok: true, detail: 'runs archive-page', run: { server: 'claude_ai_Notion', tool: 'archive-page', args: { id: 'p1' } } }]
|
|
292
|
+
? [opts.undo ?? { id: 'w1', tool: 'create-page', ok: true, detail: 'runs archive-page', run: { server: 'claude_ai_Notion', tool: 'archive-page', args: { id: 'p1' } } }]
|
|
274
293
|
: null
|
|
275
294
|
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
276
295
|
})
|
|
277
296
|
on('mcp.call', (_, e) => {
|
|
278
297
|
calls.push({ server: e.server, tool: e.tool, args: e.args })
|
|
298
|
+
if (e.tool === 'notion-fetch') return { value: { content: [{ type: 'text', text: `page ${String(e.args.id)} as it was` }], isError: false } }
|
|
279
299
|
return { value: { content: [{ type: 'text', text: opts.mcpFails ? 'no such page' : 'archived' }], isError: opts.mcpFails === true } }
|
|
280
300
|
})
|
|
281
301
|
on('tool.call', (_, e) => {
|
|
@@ -314,6 +334,39 @@ test('a connector call with an undo rule is undone through Claude Code', { optio
|
|
|
314
334
|
await ui.unmount()
|
|
315
335
|
})
|
|
316
336
|
|
|
337
|
+
test('a Notion move: the pages are read first, and undo runs every opposite call', { options: { cli: CLI } }, async ($, on) => {
|
|
338
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
339
|
+
const back = { tool: 'notion-move-pages', args: { page_or_database_ids: ['p2'], new_parent: { type: 'workspace' } } }
|
|
340
|
+
const { runs, calls } = connector(on, {
|
|
341
|
+
start: { kind: 'reversible', id: 'w1' },
|
|
342
|
+
undoable: true,
|
|
343
|
+
undo: {
|
|
344
|
+
id: 'w1',
|
|
345
|
+
tool: 'notion-move-pages',
|
|
346
|
+
ok: true,
|
|
347
|
+
detail: 'Notion: moved 2 pages back',
|
|
348
|
+
run: { server: 'claude_ai_Notion', tool: 'notion-move-pages', args: { page_or_database_ids: ['p1'], new_parent: { type: 'page_id', page_id: 'home' } }, more: [back], label: 'Notion: moved 2 pages back' },
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
await $.prompt.submit({ text: 'tidy my notion', wait: false, origin: { kind: 'composer' } })
|
|
353
|
+
await $.tool.call({ tool: 'mcp__claude_ai_Notion__notion-move-pages', page_or_database_ids: ['p1', 'p2'], new_parent: { type: 'page_id', page_id: 'archive' } } as never)
|
|
354
|
+
|
|
355
|
+
// Read before the move, through Claude Code's own connection, and handed over with the call.
|
|
356
|
+
expect(calls.map((c) => [c.tool, c.args.id])).toEqual([['notion-fetch', 'p1'], ['notion-fetch', 'p2']])
|
|
357
|
+
expect(runs[0].argv.slice(3)).toEqual(['watch-start', '--session', 's1', '--cwd', '/repo', '--before', '--', 'mcp__claude_ai_Notion__notion-move-pages'])
|
|
358
|
+
expect(JSON.parse(runs[0].stdin ?? '').before).toEqual({ p1: 'page p1 as it was', p2: 'page p2 as it was' })
|
|
359
|
+
|
|
360
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
361
|
+
await ui.press({ key: 'undo' })
|
|
362
|
+
const pressed = ui.press({ key: 'undo' })
|
|
363
|
+
await clock.advance(1000)
|
|
364
|
+
await pressed
|
|
365
|
+
expect(calls.slice(2).map((c) => c.args.page_or_database_ids)).toEqual([['p1'], ['p2']])
|
|
366
|
+
expect(runs.at(-1)?.argv.slice(3)).toEqual(['settle', 'w1'])
|
|
367
|
+
await ui.unmount()
|
|
368
|
+
})
|
|
369
|
+
|
|
317
370
|
test('when the opposite call fails, the rewind says so', { options: { cli: CLI } }, async ($, on) => {
|
|
318
371
|
const clock = mock.clock(on, { now: 5000 })
|
|
319
372
|
const { runs } = connector(on, { start: { kind: 'reversible', id: 'w1' }, undoable: true, mcpFails: true })
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -57,7 +57,7 @@ type Outcome = {
|
|
|
57
57
|
tool: string
|
|
58
58
|
ok: boolean
|
|
59
59
|
detail: string
|
|
60
|
-
run?: { server: string; tool: string; args: Record<string, unknown> }
|
|
60
|
+
run?: { server: string; tool: string; args: Record<string, unknown>; more?: { tool: string; args: Record<string, unknown> }[]; label?: string }
|
|
61
61
|
pushed?: { root: string; pushes: Pushed[] }
|
|
62
62
|
github?: GhMade[]
|
|
63
63
|
}
|
|
@@ -183,17 +183,38 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone =
|
|
|
183
183
|
// The first press asks, right there above the prompt; a second press within a
|
|
184
184
|
// few seconds undoes. A dialog would take the band off screen, and with it the
|
|
185
185
|
// key, which would then suspend Claude Code in a terminal.
|
|
186
|
-
// Whether a file path is inside the folder this session has open.
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
// Whether a file path is inside the folder this session has open. Works for
|
|
187
|
+
// macOS and Linux paths (/Users/me/app), Windows ones (C:\Users\me\app, any
|
|
188
|
+
// letter case) and Git Bash's spelling of them (/c/Users/me/app).
|
|
189
|
+
function normPath(path: string, cwd: string): string {
|
|
190
|
+
const win = /^[a-zA-Z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')
|
|
191
|
+
let raw = path.replace(/\\/g, '/')
|
|
192
|
+
if (win) raw = raw.replace(/^\/([a-zA-Z])(?=\/|$)/, '$1:')
|
|
193
|
+
const base = cwd.replace(/\\/g, '/')
|
|
194
|
+
const absolute = win ? /^[a-zA-Z]:\//.test(raw) || raw.startsWith('//') : raw.startsWith('/')
|
|
195
|
+
const abs = absolute ? raw : `${base}/${raw}`
|
|
196
|
+
const unc = win && abs.startsWith('//')
|
|
189
197
|
const parts: string[] = []
|
|
190
198
|
for (const seg of abs.split('/')) {
|
|
191
199
|
if (seg === '..') parts.pop()
|
|
192
200
|
else if (seg && seg !== '.') parts.push(seg)
|
|
193
201
|
}
|
|
194
|
-
const p = '/' + parts.join('/')
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
const p = (unc ? '//' : win ? '' : '/') + parts.join('/')
|
|
203
|
+
return win ? p.toLowerCase() : p
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function within(path: string, cwd: string): boolean {
|
|
207
|
+
const p = normPath(path, cwd)
|
|
208
|
+
const d = normPath('.', cwd)
|
|
209
|
+
return p === d || p.startsWith(d.replace(/\/$/, '') + '/')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A file's path from the session's folder, for showing; anything else as given.
|
|
213
|
+
function relPath(path: string, cwd: string): string {
|
|
214
|
+
if (!within(path, cwd)) return path
|
|
215
|
+
const slashed = path.replace(/\\/g, '/')
|
|
216
|
+
const base = cwd.replace(/\\/g, '/').replace(/\/$/, '')
|
|
217
|
+
return slashed.length > base.length + 1 && slashed.toLowerCase().startsWith(base.toLowerCase() + '/') ? slashed.slice(base.length + 1) : path
|
|
197
218
|
}
|
|
198
219
|
|
|
199
220
|
async function pressUndo($: EngineInterface) {
|
|
@@ -229,7 +250,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
229
250
|
})
|
|
230
251
|
|
|
231
252
|
const cwd = await $.session.cwd()
|
|
232
|
-
const short = (p: string) => (p
|
|
253
|
+
const short = (p: string) => relPath(p, cwd)
|
|
233
254
|
const [ran] = await Promise.all([
|
|
234
255
|
$.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', await $.session.id(), '--json', '--via-claude-code'])).catch((err: unknown) => ({
|
|
235
256
|
exitCode: 1,
|
|
@@ -254,18 +275,27 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
254
275
|
// Opposite calls on servers only Claude Code is connected to run here, newest first.
|
|
255
276
|
for (const o of outcomes) {
|
|
256
277
|
if (!o.ok || !o.run) continue
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}))
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
const run = o.run
|
|
279
|
+
let failed: { tool: string; why: string } | null = null
|
|
280
|
+
// The first opposite call, then any more on the same server (a move back to several places).
|
|
281
|
+
for (const call of [{ tool: run.tool, args: run.args }, ...(run.more ?? [])]) {
|
|
282
|
+
const done = await $.mcp.call(run.server, call.tool, call.args).catch((err: unknown) => ({
|
|
283
|
+
isError: true,
|
|
284
|
+
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : String(err) }],
|
|
285
|
+
}))
|
|
286
|
+
if (done.isError) {
|
|
287
|
+
const why = done.content.map((c) => (c.type === 'text' ? c.text : '')).join(' ').trim().split('\n')[0] || 'failed'
|
|
288
|
+
failed = { tool: call.tool, why }
|
|
289
|
+
break
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (failed) {
|
|
263
293
|
o.ok = false
|
|
264
|
-
o.detail = `${
|
|
265
|
-
await $.process.run(backstep(['settle', o.id, '--failed', why])).catch(() => null)
|
|
294
|
+
o.detail = `${failed.tool} failed: ${failed.why}`
|
|
295
|
+
await $.process.run(backstep(['settle', o.id, '--failed', failed.why])).catch(() => null)
|
|
266
296
|
break
|
|
267
297
|
}
|
|
268
|
-
o.detail = `ran ${
|
|
298
|
+
o.detail = run.label ?? `ran ${run.tool}`
|
|
269
299
|
await $.process.run(backstep(['settle', o.id])).catch(() => null)
|
|
270
300
|
}
|
|
271
301
|
// Stop the list at the first failure, as the CLI does.
|
|
@@ -366,14 +396,32 @@ const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'requestM
|
|
|
366
396
|
// call here and keeps an opposite call when an undo rule matches, which undo
|
|
367
397
|
// runs through Claude Code's own connection. Supabase SQL is snapshotted
|
|
368
398
|
// first, and a DROP comes back rewritten as a move to Backstep's trash.
|
|
399
|
+
// Notion edits and moves are undone by writing back the page as it was, so it's
|
|
400
|
+
// read here, through Claude Code's own connection, right before the call.
|
|
401
|
+
async function readNotionFirst($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<Record<string, string> | null> {
|
|
402
|
+
const m = tool.match(/^mcp__(.+?)__(notion-update-page|notion-move-pages)$/)
|
|
403
|
+
if (!m) return null
|
|
404
|
+
const ids = m[2] === 'notion-update-page' ? [args.page_id] : Array.isArray(args.page_or_database_ids) ? args.page_or_database_ids.slice(0, 20) : []
|
|
405
|
+
const before: Record<string, string> = {}
|
|
406
|
+
for (const id of ids) {
|
|
407
|
+
if (typeof id !== 'string') continue
|
|
408
|
+
const r = await $.mcp.call(m[1], 'notion-fetch', { id }).catch(() => null)
|
|
409
|
+
if (r && !r.isError) before[id] = r.content.map((c) => (c.type === 'text' ? c.text : '')).join('')
|
|
410
|
+
}
|
|
411
|
+
return before
|
|
412
|
+
}
|
|
413
|
+
|
|
369
414
|
async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Next) {
|
|
370
415
|
const tool = String(e.tool)
|
|
371
416
|
const t = await current($)
|
|
372
417
|
const args = Object.fromEntries(Object.entries(e).filter(([k]) => !RESERVED.has(k)))
|
|
373
418
|
const cwd = await $.session.cwd()
|
|
374
419
|
const sid = await $.session.id()
|
|
420
|
+
const before = await readNotionFirst($, tool, args)
|
|
375
421
|
const started = await $.process
|
|
376
|
-
.run(backstep(['watch-start', '--session', sid, '--cwd', cwd, '--', tool]), {
|
|
422
|
+
.run(backstep(['watch-start', '--session', sid, '--cwd', cwd, ...(before ? ['--before'] : []), '--', tool]), {
|
|
423
|
+
stdin: JSON.stringify(before ? { args, before } : args),
|
|
424
|
+
})
|
|
377
425
|
.catch(() => null)
|
|
378
426
|
let plan: WatchPlan | null = null
|
|
379
427
|
try {
|
|
@@ -778,7 +826,7 @@ export const register: Register = (on, options) => {
|
|
|
778
826
|
if (snap?.exitCode === 0) {
|
|
779
827
|
await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
|
|
780
828
|
} else {
|
|
781
|
-
await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split(
|
|
829
|
+
await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split(/[\\/]/).pop()}: Backstep couldn't save a copy first`))
|
|
782
830
|
}
|
|
783
831
|
}
|
|
784
832
|
} else if (tool === 'Bash') {
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backstep",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
|
|
5
|
+
"homepage": "https://getbackstep.com",
|
|
5
6
|
"scripts": {
|
|
6
7
|
"dev": "node src/cli.ts",
|
|
7
8
|
"ui": "node --disable-warning=ExperimentalWarning src/cli.ts ui",
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
"type": "module",
|
|
15
16
|
"dependencies": {
|
|
16
17
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
18
|
+
"cross-spawn": "^7.0.6",
|
|
17
19
|
"pg": "^8.23.1",
|
|
18
20
|
"zod": "^4.6.7"
|
|
19
21
|
},
|
|
@@ -21,6 +23,7 @@
|
|
|
21
23
|
"@electric-sql/pglite": "^0.5.12",
|
|
22
24
|
"@electric-sql/pglite-socket": "^0.2.15",
|
|
23
25
|
"@modelcontextprotocol/server-filesystem": "^2.0.1",
|
|
26
|
+
"@types/cross-spawn": "^6.0.6",
|
|
24
27
|
"@types/node": "^26.6.5",
|
|
25
28
|
"@types/pg": "^8.23.1",
|
|
26
29
|
"typescript": "^7.0.2"
|