backstep 0.1.1 → 0.1.2
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 +2 -0
- package/dist/cli.js +23 -0
- package/dist/telemetry.js +1 -1
- package/dist/update.js +67 -0
- package/mod/hooks/register.test.tsx +54 -0
- package/mod/hooks/register.tsx +38 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the se
|
|
|
52
52
|
| `--rules <file>` | undo rules (or set `BACKSTEP_RULES`) |
|
|
53
53
|
| `--pg <url>` | the Postgres database the wrapped server writes to. Enables database undo. Without it, SQL writes are recorded but can't be undone. |
|
|
54
54
|
|
|
55
|
+
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).
|
|
56
|
+
|
|
55
57
|
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>`.
|
|
56
58
|
|
|
57
59
|
In Claude Code you can just ask: "connect Backstep to everything I use". Supabase is connected from the project's settings (or the connection string you paste), and when the project uses Firebase but Claude can't reach it yet, Firebase's MCP server is added behind Backstep.
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { disconnectSupabase, dismissSupabase } from "./supabase.js";
|
|
|
20
20
|
import { connect } from "./connect.js";
|
|
21
21
|
import { check, printCheck } from "./check.js";
|
|
22
22
|
import { logo } from "./look.js";
|
|
23
|
+
import { checkUpdate, runUpdate, skipVersion } from "./update.js";
|
|
23
24
|
import { askConnectionString } from "./setup.js";
|
|
24
25
|
// Header values can name env vars, so tokens don't have to sit in agent config files.
|
|
25
26
|
function parseHeaders(list) {
|
|
@@ -57,6 +58,7 @@ const HELP = `backstep: undo for everything your agents touch
|
|
|
57
58
|
Supabase from the project's .env files or local database (or the
|
|
58
59
|
connection string you give), Firebase by adding its MCP server.
|
|
59
60
|
In Claude Code you can just ask: "connect Backstep to my Supabase"
|
|
61
|
+
backstep update install the newest version (Claude Code offers this as a session starts)
|
|
60
62
|
backstep status [--json] what Backstep can and can't undo for each service Claude Code reaches,
|
|
61
63
|
and what to connect next
|
|
62
64
|
backstep disconnect supabase <ref>
|
|
@@ -208,6 +210,27 @@ async function main() {
|
|
|
208
210
|
}
|
|
209
211
|
return;
|
|
210
212
|
}
|
|
213
|
+
// Run by the Claude Code mod as a session starts: is there a newer Backstep?
|
|
214
|
+
if (cmd === "update-check") {
|
|
215
|
+
console.log(JSON.stringify(await checkUpdate()));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// `backstep update` installs the newest version; --skip <v> stops asking about that one.
|
|
219
|
+
if (cmd === "update") {
|
|
220
|
+
const { values } = parseArgs({ args: rest, options: { skip: { type: "string" }, version: { type: "string" }, json: { type: "boolean", default: false } } });
|
|
221
|
+
if (values.skip) {
|
|
222
|
+
skipVersion(values.skip);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const target = values.version ?? (await checkUpdate()).latest;
|
|
226
|
+
if (!target)
|
|
227
|
+
throw new Error("couldn't reach npm to find the newest version");
|
|
228
|
+
const out = runUpdate(target);
|
|
229
|
+
console.log(values.json ? JSON.stringify(out) : out.ok ? `Backstep ${out.detail}. Restart Claude Code to use it.` : `Update failed: ${out.detail}`);
|
|
230
|
+
if (!out.ok)
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
211
234
|
if (cmd === "disconnect") {
|
|
212
235
|
const [service, ref] = rest;
|
|
213
236
|
if (service !== "supabase" || !ref)
|
package/dist/telemetry.js
CHANGED
|
@@ -29,7 +29,7 @@ function installId() {
|
|
|
29
29
|
writeFileSync(file, id + "\n", { mode: 0o600 });
|
|
30
30
|
return id;
|
|
31
31
|
}
|
|
32
|
-
function version() {
|
|
32
|
+
export function version() {
|
|
33
33
|
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
34
34
|
return JSON.parse(readFileSync(pkg, "utf8")).version;
|
|
35
35
|
}
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Keeping Backstep current. As each Claude Code session starts, the mod asks
|
|
2
|
+
// whether npm has a newer version; if so it asks the person, and an update
|
|
3
|
+
// runs the new version's own setup, the same command a first install runs.
|
|
4
|
+
// Off with BACKSTEP_UPDATE_CHECK=0.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homeDir } from "./store.js";
|
|
9
|
+
import { version } from "./telemetry.js";
|
|
10
|
+
const REGISTRY = process.env.BACKSTEP_REGISTRY ?? "https://registry.npmjs.org";
|
|
11
|
+
const file = () => join(homeDir(), "settings.json");
|
|
12
|
+
function settings() {
|
|
13
|
+
try {
|
|
14
|
+
return existsSync(file()) ? JSON.parse(readFileSync(file(), "utf8")) : {};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// 1.2.10 > 1.2.9; anything after a dash (a pre-release) counts as older than without.
|
|
21
|
+
export function newer(a, b) {
|
|
22
|
+
const parse = (v) => {
|
|
23
|
+
const [main, pre] = v.split("-", 2);
|
|
24
|
+
return { nums: main.split(".").map((n) => Number(n) || 0), pre: pre ?? null };
|
|
25
|
+
};
|
|
26
|
+
const x = parse(a);
|
|
27
|
+
const y = parse(b);
|
|
28
|
+
for (let i = 0; i < 3; i++)
|
|
29
|
+
if ((x.nums[i] ?? 0) !== (y.nums[i] ?? 0))
|
|
30
|
+
return (x.nums[i] ?? 0) > (y.nums[i] ?? 0);
|
|
31
|
+
return x.pre === null && y.pre !== null;
|
|
32
|
+
}
|
|
33
|
+
// Never slow: a session doesn't wait more than a few seconds on npm.
|
|
34
|
+
export async function checkUpdate() {
|
|
35
|
+
const current = version();
|
|
36
|
+
if (/^(0|false|off|no)$/i.test(process.env.BACKSTEP_UPDATE_CHECK ?? ""))
|
|
37
|
+
return { current, latest: null, available: false };
|
|
38
|
+
let latest = null;
|
|
39
|
+
try {
|
|
40
|
+
const r = await fetch(`${REGISTRY}/backstep/latest`, { signal: AbortSignal.timeout(4000) });
|
|
41
|
+
if (r.ok)
|
|
42
|
+
latest = (await r.json()).version ?? null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// offline: nothing to offer
|
|
46
|
+
}
|
|
47
|
+
const available = latest !== null && newer(latest, current) && settings().skipVersion !== latest;
|
|
48
|
+
return { current, latest, available };
|
|
49
|
+
}
|
|
50
|
+
export function skipVersion(v) {
|
|
51
|
+
mkdirSync(homeDir(), { recursive: true });
|
|
52
|
+
writeFileSync(file(), JSON.stringify({ ...settings(), skipVersion: v }, null, 2) + "\n");
|
|
53
|
+
}
|
|
54
|
+
// The new version's setup puts its own copy in place, points Claude Code's mod
|
|
55
|
+
// at it and re-wraps servers, exactly as a fresh install would.
|
|
56
|
+
export function runUpdate(v) {
|
|
57
|
+
const r = spawnSync("npx", ["-y", `backstep@${v}`, "setup"], {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout: 5 * 60 * 1000,
|
|
60
|
+
env: { ...process.env, NO_COLOR: "1", BACKSTEP_TELEMETRY: "0" },
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
if (r.status === 0)
|
|
64
|
+
return { ok: true, detail: `updated to ${v}` };
|
|
65
|
+
const why = `${r.stderr ?? ""}${r.stdout ?? ""}`.trim().split("\n").filter(Boolean).at(-1) ?? "npx failed";
|
|
66
|
+
return { ok: false, detail: why.replace(/^backstep: /, "") };
|
|
67
|
+
}
|
|
@@ -172,16 +172,23 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
172
172
|
tools.push(e.name)
|
|
173
173
|
return { value: { tool: `mcp__backstep__${e.name}` } }
|
|
174
174
|
})
|
|
175
|
+
const logs: string[] = []
|
|
176
|
+
on('ui.log', (_, e) => {
|
|
177
|
+
logs.push(e.text)
|
|
178
|
+
return { value: undefined }
|
|
179
|
+
})
|
|
175
180
|
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
176
181
|
|
|
177
182
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
178
183
|
await new Promise((r) => setTimeout(r, 10))
|
|
179
184
|
expect(tools).toEqual(['connect'])
|
|
185
|
+
expect(logs[0]).toContain('Backstep connected')
|
|
180
186
|
expect(runs).toEqual([
|
|
181
187
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
|
|
182
188
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
|
|
183
189
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
|
|
184
190
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'status', '--quick', '--json', '--dir', '/repo'],
|
|
191
|
+
['node', '--disable-warning=ExperimentalWarning', CLI, 'update-check'],
|
|
185
192
|
])
|
|
186
193
|
expect(toasts.find((t) => t.includes('linear is now behind Backstep'))).toBeTruthy()
|
|
187
194
|
// Supabase gets its own question, and only in a project that uses it.
|
|
@@ -430,3 +437,50 @@ test('after undoing a turn that pushed, Backstep asks and pushes the undo', { op
|
|
|
430
437
|
expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).not.toContain("couldn't be undone")
|
|
431
438
|
await ui.unmount()
|
|
432
439
|
})
|
|
440
|
+
|
|
441
|
+
function updates(on: On, answer: string) {
|
|
442
|
+
const runs: string[][] = []
|
|
443
|
+
const toasts: string[] = []
|
|
444
|
+
const asked: string[] = []
|
|
445
|
+
on('process.run', (_, e) => {
|
|
446
|
+
runs.push([...e.argv])
|
|
447
|
+
const stdout = e.argv.includes('update-check')
|
|
448
|
+
? JSON.stringify({ current: '0.1.2', latest: '0.2.0', available: true })
|
|
449
|
+
: e.argv.includes('update') && e.argv.includes('--json')
|
|
450
|
+
? JSON.stringify({ ok: true, detail: 'updated to 0.2.0' })
|
|
451
|
+
: e.argv.includes('status')
|
|
452
|
+
? JSON.stringify({ items: [], suggest: [] })
|
|
453
|
+
: '[]'
|
|
454
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
455
|
+
})
|
|
456
|
+
on('ui.toast', (_, e) => {
|
|
457
|
+
toasts.push(e.text)
|
|
458
|
+
return { value: undefined }
|
|
459
|
+
})
|
|
460
|
+
on('ui.log', () => ({ value: undefined }))
|
|
461
|
+
on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
|
|
462
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
463
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
464
|
+
asked.push(questions[0].question)
|
|
465
|
+
return { result: { questions, answers: { [questions[0].question]: answer } } }
|
|
466
|
+
})
|
|
467
|
+
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
468
|
+
return { runs, toasts, asked }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
test('a newer version is offered as a session starts, and Update installs it', { options: { cli: CLI } }, async ($, on) => {
|
|
472
|
+
const { runs, toasts, asked } = updates(on, 'Update')
|
|
473
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
474
|
+
await settle()
|
|
475
|
+
expect(asked).toEqual(['Backstep 0.2.0 is out (you have 0.1.2). Update now?'])
|
|
476
|
+
expect(runs.some((r) => r.slice(3).join(' ') === 'update --version 0.2.0 --json')).toBe(true)
|
|
477
|
+
expect(toasts.at(-1)).toBe('Backstep updated to 0.2.0. Restart Claude Code to use it.')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('Skip this version stops asking about it, and Not now changes nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
481
|
+
const { runs } = updates(on, 'Skip this version')
|
|
482
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
483
|
+
await settle()
|
|
484
|
+
expect(runs.some((r) => r.slice(3).join(' ') === 'update --skip 0.2.0')).toBe(true)
|
|
485
|
+
expect(runs.some((r) => r.includes('--version'))).toBe(false)
|
|
486
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -298,7 +298,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
|
|
|
298
298
|
const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
|
|
299
299
|
try {
|
|
300
300
|
for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
|
|
301
|
-
if (c.service === 'supabase' && c.state === 'ask')
|
|
301
|
+
if (c.service === 'supabase' && c.state === 'ask') await askSupabase($, cwd)
|
|
302
302
|
if (c.state === 'added') $.ui.toast('Backstep added Firebase for this project, so Claude can reach it and its changes can be undone. Restart Claude Code to use it.')
|
|
303
303
|
}
|
|
304
304
|
} catch {
|
|
@@ -352,6 +352,39 @@ async function askSupabase($: EngineInterface, cwd: string) {
|
|
|
352
352
|
$.ui.toast('Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.')
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
|
|
356
|
+
|
|
357
|
+
// A newer Backstep on npm: asked as a session starts, installed only on a yes.
|
|
358
|
+
async function offerUpdate($: EngineInterface) {
|
|
359
|
+
const ran = await $.process.run(backstep(['update-check']), { timeoutMs: 15_000 }).catch(() => null)
|
|
360
|
+
let u: { current?: string; latest?: string | null; available?: boolean } = {}
|
|
361
|
+
try {
|
|
362
|
+
u = JSON.parse(ran?.stdout || '{}') as typeof u
|
|
363
|
+
} catch {
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
if (!u.available || !u.latest) return
|
|
367
|
+
const answer = await $.ui
|
|
368
|
+
.ask(`Backstep ${u.latest} is out (you have ${u.current}). Update now?`, { header: 'Backstep', options: [UPDATE.yes, UPDATE.later, UPDATE.skip] })
|
|
369
|
+
.catch(() => null)
|
|
370
|
+
if (answer === UPDATE.skip) {
|
|
371
|
+
await $.process.run(backstep(['update', '--skip', u.latest])).catch(() => null)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (answer !== UPDATE.yes) return
|
|
375
|
+
$.ui.toast(`Updating Backstep to ${u.latest}…`)
|
|
376
|
+
const done = await $.process
|
|
377
|
+
.run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 300_000 })
|
|
378
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
379
|
+
let r: { ok: boolean; detail: string } = { ok: false, detail: (done.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
|
|
380
|
+
try {
|
|
381
|
+
r = JSON.parse(done.stdout) as typeof r
|
|
382
|
+
} catch {
|
|
383
|
+
// keep the error above
|
|
384
|
+
}
|
|
385
|
+
$.ui.toast(r.ok ? `Backstep ${r.detail}. Restart Claude Code to use it.` : `Backstep couldn't update: ${r.detail}. Try: npx backstep@latest setup`)
|
|
386
|
+
}
|
|
387
|
+
|
|
355
388
|
const CONNECT = 'mcp__backstep__connect'
|
|
356
389
|
|
|
357
390
|
const CONNECT_TOOL = {
|
|
@@ -403,9 +436,12 @@ export const register: Register = (on, options) => {
|
|
|
403
436
|
on('session.start', async ($, e, next) => {
|
|
404
437
|
await $.tool.register(CONNECT_TOOL)
|
|
405
438
|
const r = await next(e)
|
|
439
|
+
// Said first thing, so nobody has to wonder whether it's running.
|
|
440
|
+
$.ui.log("✓ Backstep connected · press ctrl+z to undo Claude's last turn")
|
|
406
441
|
// In the background: a session never waits on either.
|
|
407
442
|
void wrapNewServers($, e.cwd)
|
|
408
|
-
|
|
443
|
+
// One question at a time: Supabase first, then any update.
|
|
444
|
+
void checkCoverage($, e.cwd).then(() => offerUpdate($))
|
|
409
445
|
void $.process.run(backstep(['ping'])).catch(() => null)
|
|
410
446
|
return r
|
|
411
447
|
})
|