backstep 0.1.24 → 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 +46 -3
- package/dist/github.js +1 -1
- package/dist/setup.js +23 -4
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +126 -4
- package/mod/hooks/register.tsx +77 -4
- 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";
|
|
@@ -17,7 +18,7 @@ import { snapshotRest } from "./adapters/rest.js";
|
|
|
17
18
|
const PUSH = /\bgit\b[^;&|]*\bpush\b/;
|
|
18
19
|
import { classifyShell, commandDir, supabaseTarget, touchesDatabase, touchesFirebase, touchesSqlite, touchesSupabase } from "./shell.js";
|
|
19
20
|
import { watchEnd, watchStart } from "./watch.js";
|
|
20
|
-
import { sendActive } from "./telemetry.js";
|
|
21
|
+
import { sendActive, version } from "./telemetry.js";
|
|
21
22
|
import { readFileSync, rmSync } from "node:fs";
|
|
22
23
|
import { loadRules } from "./rules.js";
|
|
23
24
|
import { setup } from "./setup.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()));
|
|
@@ -315,8 +347,15 @@ async function main() {
|
|
|
315
347
|
const target = values.version ?? (await checkUpdate()).latest;
|
|
316
348
|
if (!target)
|
|
317
349
|
throw new Error("couldn't reach npm to find the newest version");
|
|
350
|
+
const was = version();
|
|
351
|
+
if (!values.json)
|
|
352
|
+
console.log(`Updating Backstep from ${was} to ${target}… Don't close this terminal until it says it's done (about a minute).`);
|
|
318
353
|
const out = runUpdate(target);
|
|
319
|
-
console.log(values.json
|
|
354
|
+
console.log(values.json
|
|
355
|
+
? JSON.stringify(out)
|
|
356
|
+
: out.ok
|
|
357
|
+
? `Backstep is updated to ${target} (you had ${was}). It's safe to close the terminal now. Restart Claude Code to start using ${target}.`
|
|
358
|
+
: `Backstep couldn't update to ${target}: ${out.detail}. You're still on ${was}, which keeps working.`);
|
|
320
359
|
if (!out.ok)
|
|
321
360
|
process.exitCode = 1;
|
|
322
361
|
return;
|
|
@@ -529,6 +568,7 @@ async function main() {
|
|
|
529
568
|
}
|
|
530
569
|
// Takes back something Claude changed on Vercel (the change, as undo reported it, on stdin). The mod runs this after asking.
|
|
531
570
|
if (cmd === "vercel-undo") {
|
|
571
|
+
requireAccess("vercel");
|
|
532
572
|
const out = undoVercel(JSON.parse(readFileSync(0, "utf8")));
|
|
533
573
|
console.log(JSON.stringify(out));
|
|
534
574
|
if (!out.ok)
|
|
@@ -536,6 +576,7 @@ async function main() {
|
|
|
536
576
|
return;
|
|
537
577
|
}
|
|
538
578
|
if (cmd === "gh-undo") {
|
|
579
|
+
requireAccess("github");
|
|
539
580
|
const out = undoGh(JSON.parse(readFileSync(0, "utf8")));
|
|
540
581
|
console.log(JSON.stringify(out));
|
|
541
582
|
if (!out.ok)
|
|
@@ -544,6 +585,7 @@ async function main() {
|
|
|
544
585
|
}
|
|
545
586
|
// Puts an undo of code Claude pushed on the remote, as a new commit. The push, as undo reported it, comes on stdin.
|
|
546
587
|
if (cmd === "revert-push") {
|
|
588
|
+
requireAccess("github");
|
|
547
589
|
const { values } = parseArgs({ args: rest, options: { root: { type: "string" } } });
|
|
548
590
|
if (!values.root)
|
|
549
591
|
throw new Error("usage: backstep revert-push --root <repo> (the push as JSON on stdin)");
|
|
@@ -602,6 +644,7 @@ async function main() {
|
|
|
602
644
|
return;
|
|
603
645
|
}
|
|
604
646
|
if (cmd === "undo") {
|
|
647
|
+
requireAccess("undo");
|
|
605
648
|
const { values, positionals } = parseArgs({
|
|
606
649
|
args: rest,
|
|
607
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/setup.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "./spawn.js";
|
|
2
|
-
import { cpSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { cpSync, existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { homeDir } from "./store.js";
|
|
@@ -24,9 +24,28 @@ function keepCopy() {
|
|
|
24
24
|
if (run("npm", ["install", "-g", `backstep@${version}`]).ok) {
|
|
25
25
|
return { root: join(run("npm", ["root", "-g"]).out, "backstep"), onPath: true };
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// The new copy is made beside the old one, then swapped in: closing the
|
|
28
|
+
// terminal partway through leaves the old copy whole and working.
|
|
29
|
+
const app = join(homeDir(), "app");
|
|
30
|
+
const modules = join(app, "node_modules");
|
|
31
|
+
const fresh = join(app, "node_modules.new");
|
|
32
|
+
const old = join(app, "node_modules.old");
|
|
33
|
+
rmSync(fresh, { recursive: true, force: true });
|
|
34
|
+
cpSync(dirname(ROOT), fresh, { recursive: true });
|
|
35
|
+
rmSync(old, { recursive: true, force: true });
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(modules))
|
|
38
|
+
renameSync(modules, old);
|
|
39
|
+
renameSync(fresh, modules);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// A file still open (Windows won't move it): copy over the old copy where it is instead.
|
|
43
|
+
if (!existsSync(modules) && existsSync(old))
|
|
44
|
+
renameSync(old, modules);
|
|
45
|
+
cpSync(fresh, modules, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
rmSync(fresh, { recursive: true, force: true });
|
|
48
|
+
rmSync(old, { recursive: true, force: true });
|
|
30
49
|
return { root: join(modules, "backstep"), onPath: false };
|
|
31
50
|
}
|
|
32
51
|
const WHERE = `In Supabase, open your project, press Connect at the top, and copy the URI under
|
|
@@ -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()
|
|
@@ -589,7 +590,16 @@ function updates(on: On, answer: string) {
|
|
|
589
590
|
toasts.push(e.text)
|
|
590
591
|
return { value: undefined }
|
|
591
592
|
})
|
|
592
|
-
|
|
593
|
+
const logs: string[] = []
|
|
594
|
+
const statuses: (string | undefined)[] = []
|
|
595
|
+
on('ui.log', (_, e) => {
|
|
596
|
+
logs.push(e.text)
|
|
597
|
+
return { value: undefined }
|
|
598
|
+
})
|
|
599
|
+
on('ui.status', (_, e) => {
|
|
600
|
+
statuses.push(e.text)
|
|
601
|
+
return { value: undefined }
|
|
602
|
+
})
|
|
593
603
|
on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
|
|
594
604
|
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
595
605
|
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
@@ -600,7 +610,7 @@ function updates(on: On, answer: string) {
|
|
|
600
610
|
on('session.cwd', () => ({ value: '/repo' }))
|
|
601
611
|
on('session.id', () => ({ value: 's1' }))
|
|
602
612
|
on('tool.call', () => ({ result: {} }))
|
|
603
|
-
return { runs, toasts, asked }
|
|
613
|
+
return { runs, toasts, asked, logs, statuses }
|
|
604
614
|
}
|
|
605
615
|
|
|
606
616
|
// Claude is about to change the database: the moment Backstep asks.
|
|
@@ -614,12 +624,17 @@ async function changeDatabase($: Parameters<Parameters<typeof test>[2]>[0], on:
|
|
|
614
624
|
}
|
|
615
625
|
|
|
616
626
|
test('a newer version is offered as a session starts, and Update installs it', { options: { cli: CLI } }, async ($, on) => {
|
|
617
|
-
const { runs, toasts, asked } = updates(on, 'Update')
|
|
627
|
+
const { runs, toasts, asked, logs, statuses } = updates(on, 'Update')
|
|
618
628
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
619
629
|
await settle()
|
|
620
630
|
expect(asked).toEqual(['Backstep 0.2.0 is out (you have 0.1.2). Update now?'])
|
|
621
631
|
expect(runs.some((r) => r.slice(3).join(' ') === 'update --version 0.2.0 --json')).toBe(true)
|
|
622
|
-
expect(toasts.at(-1)).toBe('Backstep updated to 0.2.0. Restart Claude Code to use it.')
|
|
632
|
+
expect(toasts.at(-1)).toBe('Backstep is updated to 0.2.0. Restart Claude Code to use it.')
|
|
633
|
+
// While it runs it says not to close the terminal; once done, which version it updated to.
|
|
634
|
+
expect(logs.find((l) => l.includes('Updating Backstep from 0.1.2 to 0.2.0'))).toContain("Don't close this terminal")
|
|
635
|
+
expect(statuses[0]).toContain("don't close this terminal")
|
|
636
|
+
expect(statuses.at(-1)).toBeUndefined()
|
|
637
|
+
expect(logs.at(-1)).toContain('Backstep is updated to 0.2.0 (you had 0.1.2)')
|
|
623
638
|
})
|
|
624
639
|
|
|
625
640
|
test('Skip this version stops asking about it, and Not now changes nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
@@ -1007,3 +1022,110 @@ test('a production deploy Claude made on Vercel is offered back after undo, and
|
|
|
1007
1022
|
expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes Vercel')
|
|
1008
1023
|
await ui.unmount()
|
|
1009
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,7 +713,17 @@ async function offerUpdate($: EngineInterface) {
|
|
|
661
713
|
return
|
|
662
714
|
}
|
|
663
715
|
if (answer !== UPDATE.yes) return
|
|
664
|
-
|
|
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 }
|
|
722
|
+
// Said where it stays (the conversation) and where it's seen while it runs (the status line).
|
|
723
|
+
const from = u.current ? ` from ${u.current}` : ''
|
|
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).`)
|
|
725
|
+
$.ui.toast(`Updating Backstep to ${u.latest}. Don't close this terminal until it's done.`, { timeoutMs: 15_000 })
|
|
726
|
+
$.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close this terminal`)
|
|
665
727
|
const done = await $.process
|
|
666
728
|
.run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
|
|
667
729
|
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
@@ -671,7 +733,16 @@ async function offerUpdate($: EngineInterface) {
|
|
|
671
733
|
} catch {
|
|
672
734
|
// keep the error above
|
|
673
735
|
}
|
|
674
|
-
$.ui.
|
|
736
|
+
$.ui.status(undefined)
|
|
737
|
+
if (r.ok) {
|
|
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}.`)
|
|
739
|
+
$.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
|
|
740
|
+
return true
|
|
741
|
+
} else {
|
|
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`)
|
|
743
|
+
$.ui.toast(`Backstep couldn't update: ${r.detail}. Try: npx backstep@latest setup`)
|
|
744
|
+
}
|
|
745
|
+
return false
|
|
675
746
|
}
|
|
676
747
|
|
|
677
748
|
const KEY = 'mcp__backstep__undo_key'
|
|
@@ -816,8 +887,10 @@ export const register: Register = (on, options) => {
|
|
|
816
887
|
$.ui.log(`✓ Backstep connected · press ${await keysLabel($)} to undo Claude's last turn`)
|
|
817
888
|
// In the background: a session never waits on either.
|
|
818
889
|
void wrapNewServers($, e.cwd)
|
|
819
|
-
// One question at a time: Supabase first, then any update.
|
|
820
|
-
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($)))
|
|
821
894
|
void $.process.run(backstep(['ping'])).catch(() => null)
|
|
822
895
|
return r
|
|
823
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
|
}
|