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 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
 
@@ -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 of installs still in use, so we know how
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
- export const sendInstall = () => send("install");
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() {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Press ctrl+z to rewind everything Claude did in its last turn: file edits, shell commands, and database and tool changes.",
5
5
  "types": "./types/index.d.ts",
6
6
  "userConfig": {
@@ -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
+ })
@@ -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
- $.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close this terminal until it says it's done (about a minute).`)
667
- $.ui.toast(`Updating Backstep to ${u.latest}. Don't close this terminal until it's done.`, { timeoutMs: 15_000 })
668
- $.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close this terminal`)
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 the terminal now. Restart Claude Code to start using ${u.latest}.`)
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(`✓ Backstep connected · press ${await keysLabel($)} to undo Claude's last turn`)
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).then(() => offerUpdate($))
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 = (
@@ -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
- backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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": {