Marketplace gh login: read gh 2.101's device-code wording

gh 2.101.0 (the image's) prints "! One-time code (XXXX-XXXX) copied to
clipboard" and "Press Enter to open https://github.com/login/device in
your browser...". parse_device_prompt only knew "one-time code:", so no
code event went out and Enter was never pressed: gh sat at its prompt
until the 10-minute timeout. Match the label case-insensitively, accept
":" or "(" before the code, and require something after it so a code cut
by a frame boundary is not taken early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 09:44:25 -07:00
co-authored by Claude Opus 5.5
parent f4153dce42
commit 89859b9a3c
+92 -7
View File
@@ -72,17 +72,29 @@ pub fn strip_ansi(s: &str) -> String {
AnsiStripper::default().push(s.as_bytes())
}
/// gh prints `! First copy your one-time code: XXXX-XXXX`, then either a URL
/// or "Press Enter to open <host> in your browser". Returns (code, url).
/// Read gh's device code and URL. Returns (code, url).
///
/// Two wordings are known:
/// * older gh: `! First copy your one-time code: XXXX-XXXX`, then either a
/// URL or "Press Enter to open <host> in your browser";
/// * gh 2.101 (the image's): `! One-time code (XXXX-XXXX) copied to
/// clipboard`, then "Press Enter to open https://<host>/login/device in your
/// browser...".
///
/// The URL is the first `https://…/login/device` word, else
/// `https://<host>/login/device`.
pub fn parse_device_prompt(output: &str, host: &str) -> Option<(String, String)> {
const LABEL: &str = "one-time code:";
let at = output.find(LABEL)? + LABEL.len();
let code: String = output[at..]
.trim_start()
const LABEL: &str = "one-time code";
// ASCII lowercasing keeps byte offsets, so `at` indexes `output` too.
let at = output.to_ascii_lowercase().find(LABEL)? + LABEL.len();
let rest = output[at..].trim_start_matches(|c: char| c == ':' || c == '(' || c.is_whitespace());
let code: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
if code.len() < 6 || !code.contains('-') {
// Something must follow the code (`)` or a line break): a code at the
// very end may still be growing in the next frame.
if code.len() < 6 || !code.contains('-') || rest.len() == code.len() {
return None;
}
let url = output
@@ -334,6 +346,54 @@ mod tests {
);
}
/// gh 2.101.0 (the image's gh, integration report check 6), after ANSI
/// stripping: the code is in parentheses and the URL is on the Enter line.
const GH_2_101_PROMPT: &str = "! One-time code (4F2A-9C1B) copied to clipboard\nPress Enter to open https://github.com/login/device in your browser... ";
#[test]
fn the_gh_2_101_wording_is_read() {
assert_eq!(
parse_device_prompt(GH_2_101_PROMPT, "github.com"),
Some((
"4F2A-9C1B".to_string(),
"https://github.com/login/device".to_string()
))
);
}
#[test]
fn the_url_comes_from_the_press_enter_line() {
let out = "! One-time code (AB12-CD34) copied to clipboard\nPress Enter to open https://ghe.example.com/login/device in your browser... ";
assert_eq!(
parse_device_prompt(out, "github.com"),
Some((
"AB12-CD34".to_string(),
"https://ghe.example.com/login/device".to_string()
))
);
}
#[test]
fn the_gh_2_101_wording_without_a_code_is_no_prompt() {
assert_eq!(parse_device_prompt("! One-time code (", "github.com"), None);
assert_eq!(
parse_device_prompt("! One-time code (4F2A", "github.com"),
None
);
}
#[test]
fn a_code_cut_by_a_frame_boundary_is_not_a_code_yet() {
assert_eq!(
parse_device_prompt("! One-time code (4F2A-9C", "github.com"),
None
);
assert_eq!(
parse_device_prompt("! First copy your one-time code: 4F2A-9C", "github.com"),
None
);
}
#[test]
fn no_code_yet_means_no_prompt() {
assert_eq!(
@@ -561,6 +621,31 @@ mod tests {
}
}
/// The raw bytes gh 2.101.0 prints under a tty (integration report
/// check 6), with a fake code: the code event goes out and Enter is
/// pressed, or gh never starts polling.
#[tokio::test(start_paused = true)]
async fn gh_2_101_gets_its_code_event_and_its_enter() {
let keys = Keys::default();
let (_tx, mut cancel) = oneshot::channel();
let frames = stream::iter(vec![
out("\u{1b}]11;?\u{1b}\\\u{1b}[6n"),
out("\r\n"),
out("\u{1b}]52;c;NEYyQS05QzFC\u{7}\u{1b}[0;33m!\u{1b}[0m One-time code (\u{1b}[0;1;39m4F2A-9C1B\u{1b}[0m) copied to clipboard\r\n\u{1b}[0;1;39mPress Enter\u{1b}[0m to open https://github.com/login/device in your browser... "),
]);
let (r, events) = drive(frames, keys.clone(), &mut cancel, far()).await;
assert!(r.is_ok());
assert_eq!(*keys.writes.lock().unwrap(), vec![b"\r".to_vec()]);
assert!(events.contains(&(
CODE_EVENT,
serde_json::json!({
"account_id": "acct-1",
"code": "4F2A-9C1B",
"url": "https://github.com/login/device"
})
)));
}
#[tokio::test]
async fn a_lost_stream_is_a_failure() {
let (_tx, mut cancel) = oneshot::channel();