backstep 0.1.20 → 0.1.22
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/dist/adapters/fs.js +4 -8
- package/dist/adapters/tree.js +10 -5
- package/dist/check.js +1 -1
- package/dist/connect.js +2 -2
- package/dist/github.js +1 -1
- package/dist/paths.js +31 -0
- package/dist/servers.js +1 -1
- package/dist/setup.js +3 -3
- package/dist/shell.js +2 -1
- package/dist/spawn.js +6 -0
- package/dist/undo.js +2 -1
- package/dist/uninstall.js +5 -2
- package/dist/update.js +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +21 -2
- package/mod/hooks/register.tsx +29 -8
- package/package.json +3 -1
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";
|
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/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";
|
|
@@ -13,7 +13,7 @@ import { sendInstall, telemetryOn } from "./telemetry.js";
|
|
|
13
13
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
14
|
function run(cmd, args) {
|
|
15
15
|
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
16
|
-
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim(), missing: r.error
|
|
16
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim(), missing: Boolean(r.error) };
|
|
17
17
|
}
|
|
18
18
|
// npx runs us from a cache that gets cleaned, so the mod needs a lasting copy.
|
|
19
19
|
// A global install also puts `backstep` on the PATH; where npm's global folder
|
|
@@ -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) {
|
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";
|
|
@@ -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 })
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -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,
|
|
@@ -805,7 +826,7 @@ export const register: Register = (on, options) => {
|
|
|
805
826
|
if (snap?.exitCode === 0) {
|
|
806
827
|
await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
|
|
807
828
|
} else {
|
|
808
|
-
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`))
|
|
809
830
|
}
|
|
810
831
|
}
|
|
811
832
|
} else if (tool === 'Bash') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backstep",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
|
|
5
5
|
"homepage": "https://getbackstep.com",
|
|
6
6
|
"scripts": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"type": "module",
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
18
|
+
"cross-spawn": "^7.0.6",
|
|
18
19
|
"pg": "^8.23.1",
|
|
19
20
|
"zod": "^4.6.7"
|
|
20
21
|
},
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
"@electric-sql/pglite": "^0.5.12",
|
|
23
24
|
"@electric-sql/pglite-socket": "^0.2.15",
|
|
24
25
|
"@modelcontextprotocol/server-filesystem": "^2.0.1",
|
|
26
|
+
"@types/cross-spawn": "^6.0.6",
|
|
25
27
|
"@types/node": "^26.6.5",
|
|
26
28
|
"@types/pg": "^8.23.1",
|
|
27
29
|
"typescript": "^7.0.2"
|