backstep 0.1.25 → 0.1.27
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 +3 -1
- package/dist/account.js +217 -0
- package/dist/cli.js +37 -1
- package/dist/github.js +1 -1
- package/dist/telemetry.js +5 -2
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +155 -0
- package/mod/hooks/register.tsx +146 -9
- package/mod/types/index.d.ts +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,8 @@ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the se
|
|
|
67
67
|
|
|
68
68
|
The undo key is ctrl+u (not ctrl+z: Claude Code suspends itself on ctrl+z in a terminal before anything else sees the key, so it can't be used). The first press asks "Undo Claude's last turn?" above the prompt and a second press within 5 seconds undoes, so a stray press does nothing. Change the key whenever you like: ask Claude ("change my Backstep undo key to ctrl+q"), or run `backstep key ctrl+q`. The line above the prompt is always there, even with nothing to undo, because the key only reaches Backstep while it's on screen; otherwise a press would do nothing.
|
|
69
69
|
|
|
70
|
+
In the Claude Code desktop app, keys don't reach Backstep, so the line above the message box has an **Undo last turn** button to click instead (it asks once, then undoes). Typing `/backstep-undo` works everywhere and undoes straight away.
|
|
71
|
+
|
|
70
72
|
Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off).
|
|
71
73
|
|
|
72
74
|
Other commands: `backstep ui`, `backstep log`, `backstep undo <action-id>`, `backstep undo --run <run-id>`, `backstep connect [supabase|firebase]` (connects what the project uses, the same as asking Claude), `backstep disconnect supabase <ref>`.
|
|
@@ -119,7 +121,7 @@ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override
|
|
|
119
121
|
|
|
120
122
|
## Telemetry
|
|
121
123
|
|
|
122
|
-
`backstep setup` sends one anonymous `install` event, Claude Code with the Backstep mod sends one `active` event a day, and `backstep uninstall` sends one `uninstall` event. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
|
|
124
|
+
`backstep setup` sends one anonymous `install` event the first time on a computer (and an `update` event each time after, as updates run setup again), Claude Code with the Backstep mod sends one `active` event a day, and `backstep uninstall` sends one `uninstall` event. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
|
|
123
125
|
|
|
124
126
|
Turn it off with `BACKSTEP_TELEMETRY=0`, or the standard `DO_NOT_TRACK=1`.
|
|
125
127
|
|
package/dist/account.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Accounts and plans. Undo always runs on this computer. What Backstep allows
|
|
2
|
+
// is set by a small file on getbackstep.com (backstep.json):
|
|
3
|
+
//
|
|
4
|
+
// minVersion versions older than this must update before ctrl+u works
|
|
5
|
+
// requireSignIn whether ctrl+u needs a signed-in account (off for now)
|
|
6
|
+
// message a line shown with either
|
|
7
|
+
//
|
|
8
|
+
// Signed in, this computer keeps a plan note: the account, its plan and the
|
|
9
|
+
// features that plan includes, signed by Backstep's server so it can't be
|
|
10
|
+
// edited here. It lasts a week and is renewed as sessions start.
|
|
11
|
+
//
|
|
12
|
+
// Fail open: if getbackstep.com can't be reached, Backstep keeps working on
|
|
13
|
+
// what it last heard (or as if nothing were required, if it never heard).
|
|
14
|
+
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { homeDir } from "./store.js";
|
|
18
|
+
import { version } from "./telemetry.js";
|
|
19
|
+
import { newer } from "./update.js";
|
|
20
|
+
const SITE = () => (process.env.BACKSTEP_SITE ?? "https://getbackstep.com").replace(/\/$/, "");
|
|
21
|
+
// The server's public key: it can only check notes, never make them, so it's safe to ship.
|
|
22
|
+
const PUBLIC_KEY = () => process.env.BACKSTEP_PUBLIC_KEY ?? "MCowBQYDK2VwAyEAUIhhw5NeNtGFT6o6AWn5TnjsnmjQ97OF6U4kdl4x0DA=";
|
|
23
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
// A note past its date still counts for this long, so a week offline never turns undo off.
|
|
25
|
+
const GRACE_MS = 7 * DAY_MS;
|
|
26
|
+
// What was last heard from the site counts this long when it can't be reached.
|
|
27
|
+
const REMOTE_KEEP_MS = 30 * DAY_MS;
|
|
28
|
+
const NOTHING_REQUIRED = { minVersion: "0.0.0", requireSignIn: false, message: null };
|
|
29
|
+
const remoteFile = () => join(homeDir(), "remote.json");
|
|
30
|
+
const accountFile = () => join(homeDir(), "account.json");
|
|
31
|
+
function readJson(f) {
|
|
32
|
+
try {
|
|
33
|
+
return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function writeJson(f, v) {
|
|
40
|
+
mkdirSync(homeDir(), { recursive: true });
|
|
41
|
+
writeFileSync(f, JSON.stringify(v, null, 2) + "\n", { mode: 0o600 });
|
|
42
|
+
}
|
|
43
|
+
function asRemote(j) {
|
|
44
|
+
if (!j || typeof j !== "object")
|
|
45
|
+
return null;
|
|
46
|
+
const o = j;
|
|
47
|
+
return {
|
|
48
|
+
minVersion: typeof o.minVersion === "string" ? o.minVersion : "0.0.0",
|
|
49
|
+
requireSignIn: o.requireSignIn === true,
|
|
50
|
+
message: typeof o.message === "string" && o.message.trim() ? o.message.trim() : null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// What the site said last, without asking it again.
|
|
54
|
+
export function cachedRemote(now = Date.now()) {
|
|
55
|
+
const c = readJson(remoteFile());
|
|
56
|
+
return c && now - c.at < REMOTE_KEEP_MS ? (asRemote(c.remote) ?? NOTHING_REQUIRED) : NOTHING_REQUIRED;
|
|
57
|
+
}
|
|
58
|
+
// Asks the site, never waiting more than a few seconds.
|
|
59
|
+
export async function fetchRemote() {
|
|
60
|
+
try {
|
|
61
|
+
const r = await fetch(`${SITE()}/backstep.json`, { signal: AbortSignal.timeout(4000), headers: { "cache-control": "no-cache" } });
|
|
62
|
+
if (r.ok) {
|
|
63
|
+
const remote = asRemote(await r.json());
|
|
64
|
+
if (remote) {
|
|
65
|
+
writeJson(remoteFile(), { at: Date.now(), remote });
|
|
66
|
+
return remote;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// No file there at all: nothing is required.
|
|
70
|
+
if (r.status === 404) {
|
|
71
|
+
writeJson(remoteFile(), { at: Date.now(), remote: NOTHING_REQUIRED });
|
|
72
|
+
return NOTHING_REQUIRED;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// offline or the site is down: what it said last
|
|
77
|
+
}
|
|
78
|
+
return cachedRemote();
|
|
79
|
+
}
|
|
80
|
+
const b64url = (b) => b.toString("base64url");
|
|
81
|
+
// A plan note is `payload.signature`, each base64url; the signature covers the payload text.
|
|
82
|
+
export function checkNote(raw) {
|
|
83
|
+
if (!raw || typeof raw !== "string")
|
|
84
|
+
return null;
|
|
85
|
+
const [payload, sig] = raw.split(".");
|
|
86
|
+
if (!payload || !sig)
|
|
87
|
+
return null;
|
|
88
|
+
try {
|
|
89
|
+
const key = createPublicKey({ key: Buffer.from(PUBLIC_KEY(), "base64"), format: "der", type: "spki" });
|
|
90
|
+
if (!verify(null, Buffer.from(payload), key, Buffer.from(sig, "base64url")))
|
|
91
|
+
return null;
|
|
92
|
+
const n = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
93
|
+
return typeof n.exp === "number" && Array.isArray(n.features) && typeof n.plan === "string" ? n : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const saved = () => readJson(accountFile()) ?? {};
|
|
100
|
+
// The plan note on this computer, if it's genuine and not long past its date.
|
|
101
|
+
export function currentNote(now = Date.now()) {
|
|
102
|
+
const n = checkNote(saved().note);
|
|
103
|
+
return n && n.exp + GRACE_MS > now ? n : null;
|
|
104
|
+
}
|
|
105
|
+
async function post(path, body) {
|
|
106
|
+
const r = await fetch(`${SITE()}${path}`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "content-type": "application/json" },
|
|
109
|
+
body: JSON.stringify(body),
|
|
110
|
+
signal: AbortSignal.timeout(8000),
|
|
111
|
+
});
|
|
112
|
+
let json = {};
|
|
113
|
+
try {
|
|
114
|
+
json = (await r.json());
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// no body
|
|
118
|
+
}
|
|
119
|
+
return { status: r.status, json };
|
|
120
|
+
}
|
|
121
|
+
// Renews the plan note. An account signed out or turned off on the server loses it here.
|
|
122
|
+
export async function refreshNote() {
|
|
123
|
+
const s = saved();
|
|
124
|
+
if (!s.device)
|
|
125
|
+
return "none";
|
|
126
|
+
try {
|
|
127
|
+
const r = await post("/api/note", { device: s.device });
|
|
128
|
+
if (r.status === 200 && checkNote(r.json.note)) {
|
|
129
|
+
writeJson(accountFile(), { ...s, note: r.json.note });
|
|
130
|
+
return "renewed";
|
|
131
|
+
}
|
|
132
|
+
if (r.status === 401 || r.status === 403) {
|
|
133
|
+
rmSync(accountFile(), { force: true });
|
|
134
|
+
return "revoked";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// offline
|
|
139
|
+
}
|
|
140
|
+
return "offline";
|
|
141
|
+
}
|
|
142
|
+
function judge(remote, feature, note) {
|
|
143
|
+
const current = version();
|
|
144
|
+
const say = remote.message ? ` ${remote.message}` : "";
|
|
145
|
+
if (newer(remote.minVersion, current)) {
|
|
146
|
+
return { ok: false, reason: "update", minVersion: remote.minVersion, current, detail: `Backstep ${current} is no longer supported: update to ${remote.minVersion} or newer.${say}` };
|
|
147
|
+
}
|
|
148
|
+
if (!remote.requireSignIn)
|
|
149
|
+
return { ok: true, signedIn: note !== null, plan: note?.plan ?? null, email: note?.email ?? null, required: false };
|
|
150
|
+
if (!note)
|
|
151
|
+
return { ok: false, reason: "signin", detail: `Backstep needs you to sign in. Run: npx backstep login${say}` };
|
|
152
|
+
if (feature && !note.features.includes(feature) && !note.features.includes("*")) {
|
|
153
|
+
return { ok: false, reason: "plan", detail: `${feature} isn't on your ${note.plan} plan. See plans at getbackstep.com${say}` };
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, signedIn: true, plan: note.plan, email: note.email, required: true };
|
|
156
|
+
}
|
|
157
|
+
// Whether ctrl+u (or one feature) may run, from what's already on this computer. Never waits on the network.
|
|
158
|
+
export function accessNow(feature) {
|
|
159
|
+
return judge(cachedRemote(), feature, currentNote());
|
|
160
|
+
}
|
|
161
|
+
// The same, asking the site first and renewing a note that's close to its date. Run as sessions start.
|
|
162
|
+
export async function accessOnline(feature) {
|
|
163
|
+
const remote = await fetchRemote();
|
|
164
|
+
const n = checkNote(saved().note);
|
|
165
|
+
if (saved().device && (!n || n.exp - Date.now() < 2 * DAY_MS))
|
|
166
|
+
await refreshNote();
|
|
167
|
+
return judge(remote, feature, currentNote());
|
|
168
|
+
}
|
|
169
|
+
// Throws with a line a person can act on when the feature isn't allowed. For the CLI's undo commands.
|
|
170
|
+
export function requireAccess(feature) {
|
|
171
|
+
const a = accessNow(feature);
|
|
172
|
+
if (!a.ok)
|
|
173
|
+
throw new Error(a.detail);
|
|
174
|
+
}
|
|
175
|
+
// The words a person checks on the sign-in page: the same on both sides, never the secret itself.
|
|
176
|
+
export const matchCode = (code) => {
|
|
177
|
+
const h = createHash("sha256").update(code).digest("hex").slice(0, 8).toUpperCase();
|
|
178
|
+
return `${h.slice(0, 4)}-${h.slice(4)}`;
|
|
179
|
+
};
|
|
180
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
181
|
+
// Signs this computer in: opens getbackstep.com/login with a one-time code and
|
|
182
|
+
// waits (up to ten minutes) for the person to finish there.
|
|
183
|
+
export async function login(open, say, waitMs = 10 * 60 * 1000, everyMs = 2000) {
|
|
184
|
+
const code = b64url(randomBytes(18));
|
|
185
|
+
const device = b64url(randomBytes(32));
|
|
186
|
+
const url = `${SITE()}/login.html?code=${code}`;
|
|
187
|
+
say(`Opening ${SITE().replace(/^https?:\/\//, "")} to sign in. If it doesn't open, go to:\n ${url}`);
|
|
188
|
+
say(`The page should show the code ${matchCode(code)}. Only continue if it does.`);
|
|
189
|
+
open(url);
|
|
190
|
+
const until = Date.now() + waitMs;
|
|
191
|
+
while (Date.now() < until) {
|
|
192
|
+
await pause(everyMs);
|
|
193
|
+
let r;
|
|
194
|
+
try {
|
|
195
|
+
r = await post("/api/login/claim", { code, device });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
continue; // a dropped request: ask again
|
|
199
|
+
}
|
|
200
|
+
if (r.status === 202)
|
|
201
|
+
continue;
|
|
202
|
+
const note = checkNote(r.json.note);
|
|
203
|
+
if (r.status === 200 && note) {
|
|
204
|
+
writeJson(accountFile(), { note: r.json.note, device });
|
|
205
|
+
return { ok: true, detail: `signed in as ${note.email} (${note.plan} plan)` };
|
|
206
|
+
}
|
|
207
|
+
return { ok: false, detail: typeof r.json.error === "string" ? r.json.error : `the sign-in didn't go through (${r.status})` };
|
|
208
|
+
}
|
|
209
|
+
return { ok: false, detail: "the sign-in wasn't finished in time. Run backstep login to try again" };
|
|
210
|
+
}
|
|
211
|
+
export async function logout() {
|
|
212
|
+
const s = saved();
|
|
213
|
+
if (s.device)
|
|
214
|
+
await post("/api/logout", { device: s.device }).catch(() => null);
|
|
215
|
+
rmSync(accountFile(), { force: true });
|
|
216
|
+
return s.note ? "Signed out on this computer." : "This computer wasn't signed in.";
|
|
217
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,8 @@ import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
|
|
|
8
8
|
import { insideDir, snapshotPaths } from "./adapters/fs.js";
|
|
9
9
|
import { notePushed, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
|
|
10
10
|
import { revertPush } from "./push.js";
|
|
11
|
-
import { ghMade, grantScope, undoGh } from "./github.js";
|
|
11
|
+
import { ghMade, grantScope, openPage, undoGh } from "./github.js";
|
|
12
|
+
import { accessNow, accessOnline, currentNote, login, logout, requireAccess } from "./account.js";
|
|
12
13
|
import { undoVercel, vercelBefore, vercelMade } from "./vercel.js";
|
|
13
14
|
import { snapshotDb } from "./adapters/dbsnap.js";
|
|
14
15
|
import { dbDirs, firebaseProject, postgresUrls, projectDatabases, projectUses, restKeys, supabaseDirs, unconnected } from "./connect.js";
|
|
@@ -97,6 +98,9 @@ const HELP = `backstep: undo for everything your agents touch
|
|
|
97
98
|
connection string you give), Firebase by adding its MCP server.
|
|
98
99
|
In Claude Code you can just ask: "connect Backstep to my Supabase"
|
|
99
100
|
backstep key [<key>] show the key that undoes Claude's last turn, or change it (e.g. backstep key ctrl+u)
|
|
101
|
+
backstep login sign in to your Backstep account (opens getbackstep.com)
|
|
102
|
+
backstep logout sign out on this computer
|
|
103
|
+
backstep account show who's signed in and on which plan
|
|
100
104
|
backstep update install the newest version (Claude Code offers this as a session starts)
|
|
101
105
|
backstep status [--json] what Backstep can and can't undo for each service Claude Code reaches,
|
|
102
106
|
and what to connect next
|
|
@@ -300,6 +304,34 @@ async function main() {
|
|
|
300
304
|
}
|
|
301
305
|
return;
|
|
302
306
|
}
|
|
307
|
+
if (cmd === "login") {
|
|
308
|
+
const out = await login(openPage, (line) => console.log(line));
|
|
309
|
+
console.log(out.ok ? `Backstep: ${out.detail}.` : `Backstep couldn't sign you in: ${out.detail}.`);
|
|
310
|
+
if (!out.ok)
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (cmd === "logout") {
|
|
315
|
+
console.log(await logout());
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (cmd === "account") {
|
|
319
|
+
const n = currentNote();
|
|
320
|
+
const a = await accessOnline();
|
|
321
|
+
const fresh = currentNote() ?? n;
|
|
322
|
+
console.log(fresh ? `Signed in as ${fresh.email} (${fresh.plan} plan).` : "Not signed in. Run: backstep login");
|
|
323
|
+
if (!a.ok)
|
|
324
|
+
console.log(a.detail);
|
|
325
|
+
else if (!a.required)
|
|
326
|
+
console.log("Signing in isn't needed to use Backstep right now.");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// Run by the Claude Code mod as a session starts: may ctrl+u run? Asks getbackstep.com (fail open).
|
|
330
|
+
if (cmd === "access") {
|
|
331
|
+
const { values } = parseArgs({ args: rest, options: { feature: { type: "string" }, offline: { type: "boolean", default: false } } });
|
|
332
|
+
console.log(JSON.stringify(values.offline ? accessNow(values.feature) : await accessOnline(values.feature)));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
303
335
|
// Run by the Claude Code mod as a session starts: is there a newer Backstep?
|
|
304
336
|
if (cmd === "update-check") {
|
|
305
337
|
console.log(JSON.stringify(await checkUpdate()));
|
|
@@ -536,6 +568,7 @@ async function main() {
|
|
|
536
568
|
}
|
|
537
569
|
// Takes back something Claude changed on Vercel (the change, as undo reported it, on stdin). The mod runs this after asking.
|
|
538
570
|
if (cmd === "vercel-undo") {
|
|
571
|
+
requireAccess("vercel");
|
|
539
572
|
const out = undoVercel(JSON.parse(readFileSync(0, "utf8")));
|
|
540
573
|
console.log(JSON.stringify(out));
|
|
541
574
|
if (!out.ok)
|
|
@@ -543,6 +576,7 @@ async function main() {
|
|
|
543
576
|
return;
|
|
544
577
|
}
|
|
545
578
|
if (cmd === "gh-undo") {
|
|
579
|
+
requireAccess("github");
|
|
546
580
|
const out = undoGh(JSON.parse(readFileSync(0, "utf8")));
|
|
547
581
|
console.log(JSON.stringify(out));
|
|
548
582
|
if (!out.ok)
|
|
@@ -551,6 +585,7 @@ async function main() {
|
|
|
551
585
|
}
|
|
552
586
|
// Puts an undo of code Claude pushed on the remote, as a new commit. The push, as undo reported it, comes on stdin.
|
|
553
587
|
if (cmd === "revert-push") {
|
|
588
|
+
requireAccess("github");
|
|
554
589
|
const { values } = parseArgs({ args: rest, options: { root: { type: "string" } } });
|
|
555
590
|
if (!values.root)
|
|
556
591
|
throw new Error("usage: backstep revert-push --root <repo> (the push as JSON on stdin)");
|
|
@@ -609,6 +644,7 @@ async function main() {
|
|
|
609
644
|
return;
|
|
610
645
|
}
|
|
611
646
|
if (cmd === "undo") {
|
|
647
|
+
requireAccess("undo");
|
|
612
648
|
const { values, positionals } = parseArgs({
|
|
613
649
|
args: rest,
|
|
614
650
|
allowPositionals: true,
|
package/dist/github.js
CHANGED
|
@@ -73,7 +73,7 @@ function copy(text) {
|
|
|
73
73
|
return true;
|
|
74
74
|
return false;
|
|
75
75
|
}
|
|
76
|
-
function openPage(url) {
|
|
76
|
+
export function openPage(url) {
|
|
77
77
|
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
78
78
|
spawnSync(cmd, args, { stdio: "ignore" });
|
|
79
79
|
}
|
package/dist/telemetry.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// An anonymous count of installs and
|
|
1
|
+
// An anonymous count of installs, updates and installs still in use, so we know how
|
|
2
2
|
// many people use Backstep. Each event carries a random id made on this
|
|
3
3
|
// machine, the Backstep version, the OS and the Node version. Never paths,
|
|
4
4
|
// commands, server names or anything from the journal.
|
|
@@ -62,7 +62,10 @@ async function send(event) {
|
|
|
62
62
|
// Offline, blocked, or slow: skip it.
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
// Setup runs on a first install and again on every update. Only the first, on a
|
|
66
|
+
// computer that has never sent anything (no install-id yet), counts as an
|
|
67
|
+
// install; every later setup is an update, so installs stay new installs.
|
|
68
|
+
export const sendInstall = () => send(existsSync(join(homeDir(), "install-id")) ? "update" : "install");
|
|
66
69
|
export const sendUninstall = () => send("uninstall");
|
|
67
70
|
// At most once a day, as Claude Code starts with Backstep's mod.
|
|
68
71
|
export async function sendActive() {
|
|
@@ -261,6 +261,7 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
261
261
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
|
|
262
262
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'status', '--quick', '--json', '--dir', '/repo'],
|
|
263
263
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
|
|
264
|
+
['node', '--disable-warning=ExperimentalWarning', CLI, 'access'],
|
|
264
265
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'update-check'],
|
|
265
266
|
])
|
|
266
267
|
expect(toasts.find((t) => t.includes('linear is now behind Backstep'))).toBeTruthy()
|
|
@@ -1021,3 +1022,157 @@ test('a production deploy Claude made on Vercel is offered back after undo, and
|
|
|
1021
1022
|
expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes Vercel')
|
|
1022
1023
|
await ui.unmount()
|
|
1023
1024
|
})
|
|
1025
|
+
|
|
1026
|
+
function gated(on: On, access: Record<string, unknown>, answer: string) {
|
|
1027
|
+
const runs: string[][] = []
|
|
1028
|
+
const toasts: string[] = []
|
|
1029
|
+
const logs: string[] = []
|
|
1030
|
+
const asked: string[] = []
|
|
1031
|
+
on('process.run', (_, e) => {
|
|
1032
|
+
runs.push([...e.argv])
|
|
1033
|
+
const stdout = e.argv.includes('access')
|
|
1034
|
+
? JSON.stringify(access)
|
|
1035
|
+
: e.argv.includes('update-check')
|
|
1036
|
+
? JSON.stringify({ current: '0.1.25', latest: '0.1.40', available: true })
|
|
1037
|
+
: e.argv.includes('update') && e.argv.includes('--json')
|
|
1038
|
+
? JSON.stringify({ ok: true, detail: 'updated to 0.1.40' })
|
|
1039
|
+
: e.argv.includes('status')
|
|
1040
|
+
? JSON.stringify({ items: [], suggest: [] })
|
|
1041
|
+
: e.argv.includes('snap')
|
|
1042
|
+
? JSON.stringify({ id: 'a1', undoable: true })
|
|
1043
|
+
: '[]'
|
|
1044
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
1045
|
+
})
|
|
1046
|
+
on('ui.toast', (_, e) => {
|
|
1047
|
+
toasts.push(e.text)
|
|
1048
|
+
return { value: undefined }
|
|
1049
|
+
})
|
|
1050
|
+
on('ui.log', (_, e) => {
|
|
1051
|
+
logs.push(e.text)
|
|
1052
|
+
return { value: undefined }
|
|
1053
|
+
})
|
|
1054
|
+
on('ui.status', () => ({ value: undefined }))
|
|
1055
|
+
on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
|
|
1056
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
1057
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
1058
|
+
asked.push(questions[0].question)
|
|
1059
|
+
return { result: { questions, answers: { [questions[0].question]: answer } } }
|
|
1060
|
+
})
|
|
1061
|
+
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
1062
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
1063
|
+
on('session.id', () => ({ value: 's1' }))
|
|
1064
|
+
on('tool.call', () => ({ result: {} }))
|
|
1065
|
+
on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
|
|
1066
|
+
on('ui.render', ($, e) => {
|
|
1067
|
+
const { Text } = $.ui.resolve(e)
|
|
1068
|
+
return <Text>engine</Text>
|
|
1069
|
+
})
|
|
1070
|
+
return { runs, toasts, logs, asked }
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const TOO_OLD = { ok: false, reason: 'update', current: '0.1.25', minVersion: '0.1.30', detail: 'Backstep 0.1.25 is no longer supported: update to 0.1.30 or newer.' }
|
|
1074
|
+
|
|
1075
|
+
test('a version getbackstep.com says is too old must update: ctrl+u stays off until it does', { options: { cli: CLI } }, async ($, on) => {
|
|
1076
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1077
|
+
const { runs, toasts, asked } = gated(on, TOO_OLD, 'Not now')
|
|
1078
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
1079
|
+
await clock.settle()
|
|
1080
|
+
expect(asked).toEqual(['This version of Backstep (0.1.25) is no longer supported, so ctrl+u is off until it updates. Update to 0.1.40 now?'])
|
|
1081
|
+
// The ordinary "a new version is out" question isn't asked on top.
|
|
1082
|
+
expect(asked.length).toBe(1)
|
|
1083
|
+
|
|
1084
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1085
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/a.ts', old_string: 'a', new_string: 'b' } as never)
|
|
1086
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1087
|
+
await ui.press({ key: 'undo' })
|
|
1088
|
+
await ui.press({ key: 'undo' })
|
|
1089
|
+
await clock.settle()
|
|
1090
|
+
expect(runs.some((r) => r.includes('undo'))).toBe(false)
|
|
1091
|
+
expect(toasts.at(-1)).toContain('no longer supported')
|
|
1092
|
+
await ui.unmount()
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
test('Update now installs the newest version and turns ctrl+u back on', { options: { cli: CLI } }, async ($, on) => {
|
|
1096
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1097
|
+
const { runs, logs } = gated(on, TOO_OLD, 'Update now')
|
|
1098
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
1099
|
+
await clock.settle()
|
|
1100
|
+
expect(runs.some((r) => r.slice(3).join(' ') === 'update --version 0.1.40 --json')).toBe(true)
|
|
1101
|
+
expect(logs.find((l) => l.includes('Backstep is updated to 0.1.40 (you had 0.1.25)'))).toBeTruthy()
|
|
1102
|
+
|
|
1103
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1104
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/a.ts', old_string: 'a', new_string: 'b' } as never)
|
|
1105
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1106
|
+
await ui.press({ key: 'undo' })
|
|
1107
|
+
const pressed = ui.press({ key: 'undo' })
|
|
1108
|
+
await clock.advance(1000)
|
|
1109
|
+
await pressed
|
|
1110
|
+
await clock.settle()
|
|
1111
|
+
expect(runs.some((r) => r.includes('undo'))).toBe(true)
|
|
1112
|
+
await ui.unmount()
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
test('when signing in is required and nobody is, ctrl+u says how to sign in instead of undoing', { options: { cli: CLI } }, async ($, on) => {
|
|
1116
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1117
|
+
const { runs, toasts, asked } = gated(on, { ok: false, reason: 'signin', detail: 'Backstep needs you to sign in. Run: npx backstep login' }, 'Not now')
|
|
1118
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
1119
|
+
await clock.settle()
|
|
1120
|
+
expect(toasts.find((t) => t.includes('npx backstep login'))).toBeTruthy()
|
|
1121
|
+
// Not a version problem: the ordinary update offer still comes.
|
|
1122
|
+
expect(asked).toEqual(['Backstep 0.1.40 is out (you have 0.1.25). Update now?'])
|
|
1123
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1124
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/a.ts', old_string: 'a', new_string: 'b' } as never)
|
|
1125
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1126
|
+
await ui.press({ key: 'undo' })
|
|
1127
|
+
await clock.settle()
|
|
1128
|
+
expect(runs.some((r) => r.includes('undo'))).toBe(false)
|
|
1129
|
+
expect(toasts.at(-1)).toContain('npx backstep login')
|
|
1130
|
+
await ui.unmount()
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
test('in the desktop app the band says Undo (a click), since ctrl+u never reaches it there', { options: { cli: CLI } }, async ($, on) => {
|
|
1134
|
+
mock.clock(on, { now: 5000 })
|
|
1135
|
+
engine(on, { exitCode: 0, stdout: '[]' })
|
|
1136
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1137
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
|
|
1138
|
+
|
|
1139
|
+
const desk = await $.ui.mount({ ...BAND, surface: 'desktop' })
|
|
1140
|
+
expect((await desk.find({ type: 'Button', key: 'undo' }))?.text).toBe('◀ Undo last turn')
|
|
1141
|
+
expect(JSON.stringify(await desk.find({ type: 'Box', text: /Undo last turn/ }))).not.toContain('ctrl')
|
|
1142
|
+
await desk.press({ key: 'undo' })
|
|
1143
|
+
expect(await desk.find({ type: 'Text', text: /Undo Claude's last turn\?/ })).toBeTruthy()
|
|
1144
|
+
expect((await desk.find({ type: 'Button', key: 'undo' }))?.text).toBe('Yes, undo it')
|
|
1145
|
+
expect((await desk.find({ type: 'Button', key: 'cancel' }))?.text).toBe('Cancel')
|
|
1146
|
+
await desk.unmount()
|
|
1147
|
+
|
|
1148
|
+
const term = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1149
|
+
expect((await term.find({ type: 'Button', key: 'undo' }))?.text).toContain('again to undo')
|
|
1150
|
+
await term.unmount()
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
test('/backstep-undo undoes the last turn straight away, and says when there is nothing to undo', { options: { cli: CLI } }, async ($, on) => {
|
|
1154
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
1155
|
+
const { runs } = engine(on, { exitCode: 0, stdout: JSON.stringify([{ id: 'c1', tool: 'Edit', ok: true, detail: 'restored /repo/src/a.ts' }]) })
|
|
1156
|
+
|
|
1157
|
+
expect(JSON.stringify(await $.command.run({ command: 'backstep-undo' } as never))).toContain('Nothing to undo yet')
|
|
1158
|
+
|
|
1159
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
1160
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
|
|
1161
|
+
const ran = $.command.run({ command: 'backstep-undo' } as never)
|
|
1162
|
+
await clock.advance(1000)
|
|
1163
|
+
const r = await ran
|
|
1164
|
+
expect(runs.some((a) => a[3] === 'undo')).toBe(true)
|
|
1165
|
+
expect(JSON.stringify(r)).toContain('Rewound 1 change from the last turn.')
|
|
1166
|
+
})
|
|
1167
|
+
|
|
1168
|
+
test('in the desktop app, the update is offered the same way and says not to close Claude Code', { options: { cli: CLI } }, async ($, on) => {
|
|
1169
|
+
const { asked, logs, statuses } = updates(on, 'Update')
|
|
1170
|
+
await $.session.start({ cwd: '/repo', surface: 'desktop', hasPerson: true } as never)
|
|
1171
|
+
await settle()
|
|
1172
|
+
expect(asked).toEqual(['Backstep 0.2.0 is out (you have 0.1.2). Update now?'])
|
|
1173
|
+
const said = logs.join('\n')
|
|
1174
|
+
expect(said).toContain('type /backstep-undo')
|
|
1175
|
+
expect(said).toContain("Don't close Claude Code until it says it's done")
|
|
1176
|
+
expect(statuses[0]).toContain("don't close Claude Code")
|
|
1177
|
+
expect(said).not.toContain('terminal')
|
|
1178
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -18,6 +18,8 @@ const armed = atom({ plugin: 'backstep', key: 'armed' } as const, null as number
|
|
|
18
18
|
const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as string[])
|
|
19
19
|
// The one-time code GitHub's approval page wants, on the band while GitHub waits.
|
|
20
20
|
const githubCode = atom({ plugin: 'backstep', key: 'githubCode' } as const, null as GithubCode | null)
|
|
21
|
+
// Why ctrl+u can't run here, when getbackstep.com says so (a version too old, or no sign-in when one is needed).
|
|
22
|
+
const blocked = atom({ plugin: 'backstep', key: 'blocked' } as const, null as string | null)
|
|
21
23
|
// Backstep's mark, as on the logo: the front rewind triangle in the terminal's
|
|
22
24
|
// own black or white, the one behind it in the logo's pink.
|
|
23
25
|
const PINK = '#ee7bb6'
|
|
@@ -254,6 +256,13 @@ function relPath(path: string, cwd: string): string {
|
|
|
254
256
|
|
|
255
257
|
async function pressUndo($: EngineInterface) {
|
|
256
258
|
if (await read($, rewind)) return
|
|
259
|
+
// Too old a version, or signing in is required and nobody is: say what to do instead.
|
|
260
|
+
const off = await read($, blocked)
|
|
261
|
+
if (off) {
|
|
262
|
+
$.ui.toast(off, { timeoutMs: 10_000 })
|
|
263
|
+
$.ui.log(`${MARK_TEXT} ${off}`)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
257
266
|
const target = (await read($, turns)).findLast(hasWork)
|
|
258
267
|
if (!target) {
|
|
259
268
|
$.ui.toast('Nothing to undo yet: Claude hasn\'t changed anything Backstep can take back.')
|
|
@@ -272,6 +281,18 @@ async function pressUndo($: EngineInterface) {
|
|
|
272
281
|
})
|
|
273
282
|
}
|
|
274
283
|
|
|
284
|
+
// /backstep-undo: the command is the confirmation, so it undoes straight away.
|
|
285
|
+
async function undoByCommand($: EngineInterface): Promise<string> {
|
|
286
|
+
if (await read($, rewind)) return 'Backstep is already undoing a turn.'
|
|
287
|
+
const off = await read($, blocked)
|
|
288
|
+
if (off) return `${MARK_TEXT} ${off}`
|
|
289
|
+
if (!(await read($, turns)).some(hasWork)) return "Nothing to undo yet: Claude hasn't changed anything Backstep can take back."
|
|
290
|
+
await update($, armed, () => null)
|
|
291
|
+
const { before } = await undoTargets($)
|
|
292
|
+
await undoLastTurn($, before ? 2 : 1)
|
|
293
|
+
return (await read($, rewind))?.message ?? 'Done.'
|
|
294
|
+
}
|
|
295
|
+
|
|
275
296
|
// Undoes the last `count` turns that changed something, newest first.
|
|
276
297
|
async function undoLastTurn($: EngineInterface, count = 1) {
|
|
277
298
|
if (await read($, rewind)) return
|
|
@@ -387,7 +408,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
387
408
|
|
|
388
409
|
if (done) {
|
|
389
410
|
const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
|
|
390
|
-
const key = await keysLabel($)
|
|
411
|
+
const key = surface === 'terminal' ? await keysLabel($) : 'the Undo button or /backstep-undo'
|
|
391
412
|
await update($, note, () =>
|
|
392
413
|
`[Backstep] The user pressed undo (${key}) on your ${taken.length > 1 ? `last ${taken.length} turns` : 'previous turn'}. These changes were reverted:\n${list}\n` +
|
|
393
414
|
(lost ? `${plural(lost, 'action')} from that turn couldn't be undone (they reached outside this machine with no way back, or the project was too big to snapshot).\n` : '') +
|
|
@@ -643,6 +664,49 @@ async function askDatabase($: EngineInterface, cwd: string, service = 'supabase'
|
|
|
643
664
|
|
|
644
665
|
const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
|
|
645
666
|
|
|
667
|
+
type Access = { ok: boolean; reason?: 'update' | 'signin' | 'plan'; detail?: string; current?: string; minVersion?: string }
|
|
668
|
+
|
|
669
|
+
// As a session starts: may ctrl+u run here? getbackstep.com can say a version
|
|
670
|
+
// is too old or that signing in is needed (both off unless it says so; if it
|
|
671
|
+
// can't be reached, Backstep works as before). Returns whether this version
|
|
672
|
+
// had to update, so the ordinary update offer isn't asked as well.
|
|
673
|
+
async function checkAccess($: EngineInterface): Promise<boolean> {
|
|
674
|
+
const ran = await $.process.run(backstep(['access']), { timeoutMs: 20_000 }).catch(() => null)
|
|
675
|
+
let a: Access = { ok: true }
|
|
676
|
+
try {
|
|
677
|
+
a = JSON.parse(ran?.stdout || '{"ok":true}') as Access
|
|
678
|
+
} catch {
|
|
679
|
+
return false
|
|
680
|
+
}
|
|
681
|
+
// Only a clear no turns ctrl+u off: anything else (an older CLI, odd output) leaves it on.
|
|
682
|
+
if (!a || typeof a !== 'object' || a.ok !== false || !a.detail) {
|
|
683
|
+
await update($, blocked, () => null)
|
|
684
|
+
return false
|
|
685
|
+
}
|
|
686
|
+
await update($, blocked, () => a.detail ?? null)
|
|
687
|
+
$.ui.log(`${MARK_TEXT} ${a.detail}`)
|
|
688
|
+
if (a.reason !== 'update') {
|
|
689
|
+
$.ui.toast(a.detail ?? 'Backstep is turned off here.', { timeoutMs: 15_000 })
|
|
690
|
+
return false
|
|
691
|
+
}
|
|
692
|
+
// Too old to keep running: asked to update, and ctrl+u waits until it has.
|
|
693
|
+
const checked = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
|
|
694
|
+
let latest = a.minVersion ?? ''
|
|
695
|
+
try {
|
|
696
|
+
latest = (JSON.parse(checked?.stdout || '{}') as { latest?: string | null }).latest ?? latest
|
|
697
|
+
} catch {
|
|
698
|
+
// the minimum, then
|
|
699
|
+
}
|
|
700
|
+
const answer = await $.ui
|
|
701
|
+
.ask(`This version of Backstep (${a.current}) is no longer supported, so ${surface === 'terminal' ? await keysLabel($) : 'undo'} is off until it updates. Update to ${latest} now?`, {
|
|
702
|
+
header: 'Backstep',
|
|
703
|
+
options: ['Update now', 'Not now'],
|
|
704
|
+
})
|
|
705
|
+
.catch(() => null)
|
|
706
|
+
if (answer === 'Update now' && latest && (await installUpdate($, a.current, latest, true))) await update($, blocked, () => null)
|
|
707
|
+
return true
|
|
708
|
+
}
|
|
709
|
+
|
|
646
710
|
// A newer Backstep on npm: asked as a session starts, installed only on a yes.
|
|
647
711
|
async function offerUpdate($: EngineInterface) {
|
|
648
712
|
const ran = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
|
|
@@ -661,11 +725,18 @@ async function offerUpdate($: EngineInterface) {
|
|
|
661
725
|
return
|
|
662
726
|
}
|
|
663
727
|
if (answer !== UPDATE.yes) return
|
|
728
|
+
await installUpdate($, u.current, u.latest)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Installs a version, saying not to close the terminal while it runs and which version it ended on.
|
|
732
|
+
async function installUpdate($: EngineInterface, current: string | undefined, latest: string, forced = false): Promise<boolean> {
|
|
733
|
+
const u = { current, latest }
|
|
664
734
|
// Said where it stays (the conversation) and where it's seen while it runs (the status line).
|
|
665
735
|
const from = u.current ? ` from ${u.current}` : ''
|
|
666
|
-
|
|
667
|
-
$.ui.
|
|
668
|
-
$.ui.
|
|
736
|
+
const win = surface === 'terminal' ? 'this terminal' : 'Claude Code'
|
|
737
|
+
$.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close ${win} until it says it's done (about a minute).`)
|
|
738
|
+
$.ui.toast(`Updating Backstep to ${u.latest}. Don't close ${win} until it's done.`, { timeoutMs: 15_000 })
|
|
739
|
+
$.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close ${win}`)
|
|
669
740
|
const done = await $.process
|
|
670
741
|
.run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
|
|
671
742
|
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
@@ -677,12 +748,14 @@ async function offerUpdate($: EngineInterface) {
|
|
|
677
748
|
}
|
|
678
749
|
$.ui.status(undefined)
|
|
679
750
|
if (r.ok) {
|
|
680
|
-
$.ui.log(`${MARK_TEXT} Backstep is updated to ${u.latest}${u.current ? ` (you had ${u.current})` : ''}. It's safe to close
|
|
751
|
+
$.ui.log(`${MARK_TEXT} Backstep is updated to ${u.latest}${u.current ? ` (you had ${u.current})` : ''}. It's safe to close ${win} now. Restart Claude Code to start using ${u.latest}.`)
|
|
681
752
|
$.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
|
|
753
|
+
return true
|
|
682
754
|
} else {
|
|
683
|
-
$.ui.log(`${MARK_TEXT} Backstep couldn't update to ${u.latest}: ${r.detail}. You're still on ${u.current ?? 'the version you had'}, which keeps working. To try again, run: npx backstep@latest setup`)
|
|
755
|
+
$.ui.log(`${MARK_TEXT} Backstep couldn't update to ${u.latest}: ${r.detail}. You're still on ${u.current ?? 'the version you had'}${forced ? '' : ', which keeps working'}. To try again, run: npx backstep@latest setup`)
|
|
684
756
|
$.ui.toast(`Backstep couldn't update: ${r.detail}. Try: npx backstep@latest setup`)
|
|
685
757
|
}
|
|
758
|
+
return false
|
|
686
759
|
}
|
|
687
760
|
|
|
688
761
|
const KEY = 'mcp__backstep__undo_key'
|
|
@@ -806,15 +879,21 @@ async function uninstallBackstep($: EngineInterface): Promise<string> {
|
|
|
806
879
|
return `Backstep is removed. Restart Claude Code to finish.\n${lines}`
|
|
807
880
|
}
|
|
808
881
|
|
|
882
|
+
// Where this session is drawn: the terminal, or an app (the desktop app, VS Code) where there's no terminal to mention.
|
|
883
|
+
let surface = 'terminal'
|
|
884
|
+
|
|
809
885
|
export const register: Register = (on, options) => {
|
|
810
886
|
cli = String(options.cli ?? 'backstep')
|
|
811
887
|
|
|
812
888
|
on('session.start', async ($, e, next) => {
|
|
889
|
+
surface = e.surface ?? 'terminal'
|
|
813
890
|
await $.tool.register(CONNECT_TOOL)
|
|
814
891
|
await $.tool.register(KEY_TOOL)
|
|
815
892
|
await $.tool.register(UNINSTALL_TOOL)
|
|
816
893
|
// A command that can't be added never holds up the session: asking Claude still uninstalls.
|
|
817
894
|
await $.command.register({ name: 'uninstall-backstep', description: 'Remove Backstep and everything it set up from this computer' }).catch(() => null)
|
|
895
|
+
// Undo by command too: the desktop app has no way for ctrl+u to reach Backstep yet.
|
|
896
|
+
await $.command.register({ name: 'backstep-undo', description: "Undo Claude's last turn with Backstep" }).catch(() => null)
|
|
818
897
|
const r = await next(e)
|
|
819
898
|
const boundOut = await $.process.run(backstep(['key', '--json'])).catch(() => null)
|
|
820
899
|
try {
|
|
@@ -824,11 +903,17 @@ export const register: Register = (on, options) => {
|
|
|
824
903
|
// keep the default
|
|
825
904
|
}
|
|
826
905
|
// Said first thing, so nobody has to wonder whether it's running.
|
|
827
|
-
$.ui.log(
|
|
906
|
+
$.ui.log(
|
|
907
|
+
e.surface === 'terminal'
|
|
908
|
+
? `✓ Backstep connected · press ${await keysLabel($)} to undo Claude's last turn`
|
|
909
|
+
: "✓ Backstep connected · click Undo last turn above the message box, or type /backstep-undo, to undo Claude's last turn",
|
|
910
|
+
)
|
|
828
911
|
// In the background: a session never waits on either.
|
|
829
912
|
void wrapNewServers($, e.cwd)
|
|
830
|
-
// One question at a time: Supabase first, then any update.
|
|
831
|
-
void checkCoverage($, e.cwd)
|
|
913
|
+
// One question at a time: Supabase first, then whether this version may run, then any update.
|
|
914
|
+
void checkCoverage($, e.cwd)
|
|
915
|
+
.then(() => checkAccess($))
|
|
916
|
+
.then((forced) => (forced ? undefined : offerUpdate($)))
|
|
832
917
|
void $.process.run(backstep(['ping'])).catch(() => null)
|
|
833
918
|
return r
|
|
834
919
|
})
|
|
@@ -855,6 +940,7 @@ export const register: Register = (on, options) => {
|
|
|
855
940
|
on('tool.call', { tool: UNINSTALL }, async ($) => ({ result: await uninstallBackstep($) }))
|
|
856
941
|
|
|
857
942
|
on('command.run', { command: 'uninstall-backstep' }, async ($) => ({ text: await uninstallBackstep($) }))
|
|
943
|
+
on('command.run', { command: 'backstep-undo' }, async ($) => ({ text: await undoByCommand($) }))
|
|
858
944
|
|
|
859
945
|
on('tool.call', async ($, e, next) => {
|
|
860
946
|
const tool = String(e.tool)
|
|
@@ -1004,6 +1090,57 @@ export const register: Register = (on, options) => {
|
|
|
1004
1090
|
|
|
1005
1091
|
const { target, before } = await undoTargets($)
|
|
1006
1092
|
const label = (await read($, keys)).map(keyLabel).join(' / ')
|
|
1093
|
+
|
|
1094
|
+
// The desktop app (and any app that isn't the terminal): ctrl+u never reaches
|
|
1095
|
+
// Backstep there, so it's real buttons to click and no key is mentioned.
|
|
1096
|
+
const appBand = async (target: Turn | undefined, before: Turn | undefined) => {
|
|
1097
|
+
const cancel = (
|
|
1098
|
+
<Button key="cancel" variant="secondary" onPress={() => void update($, armed, () => null)}>
|
|
1099
|
+
Cancel
|
|
1100
|
+
</Button>
|
|
1101
|
+
)
|
|
1102
|
+
if (target && (await read($, armed)) !== null) {
|
|
1103
|
+
return (
|
|
1104
|
+
<Box flexDirection="column">
|
|
1105
|
+
<Box>
|
|
1106
|
+
{mark()}
|
|
1107
|
+
<Text color="warning" bold>{before ? 'That turn only saved and pushed to git.' : "Undo Claude's last turn?"}</Text>
|
|
1108
|
+
</Box>
|
|
1109
|
+
<Text dimColor>{before ? ` Undo it and the turn before it${quoted(before, 50)}?` : ` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
|
|
1110
|
+
<Box marginTop={1}>
|
|
1111
|
+
<Text>{' '}</Text>
|
|
1112
|
+
<Button key="undo" variant="primary" onPress={() => void pressUndo($)}>
|
|
1113
|
+
{before ? 'Yes, undo both' : 'Yes, undo it'}
|
|
1114
|
+
</Button>
|
|
1115
|
+
<Text>{' '}</Text>
|
|
1116
|
+
{before ? (
|
|
1117
|
+
<Button key="just" variant="secondary" onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
|
|
1118
|
+
Just the push
|
|
1119
|
+
</Button>
|
|
1120
|
+
) : null}
|
|
1121
|
+
{before ? <Text>{' '}</Text> : null}
|
|
1122
|
+
{cancel}
|
|
1123
|
+
</Box>
|
|
1124
|
+
</Box>
|
|
1125
|
+
)
|
|
1126
|
+
}
|
|
1127
|
+
return (
|
|
1128
|
+
<Box>
|
|
1129
|
+
{mark()}
|
|
1130
|
+
<Box flexShrink={1} minWidth={0}>
|
|
1131
|
+
<Text dimColor wrap="truncate-end">{target ? `Last turn:${quoted(target, 28)} · ${summary(target)}` : 'Backstep is running · nothing to undo yet'}</Text>
|
|
1132
|
+
</Box>
|
|
1133
|
+
<Box flexShrink={0} marginLeft={2}>
|
|
1134
|
+
<Button key="undo" variant="primary" onPress={() => void pressUndo($)}>
|
|
1135
|
+
◀ Undo last turn
|
|
1136
|
+
</Button>
|
|
1137
|
+
</Box>
|
|
1138
|
+
</Box>
|
|
1139
|
+
)
|
|
1140
|
+
}
|
|
1141
|
+
// Only the terminal hands ctrl+u to the button; anywhere else (the desktop app) it's a click.
|
|
1142
|
+
const keyed = e.surface === 'terminal'
|
|
1143
|
+
if (!keyed) return appBand(target, before)
|
|
1007
1144
|
if (target && (await read($, armed)) !== null) {
|
|
1008
1145
|
// Laid out a line at a time, so nothing gets squeezed into columns on a narrow terminal.
|
|
1009
1146
|
const cancel = (
|
package/mod/types/index.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ declare module 'claude-code' {
|
|
|
37
37
|
// armed: when the undo key was first pressed; a second press within a few seconds undoes.
|
|
38
38
|
// keys: the keys bound to undo, kept here so the band redraws when they change.
|
|
39
39
|
// githubCode: the code GitHub's approval page wants, shown on the band until GitHub answers.
|
|
40
|
-
|
|
40
|
+
// blocked: why ctrl+u can't run, when getbackstep.com says this version is too old or a sign-in is needed.
|
|
41
|
+
backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null; blocked: string | null }
|
|
41
42
|
}
|
|
42
43
|
}
|