backstep 0.1.13 → 0.1.14
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/cli.js +2 -1
- package/dist/github.js +4 -2
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +25 -3
- package/mod/hooks/register.tsx +63 -5
- package/mod/types/index.d.ts +5 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -464,8 +464,9 @@ async function main() {
|
|
|
464
464
|
}
|
|
465
465
|
// Takes away something Claude made on GitHub (the thing, as undo reported it, on stdin). The mod runs this after asking.
|
|
466
466
|
// Gets the person's OK on GitHub for another gh permission: code on the clipboard, approval page opened.
|
|
467
|
+
// The code is printed on a line of its own straight away, so the mod can show it; the answer is the last line.
|
|
467
468
|
if (cmd === "gh-auth") {
|
|
468
|
-
const out = await grantScope(rest[0] ?? "");
|
|
469
|
+
const out = await grantScope(rest[0] ?? "", (code) => console.log(JSON.stringify({ code, url: "https://github.com/login/device" })));
|
|
469
470
|
console.log(JSON.stringify(out));
|
|
470
471
|
if (!out.ok)
|
|
471
472
|
process.exitCode = 1;
|
package/dist/github.js
CHANGED
|
@@ -80,8 +80,9 @@ function openPage(url) {
|
|
|
80
80
|
// Gives gh's sign-in another permission (like delete_repo) with GitHub's own
|
|
81
81
|
// approval page: the one-time code is put on the clipboard and the page opened,
|
|
82
82
|
// so the person only pastes it and approves. Resolves once GitHub says yes,
|
|
83
|
-
// or no, or nine minutes pass (Claude Code gives a command ten).
|
|
84
|
-
|
|
83
|
+
// or no, or nine minutes pass (Claude Code gives a command ten). `onCode` hears
|
|
84
|
+
// the code as soon as gh prints it, so it can be shown while GitHub waits.
|
|
85
|
+
export function grantScope(scope, onCode) {
|
|
85
86
|
return new Promise((resolve) => {
|
|
86
87
|
// gh would open the page too; Backstep already does, so gh's browser is told to do nothing.
|
|
87
88
|
const env = { ...process.env, NO_COLOR: "1", GH_BROWSER: process.platform === "win32" ? "cmd /c exit" : "true" };
|
|
@@ -96,6 +97,7 @@ export function grantScope(scope) {
|
|
|
96
97
|
shown = true;
|
|
97
98
|
copy(code);
|
|
98
99
|
openPage(DEVICE);
|
|
100
|
+
onCode?.(code);
|
|
99
101
|
// gh waits for Enter before it would open the page itself; it's already open.
|
|
100
102
|
child.stdin.write("\n");
|
|
101
103
|
}
|
|
@@ -723,13 +723,25 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
|
|
|
723
723
|
runs.filter((r) => r.argv.includes('gh-undo')).length === 1
|
|
724
724
|
? { ok: false, needs: 'delete_repo', detail: 'GitHub needs your OK before bzells11/taskflow can be deleted' }
|
|
725
725
|
: { ok: true, detail: 'deleted the repository bzells11/taskflow' }
|
|
726
|
-
: e.argv.includes('gh-auth')
|
|
727
|
-
? { ok: true, detail: 'GitHub approved it' }
|
|
728
726
|
: e.argv.includes('db-check')
|
|
729
727
|
? { ask: null }
|
|
730
728
|
: null
|
|
731
729
|
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
732
730
|
})
|
|
731
|
+
// GitHub's approval: the code comes first, then the answer once the person approves (when `approve` is called).
|
|
732
|
+
let approve = () => {}
|
|
733
|
+
const approved = new Promise<void>((resolve) => (approve = resolve))
|
|
734
|
+
let codeShown: Promise<void> = Promise.resolve()
|
|
735
|
+
let gotCode = () => {}
|
|
736
|
+
codeShown = new Promise<void>((resolve) => (gotCode = resolve))
|
|
737
|
+
on('process.spawn', async function* (_, e) {
|
|
738
|
+
runs.push({ argv: [...e.argv] })
|
|
739
|
+
yield { stream: 'stdout' as const, text: '{"code":"ABCD-1234","url":"https://github.com/login/device"}\n' }
|
|
740
|
+
gotCode()
|
|
741
|
+
await approved
|
|
742
|
+
yield { stream: 'stdout' as const, text: '{"ok":true,"detail":"GitHub approved it"}\n' }
|
|
743
|
+
return { value: { code: 0, signal: null } }
|
|
744
|
+
})
|
|
733
745
|
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
734
746
|
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
735
747
|
asked.push(questions[0].question)
|
|
@@ -753,7 +765,17 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
|
|
|
753
765
|
const pressed = ui.press({ key: 'undo' })
|
|
754
766
|
await clock.advance(1000)
|
|
755
767
|
await pressed
|
|
768
|
+
await codeShown
|
|
769
|
+
await clock.settle()
|
|
770
|
+
// While GitHub waits, the band says it's a step to take and shows the code, not a failure.
|
|
771
|
+
expect(await ui.find({ type: 'Text', text: /One more step to delete the GitHub repository bzells11\/taskflow/ })).toBeTruthy()
|
|
772
|
+
expect(await ui.find({ type: 'Text', text: 'ABCD-1234' })).toBeTruthy()
|
|
773
|
+
// The push went to the repository being deleted, so it isn't listed as not undone.
|
|
774
|
+
expect(await ui.find({ type: 'Text', text: /Not undone/ })).toBeFalsy()
|
|
775
|
+
approve()
|
|
756
776
|
await clock.settle()
|
|
777
|
+
expect(await ui.find({ type: 'Text', text: 'ABCD-1234' })).toBeFalsy()
|
|
778
|
+
expect(await ui.find({ type: 'Text', text: /on GitHub: deleted the repository bzells11\/taskflow/ })).toBeTruthy()
|
|
757
779
|
expect(asked).toEqual(['Claude created the GitHub repository bzells11/taskflow. Delete it on GitHub too?'])
|
|
758
780
|
expect(JSON.parse(runs.find((r) => r.argv.includes('gh-undo'))?.stdin ?? '{}')).toEqual(repo)
|
|
759
781
|
// Missing permission: GitHub's OK is fetched by Backstep itself, then the delete is tried again.
|
|
@@ -762,7 +784,7 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
|
|
|
762
784
|
'gh-auth delete_repo',
|
|
763
785
|
'gh-undo',
|
|
764
786
|
])
|
|
765
|
-
expect(logs.find((l) => l.includes('
|
|
787
|
+
expect(logs.find((l) => l.includes('paste the code ABCD-1234 on the GitHub page'))).toBeTruthy()
|
|
766
788
|
expect(logs.find((l) => l.includes('On GitHub: deleted the repository'))).toBeTruthy()
|
|
767
789
|
// The repository is gone, so its push isn't asked about.
|
|
768
790
|
expect(runs.some((r) => r.argv.includes('revert-push'))).toBe(false)
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { atom, read, update } from 'claude-code'
|
|
8
8
|
import type { EngineInterface, Register } from 'claude-code'
|
|
9
9
|
|
|
10
|
-
import type { Rewind, RewindItem, Turn } from '../types'
|
|
10
|
+
import type { GithubCode, Rewind, RewindItem, Turn } from '../types'
|
|
11
11
|
|
|
12
12
|
const turns = atom({ plugin: 'backstep', key: 'turns' } as const, [] as Turn[])
|
|
13
13
|
const rewind = atom({ plugin: 'backstep', key: 'rewind' } as const, null as Rewind | null)
|
|
@@ -16,6 +16,8 @@ const armed = atom({ plugin: 'backstep', key: 'armed' } as const, null as number
|
|
|
16
16
|
// The keys bound to undo, read as a session starts and when the person changes
|
|
17
17
|
// them. Kept in state, not a variable, so the band redraws with the new key.
|
|
18
18
|
const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as string[])
|
|
19
|
+
// The one-time code GitHub's approval page wants, on the band while GitHub waits.
|
|
20
|
+
const githubCode = atom({ plugin: 'backstep', key: 'githubCode' } as const, null as GithubCode | null)
|
|
19
21
|
// Backstep's mark, as on the logo: the front rewind triangle in the terminal's
|
|
20
22
|
// own black or white, the one behind it in the logo's pink.
|
|
21
23
|
const PINK = '#ee7bb6'
|
|
@@ -80,6 +82,39 @@ async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, st
|
|
|
80
82
|
|
|
81
83
|
const ghUndo = ($: EngineInterface, g: GhMade) => jsonRun($, ['gh-undo'], 120_000, JSON.stringify(g))
|
|
82
84
|
|
|
85
|
+
// Gets GitHub's OK for one more gh permission. The CLI prints the one-time code
|
|
86
|
+
// on a line of its own as soon as GitHub gives it, so the band shows it while
|
|
87
|
+
// GitHub waits; the answer is its last line.
|
|
88
|
+
async function ghAuth($: EngineInterface, scope: string, what: string): Promise<{ ok: boolean; detail: string }> {
|
|
89
|
+
let out = ''
|
|
90
|
+
try {
|
|
91
|
+
const child = $.process.spawn({ argv: backstep(['gh-auth', scope]) })
|
|
92
|
+
for await (const { stream, text } of child) {
|
|
93
|
+
if (stream !== 'stdout') continue
|
|
94
|
+
out += text
|
|
95
|
+
const code = out.match(/"code":"([A-Z0-9]{4}-[A-Z0-9]{4})"/)?.[1]
|
|
96
|
+
if (code && !(await read($, githubCode))) {
|
|
97
|
+
await update($, githubCode, () => ({ code, what }))
|
|
98
|
+
$.ui.log(`${MARK_TEXT} One more step to ${what}: paste the code ${code} on the GitHub page that just opened, then press Authorize. (It's already copied, so ⌘V / Ctrl+V works.)`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
out += `\n${JSON.stringify({ ok: false, detail: err instanceof Error ? err.message : String(err) })}`
|
|
103
|
+
} finally {
|
|
104
|
+
await update($, githubCode, () => null)
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(out.trim().split('\n').at(-1) ?? '') as { ok: boolean; detail: string }
|
|
108
|
+
} catch {
|
|
109
|
+
return { ok: false, detail: "GitHub didn't answer" }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Adds a line to the band's list once something offered after the rewind is settled.
|
|
114
|
+
async function addItem($: EngineInterface, item: RewindItem) {
|
|
115
|
+
await update($, rewind, (r) => (r ? { ...r, items: [...r.items, item], shown: r.shown + 1 } : r))
|
|
116
|
+
}
|
|
117
|
+
|
|
83
118
|
// Asks, for each thing Claude made on GitHub in the undone turns, whether to take it away there too.
|
|
84
119
|
// Returns the repositories deleted, whose pushes then need no undo of their own.
|
|
85
120
|
async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise<Set<string>> {
|
|
@@ -96,12 +131,12 @@ async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise
|
|
|
96
131
|
let r = await ghUndo($, g)
|
|
97
132
|
// gh's sign-in needs one more permission: get it on GitHub's own page, then try again.
|
|
98
133
|
if (!r.ok && r.needs) {
|
|
99
|
-
$.ui.toast(
|
|
100
|
-
|
|
101
|
-
const granted = await jsonRun($, ['gh-auth', r.needs], 600_000)
|
|
134
|
+
$.ui.toast('One more step: paste the code shown above the prompt on the GitHub page that just opened, then press Authorize.', { timeoutMs: 15_000 })
|
|
135
|
+
const granted = await ghAuth($, r.needs, `${yes === 'Close it' ? 'close' : 'delete'} ${describeGh(g)}`)
|
|
102
136
|
r = granted.ok ? await ghUndo($, g) : { ok: false, detail: `GitHub didn't give the OK (${granted.detail}), so ${describeGh(g)} is still there` }
|
|
103
137
|
}
|
|
104
138
|
if (r.ok && g.kind === 'repo') gone.add(g.repo)
|
|
139
|
+
await addItem($, r.ok ? { label: `on GitHub: ${r.detail}`, ok: true } : { label: `on GitHub: ${r.detail}`, ok: false })
|
|
105
140
|
$.ui.log(r.ok ? `${MARK_TEXT} On GitHub: ${r.detail}.` : `${MARK_TEXT} Not undone on GitHub: ${r.detail}`)
|
|
106
141
|
$.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
|
|
107
142
|
}
|
|
@@ -237,9 +272,11 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
237
272
|
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
238
273
|
const offered = outcomes.some((o) => o.ok && o.pushed)
|
|
239
274
|
const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
|
|
275
|
+
// Deleting the repository takes whatever was pushed to it along.
|
|
276
|
+
const offeredRepo = outcomes.some((o) => o.ok && o.github?.some((g) => g.kind === 'repo'))
|
|
240
277
|
const kept = taken
|
|
241
278
|
.flatMap((t) => t.lostItems ?? [])
|
|
242
|
-
.filter((l) => !(offered && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
|
|
279
|
+
.filter((l) => !((offered || offeredRepo) && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
|
|
243
280
|
.map((label) => ({ label, ok: false }))
|
|
244
281
|
const items: RewindItem[] = [...undone, ...stopped, ...kept]
|
|
245
282
|
const done = undone.length
|
|
@@ -755,6 +792,24 @@ export const register: Register = (on, options) => {
|
|
|
755
792
|
)
|
|
756
793
|
}
|
|
757
794
|
|
|
795
|
+
const waiting = await read($, githubCode)
|
|
796
|
+
// GitHub is waiting on the person: the code, big and plain, says this is a step to take, not a failure.
|
|
797
|
+
const step = waiting ? (
|
|
798
|
+
<Box flexDirection="column" marginTop={r ? 1 : 0}>
|
|
799
|
+
<Box>
|
|
800
|
+
<Text bold>◀</Text>
|
|
801
|
+
<Text color={PINK} bold>◀</Text>
|
|
802
|
+
<Text color="warning" bold>{` One more step to ${waiting.what}:`}</Text>
|
|
803
|
+
</Box>
|
|
804
|
+
<Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
|
|
805
|
+
<Box>
|
|
806
|
+
<Text>{' '}</Text>
|
|
807
|
+
<Text color={PINK} bold>{waiting.code}</Text>
|
|
808
|
+
<Text dimColor>{' (already copied: ⌘V / Ctrl+V)'}</Text>
|
|
809
|
+
</Box>
|
|
810
|
+
</Box>
|
|
811
|
+
) : null
|
|
812
|
+
|
|
758
813
|
if (r) {
|
|
759
814
|
const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
|
|
760
815
|
const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
|
|
@@ -782,10 +837,13 @@ export const register: Register = (on, options) => {
|
|
|
782
837
|
{i.label}
|
|
783
838
|
</Text>
|
|
784
839
|
))}
|
|
840
|
+
{step}
|
|
785
841
|
</Box>
|
|
786
842
|
)
|
|
787
843
|
}
|
|
788
844
|
|
|
845
|
+
if (step) return step
|
|
846
|
+
|
|
789
847
|
const { target, before } = await undoTargets($)
|
|
790
848
|
const label = (await read($, keys)).map(keyLabel).join(' / ')
|
|
791
849
|
if (target && (await read($, armed)) !== null) {
|
package/mod/types/index.d.ts
CHANGED
|
@@ -29,10 +29,14 @@ export type Rewind = {
|
|
|
29
29
|
message: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// GitHub waiting on the person's OK for one more permission: the one-time code to paste, and what it's for.
|
|
33
|
+
export type GithubCode = { code: string; what: string }
|
|
34
|
+
|
|
32
35
|
declare module 'claude-code' {
|
|
33
36
|
interface PluginState {
|
|
34
37
|
// armed: when the undo key was first pressed; a second press within a few seconds undoes.
|
|
35
38
|
// keys: the keys bound to undo, kept here so the band redraws when they change.
|
|
36
|
-
|
|
39
|
+
// githubCode: the code GitHub's approval page wants, shown on the band until GitHub answers.
|
|
40
|
+
backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null }
|
|
37
41
|
}
|
|
38
42
|
}
|