backstep 0.1.9 → 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 +172 -8
- package/mod/hooks/register.tsx +252 -77
- package/mod/types/index.d.ts +6 -0
- package/package.json +1 -1
|
@@ -13,9 +13,14 @@ const CLI = '/opt/backstep/cli.js'
|
|
|
13
13
|
// `undo` answers with `undo`. Every argv the mod runs is recorded.
|
|
14
14
|
function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }) {
|
|
15
15
|
const runs: string[][] = []
|
|
16
|
+
const logs: string[] = []
|
|
16
17
|
const prompts: { text: string; context?: readonly string[] }[] = []
|
|
17
18
|
on('session.cwd', () => ({ value: '/repo' }))
|
|
18
19
|
on('session.id', () => ({ value: 's1' }))
|
|
20
|
+
on('ui.log', (_, e) => {
|
|
21
|
+
logs.push(e.text)
|
|
22
|
+
return { value: undefined }
|
|
23
|
+
})
|
|
19
24
|
on('process.run', (_, e) => {
|
|
20
25
|
runs.push([...e.argv])
|
|
21
26
|
const r = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? { exitCode: 0, stdout: JSON.stringify(shell) } : { exitCode: 0, stdout: '' }
|
|
@@ -30,7 +35,7 @@ function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: strin
|
|
|
30
35
|
const { Text } = $.ui.resolve(e)
|
|
31
36
|
return <Text>engine</Text>
|
|
32
37
|
})
|
|
33
|
-
return { runs, prompts }
|
|
38
|
+
return { runs, prompts, logs }
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
test('an edit is snapshotted and the band offers to undo the turn', { options: { cli: CLI } }, async ($, on) => {
|
|
@@ -74,15 +79,18 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
|
|
|
74
79
|
|
|
75
80
|
await clock.advance(1000)
|
|
76
81
|
await pressed
|
|
82
|
+
await clock.advance(500)
|
|
77
83
|
expect((await ui.find({ type: 'Text', text: /Rewound/ }))?.text).toContain('Rewound 1 change from the last turn.')
|
|
78
84
|
expect((await ui.find({ type: 'Text', text: /src\/a\.ts restored/ }))).toBeTruthy()
|
|
79
85
|
|
|
80
|
-
// The
|
|
81
|
-
await clock.advance(
|
|
82
|
-
expect(await ui.find({ type: 'Text', text: /
|
|
83
|
-
await ui.unmount()
|
|
86
|
+
// The list stays up, however long, until the next message.
|
|
87
|
+
await clock.advance(60_000)
|
|
88
|
+
expect((await ui.find({ type: 'Text', text: /Rewound/ }))).toBeTruthy()
|
|
84
89
|
|
|
85
90
|
await $.prompt.submit({ text: 'try again', wait: false, origin: { kind: 'composer' } })
|
|
91
|
+
// Then the band goes back to the undo line; with the turn undone, nothing is left to offer.
|
|
92
|
+
expect(await ui.find({ type: 'Text', text: /Rewound/ })).toBeFalsy()
|
|
93
|
+
await ui.unmount()
|
|
86
94
|
const note = prompts.at(-1)?.context?.at(-1) ?? ''
|
|
87
95
|
expect(note).toContain('These changes were reverted:\n- src/a.ts restored')
|
|
88
96
|
|
|
@@ -110,14 +118,14 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
|
|
|
110
118
|
const pressed = ui.press({ key: 'undo' })
|
|
111
119
|
await clock.advance(1000)
|
|
112
120
|
await pressed
|
|
121
|
+
await clock.advance(500)
|
|
113
122
|
expect((await ui.find({ type: 'Text', text: /Stopped/ }))?.text).toContain('Stopped after 1 change')
|
|
114
123
|
expect((await ui.find({ type: 'Text', text: /row changed since/ }))).toBeTruthy()
|
|
115
124
|
|
|
116
|
-
// Still undoable
|
|
117
|
-
await
|
|
125
|
+
// Still undoable after the next message, since the turn was not fully rewound.
|
|
126
|
+
await $.prompt.submit({ text: 'hmm', wait: false, origin: { kind: 'composer' } })
|
|
118
127
|
expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
|
|
119
128
|
await ui.unmount()
|
|
120
|
-
expect(prompts).toHaveLength(1)
|
|
121
129
|
})
|
|
122
130
|
|
|
123
131
|
test('with nothing to undo the band still holds the key, and a press says there is nothing to undo', async ($, on) => {
|
|
@@ -219,6 +227,7 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
|
|
|
219
227
|
toasts.push(e.text)
|
|
220
228
|
return { value: undefined }
|
|
221
229
|
})
|
|
230
|
+
on('ui.log', () => ({ value: undefined }))
|
|
222
231
|
on('session.cwd', () => ({ value: '/repo' }))
|
|
223
232
|
on('session.id', () => ({ value: 's1' }))
|
|
224
233
|
on('process.run', (_, e) => {
|
|
@@ -531,6 +540,7 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
|
|
|
531
540
|
|
|
532
541
|
await ui.press({ key: 'undo' })
|
|
533
542
|
expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn\?/ }))).toBeTruthy()
|
|
543
|
+
expect((await ui.find({ type: 'Text', text: /"fix it" · 1 edit to 1 file/ }))).toBeTruthy()
|
|
534
544
|
expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo')
|
|
535
545
|
await clock.advance(6000)
|
|
536
546
|
expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
|
|
@@ -606,3 +616,157 @@ test('about to change a Supabase project that is not connected, Backstep asks fi
|
|
|
606
616
|
await $.tool.call({ tool: 'Bash', command })
|
|
607
617
|
expect(asked).toHaveLength(1)
|
|
608
618
|
})
|
|
619
|
+
|
|
620
|
+
// An engine where each shell command's plan is given by a test, and undo answers as told.
|
|
621
|
+
function shellEngine(on: On, plans: (command: string) => object, undo: object[]) {
|
|
622
|
+
const runs: string[][] = []
|
|
623
|
+
const logs: string[] = []
|
|
624
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
625
|
+
on('session.id', () => ({ value: 's1' }))
|
|
626
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
627
|
+
on('ui.log', (_, e) => {
|
|
628
|
+
logs.push(e.text)
|
|
629
|
+
return { value: undefined }
|
|
630
|
+
})
|
|
631
|
+
on('process.run', (_, e) => {
|
|
632
|
+
runs.push([...e.argv])
|
|
633
|
+
const command = e.argv.at(-1) ?? ''
|
|
634
|
+
const out = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? plans(command) : e.argv.includes('db-check') ? { ask: null } : null
|
|
635
|
+
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
636
|
+
})
|
|
637
|
+
on('tool.call', { tool: 'AskUserQuestion' }, () => ({ result: { questions: [], answers: {} } }))
|
|
638
|
+
on('tool.call', () => ({ result: {} }))
|
|
639
|
+
on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
|
|
640
|
+
on('ui.render', ($, e) => {
|
|
641
|
+
const { Text } = $.ui.resolve(e)
|
|
642
|
+
return <Text>engine</Text>
|
|
643
|
+
})
|
|
644
|
+
return { runs, logs }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
test('when the last turn only committed and pushed, undo offers to take back the work it saved too', { options: { cli: CLI } }, async ($, on) => {
|
|
648
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
649
|
+
const { runs } = shellEngine(on, () => ({ kind: 'outbound', reason: 'pushes to a remote', id: 'p1', undoable: true }), [])
|
|
650
|
+
|
|
651
|
+
await $.prompt.submit({ text: 'add the kanban board', wait: false, origin: { kind: 'composer' } })
|
|
652
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/board.tsx', old_string: 'a', new_string: 'b' })
|
|
653
|
+
await clock.advance(1000)
|
|
654
|
+
await $.prompt.submit({ text: 'commit and push it', wait: false, origin: { kind: 'composer' } })
|
|
655
|
+
await $.tool.call({ tool: 'Bash', command: 'git add -A && git commit -m "board" && git push' })
|
|
656
|
+
|
|
657
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
658
|
+
expect((await ui.find({ type: 'Text', text: /Last turn: "commit and push it"/ }))).toBeTruthy()
|
|
659
|
+
await ui.press({ key: 'undo' })
|
|
660
|
+
expect((await ui.find({ type: 'Text', text: /only saved and pushed to git/ }))).toBeTruthy()
|
|
661
|
+
expect((await ui.find({ type: 'Text', text: /Undo it and the turn before it "add the kanban board"\?/ }))).toBeTruthy()
|
|
662
|
+
expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo both')
|
|
663
|
+
expect(await ui.find({ type: 'Button', key: 'just' })).toBeTruthy()
|
|
664
|
+
|
|
665
|
+
const pressed = ui.press({ key: 'undo' })
|
|
666
|
+
await clock.advance(1000)
|
|
667
|
+
await pressed
|
|
668
|
+
// Both turns: everything since the first one started.
|
|
669
|
+
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--json', '--via-claude-code'])
|
|
670
|
+
await ui.unmount()
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
test('after an undo, everything undone and everything that was not is listed, in the band and the conversation', { options: { cli: CLI } }, async ($, on) => {
|
|
674
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
675
|
+
const { logs } = shellEngine(
|
|
676
|
+
on,
|
|
677
|
+
(command) => (command.startsWith('curl') ? { kind: 'outbound', reason: 'sends a request', id: 'r1', undoable: true } : { kind: 'write', id: 'w1', undoable: true }),
|
|
678
|
+
[{ id: 'w1', tool: 'Bash', ok: true, detail: 'restored /repo/src/a.ts\nremoved /repo/src/b.ts' }],
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
await $.prompt.submit({ text: 'tidy and notify', wait: false, origin: { kind: 'composer' } })
|
|
682
|
+
await $.tool.call({ tool: 'Bash', command: 'rm src/a.ts' })
|
|
683
|
+
await $.tool.call({ tool: 'Bash', command: 'curl -X POST https://hooks.example.com/notify -d done' })
|
|
684
|
+
|
|
685
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
686
|
+
await ui.press({ key: 'undo' })
|
|
687
|
+
const pressed = ui.press({ key: 'undo' })
|
|
688
|
+
await clock.advance(1000)
|
|
689
|
+
await pressed
|
|
690
|
+
await clock.advance(500)
|
|
691
|
+
expect((await ui.find({ type: 'Text', text: /src\/a\.ts restored/ }))).toBeTruthy()
|
|
692
|
+
expect((await ui.find({ type: 'Text', text: /Not undone/ }))).toBeTruthy()
|
|
693
|
+
expect((await ui.find({ type: 'Text', text: /curl -X POST https:\/\/hooks\.example\.com\/notify -d done: sends a request/ }))).toBeTruthy()
|
|
694
|
+
|
|
695
|
+
const report = logs.find((l) => l.includes('Backstep undid'))!
|
|
696
|
+
expect(report).toContain('undid 2 changes from the last turn ("tidy and notify")')
|
|
697
|
+
expect(report).toContain('◀ src/b.ts removed')
|
|
698
|
+
expect(report).toContain('Not undone:\n ✗ curl -X POST')
|
|
699
|
+
await ui.unmount()
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
test('a GitHub repository Claude created is offered for deletion after undo, and its push needs no undo of its own', { options: { cli: CLI } }, async ($, on) => {
|
|
703
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
704
|
+
const repo = { kind: 'repo', repo: 'bzells11/taskflow', url: 'https://github.com/bzells11/taskflow' }
|
|
705
|
+
const runs: { argv: string[]; stdin?: string }[] = []
|
|
706
|
+
const asked: string[] = []
|
|
707
|
+
const logs: string[] = []
|
|
708
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
709
|
+
on('session.id', () => ({ value: 's1' }))
|
|
710
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
711
|
+
on('ui.log', (_, e) => {
|
|
712
|
+
logs.push(e.text)
|
|
713
|
+
return { value: undefined }
|
|
714
|
+
})
|
|
715
|
+
on('process.run', (_, e) => {
|
|
716
|
+
runs.push({ argv: [...e.argv], stdin: e.init?.stdin })
|
|
717
|
+
const out = e.argv.includes('shell')
|
|
718
|
+
? { kind: 'outbound', reason: 'changes GitHub', id: 'g1', undoable: true }
|
|
719
|
+
: e.argv.includes('undo')
|
|
720
|
+
? [{ id: 'g1', tool: 'Bash', ok: true, detail: 'restored main', github: [repo], pushed: { root: '/repo', pushes: [{ remote: 'origin', branch: 'main', before: null, after: 'b', commits: 1, host: 'GitHub' }] } }]
|
|
721
|
+
: e.argv.includes('gh-undo')
|
|
722
|
+
? // The first try finds gh's sign-in can't delete repositories yet.
|
|
723
|
+
runs.filter((r) => r.argv.includes('gh-undo')).length === 1
|
|
724
|
+
? { ok: false, needs: 'delete_repo', detail: 'GitHub needs your OK before bzells11/taskflow can be deleted' }
|
|
725
|
+
: { ok: true, detail: 'deleted the repository bzells11/taskflow' }
|
|
726
|
+
: e.argv.includes('gh-auth')
|
|
727
|
+
? { ok: true, detail: 'GitHub approved it' }
|
|
728
|
+
: e.argv.includes('db-check')
|
|
729
|
+
? { ask: null }
|
|
730
|
+
: null
|
|
731
|
+
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
732
|
+
})
|
|
733
|
+
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
734
|
+
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
735
|
+
asked.push(questions[0].question)
|
|
736
|
+
return { result: { questions, answers: { [questions[0].question]: 'Delete it' } } }
|
|
737
|
+
})
|
|
738
|
+
on('tool.call', () => ({ result: {}, text: '✓ Created repository bzells11/taskflow on GitHub' }))
|
|
739
|
+
on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
|
|
740
|
+
on('ui.render', ($, e) => {
|
|
741
|
+
const { Text } = $.ui.resolve(e)
|
|
742
|
+
return <Text>engine</Text>
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
await $.prompt.submit({ text: 'make a github repo', wait: false, origin: { kind: 'composer' } })
|
|
746
|
+
await $.tool.call({ tool: 'Bash', command: 'gh repo create taskflow --public --source . --push' })
|
|
747
|
+
// What gh printed is noted once it has run.
|
|
748
|
+
const end = runs.find((r) => r.argv.includes('shell-end'))
|
|
749
|
+
expect(end?.stdin).toContain('Created repository bzells11/taskflow')
|
|
750
|
+
|
|
751
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
752
|
+
await ui.press({ key: 'undo' })
|
|
753
|
+
const pressed = ui.press({ key: 'undo' })
|
|
754
|
+
await clock.advance(1000)
|
|
755
|
+
await pressed
|
|
756
|
+
await clock.settle()
|
|
757
|
+
expect(asked).toEqual(['Claude created the GitHub repository bzells11/taskflow. Delete it on GitHub too?'])
|
|
758
|
+
expect(JSON.parse(runs.find((r) => r.argv.includes('gh-undo'))?.stdin ?? '{}')).toEqual(repo)
|
|
759
|
+
// Missing permission: GitHub's OK is fetched by Backstep itself, then the delete is tried again.
|
|
760
|
+
expect(runs.filter((r) => r.argv.includes('gh-undo') || r.argv.includes('gh-auth')).map((r) => r.argv.slice(3).join(' '))).toEqual([
|
|
761
|
+
'gh-undo',
|
|
762
|
+
'gh-auth delete_repo',
|
|
763
|
+
'gh-undo',
|
|
764
|
+
])
|
|
765
|
+
expect(logs.find((l) => l.includes('opened the approval page and copied the code'))).toBeTruthy()
|
|
766
|
+
expect(logs.find((l) => l.includes('On GitHub: deleted the repository'))).toBeTruthy()
|
|
767
|
+
// The repository is gone, so its push isn't asked about.
|
|
768
|
+
expect(runs.some((r) => r.argv.includes('revert-push'))).toBe(false)
|
|
769
|
+
// And "changes GitHub" isn't listed as not undone, since it was offered.
|
|
770
|
+
expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes GitHub')
|
|
771
|
+
await ui.unmount()
|
|
772
|
+
})
|