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 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
- export function grantScope(scope) {
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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": {
@@ -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('opened the approval page and copied the code'))).toBeTruthy()
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)
@@ -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("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)
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) {
@@ -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
- backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[] }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "scripts": {
6
6
  "dev": "node src/cli.ts",