backstep 0.1.22 → 0.1.24

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/check.js CHANGED
@@ -84,7 +84,8 @@ export function check(dir, opts = {}) {
84
84
  // do with undo (ones that are broken, or need signing in) aren't Backstep's to list.
85
85
  export function printCheck(c, bs = "backstep") {
86
86
  const cols = width() - 6;
87
- const undoable = [...new Set(c.items.filter((i) => i.level === "undoable").map((i) => i.name))];
87
+ // Supabase is said once, in the databases line below.
88
+ const undoable = [...new Set(c.items.filter((i) => i.level === "undoable" && !SUPABASE.test(i.name)).map((i) => i.name))];
88
89
  const soon = [...new Set(c.items.filter((i) => i.level === "connect" && FIREBASE.test(i.name)).map((i) => i.name))];
89
90
  const supabase = c.items.find((i) => SUPABASE.test(i.name));
90
91
  const supabaseLeft = supabase?.level === "connect" || c.suggest.some((s) => s.title === "Connect Supabase");
@@ -92,6 +93,8 @@ export function printCheck(c, bs = "backstep") {
92
93
  console.log(`\n ${white(`What ${(undoKeys().length ? undoKeys() : [DEFAULT_KEY]).map(keyLabel).join(" or ")} can undo`)}`);
93
94
  console.log(` ${pink("✓")} Files Claude edits and commands it runs`);
94
95
  console.log(` ${pink("✓")} Code Claude pushes to GitHub ${dim("(automatic, nothing to connect)")}`);
96
+ console.log(` ${pink("✓")} Vercel deploys and env vars Claude adds ${dim("(automatic, through your vercel login)")}`);
97
+ console.log(` ${pink("✓")} Supabase and other databases ${dim(supabase?.level === "undoable" ? "(Supabase is connected)" : "(Backstep asks when you open a project that uses one)")}`);
95
98
  for (const n of undoable)
96
99
  console.log(` ${pink("✓")} ${n}`);
97
100
  for (const n of soon)
@@ -103,10 +106,6 @@ export function printCheck(c, bs = "backstep") {
103
106
  console.log(` ${pink("→")} Or just ask Claude: ${white('"connect Backstep to Supabase"')}`);
104
107
  console.log("");
105
108
  }
106
- else if (!supabase || supabase.level !== "undoable") {
107
- console.log(` ${dim(wrap("Use Supabase? Open your project in Claude Code and Backstep will check for it and offer to connect.", cols, 2))}`);
108
- console.log("");
109
- }
110
109
  if (c.suggest.some((s) => s.title === "Add Firebase")) {
111
110
  console.log(` ${white("Your project uses Firebase")}`);
112
111
  console.log(` ${pink("→")} Ask Claude: ${white('"connect Backstep to Firebase"')}`);
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ 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
11
  import { ghMade, grantScope, undoGh } from "./github.js";
12
+ import { undoVercel, vercelBefore, vercelMade } from "./vercel.js";
12
13
  import { snapshotDb } from "./adapters/dbsnap.js";
13
14
  import { dbDirs, firebaseProject, postgresUrls, projectDatabases, projectUses, restKeys, supabaseDirs, unconnected } from "./connect.js";
14
15
  import { snapshotFirebase } from "./adapters/firestore.js";
@@ -468,7 +469,9 @@ async function main() {
468
469
  // SQLite files in the project, even git-ignored ones, which the project snapshot leaves out.
469
470
  const sqlite = tree && touchesSqlite(command) ? snapshotSqlite(a.id, tree.root) : null;
470
471
  const extra = { ...(dbs.length ? { dbs } : {}), ...(rests.length ? { rests } : {}), ...(fbs.length ? { fbs } : {}), ...(sqlite?.files.length ? { sqlite } : {}) };
471
- const undo = tree ? { ...tree, ...extra } : dbs.length || rests.length || fbs.length ? { adapter: "dbs", dbs, rests, fbs } : null;
472
+ // A command that could change what's live on Vercel: what's live now, so undo can put it back.
473
+ const vb = tree && classifyShell(command).reason === "changes Vercel" ? vercelBefore(command, cwd) : [];
474
+ const undo = tree ? { ...tree, ...extra, ...(vb.length ? { vercelBefore: vb } : {}) } : dbs.length || rests.length || fbs.length ? { adapter: "dbs", dbs, rests, fbs } : null;
472
475
  if (undo)
473
476
  store.updateAction(a.id, { undo: JSON.stringify(undo) });
474
477
  // With its database saved, a command that reaches it is no longer lost to undo.
@@ -498,15 +501,19 @@ async function main() {
498
501
  // After a shell command that pushed: notes what it sent (run by the Claude Code mod).
499
502
  // Also notes what a gh command made on GitHub, from its output on stdin.
500
503
  if (cmd === "shell-end") {
504
+ const { values, positionals } = parseArgs({ args: rest, options: { cwd: { type: "string" } }, allowPositionals: true });
501
505
  const store = new Store();
502
- const a = store.getAction(rest[0] ?? "");
506
+ const a = store.getAction(positionals[0] ?? "");
503
507
  const u = a?.undo ? JSON.parse(a.undo) : null;
504
508
  const output = process.stdin.isTTY ? "" : readFileSync(0, "utf8");
505
509
  if (a && u?.adapter === "tree") {
510
+ const command = JSON.parse(a.args).command ?? "";
506
511
  const pushed = u.git?.remotes ? notePushed(u) : [];
507
- const github = ghMade(JSON.parse(a.args).command ?? "", output);
508
- if (pushed.length || github.length)
509
- store.updateAction(a.id, { undo: JSON.stringify({ ...u, ...(pushed.length ? { pushed } : {}), ...(github.length ? { github } : {}) }) });
512
+ const github = ghMade(command, output);
513
+ const vercel = classifyShell(command).reason === "changes Vercel" ? vercelMade(command, values.cwd ?? process.cwd(), output, u.vercelBefore) : [];
514
+ if (pushed.length || github.length || vercel.length) {
515
+ store.updateAction(a.id, { undo: JSON.stringify({ ...u, ...(pushed.length ? { pushed } : {}), ...(github.length ? { github } : {}), ...(vercel.length ? { vercel } : {}) }) });
516
+ }
510
517
  }
511
518
  return;
512
519
  }
@@ -520,6 +527,14 @@ async function main() {
520
527
  process.exitCode = 1;
521
528
  return;
522
529
  }
530
+ // Takes back something Claude changed on Vercel (the change, as undo reported it, on stdin). The mod runs this after asking.
531
+ if (cmd === "vercel-undo") {
532
+ const out = undoVercel(JSON.parse(readFileSync(0, "utf8")));
533
+ console.log(JSON.stringify(out));
534
+ if (!out.ok)
535
+ process.exitCode = 1;
536
+ return;
537
+ }
523
538
  if (cmd === "gh-undo") {
524
539
  const out = undoGh(JSON.parse(readFileSync(0, "utf8")));
525
540
  console.log(JSON.stringify(out));
package/dist/shell.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Reads pass straight through; commands that reach outside the machine can't be
3
3
  // taken back, so they're held; everything else gets the project snapshotted.
4
4
  import { resolveIn } from "./paths.js";
5
+ import { vercelKind } from "./vercel.js";
5
6
  // Programs that only look. A pipeline or chain is a read only if every part is.
6
7
  const READ_PROGRAMS = new Set([
7
8
  "ls", "ll", "la", "cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "ag", "fd", "find", "tree",
@@ -23,7 +24,7 @@ const OUTBOUND = [
23
24
  [/^(npm|pnpm|yarn|bun) (publish|unpublish|deprecate|dist-tag)\b/, "publishes a package"],
24
25
  [/^(cargo|gem|twine|poetry|flit) (publish|push|upload)\b/, "publishes a package"],
25
26
  [/^gh (pr (create|merge|close|comment|review|edit)|issue (create|close|comment|edit|delete)|release (create|delete|upload)|repo (create|delete|edit)|secret|workflow run|api)\b/, "changes GitHub"],
26
- [/^(vercel|netlify|fly|flyctl|heroku|firebase|wrangler|railway|surge)\b(?!.*\s(--version|-v|--help|login|whoami|logs|ls|list)(\s|$))/, "deploys"],
27
+ [/^(netlify|fly|flyctl|heroku|firebase|wrangler|railway|surge)\b(?!.*\s(--version|-v|--help|login|whoami|logs|ls|list)(\s|$))/, "deploys"],
27
28
  [/^(kubectl (apply|delete|create|patch|replace|scale|rollout)|helm (install|upgrade|uninstall|rollback)|terraform (apply|destroy)|pulumi (up|destroy)|aws |gcloud |az )/, "changes cloud resources"],
28
29
  [/^docker (push|login)\b/, "pushes an image"],
29
30
  [/^(ssh|scp|sftp|rsync .*:\S)/, "runs on another machine"],
@@ -100,9 +101,19 @@ function isRead(part) {
100
101
  return true;
101
102
  return false;
102
103
  }
104
+ // Each part of a command, ready to judge: split on ; && || | and newlines, wrappers dropped.
105
+ export function commandParts(command) {
106
+ return parts(command).map(strip);
107
+ }
103
108
  export function classifyShell(command) {
104
- const ps = parts(command).map(strip);
109
+ const ps = commandParts(command);
105
110
  for (const p of ps) {
111
+ // Vercel: deploys and added variables come back with undo (src/vercel.ts); other changes there don't.
112
+ const v = vercelKind(p);
113
+ if (v === "undoable")
114
+ return { kind: "outbound", reason: "changes Vercel" };
115
+ if (v === "other")
116
+ return { kind: "outbound", reason: "changes Vercel in a way Backstep can't undo" };
106
117
  for (const [re, reason] of OUTBOUND)
107
118
  if (re.test(p))
108
119
  return { kind: "outbound", reason };
package/dist/undo.js CHANGED
@@ -172,7 +172,8 @@ async function undoOne(store, a, opts = {}) {
172
172
  rmSync(snapshotDir(a.id), { recursive: true, force: true });
173
173
  const pushed = u.adapter === "tree" && u.pushed?.length ? { root: u.root, pushes: u.pushed } : undefined;
174
174
  const github = u.adapter === "tree" && u.github?.length ? u.github : undefined;
175
- return { id: a.id, tool: a.tool, ok: true, detail, ...(pushed ? { pushed } : {}), ...(github ? { github } : {}) };
175
+ const vercel = u.adapter === "tree" && u.vercel?.length ? u.vercel : undefined;
176
+ return { id: a.id, tool: a.tool, ok: true, detail, ...(pushed ? { pushed } : {}), ...(github ? { github } : {}), ...(vercel ? { vercel } : {}) };
176
177
  }
177
178
  catch (err) {
178
179
  const detail = err instanceof Error ? err.message : String(err);
package/dist/vercel.js ADDED
@@ -0,0 +1,223 @@
1
+ // Vercel, through its command line: what Claude changes there with `vercel`,
2
+ // noted so undo can offer to take it back with `vercel` itself, signed in as
3
+ // the person.
4
+ //
5
+ // a production deploy, promote or rollback → put back the deployment that was live before
6
+ // a preview deploy → delete that deployment
7
+ // an environment variable added → remove it again
8
+ //
9
+ // What was live is read just before the command runs (only for commands that
10
+ // can change it), and again after, so undo knows what to put back and can
11
+ // leave things alone if something newer went live since.
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { spawnSync } from "./spawn.js";
15
+ import { resolveIn } from "./paths.js";
16
+ import { commandDir, commandParts } from "./shell.js";
17
+ // Options that take a value, so the word after them isn't read as a subcommand.
18
+ const VALUE = new Set([
19
+ "--cwd", "--scope", "-S", "--token", "-t", "-A", "--local-config", "-Q", "--global-config", "--team", "-T",
20
+ "--target", "--environment", "-e", "--env", "-b", "--build-env", "-m", "--meta", "--archive", "--regions",
21
+ "--git-branch", "-p", "--project", "--timeout",
22
+ ]);
23
+ // Subcommands that only read, or only change files on this machine (which the project snapshot covers).
24
+ const LOCAL = new Set(["ls", "list", "inspect", "logs", "whoami", "login", "logout", "link", "pull", "build", "dev", "help", "switch", "open", "telemetry", "bisect", "init"]);
25
+ const LISTS = new Set(["ls", "list", "inspect", "pull", "run", "status"]);
26
+ // Not a subcommand: a folder to deploy (`vercel ./site`).
27
+ const PATHLIKE = /^(\.|\/|~|[a-zA-Z]:[\\/])/;
28
+ function parse(part) {
29
+ const words = (part.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^(["'])(.*)\1$/, "$2"));
30
+ if (words[0] !== "vercel" && words[0] !== "vc")
31
+ return null;
32
+ const pos = [];
33
+ const flags = new Map();
34
+ for (let i = 1; i < words.length; i++) {
35
+ const w = words[i];
36
+ if (w === "--")
37
+ break;
38
+ if (w.startsWith("-") && w.length > 1) {
39
+ const eq = w.indexOf("=");
40
+ if (eq > 0)
41
+ flags.set(w.slice(0, eq), w.slice(eq + 1));
42
+ else if (VALUE.has(w) && i + 1 < words.length)
43
+ flags.set(w, words[++i]);
44
+ else
45
+ flags.set(w, true);
46
+ }
47
+ else
48
+ pos.push(w);
49
+ }
50
+ return { pos, flags };
51
+ }
52
+ const flag = (p, ...names) => names.map((n) => p.flags.get(n)).find((v) => v !== undefined);
53
+ const isDeploy = (p) => p.pos[0] === undefined || p.pos[0] === "deploy" || p.pos[0] === "redeploy" || PATHLIKE.test(p.pos[0]);
54
+ const isProd = (p) => p.flags.has("--prod") || flag(p, "--target", "--environment") === "production";
55
+ // Commands that change which deployment is live.
56
+ const changesLive = (p) => (isDeploy(p) && isProd(p)) || ((p.pos[0] === "promote" || p.pos[0] === "rollback") && !LISTS.has(p.pos[1] ?? ""));
57
+ const isEnvAdd = (p) => p.pos[0] === "env" && p.pos[1] === "add";
58
+ // What a part of a shell command does to Vercel: nothing outside this machine,
59
+ // something Backstep can take back, or something it can't.
60
+ export function vercelKind(part) {
61
+ const p = parse(part);
62
+ if (!p)
63
+ return null;
64
+ if (p.flags.has("--version") || p.flags.has("-v") || p.flags.has("--help") || p.flags.has("-h") || p.flags.has("--dry"))
65
+ return "local";
66
+ const [sub, next] = p.pos;
67
+ if (sub !== undefined && LOCAL.has(sub))
68
+ return "local";
69
+ if (sub === "api")
70
+ return flag(p, "-X", "--method") === undefined && !p.flags.has("--input") && !p.flags.has("-F") && !p.flags.has("-f") ? "local" : "other";
71
+ if (sub !== undefined && !isDeploy(p) && LISTS.has(next ?? ""))
72
+ return "local";
73
+ // Overwriting a variable loses the old value, which Backstep never saw.
74
+ if (isEnvAdd(p))
75
+ return p.flags.has("--force") ? "other" : "undoable";
76
+ if (isDeploy(p) || changesLive(p))
77
+ return "undoable";
78
+ return "other";
79
+ }
80
+ // The folder a part of the command works in, and the Vercel project linked there.
81
+ function folder(p, command, cwd) {
82
+ const base = commandDir(command, cwd);
83
+ const c = flag(p, "--cwd");
84
+ return typeof c === "string" ? resolveIn(c, base) : base;
85
+ }
86
+ function readLink(dir) {
87
+ const f = join(dir, ".vercel", "project.json");
88
+ if (!existsSync(f))
89
+ return null;
90
+ try {
91
+ const j = JSON.parse(readFileSync(f, "utf8"));
92
+ return j.projectId ? { project: j.projectId, team: j.orgId?.startsWith("team_") ? j.orgId : null } : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ function vercel(dir, args, timeout = 120_000) {
99
+ const r = spawnSync("vercel", [...args, "--no-color"], { cwd: dir, encoding: "utf8", timeout, env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, stdio: ["ignore", "pipe", "pipe"] });
100
+ return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
101
+ }
102
+ const lastLine = (out, or) => out.split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? or;
103
+ // The project's name and the deployment live now, from Vercel's API (with the person's own sign-in).
104
+ function liveNow(dir, link) {
105
+ const r = spawnSync("vercel", ["api", `/v9/projects/${link.project}${link.team ? `?teamId=${link.team}` : ""}`, "--raw"], {
106
+ cwd: dir,
107
+ encoding: "utf8",
108
+ timeout: 20_000,
109
+ env: { ...process.env, NO_COLOR: "1" },
110
+ stdio: ["ignore", "pipe", "pipe"],
111
+ });
112
+ if (r.status !== 0)
113
+ return null;
114
+ try {
115
+ const j = JSON.parse(r.stdout);
116
+ const t = j.targets?.production;
117
+ return { name: j.name ?? link.project, live: t?.id && t.url ? { id: t.id, url: t.url } : null };
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ // Before the command runs: what's live in each project it could change.
124
+ export function vercelBefore(command, cwd) {
125
+ const out = [];
126
+ for (const part of commandParts(command)) {
127
+ const p = parse(part);
128
+ if (!p || !changesLive(p))
129
+ continue;
130
+ const dir = folder(p, command, cwd);
131
+ if (out.some((b) => b.dir === dir))
132
+ continue;
133
+ const link = readLink(dir);
134
+ const now = link ? liveNow(dir, link) : null;
135
+ out.push({ dir, link, name: now?.name ?? null, live: now?.live ?? null });
136
+ }
137
+ return out;
138
+ }
139
+ // The deployment a deploy made, from what `vercel` printed.
140
+ function deployedUrl(output) {
141
+ const said = output.match(/\b(?:Preview|Production):\s*(https:\/\/[^\s[\]]+)/);
142
+ if (said)
143
+ return said[1];
144
+ // `vercel` prints the bare deployment URL on stdout; aliases come after "Aliased".
145
+ const urls = output
146
+ .split("\n")
147
+ .filter((l) => !/Aliased|Inspect/i.test(l))
148
+ .flatMap((l) => l.match(/https:\/\/[a-z0-9-]+\.vercel\.app\b/gi) ?? []);
149
+ return urls[0] ?? null;
150
+ }
151
+ // After the command ran: what it changed on Vercel that undo can take back.
152
+ export function vercelMade(command, cwd, output, before = []) {
153
+ const out = [];
154
+ for (const part of commandParts(command)) {
155
+ const p = parse(part);
156
+ if (!p)
157
+ continue;
158
+ const dir = folder(p, command, cwd);
159
+ if (changesLive(p)) {
160
+ if (out.some((m) => m.kind === "live" && m.dir === dir))
161
+ continue;
162
+ // A deploy in a folder that wasn't linked links it as it runs.
163
+ const link = readLink(dir);
164
+ const now = link ? liveNow(dir, link) : null;
165
+ if (!link || !now?.live)
166
+ continue;
167
+ const was = before.find((b) => b.dir === dir);
168
+ // Read before it ran in a linked folder, but Vercel couldn't be reached: no way to know what to put back.
169
+ if (was?.link && !was.live && was.name === null)
170
+ continue;
171
+ const prev = was?.link?.project === link.project ? was.live : null;
172
+ if (prev?.id === now.live.id)
173
+ continue;
174
+ out.push({ kind: "live", dir, project: link.project, team: link.team, name: now.name, before: prev, after: now.live });
175
+ }
176
+ else if (isDeploy(p) && !isProd(p)) {
177
+ const url = deployedUrl(output);
178
+ if (url && !out.some((m) => m.kind === "preview" && m.url === url))
179
+ out.push({ kind: "preview", dir, url });
180
+ }
181
+ else if (isEnvAdd(p) && !p.flags.has("--force") && /Added Environment Variable/i.test(output)) {
182
+ const name = p.pos[2];
183
+ const given = p.pos[3] ?? flag(p, "--environment", "-e");
184
+ if (!name || typeof given !== "string")
185
+ continue;
186
+ const branch = p.pos[4] ?? flag(p, "--git-branch");
187
+ out.push({ kind: "env", dir, name, envs: given.split(",").map((e) => e.trim()).filter(Boolean), ...(typeof branch === "string" ? { branch } : {}) });
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+ const host = (url) => url.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
193
+ export const describeVercel = (m) => m.kind === "live" ? `the new live version of ${m.name}` : m.kind === "preview" ? `the preview deployment ${host(m.url)}` : `the environment variable ${m.name} (${m.envs.join(", ")})`;
194
+ export function undoVercel(m) {
195
+ if (m.kind === "preview") {
196
+ const r = vercel(m.dir, ["remove", host(m.url), "--yes"]);
197
+ return r.ok ? { ok: true, detail: `deleted the preview deployment ${host(m.url)}` } : { ok: false, detail: `couldn't delete ${host(m.url)}: ${lastLine(r.out, "vercel failed")}` };
198
+ }
199
+ if (m.kind === "env") {
200
+ for (const env of m.envs) {
201
+ const r = vercel(m.dir, ["env", "rm", m.name, "--environment", env, ...(m.branch ? ["--git-branch", m.branch] : []), "--yes"]);
202
+ if (!r.ok && !/not found|doesn't exist|does not exist/i.test(r.out))
203
+ return { ok: false, detail: `couldn't remove ${m.name} from ${env}: ${lastLine(r.out, "vercel failed")}` };
204
+ }
205
+ return { ok: true, detail: `removed the environment variable ${m.name} (${m.envs.join(", ")})` };
206
+ }
207
+ const now = liveNow(m.dir, { project: m.project, team: m.team });
208
+ if (!now)
209
+ return { ok: false, detail: `couldn't reach Vercel to check what's live on ${m.name}` };
210
+ if (now.live?.id !== m.after.id)
211
+ return { ok: false, detail: `left ${m.name} alone: a newer deployment went live after Claude's` };
212
+ if (!m.before) {
213
+ // Nothing was live before Claude's deploy: taking it back means taking it down.
214
+ const r = vercel(m.dir, ["remove", m.after.url, "--yes"]);
215
+ return r.ok ? { ok: true, detail: `deleted the deployment Claude put live on ${m.name} (nothing was live before it)` } : { ok: false, detail: `couldn't delete it: ${lastLine(r.out, "vercel failed")}` };
216
+ }
217
+ let r = vercel(m.dir, ["promote", m.before.url, "--yes"], 200_000);
218
+ if (!r.ok)
219
+ r = vercel(m.dir, ["rollback", m.before.url, "--yes"], 200_000);
220
+ return r.ok
221
+ ? { ok: true, detail: `put back the version of ${m.name} that was live before (${host(m.before.url)})` }
222
+ : { ok: false, detail: `couldn't put back ${m.name}'s previous version: ${lastLine(r.out, "vercel failed")}` };
223
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Press ctrl+z to rewind everything Claude did in its last turn: file edits, shell commands, and database and tool changes.",
5
5
  "types": "./types/index.d.ts",
6
6
  "userConfig": {
@@ -552,7 +552,7 @@ test('after undoing a turn that pushed, Backstep asks and pushes the undo', { op
552
552
  await $.prompt.submit({ text: 'ship it', wait: false, origin: { kind: 'composer' } })
553
553
  await $.tool.call({ tool: 'Bash', command: 'git push' })
554
554
  // The push is noted once it has run.
555
- expect(runs.at(-1)?.argv.slice(3)).toEqual(['shell-end', 'p1'])
555
+ expect(runs.at(-1)?.argv.slice(3)).toEqual(['shell-end', 'p1', '--cwd', '/repo'])
556
556
 
557
557
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
558
558
  await ui.press({ key: 'undo' })
@@ -948,3 +948,62 @@ test('asking Claude to uninstall asks too, and Keep it removes nothing', { optio
948
948
  expect(runs.filter((a) => a.includes('uninstall'))).toHaveLength(0)
949
949
  expect(r.result).toBe('Kept Backstep: nothing was removed.')
950
950
  })
951
+
952
+ test('a production deploy Claude made on Vercel is offered back after undo, and Put it back runs vercel-undo', { options: { cli: CLI } }, async ($, on) => {
953
+ const clock = mock.clock(on, { now: 5000 })
954
+ const live = { kind: 'live', dir: '/repo', project: 'prj_1', team: 'team_1', name: 'shop', before: { id: 'dpl_old', url: 'shop-old.vercel.app' }, after: { id: 'dpl_new', url: 'shop-new.vercel.app' } }
955
+ const runs: { argv: string[]; stdin?: string }[] = []
956
+ const asked: string[] = []
957
+ const logs: string[] = []
958
+ on('session.cwd', () => ({ value: '/repo' }))
959
+ on('session.id', () => ({ value: 's1' }))
960
+ on('ui.toast', () => ({ value: undefined }))
961
+ on('ui.log', (_, e) => {
962
+ logs.push(e.text)
963
+ return { value: undefined }
964
+ })
965
+ on('process.run', (_, e) => {
966
+ runs.push({ argv: [...e.argv], stdin: e.init?.stdin })
967
+ const out = e.argv.includes('shell')
968
+ ? { kind: 'outbound', reason: 'changes Vercel', id: 'v1', undoable: true }
969
+ : e.argv.includes('undo')
970
+ ? [{ id: 'v1', tool: 'Bash', ok: true, detail: 'restored index.html', vercel: [live] }]
971
+ : e.argv.includes('vercel-undo')
972
+ ? { ok: true, detail: 'put back the version of shop that was live before (shop-old.vercel.app)' }
973
+ : e.argv.includes('db-check')
974
+ ? { ask: null }
975
+ : null
976
+ return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
977
+ })
978
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
979
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
980
+ asked.push(questions[0].question)
981
+ return { result: { questions, answers: { [questions[0].question]: 'Put it back' } } }
982
+ })
983
+ on('tool.call', () => ({ result: {}, text: 'Production: https://shop-new.vercel.app [3s]' }))
984
+ on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
985
+ on('ui.render', ($, e) => {
986
+ const { Text } = $.ui.resolve(e)
987
+ return <Text>engine</Text>
988
+ })
989
+
990
+ await $.prompt.submit({ text: 'deploy it', wait: false, origin: { kind: 'composer' } })
991
+ await $.tool.call({ tool: 'Bash', command: 'vercel --prod --yes' })
992
+ // What vercel printed is noted once it has run, with the folder it ran in.
993
+ const end = runs.find((r) => r.argv.includes('shell-end'))
994
+ expect(end?.argv.slice(3)).toEqual(['shell-end', 'v1', '--cwd', '/repo'])
995
+ expect(end?.stdin).toContain('shop-new.vercel.app')
996
+
997
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
998
+ await ui.press({ key: 'undo' })
999
+ const pressed = ui.press({ key: 'undo' })
1000
+ await clock.advance(1000)
1001
+ await pressed
1002
+ await clock.settle()
1003
+ expect(asked).toEqual(['Claude put a new version of shop live on Vercel. Put back the version that was live before?'])
1004
+ expect(JSON.parse(runs.find((r) => r.argv.includes('vercel-undo'))?.stdin ?? '{}')).toEqual(live)
1005
+ expect(await ui.find({ type: 'Text', text: /on Vercel: put back the version of shop/ })).toBeTruthy()
1006
+ // Offered back, so the deploy isn't listed as not undone.
1007
+ expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes Vercel')
1008
+ await ui.unmount()
1009
+ })
@@ -60,6 +60,7 @@ type Outcome = {
60
60
  run?: { server: string; tool: string; args: Record<string, unknown>; more?: { tool: string; args: Record<string, unknown> }[]; label?: string }
61
61
  pushed?: { root: string; pushes: Pushed[] }
62
62
  github?: GhMade[]
63
+ vercel?: VercelMade[]
63
64
  }
64
65
  const PUSH = /\bgit\b[^;&|]*\bpush\b/
65
66
  // A gh command that makes something on GitHub: noted once it runs, so undo can offer to take it away.
@@ -68,6 +69,23 @@ type GhMade = { kind: 'repo' | 'issue' | 'pr' | 'release'; repo: string; number?
68
69
  const describeGh = (g: GhMade) =>
69
70
  g.kind === 'repo' ? `the GitHub repository ${g.repo}` : g.kind === 'release' ? `the release ${g.tag} in ${g.repo}` : `${g.kind === 'pr' ? 'pull request' : 'issue'} #${g.number} in ${g.repo}`
70
71
 
72
+ // A vercel command: once it runs, what it changed there is noted, so undo can offer to take it back.
73
+ const VERCEL = /(^|[\s;&|(])(npx\s+(-y\s+)?)?(vercel|vc)(\s|$)/
74
+ type VercelMade =
75
+ | { kind: 'live'; dir: string; project: string; team: string | null; name: string; before: { id: string; url: string } | null; after: { id: string; url: string } }
76
+ | { kind: 'preview'; dir: string; url: string }
77
+ | { kind: 'env'; dir: string; name: string; envs: string[]; branch?: string }
78
+ const vercelHost = (url: string) => url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
79
+ // The question asked after undo, and the answer that takes it back on Vercel.
80
+ function vercelAsk(m: VercelMade): { q: string; yes: string } {
81
+ if (m.kind === 'live')
82
+ return m.before
83
+ ? { q: `Claude put a new version of ${m.name} live on Vercel. Put back the version that was live before?`, yes: 'Put it back' }
84
+ : { q: `Claude put ${m.name} live on Vercel for the first time. Take it down (delete that deployment)?`, yes: 'Take it down' }
85
+ if (m.kind === 'preview') return { q: `Claude made a preview deployment on Vercel (${vercelHost(m.url)}). Delete it?`, yes: 'Delete it' }
86
+ return { q: `Claude added the environment variable ${m.name} on Vercel (${m.envs.join(', ')}). Remove it?`, yes: 'Remove it' }
87
+ }
88
+
71
89
  // Runs a Backstep command that answers with { ok, detail }, however it fails.
72
90
  async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, stdin?: string): Promise<{ ok: boolean; detail: string; needs?: string }> {
73
91
  const ran = await $.process
@@ -143,6 +161,23 @@ async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise
143
161
  return gone
144
162
  }
145
163
 
164
+ // Asks, for each thing Claude changed on Vercel in the undone turns, whether to take it back there too.
165
+ async function offerVercelUndo($: EngineInterface, outcomes: Outcome[]) {
166
+ for (const m of outcomes.flatMap((o) => (o.ok && o.vercel ? o.vercel : []))) {
167
+ const { q, yes } = vercelAsk(m)
168
+ const answer = await $.ui.ask(q, { header: 'Backstep', options: [yes, 'Leave it'] }).catch(() => null)
169
+ if (answer !== yes) {
170
+ $.ui.log(`${MARK_TEXT} Not undone on Vercel: you chose to leave it.`)
171
+ continue
172
+ }
173
+ const r = await jsonRun($, ['vercel-undo'], 240_000, JSON.stringify(m))
174
+ await addItem($, { label: `on Vercel: ${r.detail}`, ok: r.ok })
175
+ $.ui.log(r.ok ? `${MARK_TEXT} On Vercel: ${r.detail}.` : `${MARK_TEXT} Not undone on Vercel: ${r.detail}`)
176
+ $.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
177
+ await update($, note, (n) => (n ? `${n}\n${r.ok ? `On Vercel: ${r.detail}.` : `Vercel was left as it was: ${r.detail}.`}` : n))
178
+ }
179
+ }
180
+
146
181
  const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
147
182
 
148
183
  // Undo rewound the code here; this asks before taking it back on GitHub too,
@@ -315,11 +350,12 @@ async function undoLastTurn($: EngineInterface, count = 1) {
315
350
  // A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
316
351
  const offered = outcomes.some((o) => o.ok && o.pushed)
317
352
  const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
353
+ const offeredVercel = outcomes.some((o) => o.ok && o.vercel?.length)
318
354
  // Deleting the repository takes whatever was pushed to it along.
319
355
  const offeredRepo = outcomes.some((o) => o.ok && o.github?.some((g) => g.kind === 'repo'))
320
356
  const kept = taken
321
357
  .flatMap((t) => t.lostItems ?? [])
322
- .filter((l) => !((offered || offeredRepo) && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
358
+ .filter((l) => !((offered || offeredRepo) && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)) && !(offeredVercel && /: changes Vercel$/.test(l)))
323
359
  .map((label) => ({ label, ok: false }))
324
360
  const items: RewindItem[] = [...undone, ...stopped, ...kept]
325
361
  const done = undone.length
@@ -359,8 +395,10 @@ async function undoLastTurn($: EngineInterface, count = 1) {
359
395
  )
360
396
  }
361
397
 
362
- // Asked once the band shows what came back: GitHub things first, then pushes.
363
- void offerGithubUndo($, outcomes).then((gone) => offerPushUndo($, outcomes, gone.size > 0))
398
+ // Asked once the band shows what came back: GitHub things first, then pushes, then Vercel.
399
+ void offerGithubUndo($, outcomes)
400
+ .then((gone) => offerPushUndo($, outcomes, gone.size > 0))
401
+ .then(() => offerVercelUndo($, outcomes))
364
402
 
365
403
  // Reveal the items one by one. The list stays until the person's next
366
404
  // message (it's in the conversation too), so nothing is missed.
@@ -867,9 +905,9 @@ export const register: Register = (on, options) => {
867
905
  )
868
906
  }
869
907
  // A push: once it's run, note what it sent, so undo can offer to take it back there.
870
- if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command))) {
908
+ if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command) || VERCEL.test(command))) {
871
909
  const r = await next(e)
872
- await $.process.run(backstep(['shell-end', plan.id]), { stdin: r.text ?? '' }).catch(() => null)
910
+ await $.process.run(backstep(['shell-end', plan.id, '--cwd', cwd]), { stdin: r.text ?? '', timeoutMs: 60_000 }).catch(() => null)
873
911
  return r
874
912
  }
875
913
  } else if (tool.startsWith('mcp__')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "homepage": "https://getbackstep.com",
6
6
  "scripts": {