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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user