backstep 0.1.23 → 0.1.25
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 +1 -0
- package/dist/cli.js +29 -7
- package/dist/setup.js +23 -4
- package/dist/shell.js +13 -2
- package/dist/undo.js +2 -1
- package/dist/vercel.js +223 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +78 -5
- package/mod/hooks/register.tsx +56 -7
- package/package.json +1 -1
package/dist/check.js
CHANGED
|
@@ -93,6 +93,7 @@ export function printCheck(c, bs = "backstep") {
|
|
|
93
93
|
console.log(`\n ${white(`What ${(undoKeys().length ? undoKeys() : [DEFAULT_KEY]).map(keyLabel).join(" or ")} can undo`)}`);
|
|
94
94
|
console.log(` ${pink("✓")} Files Claude edits and commands it runs`);
|
|
95
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)")}`);
|
|
96
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)")}`);
|
|
97
98
|
for (const n of undoable)
|
|
98
99
|
console.log(` ${pink("✓")} ${n}`);
|
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";
|
|
@@ -16,7 +17,7 @@ import { snapshotRest } from "./adapters/rest.js";
|
|
|
16
17
|
const PUSH = /\bgit\b[^;&|]*\bpush\b/;
|
|
17
18
|
import { classifyShell, commandDir, supabaseTarget, touchesDatabase, touchesFirebase, touchesSqlite, touchesSupabase } from "./shell.js";
|
|
18
19
|
import { watchEnd, watchStart } from "./watch.js";
|
|
19
|
-
import { sendActive } from "./telemetry.js";
|
|
20
|
+
import { sendActive, version } from "./telemetry.js";
|
|
20
21
|
import { readFileSync, rmSync } from "node:fs";
|
|
21
22
|
import { loadRules } from "./rules.js";
|
|
22
23
|
import { setup } from "./setup.js";
|
|
@@ -314,8 +315,15 @@ async function main() {
|
|
|
314
315
|
const target = values.version ?? (await checkUpdate()).latest;
|
|
315
316
|
if (!target)
|
|
316
317
|
throw new Error("couldn't reach npm to find the newest version");
|
|
318
|
+
const was = version();
|
|
319
|
+
if (!values.json)
|
|
320
|
+
console.log(`Updating Backstep from ${was} to ${target}… Don't close this terminal until it says it's done (about a minute).`);
|
|
317
321
|
const out = runUpdate(target);
|
|
318
|
-
console.log(values.json
|
|
322
|
+
console.log(values.json
|
|
323
|
+
? JSON.stringify(out)
|
|
324
|
+
: out.ok
|
|
325
|
+
? `Backstep is updated to ${target} (you had ${was}). It's safe to close the terminal now. Restart Claude Code to start using ${target}.`
|
|
326
|
+
: `Backstep couldn't update to ${target}: ${out.detail}. You're still on ${was}, which keeps working.`);
|
|
319
327
|
if (!out.ok)
|
|
320
328
|
process.exitCode = 1;
|
|
321
329
|
return;
|
|
@@ -468,7 +476,9 @@ async function main() {
|
|
|
468
476
|
// SQLite files in the project, even git-ignored ones, which the project snapshot leaves out.
|
|
469
477
|
const sqlite = tree && touchesSqlite(command) ? snapshotSqlite(a.id, tree.root) : null;
|
|
470
478
|
const extra = { ...(dbs.length ? { dbs } : {}), ...(rests.length ? { rests } : {}), ...(fbs.length ? { fbs } : {}), ...(sqlite?.files.length ? { sqlite } : {}) };
|
|
471
|
-
|
|
479
|
+
// A command that could change what's live on Vercel: what's live now, so undo can put it back.
|
|
480
|
+
const vb = tree && classifyShell(command).reason === "changes Vercel" ? vercelBefore(command, cwd) : [];
|
|
481
|
+
const undo = tree ? { ...tree, ...extra, ...(vb.length ? { vercelBefore: vb } : {}) } : dbs.length || rests.length || fbs.length ? { adapter: "dbs", dbs, rests, fbs } : null;
|
|
472
482
|
if (undo)
|
|
473
483
|
store.updateAction(a.id, { undo: JSON.stringify(undo) });
|
|
474
484
|
// With its database saved, a command that reaches it is no longer lost to undo.
|
|
@@ -498,15 +508,19 @@ async function main() {
|
|
|
498
508
|
// After a shell command that pushed: notes what it sent (run by the Claude Code mod).
|
|
499
509
|
// Also notes what a gh command made on GitHub, from its output on stdin.
|
|
500
510
|
if (cmd === "shell-end") {
|
|
511
|
+
const { values, positionals } = parseArgs({ args: rest, options: { cwd: { type: "string" } }, allowPositionals: true });
|
|
501
512
|
const store = new Store();
|
|
502
|
-
const a = store.getAction(
|
|
513
|
+
const a = store.getAction(positionals[0] ?? "");
|
|
503
514
|
const u = a?.undo ? JSON.parse(a.undo) : null;
|
|
504
515
|
const output = process.stdin.isTTY ? "" : readFileSync(0, "utf8");
|
|
505
516
|
if (a && u?.adapter === "tree") {
|
|
517
|
+
const command = JSON.parse(a.args).command ?? "";
|
|
506
518
|
const pushed = u.git?.remotes ? notePushed(u) : [];
|
|
507
|
-
const github = ghMade(
|
|
508
|
-
|
|
509
|
-
|
|
519
|
+
const github = ghMade(command, output);
|
|
520
|
+
const vercel = classifyShell(command).reason === "changes Vercel" ? vercelMade(command, values.cwd ?? process.cwd(), output, u.vercelBefore) : [];
|
|
521
|
+
if (pushed.length || github.length || vercel.length) {
|
|
522
|
+
store.updateAction(a.id, { undo: JSON.stringify({ ...u, ...(pushed.length ? { pushed } : {}), ...(github.length ? { github } : {}), ...(vercel.length ? { vercel } : {}) }) });
|
|
523
|
+
}
|
|
510
524
|
}
|
|
511
525
|
return;
|
|
512
526
|
}
|
|
@@ -520,6 +534,14 @@ async function main() {
|
|
|
520
534
|
process.exitCode = 1;
|
|
521
535
|
return;
|
|
522
536
|
}
|
|
537
|
+
// Takes back something Claude changed on Vercel (the change, as undo reported it, on stdin). The mod runs this after asking.
|
|
538
|
+
if (cmd === "vercel-undo") {
|
|
539
|
+
const out = undoVercel(JSON.parse(readFileSync(0, "utf8")));
|
|
540
|
+
console.log(JSON.stringify(out));
|
|
541
|
+
if (!out.ok)
|
|
542
|
+
process.exitCode = 1;
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
523
545
|
if (cmd === "gh-undo") {
|
|
524
546
|
const out = undoGh(JSON.parse(readFileSync(0, "utf8")));
|
|
525
547
|
console.log(JSON.stringify(out));
|
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
|
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
|
-
[/^(
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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' })
|
|
@@ -589,7 +589,16 @@ function updates(on: On, answer: string) {
|
|
|
589
589
|
toasts.push(e.text)
|
|
590
590
|
return { value: undefined }
|
|
591
591
|
})
|
|
592
|
-
|
|
592
|
+
const logs: string[] = []
|
|
593
|
+
const statuses: (string | undefined)[] = []
|
|
594
|
+
on('ui.log', (_, e) => {
|
|
595
|
+
logs.push(e.text)
|
|
596
|
+
return { value: undefined }
|
|
597
|
+
})
|
|
598
|
+
on('ui.status', (_, e) => {
|
|
599
|
+
statuses.push(e.text)
|
|
600
|
+
return { value: undefined }
|
|
601
|
+
})
|
|
593
602
|
on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
|
|
594
603
|
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
595
604
|
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
@@ -600,7 +609,7 @@ function updates(on: On, answer: string) {
|
|
|
600
609
|
on('session.cwd', () => ({ value: '/repo' }))
|
|
601
610
|
on('session.id', () => ({ value: 's1' }))
|
|
602
611
|
on('tool.call', () => ({ result: {} }))
|
|
603
|
-
return { runs, toasts, asked }
|
|
612
|
+
return { runs, toasts, asked, logs, statuses }
|
|
604
613
|
}
|
|
605
614
|
|
|
606
615
|
// Claude is about to change the database: the moment Backstep asks.
|
|
@@ -614,12 +623,17 @@ async function changeDatabase($: Parameters<Parameters<typeof test>[2]>[0], on:
|
|
|
614
623
|
}
|
|
615
624
|
|
|
616
625
|
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')
|
|
626
|
+
const { runs, toasts, asked, logs, statuses } = updates(on, 'Update')
|
|
618
627
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
619
628
|
await settle()
|
|
620
629
|
expect(asked).toEqual(['Backstep 0.2.0 is out (you have 0.1.2). Update now?'])
|
|
621
630
|
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.')
|
|
631
|
+
expect(toasts.at(-1)).toBe('Backstep is updated to 0.2.0. Restart Claude Code to use it.')
|
|
632
|
+
// While it runs it says not to close the terminal; once done, which version it updated to.
|
|
633
|
+
expect(logs.find((l) => l.includes('Updating Backstep from 0.1.2 to 0.2.0'))).toContain("Don't close this terminal")
|
|
634
|
+
expect(statuses[0]).toContain("don't close this terminal")
|
|
635
|
+
expect(statuses.at(-1)).toBeUndefined()
|
|
636
|
+
expect(logs.at(-1)).toContain('Backstep is updated to 0.2.0 (you had 0.1.2)')
|
|
623
637
|
})
|
|
624
638
|
|
|
625
639
|
test('Skip this version stops asking about it, and Not now changes nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
@@ -948,3 +962,62 @@ test('asking Claude to uninstall asks too, and Keep it removes nothing', { optio
|
|
|
948
962
|
expect(runs.filter((a) => a.includes('uninstall'))).toHaveLength(0)
|
|
949
963
|
expect(r.result).toBe('Kept Backstep: nothing was removed.')
|
|
950
964
|
})
|
|
965
|
+
|
|
966
|
+
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) => {
|
|
967
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
968
|
+
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' } }
|
|
969
|
+
const runs: { argv: string[]; stdin?: string }[] = []
|
|
970
|
+
const asked: string[] = []
|
|
971
|
+
const logs: string[] = []
|
|
972
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
973
|
+
on('session.id', () => ({ value: 's1' }))
|
|
974
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
975
|
+
on('ui.log', (_, e) => {
|
|
976
|
+
logs.push(e.text)
|
|
977
|
+
return { value: undefined }
|
|
978
|
+
})
|
|
979
|
+
on('process.run', (_, e) => {
|
|
980
|
+
runs.push({ argv: [...e.argv], stdin: e.init?.stdin })
|
|
981
|
+
const out = e.argv.includes('shell')
|
|
982
|
+
? { kind: 'outbound', reason: 'changes Vercel', id: 'v1', undoable: true }
|
|
983
|
+
: e.argv.includes('undo')
|
|
984
|
+
? [{ id: 'v1', tool: 'Bash', ok: true, detail: 'restored index.html', vercel: [live] }]
|
|
985
|
+
: e.argv.includes('vercel-undo')
|
|
986
|
+
? { ok: true, detail: 'put back the version of shop that was live before (shop-old.vercel.app)' }
|
|
987
|
+
: e.argv.includes('db-check')
|
|
988
|
+
? { ask: null }
|
|
989
|
+
: null
|
|
990
|
+
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
991
|
+
})
|
|
992
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
993
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
994
|
+
asked.push(questions[0].question)
|
|
995
|
+
return { result: { questions, answers: { [questions[0].question]: 'Put it back' } } }
|
|
996
|
+
})
|
|
997
|
+
on('tool.call', () => ({ result: {}, text: 'Production: https://shop-new.vercel.app [3s]' }))
|
|
998
|
+
on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
|
|
999
|
+
on('ui.render', ($, e) => {
|
|
1000
|
+
const { Text } = $.ui.resolve(e)
|
|
1001
|
+
return <Text>engine</Text>
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
await $.prompt.submit({ text: 'deploy it', wait: false, origin: { kind: 'composer' } })
|
|
1005
|
+
await $.tool.call({ tool: 'Bash', command: 'vercel --prod --yes' })
|
|
1006
|
+
// What vercel printed is noted once it has run, with the folder it ran in.
|
|
1007
|
+
const end = runs.find((r) => r.argv.includes('shell-end'))
|
|
1008
|
+
expect(end?.argv.slice(3)).toEqual(['shell-end', 'v1', '--cwd', '/repo'])
|
|
1009
|
+
expect(end?.stdin).toContain('shop-new.vercel.app')
|
|
1010
|
+
|
|
1011
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
1012
|
+
await ui.press({ key: 'undo' })
|
|
1013
|
+
const pressed = ui.press({ key: 'undo' })
|
|
1014
|
+
await clock.advance(1000)
|
|
1015
|
+
await pressed
|
|
1016
|
+
await clock.settle()
|
|
1017
|
+
expect(asked).toEqual(['Claude put a new version of shop live on Vercel. Put back the version that was live before?'])
|
|
1018
|
+
expect(JSON.parse(runs.find((r) => r.argv.includes('vercel-undo'))?.stdin ?? '{}')).toEqual(live)
|
|
1019
|
+
expect(await ui.find({ type: 'Text', text: /on Vercel: put back the version of shop/ })).toBeTruthy()
|
|
1020
|
+
// Offered back, so the deploy isn't listed as not undone.
|
|
1021
|
+
expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes Vercel')
|
|
1022
|
+
await ui.unmount()
|
|
1023
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -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)
|
|
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.
|
|
@@ -623,7 +661,11 @@ async function offerUpdate($: EngineInterface) {
|
|
|
623
661
|
return
|
|
624
662
|
}
|
|
625
663
|
if (answer !== UPDATE.yes) return
|
|
626
|
-
|
|
664
|
+
// Said where it stays (the conversation) and where it's seen while it runs (the status line).
|
|
665
|
+
const from = u.current ? ` from ${u.current}` : ''
|
|
666
|
+
$.ui.log(`${MARK_TEXT} Updating Backstep${from} to ${u.latest}… Don't close this terminal until it says it's done (about a minute).`)
|
|
667
|
+
$.ui.toast(`Updating Backstep to ${u.latest}. Don't close this terminal until it's done.`, { timeoutMs: 15_000 })
|
|
668
|
+
$.ui.status(`◀◀ Updating Backstep to ${u.latest}… don't close this terminal`)
|
|
627
669
|
const done = await $.process
|
|
628
670
|
.run(backstep(['update', '--version', u.latest, '--json']), { timeoutMs: 600_000 })
|
|
629
671
|
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
@@ -633,7 +675,14 @@ async function offerUpdate($: EngineInterface) {
|
|
|
633
675
|
} catch {
|
|
634
676
|
// keep the error above
|
|
635
677
|
}
|
|
636
|
-
$.ui.
|
|
678
|
+
$.ui.status(undefined)
|
|
679
|
+
if (r.ok) {
|
|
680
|
+
$.ui.log(`${MARK_TEXT} Backstep is updated to ${u.latest}${u.current ? ` (you had ${u.current})` : ''}. It's safe to close the terminal now. Restart Claude Code to start using ${u.latest}.`)
|
|
681
|
+
$.ui.toast(`Backstep is updated to ${u.latest}. Restart Claude Code to use it.`)
|
|
682
|
+
} else {
|
|
683
|
+
$.ui.log(`${MARK_TEXT} Backstep couldn't update to ${u.latest}: ${r.detail}. You're still on ${u.current ?? 'the version you had'}, which keeps working. To try again, run: npx backstep@latest setup`)
|
|
684
|
+
$.ui.toast(`Backstep couldn't update: ${r.detail}. Try: npx backstep@latest setup`)
|
|
685
|
+
}
|
|
637
686
|
}
|
|
638
687
|
|
|
639
688
|
const KEY = 'mcp__backstep__undo_key'
|
|
@@ -867,9 +916,9 @@ export const register: Register = (on, options) => {
|
|
|
867
916
|
)
|
|
868
917
|
}
|
|
869
918
|
// 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))) {
|
|
919
|
+
if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command) || VERCEL.test(command))) {
|
|
871
920
|
const r = await next(e)
|
|
872
|
-
await $.process.run(backstep(['shell-end', plan.id]), { stdin: r.text ?? '' }).catch(() => null)
|
|
921
|
+
await $.process.run(backstep(['shell-end', plan.id, '--cwd', cwd]), { stdin: r.text ?? '', timeoutMs: 60_000 }).catch(() => null)
|
|
873
922
|
return r
|
|
874
923
|
}
|
|
875
924
|
} else if (tool.startsWith('mcp__')) {
|