backstep 0.1.25 → 0.1.26
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/account.js +217 -0
- package/dist/cli.js +37 -1
- package/dist/github.js +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +108 -0
- package/mod/hooks/register.tsx +65 -3
- package/mod/types/index.d.ts +2 -1
- package/package.json +1 -1
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
|
}
|
|
@@ -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,110 @@ 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
|
+
})
|
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.')
|
|
@@ -643,6 +652,49 @@ async function askDatabase($: EngineInterface, cwd: string, service = 'supabase'
|
|
|
643
652
|
|
|
644
653
|
const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
|
|
645
654
|
|
|
655
|
+
type Access = { ok: boolean; reason?: 'update' | 'signin' | 'plan'; detail?: string; current?: string; minVersion?: string }
|
|
656
|
+
|
|
657
|
+
// As a session starts: may ctrl+u run here? getbackstep.com can say a version
|
|
658
|
+
// is too old or that signing in is needed (both off unless it says so; if it
|
|
659
|
+
// can't be reached, Backstep works as before). Returns whether this version
|
|
660
|
+
// had to update, so the ordinary update offer isn't asked as well.
|
|
661
|
+
async function checkAccess($: EngineInterface): Promise<boolean> {
|
|
662
|
+
const ran = await $.process.run(backstep(['access']), { timeoutMs: 20_000 }).catch(() => null)
|
|
663
|
+
let a: Access = { ok: true }
|
|
664
|
+
try {
|
|
665
|
+
a = JSON.parse(ran?.stdout || '{"ok":true}') as Access
|
|
666
|
+
} catch {
|
|
667
|
+
return false
|
|
668
|
+
}
|
|
669
|
+
// Only a clear no turns ctrl+u off: anything else (an older CLI, odd output) leaves it on.
|
|
670
|
+
if (!a || typeof a !== 'object' || a.ok !== false || !a.detail) {
|
|
671
|
+
await update($, blocked, () => null)
|
|
672
|
+
return false
|
|
673
|
+
}
|
|
674
|
+
await update($, blocked, () => a.detail ?? null)
|
|
675
|
+
$.ui.log(`${MARK_TEXT} ${a.detail}`)
|
|
676
|
+
if (a.reason !== 'update') {
|
|
677
|
+
$.ui.toast(a.detail ?? 'Backstep is turned off here.', { timeoutMs: 15_000 })
|
|
678
|
+
return false
|
|
679
|
+
}
|
|
680
|
+
// Too old to keep running: asked to update, and ctrl+u waits until it has.
|
|
681
|
+
const checked = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
|
|
682
|
+
let latest = a.minVersion ?? ''
|
|
683
|
+
try {
|
|
684
|
+
latest = (JSON.parse(checked?.stdout || '{}') as { latest?: string | null }).latest ?? latest
|
|
685
|
+
} catch {
|
|
686
|
+
// the minimum, then
|
|
687
|
+
}
|
|
688
|
+
const answer = await $.ui
|
|
689
|
+
.ask(`This version of Backstep (${a.current}) is no longer supported, so ${await keysLabel($)} is off until it updates. Update to ${latest} now?`, {
|
|
690
|
+
header: 'Backstep',
|
|
691
|
+
options: ['Update now', 'Not now'],
|
|
692
|
+
})
|
|
693
|
+
.catch(() => null)
|
|
694
|
+
if (answer === 'Update now' && latest && (await installUpdate($, a.current, latest, true))) await update($, blocked, () => null)
|
|
695
|
+
return true
|
|
696
|
+
}
|
|
697
|
+
|
|
646
698
|
// A newer Backstep on npm: asked as a session starts, installed only on a yes.
|
|
647
699
|
async function offerUpdate($: EngineInterface) {
|
|
648
700
|
const ran = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
|
|
@@ -661,6 +713,12 @@ async function offerUpdate($: EngineInterface) {
|
|
|
661
713
|
return
|
|
662
714
|
}
|
|
663
715
|
if (answer !== UPDATE.yes) return
|
|
716
|
+
await installUpdate($, u.current, u.latest)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Installs a version, saying not to close the terminal while it runs and which version it ended on.
|
|
720
|
+
async function installUpdate($: EngineInterface, current: string | undefined, latest: string, forced = false): Promise<boolean> {
|
|
721
|
+
const u = { current, latest }
|
|
664
722
|
// Said where it stays (the conversation) and where it's seen while it runs (the status line).
|
|
665
723
|
const from = u.current ? ` from ${u.current}` : ''
|
|
666
724
|
$.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close this terminal until it says it's done (about a minute).`)
|
|
@@ -679,10 +737,12 @@ async function offerUpdate($: EngineInterface) {
|
|
|
679
737
|
if (r.ok) {
|
|
680
738
|
$.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}.`)
|
|
681
739
|
$.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
|
|
740
|
+
return true
|
|
682
741
|
} 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`)
|
|
742
|
+
$.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
743
|
$.ui.toast(`Backstep couldn't update: ${r.detail}. Try: npx backstep@latest setup`)
|
|
685
744
|
}
|
|
745
|
+
return false
|
|
686
746
|
}
|
|
687
747
|
|
|
688
748
|
const KEY = 'mcp__backstep__undo_key'
|
|
@@ -827,8 +887,10 @@ export const register: Register = (on, options) => {
|
|
|
827
887
|
$.ui.log(`✓ Backstep connected · press ${await keysLabel($)} to undo Claude's last turn`)
|
|
828
888
|
// In the background: a session never waits on either.
|
|
829
889
|
void wrapNewServers($, e.cwd)
|
|
830
|
-
// One question at a time: Supabase first, then any update.
|
|
831
|
-
void checkCoverage($, e.cwd)
|
|
890
|
+
// One question at a time: Supabase first, then whether this version may run, then any update.
|
|
891
|
+
void checkCoverage($, e.cwd)
|
|
892
|
+
.then(() => checkAccess($))
|
|
893
|
+
.then((forced) => (forced ? undefined : offerUpdate($)))
|
|
832
894
|
void $.process.run(backstep(['ping'])).catch(() => null)
|
|
833
895
|
return r
|
|
834
896
|
})
|
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
|
}
|