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 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
+ })
@@ -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') void askSupabase($, cwd)
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
- void checkCoverage($, e.cwd)
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
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "scripts": {
6
6
  "dev": "node src/cli.ts",