Skip to content

Commit 0b53fa6

Browse files
mkh09353Max Headley
authored andcommitted
Add post-setup login continuation flow
1 parent 5e29ec0 commit 0b53fa6

2 files changed

Lines changed: 123 additions & 5 deletions

File tree

src/auth_commands.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
141141
" (e.g. -s drive,gmail,sheets)\n",
142142
" setup Configure GCP project + OAuth client (requires gcloud)\n",
143143
" --project Use a specific GCP project\n",
144+
" --login Run `gws auth login` after successful setup\n",
144145
" status Show current authentication state\n",
145146
" export Print decrypted credentials to stdout\n",
146147
" logout Clear saved credentials and token cache",
@@ -153,7 +154,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
153154
}
154155

155156
match args[0].as_str() {
156-
"login" => handle_login(&args[1..]).await,
157+
"login" => run_login(&args[1..]).await,
157158
"setup" => crate::setup::run_setup(&args[1..]).await,
158159
"status" => handle_status().await,
159160
"export" => {
@@ -166,6 +167,13 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
166167
))),
167168
}
168169
}
170+
171+
/// Run the `auth login` flow.
172+
///
173+
/// Exposed for internal orchestration (e.g. `auth setup --login`).
174+
pub async fn run_login(args: &[String]) -> Result<(), GwsError> {
175+
handle_login(args).await
176+
}
169177
/// Custom delegate that prints the OAuth URL on its own line for easy copying.
170178
/// Optionally includes `login_hint` in the URL for account pre-selection.
171179
struct CliFlowDelegate {

src/setup.rs

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -373,12 +373,14 @@ pub async fn fetch_scopes_for_apis(enabled_api_ids: &[String]) -> Vec<Discovered
373373
pub struct SetupOptions {
374374
pub project: Option<String>,
375375
pub dry_run: bool,
376+
pub login: bool,
376377
}
377378

378379
/// Parse setup flags from args.
379380
pub fn parse_setup_args(args: &[String]) -> SetupOptions {
380381
let mut project = None;
381382
let mut dry_run = false;
383+
let mut login = false;
382384
let mut i = 0;
383385
while i < args.len() {
384386
if args[i] == "--project" && i + 1 < args.len() {
@@ -390,11 +392,18 @@ pub fn parse_setup_args(args: &[String]) -> SetupOptions {
390392
} else if args[i] == "--dry-run" {
391393
dry_run = true;
392394
i += 1;
395+
} else if args[i] == "--login" {
396+
login = true;
397+
i += 1;
393398
} else {
394399
i += 1;
395400
}
396401
}
397-
SetupOptions { project, dry_run }
402+
SetupOptions {
403+
project,
404+
dry_run,
405+
login,
406+
}
398407
}
399408

400409
// ── gcloud helpers ──────────────────────────────────────────────
@@ -637,7 +646,6 @@ fn is_tos_precondition_error(gcloud_output: &str) -> bool {
637646
fn is_invalid_project_id_error(gcloud_output: &str) -> bool {
638647
let lower = gcloud_output.to_ascii_lowercase();
639648
lower.contains("argument project_id: bad value")
640-
|| lower.contains("project ids are immutable")
641649
|| lower.contains("project ids must be between 6 and 30 characters")
642650
}
643651

@@ -646,6 +654,7 @@ fn is_project_id_in_use_error(gcloud_output: &str) -> bool {
646654
lower.contains("already in use")
647655
|| lower.contains("already exists")
648656
|| lower.contains("already being used")
657+
|| lower.contains("project ids are immutable")
649658
}
650659

651660
fn primary_gcloud_error_line(gcloud_output: &str) -> Option<String> {
@@ -1555,6 +1564,38 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result<SetupStage, Gws
15551564
Ok(SetupStage::Finish)
15561565
}
15571566

1567+
fn should_offer_login_prompt(
1568+
interactive: bool,
1569+
dry_run: bool,
1570+
login_requested: bool,
1571+
stdout_is_terminal: bool,
1572+
) -> bool {
1573+
interactive && !dry_run && !login_requested && stdout_is_terminal
1574+
}
1575+
1576+
fn prompt_login_after_setup() -> Result<bool, GwsError> {
1577+
use std::io::Write;
1578+
1579+
let mut input = String::new();
1580+
loop {
1581+
eprint!("Run `gws auth login` now? [Y/n]: ");
1582+
std::io::stderr()
1583+
.flush()
1584+
.map_err(|e| GwsError::Validation(format!("Failed to flush prompt: {e}")))?;
1585+
1586+
input.clear();
1587+
std::io::stdin()
1588+
.read_line(&mut input)
1589+
.map_err(|e| GwsError::Validation(format!("Failed to read prompt input: {e}")))?;
1590+
1591+
match input.trim().to_ascii_lowercase().as_str() {
1592+
"" | "y" | "yes" => return Ok(true),
1593+
"n" | "no" => return Ok(false),
1594+
_ => eprintln!("Please answer 'y' or 'n'."),
1595+
}
1596+
}
1597+
}
1598+
15581599
/// Run the full setup flow. Orchestrates all steps and outputs JSON summary.
15591600
pub async fn run_setup(args: &[String]) -> Result<(), GwsError> {
15601601
let opts = parse_setup_args(args);
@@ -1604,9 +1645,28 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> {
16041645

16051646
ctx.finish_wizard();
16061647

1648+
let run_login = if ctx.opts.login {
1649+
true
1650+
} else if should_offer_login_prompt(
1651+
ctx.interactive,
1652+
ctx.dry_run,
1653+
ctx.opts.login,
1654+
std::io::IsTerminal::is_terminal(&std::io::stdout()),
1655+
) {
1656+
prompt_login_after_setup()?
1657+
} else {
1658+
false
1659+
};
1660+
1661+
let message = if run_login {
1662+
"Setup complete! Starting `gws auth login`..."
1663+
} else {
1664+
"Setup complete! Run `gws auth login` to authenticate."
1665+
};
1666+
16071667
let output = json!({
16081668
"status": "success",
1609-
"message": "Setup complete! Run `gws auth login` to authenticate.",
1669+
"message": message,
16101670
"account": ctx.account,
16111671
"project": ctx.project_id,
16121672
"apis_enabled": ctx.enabled.len(),
@@ -1619,7 +1679,11 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> {
16191679
serde_json::to_string_pretty(&output).unwrap_or_default()
16201680
);
16211681

1622-
eprintln!("\n✅ Setup complete! Run `gws auth login` to authenticate.");
1682+
eprintln!("\n✅ {message}");
1683+
1684+
if run_login {
1685+
crate::auth_commands::run_login(&[]).await?;
1686+
}
16231687

16241688
Ok(())
16251689
}
@@ -1764,34 +1828,39 @@ mod tests {
17641828
let opts = parse_setup_args(&[]);
17651829
assert!(opts.project.is_none());
17661830
assert!(!opts.dry_run);
1831+
assert!(!opts.login);
17671832
}
17681833

17691834
#[test]
17701835
fn test_parse_setup_args_with_project() {
17711836
let args = vec!["--project".into(), "my-project".into()];
17721837
let opts = parse_setup_args(&args);
17731838
assert_eq!(opts.project.as_deref(), Some("my-project"));
1839+
assert!(!opts.login);
17741840
}
17751841

17761842
#[test]
17771843
fn test_parse_setup_args_with_project_equals() {
17781844
let args = vec!["--project=my-project".into()];
17791845
let opts = parse_setup_args(&args);
17801846
assert_eq!(opts.project.as_deref(), Some("my-project"));
1847+
assert!(!opts.login);
17811848
}
17821849

17831850
#[test]
17841851
fn test_parse_setup_args_ignores_unknown() {
17851852
let args = vec!["--verbose".into(), "--unknown".into()];
17861853
let opts = parse_setup_args(&args);
17871854
assert!(opts.project.is_none());
1855+
assert!(!opts.login);
17881856
}
17891857

17901858
#[test]
17911859
fn test_parse_setup_args_dry_run() {
17921860
let args = vec!["--dry-run".into()];
17931861
let opts = parse_setup_args(&args);
17941862
assert!(opts.dry_run);
1863+
assert!(!opts.login);
17951864
}
17961865

17971866
#[test]
@@ -1800,6 +1869,36 @@ mod tests {
18001869
let opts = parse_setup_args(&args);
18011870
assert!(opts.dry_run);
18021871
assert_eq!(opts.project.as_deref(), Some("p"));
1872+
assert!(!opts.login);
1873+
}
1874+
1875+
#[test]
1876+
fn test_parse_setup_args_login_flag() {
1877+
let args: Vec<String> = vec!["--login".into()];
1878+
let opts = parse_setup_args(&args);
1879+
assert!(opts.login);
1880+
assert!(!opts.dry_run);
1881+
assert!(opts.project.is_none());
1882+
}
1883+
1884+
#[test]
1885+
fn test_should_offer_login_prompt_default_interactive() {
1886+
assert!(should_offer_login_prompt(true, false, false, true));
1887+
}
1888+
1889+
#[test]
1890+
fn test_should_not_offer_login_prompt_when_login_requested() {
1891+
assert!(!should_offer_login_prompt(true, false, true, true));
1892+
}
1893+
1894+
#[test]
1895+
fn test_should_not_offer_login_prompt_non_interactive() {
1896+
assert!(!should_offer_login_prompt(false, false, false, true));
1897+
}
1898+
1899+
#[test]
1900+
fn test_should_not_offer_login_prompt_dry_run() {
1901+
assert!(!should_offer_login_prompt(true, true, false, true));
18031902
}
18041903

18051904
#[test]
@@ -1843,6 +1942,17 @@ mod tests {
18431942
assert!(msg.contains("different unique project ID"));
18441943
}
18451944

1945+
#[test]
1946+
fn test_format_project_create_failure_immutable_guidance() {
1947+
let msg = format_project_create_failure(
1948+
"example-project-123456",
1949+
"",
1950+
"Project IDs are immutable and can be set only during project creation.",
1951+
);
1952+
1953+
assert!(msg.contains("ID is already in use"));
1954+
}
1955+
18461956
// ── Account selection → gcloud action ───────────────────────
18471957

18481958
#[test]

0 commit comments

Comments
 (0)