backstep 0.1.10 → 0.1.13
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 +4 -0
- package/dist/adapters/firestore.js +160 -0
- package/dist/adapters/tree.js +100 -3
- package/dist/cli.js +79 -17
- package/dist/connect.js +166 -8
- package/dist/github.js +111 -0
- package/dist/shell.js +9 -0
- package/dist/supabase.js +17 -3
- package/dist/undo.js +13 -5
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +86 -10
- package/mod/hooks/register.tsx +135 -60
- package/package.json +1 -1
package/mod/hooks/register.tsx
CHANGED
|
@@ -57,16 +57,66 @@ type Outcome = {
|
|
|
57
57
|
detail: string
|
|
58
58
|
run?: { server: string; tool: string; args: Record<string, unknown> }
|
|
59
59
|
pushed?: { root: string; pushes: Pushed[] }
|
|
60
|
+
github?: GhMade[]
|
|
60
61
|
}
|
|
61
62
|
const PUSH = /\bgit\b[^;&|]*\bpush\b/
|
|
63
|
+
// A gh command that makes something on GitHub: noted once it runs, so undo can offer to take it away.
|
|
64
|
+
const GH_CREATE = /\bgh\s+(repo|issue|pr|release)\s+create\b/
|
|
65
|
+
type GhMade = { kind: 'repo' | 'issue' | 'pr' | 'release'; repo: string; number?: number; tag?: string; url: string }
|
|
66
|
+
const describeGh = (g: GhMade) =>
|
|
67
|
+
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}`
|
|
68
|
+
|
|
69
|
+
// Runs a Backstep command that answers with { ok, detail }, however it fails.
|
|
70
|
+
async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, stdin?: string): Promise<{ ok: boolean; detail: string; needs?: string }> {
|
|
71
|
+
const ran = await $.process
|
|
72
|
+
.run(backstep(args), { timeoutMs, ...(stdin !== undefined ? { stdin } : {}) })
|
|
73
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(ran.stdout) as { ok: boolean; detail: string; needs?: string }
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, detail: (ran.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ghUndo = ($: EngineInterface, g: GhMade) => jsonRun($, ['gh-undo'], 120_000, JSON.stringify(g))
|
|
82
|
+
|
|
83
|
+
// Asks, for each thing Claude made on GitHub in the undone turns, whether to take it away there too.
|
|
84
|
+
// Returns the repositories deleted, whose pushes then need no undo of their own.
|
|
85
|
+
async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise<Set<string>> {
|
|
86
|
+
const gone = new Set<string>()
|
|
87
|
+
for (const g of outcomes.flatMap((o) => (o.ok && o.github ? o.github : []))) {
|
|
88
|
+
const yes = g.kind === 'repo' ? 'Delete it' : g.kind === 'issue' || g.kind === 'pr' ? 'Close it' : 'Delete it'
|
|
89
|
+
const answer = await $.ui
|
|
90
|
+
.ask(`Claude created ${describeGh(g)}. ${yes === 'Close it' ? 'Close' : 'Delete'} it on GitHub too?`, { header: 'Backstep', options: [yes, 'Leave it'] })
|
|
91
|
+
.catch(() => null)
|
|
92
|
+
if (answer !== yes) {
|
|
93
|
+
$.ui.log(`${MARK_TEXT} Not undone on GitHub: ${describeGh(g)} stays (you chose to leave it).`)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
let r = await ghUndo($, g)
|
|
97
|
+
// gh's sign-in needs one more permission: get it on GitHub's own page, then try again.
|
|
98
|
+
if (!r.ok && r.needs) {
|
|
99
|
+
$.ui.toast("GitHub needs your OK first. Backstep opened GitHub in your browser and copied the code: paste it there and press Authorize.", { timeoutMs: 15_000 })
|
|
100
|
+
$.ui.log(`${MARK_TEXT} GitHub needs your OK first: Backstep opened the approval page and copied the code to your clipboard. Paste it and press Authorize.`)
|
|
101
|
+
const granted = await jsonRun($, ['gh-auth', r.needs], 600_000)
|
|
102
|
+
r = granted.ok ? await ghUndo($, g) : { ok: false, detail: `GitHub didn't give the OK (${granted.detail}), so ${describeGh(g)} is still there` }
|
|
103
|
+
}
|
|
104
|
+
if (r.ok && g.kind === 'repo') gone.add(g.repo)
|
|
105
|
+
$.ui.log(r.ok ? `${MARK_TEXT} On GitHub: ${r.detail}.` : `${MARK_TEXT} Not undone on GitHub: ${r.detail}`)
|
|
106
|
+
$.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
|
|
107
|
+
}
|
|
108
|
+
return gone
|
|
109
|
+
}
|
|
62
110
|
|
|
63
111
|
const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
|
|
64
112
|
|
|
65
113
|
// Undo rewound the code here; this asks before taking it back on GitHub too,
|
|
66
114
|
// as a new commit that undoes Claude's (never a force push).
|
|
67
|
-
async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
|
|
115
|
+
async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone = false) {
|
|
68
116
|
for (const o of outcomes) {
|
|
69
117
|
if (!o.ok || !o.pushed) continue
|
|
118
|
+
// The repository it went to is deleted: nothing left there to undo.
|
|
119
|
+
if (repoGone) continue
|
|
70
120
|
for (const p of o.pushed.pushes) {
|
|
71
121
|
const what =
|
|
72
122
|
p.before === null
|
|
@@ -184,11 +234,12 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
184
234
|
.map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
|
|
185
235
|
)
|
|
186
236
|
const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
|
|
187
|
-
// A push
|
|
237
|
+
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
188
238
|
const offered = outcomes.some((o) => o.ok && o.pushed)
|
|
239
|
+
const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
|
|
189
240
|
const kept = taken
|
|
190
241
|
.flatMap((t) => t.lostItems ?? [])
|
|
191
|
-
.filter((l) => !(offered && /pushes to a remote/.test(l)))
|
|
242
|
+
.filter((l) => !(offered && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
|
|
192
243
|
.map((label) => ({ label, ok: false }))
|
|
193
244
|
const items: RewindItem[] = [...undone, ...stopped, ...kept]
|
|
194
245
|
const done = undone.length
|
|
@@ -228,17 +279,15 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
228
279
|
)
|
|
229
280
|
}
|
|
230
281
|
|
|
231
|
-
// Asked once the band shows what came back.
|
|
232
|
-
void offerPushUndo($, outcomes)
|
|
282
|
+
// Asked once the band shows what came back: GitHub things first, then pushes.
|
|
283
|
+
void offerGithubUndo($, outcomes).then((gone) => offerPushUndo($, outcomes, gone.size > 0))
|
|
233
284
|
|
|
234
|
-
// Reveal the
|
|
285
|
+
// Reveal the items one by one. The list stays until the person's next
|
|
286
|
+
// message (it's in the conversation too), so nothing is missed.
|
|
235
287
|
const reveal = $.clock.every(90, () => {
|
|
236
288
|
void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
|
|
237
289
|
})
|
|
238
|
-
$.clock.after(
|
|
239
|
-
reveal.cancel()
|
|
240
|
-
void update($, rewind, () => null)
|
|
241
|
-
})
|
|
290
|
+
$.clock.after(90 * (items.length + 2), () => reveal.cancel())
|
|
242
291
|
}
|
|
243
292
|
|
|
244
293
|
type Next = (e: Record<string, unknown>) => Promise<{ deny?: string; isError?: true; result?: unknown; text?: string }>
|
|
@@ -332,11 +381,12 @@ const brief = (command: string) => {
|
|
|
332
381
|
// Something in a turn that can't be undone, named, so undo can list it.
|
|
333
382
|
const lostOne = (x: Turn, what: string): Turn => ({ ...x, lost: (x.lost ?? 0) + 1, lostItems: [...(x.lostItems ?? []), what].slice(-20) })
|
|
334
383
|
|
|
335
|
-
// A turn named by what the person asked:
|
|
336
|
-
const
|
|
384
|
+
// A turn named by what the person asked: "commit and push it", cut to `max`.
|
|
385
|
+
const quoted = (t: Turn, max = 40) => {
|
|
337
386
|
const p = (t.prompt ?? '').replace(/\s+/g, ' ').trim()
|
|
338
|
-
return p ? `
|
|
387
|
+
return p ? ` "${p.length > max ? `${p.slice(0, max - 1)}…` : p}"` : ''
|
|
339
388
|
}
|
|
389
|
+
const named = (t: Turn) => (t.prompt ? ` (${quoted(t).trim()})` : '')
|
|
340
390
|
|
|
341
391
|
// Each part only saves to git (add, commit, push and friends), or only moves around.
|
|
342
392
|
const GIT_SAVE = /^(git\s+(add|commit|push|status|log|diff|show|branch|remote|fetch|rev-parse|config)\b|cd\s|echo\s|true$)/
|
|
@@ -394,7 +444,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
|
|
|
394
444
|
const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
|
|
395
445
|
try {
|
|
396
446
|
for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
|
|
397
|
-
if (c.
|
|
447
|
+
if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
|
|
398
448
|
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.')
|
|
399
449
|
}
|
|
400
450
|
} catch {
|
|
@@ -422,36 +472,45 @@ const ASK = {
|
|
|
422
472
|
// Projects asked about this session: "Not now" means not again until the next one.
|
|
423
473
|
const asked = new Set<string>()
|
|
424
474
|
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
475
|
+
// How each database is named in the question, and what the answer connects.
|
|
476
|
+
const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
|
|
477
|
+
|
|
478
|
+
// Asked when Claude Code opens a project that uses a database (Supabase, other
|
|
479
|
+
// Postgres, Firebase), or Claude is about to change one, and Backstep isn't
|
|
480
|
+
// connected to it; the answer is the only thing that connects it.
|
|
481
|
+
async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
|
|
482
|
+
const key = `${service}:${cwd}`
|
|
483
|
+
if (asked.has(key)) return
|
|
484
|
+
asked.add(key)
|
|
485
|
+
const name = DB_NAME[service] ?? service
|
|
431
486
|
const answer = await $.ui
|
|
432
|
-
.ask(
|
|
487
|
+
.ask(`This project uses ${name}, but Backstep isn't connected to it, so Claude's changes to it can't be undone. Connect it?`, {
|
|
433
488
|
header: 'Backstep',
|
|
434
489
|
options: [ASK.connect, ASK.later, ASK.never],
|
|
435
490
|
})
|
|
436
491
|
.catch(() => null)
|
|
437
492
|
if (answer === ASK.never) {
|
|
438
|
-
await $.process.run(backstep(['connect',
|
|
493
|
+
await $.process.run(backstep(['connect', service, '--dir', cwd, '--dismiss'])).catch(() => null)
|
|
439
494
|
return
|
|
440
495
|
}
|
|
441
496
|
if (answer !== ASK.connect) return
|
|
442
|
-
const ran = await $.process.run(backstep(['connect',
|
|
443
|
-
let
|
|
497
|
+
const ran = await $.process.run(backstep(['connect', service, '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
|
|
498
|
+
let all: Connected[] = []
|
|
444
499
|
try {
|
|
445
|
-
|
|
500
|
+
all = JSON.parse(ran?.stdout || '[]') as Connected[]
|
|
446
501
|
} catch {
|
|
447
|
-
|
|
502
|
+
all = []
|
|
448
503
|
}
|
|
449
|
-
|
|
450
|
-
|
|
504
|
+
const s = all.find((c) => c.state === 'connected' || c.state === 'covered') ?? all.find((c) => c.state === 'needs')
|
|
505
|
+
if (s && (s.state === 'connected' || s.state === 'covered')) {
|
|
506
|
+
$.ui.toast(`Backstep ${s.detail}.`)
|
|
507
|
+
$.ui.log(`${MARK_TEXT} Backstep ${s.detail}.`)
|
|
451
508
|
return
|
|
452
509
|
}
|
|
453
|
-
//
|
|
454
|
-
|
|
510
|
+
// What's still needed, said where it stays.
|
|
511
|
+
const need = s?.detail ?? `ask Claude to "connect Backstep to ${service}"`
|
|
512
|
+
$.ui.log(`${MARK_TEXT} Not connected to ${name} yet: ${need}`)
|
|
513
|
+
$.ui.toast(service === 'supabase' ? 'Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.' : `Backstep isn't connected to ${name} yet: ${need}`)
|
|
455
514
|
}
|
|
456
515
|
|
|
457
516
|
const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
|
|
@@ -596,6 +655,9 @@ export const register: Register = (on, options) => {
|
|
|
596
655
|
on('prompt.submit', async ($, e, next) => {
|
|
597
656
|
if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
|
|
598
657
|
const startedAt = await $.clock.now()
|
|
658
|
+
// The last undo's list has been seen: the band goes back to the undo line.
|
|
659
|
+
const shown = await read($, rewind)
|
|
660
|
+
if (shown && shown.phase !== 'running') await update($, rewind, () => null)
|
|
599
661
|
await update($, turns, (l) => [...(l ?? []), newTurn(startedAt, e.text)].slice(-KEEP_TURNS))
|
|
600
662
|
const pending = await read($, note)
|
|
601
663
|
if (!pending) return next(e)
|
|
@@ -636,8 +698,8 @@ export const register: Register = (on, options) => {
|
|
|
636
698
|
// so it can be saved before the command runs.
|
|
637
699
|
const checked = await $.process.run(backstep(['db-check', '--cwd', cwd, '--', command])).catch(() => null)
|
|
638
700
|
try {
|
|
639
|
-
const ask =
|
|
640
|
-
if (ask) await
|
|
701
|
+
const { ask, service } = JSON.parse(checked?.stdout || '{}') as { ask?: string | null; service?: string | null }
|
|
702
|
+
if (ask) await askDatabase($, ask, service ?? 'supabase')
|
|
641
703
|
} catch {
|
|
642
704
|
// can't tell: run it as before
|
|
643
705
|
}
|
|
@@ -665,9 +727,9 @@ export const register: Register = (on, options) => {
|
|
|
665
727
|
)
|
|
666
728
|
}
|
|
667
729
|
// A push: once it's run, note what it sent, so undo can offer to take it back there.
|
|
668
|
-
if (plan?.id && PUSH.test(command)) {
|
|
730
|
+
if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command))) {
|
|
669
731
|
const r = await next(e)
|
|
670
|
-
await $.process.run(backstep(['shell-end', plan.id])).catch(() => null)
|
|
732
|
+
await $.process.run(backstep(['shell-end', plan.id]), { stdin: r.text ?? '' }).catch(() => null)
|
|
671
733
|
return r
|
|
672
734
|
}
|
|
673
735
|
} else if (tool.startsWith('mcp__')) {
|
|
@@ -727,40 +789,53 @@ export const register: Register = (on, options) => {
|
|
|
727
789
|
const { target, before } = await undoTargets($)
|
|
728
790
|
const label = (await read($, keys)).map(keyLabel).join(' / ')
|
|
729
791
|
if (target && (await read($, armed)) !== null) {
|
|
792
|
+
// Laid out a line at a time, so nothing gets squeezed into columns on a narrow terminal.
|
|
793
|
+
const cancel = (
|
|
794
|
+
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
795
|
+
cancel
|
|
796
|
+
</Button>
|
|
797
|
+
)
|
|
730
798
|
// The last turn only committed and pushed: the work it saved is what the person most likely wants back.
|
|
731
799
|
if (before) {
|
|
732
800
|
return (
|
|
801
|
+
<Box flexDirection="column">
|
|
802
|
+
<Box>
|
|
803
|
+
<Text bold>◀</Text>
|
|
804
|
+
<Text color={PINK} bold>◀</Text>
|
|
805
|
+
<Text color="warning" bold>{' That turn only saved and pushed to git.'}</Text>
|
|
806
|
+
</Box>
|
|
807
|
+
<Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
|
|
808
|
+
<Box>
|
|
809
|
+
<Text>{' '}</Text>
|
|
810
|
+
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
811
|
+
{`${label} again to undo both`}
|
|
812
|
+
</Button>
|
|
813
|
+
<Text dimColor>{' · '}</Text>
|
|
814
|
+
<Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
|
|
815
|
+
just the push
|
|
816
|
+
</Button>
|
|
817
|
+
<Text dimColor>{' · '}</Text>
|
|
818
|
+
{cancel}
|
|
819
|
+
</Box>
|
|
820
|
+
</Box>
|
|
821
|
+
)
|
|
822
|
+
}
|
|
823
|
+
return (
|
|
824
|
+
<Box flexDirection="column">
|
|
733
825
|
<Box>
|
|
734
826
|
<Text bold>◀</Text>
|
|
735
827
|
<Text color={PINK} bold>◀</Text>
|
|
736
|
-
<Text color="warning" bold>
|
|
828
|
+
<Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
|
|
829
|
+
</Box>
|
|
830
|
+
<Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
|
|
831
|
+
<Box>
|
|
832
|
+
<Text>{' '}</Text>
|
|
737
833
|
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
738
|
-
{`${label} again to undo
|
|
739
|
-
</Button>
|
|
740
|
-
<Text dimColor> · </Text>
|
|
741
|
-
<Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
|
|
742
|
-
just the push
|
|
743
|
-
</Button>
|
|
744
|
-
<Text dimColor> · </Text>
|
|
745
|
-
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
746
|
-
cancel
|
|
834
|
+
{`${label} again to undo`}
|
|
747
835
|
</Button>
|
|
836
|
+
<Text dimColor>{' · '}</Text>
|
|
837
|
+
{cancel}
|
|
748
838
|
</Box>
|
|
749
|
-
)
|
|
750
|
-
}
|
|
751
|
-
return (
|
|
752
|
-
<Box>
|
|
753
|
-
<Text bold>◀</Text>
|
|
754
|
-
<Text color={PINK} bold>◀</Text>
|
|
755
|
-
<Text color="warning" bold>{` Undo Claude's last turn${named(target)}? `}</Text>
|
|
756
|
-
<Text dimColor>{summary(target)} </Text>
|
|
757
|
-
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
758
|
-
{`${label} again to undo`}
|
|
759
|
-
</Button>
|
|
760
|
-
<Text dimColor> · </Text>
|
|
761
|
-
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
762
|
-
cancel
|
|
763
|
-
</Button>
|
|
764
839
|
</Box>
|
|
765
840
|
)
|
|
766
841
|
}
|
|
@@ -773,7 +848,7 @@ export const register: Register = (on, options) => {
|
|
|
773
848
|
<Text bold>◀</Text>
|
|
774
849
|
<Text color={PINK} bold>◀</Text>
|
|
775
850
|
<Text> </Text>
|
|
776
|
-
<Text dimColor>{target ? `Last turn
|
|
851
|
+
<Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
|
|
777
852
|
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
|
|
778
853
|
{`${label} to undo`}
|
|
779
854
|
</Button>
|