From 2e9dcdf3cccff08359da2ed783ad3b113a81f65a Mon Sep 17 00:00:00 2001 From: Itamar Zand Date: Tue, 2 Jun 2026 22:44:05 +0300 Subject: [PATCH] feat(cli): structured error for non-interactive workspace selection At the workspace-selection step, a non-interactive caller (no TTY or --json, with >=2 workspaces) now gets WORKSPACE_SELECTION_REQUIRED listing the workspaces under context.workspaces, instead of a plain "requires a real terminal" string. The caller re-runs with `alien workspaces set `. The interactive arrow-key menu is unchanged. Also remove `alien login --json`: structured workspace data comes from `alien workspaces ls --json`, so a JSON mode on login is redundant. --- .../alien-cli/src/commands/platform/login.rs | 46 +++++-------------- .../src/commands/platform/workspace.rs | 10 ++-- crates/alien-cli/src/error.rs | 38 +++++++++++++++ crates/alien-cli/src/lib.rs | 2 +- crates/alien-cli/src/ui.rs | 2 +- 5 files changed, 58 insertions(+), 40 deletions(-) diff --git a/crates/alien-cli/src/commands/platform/login.rs b/crates/alien-cli/src/commands/platform/login.rs index 31beaa8bc..50beed5f6 100644 --- a/crates/alien-cli/src/commands/platform/login.rs +++ b/crates/alien-cli/src/commands/platform/login.rs @@ -2,10 +2,8 @@ use crate::auth::{force_login, save_workspace}; use crate::commands::platform::workspace::{prompt_workspace, validate_workspace_name}; use crate::error::Result; use crate::execution_context::ExecutionMode; -use crate::output::print_json; use crate::ui::{command, contextual_heading, dim_label, success_line}; use clap::Parser; -use serde::Serialize; #[derive(Parser, Debug, Clone)] #[command( @@ -13,25 +11,12 @@ use serde::Serialize; long_about = "Authenticate with the Alien platform and set the default workspace used by platform-managed commands.", after_help = "EXAMPLES: alien login - alien login --workspace my-workspace - alien login --workspace my-workspace --json" + alien login --workspace my-workspace" )] -pub struct LoginArgs { - /// Emit structured JSON output - #[arg(long)] - pub json: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LoginOutput { - workspace: String, - used_api_key: bool, -} +pub struct LoginArgs {} -pub async fn login_task(args: LoginArgs, ctx: ExecutionMode) -> Result<()> { +pub async fn login_task(_args: LoginArgs, ctx: ExecutionMode) -> Result<()> { let auth_opts = ctx.auth_opts(); - let used_api_key = auth_opts.api_key.is_some(); let http = force_login(&auth_opts).await?; let workspace = if let ExecutionMode::Platform { @@ -41,26 +26,19 @@ pub async fn login_task(args: LoginArgs, ctx: ExecutionMode) -> Result<()> { { validate_workspace_name(&http, workspace).await? } else { - prompt_workspace(&http, args.json).await? + prompt_workspace(&http, false).await? }; save_workspace(&workspace)?; - if args.json { - print_json(&LoginOutput { - workspace, - used_api_key, - })?; - } else { - println!("{}", contextual_heading("Logged in to", &workspace, &[])); - println!("{}", success_line("Workspace ready.")); - println!( - "{} run {} in a project directory or {}.", - dim_label("Next"), - command("alien link"), - command("alien release --project ") - ); - } + println!("{}", contextual_heading("Logged in to", &workspace, &[])); + println!("{}", success_line("Workspace ready.")); + println!( + "{} run {} in a project directory or {}.", + dim_label("Next"), + command("alien link"), + command("alien release --project ") + ); Ok(()) } diff --git a/crates/alien-cli/src/commands/platform/workspace.rs b/crates/alien-cli/src/commands/platform/workspace.rs index 0c802866f..d4ac0f25b 100644 --- a/crates/alien-cli/src/commands/platform/workspace.rs +++ b/crates/alien-cli/src/commands/platform/workspace.rs @@ -2,7 +2,7 @@ use crate::auth::{load_workspace, save_workspace}; use crate::error::{ErrorData, Result}; use crate::execution_context::ExecutionMode; use crate::interaction::InteractionMode; -use crate::output::{can_prompt, print_json, prompt_select}; +use crate::output::{print_json, prompt_select}; use crate::ui::{command, dim_label, make_table, print_table, success_line}; use alien_error::{AlienError, Context}; use alien_platform_api::SdkResultExt; @@ -167,9 +167,11 @@ pub async fn prompt_workspace(http: &crate::auth::AuthHttp, json_mode: bool) -> return Ok(workspaces[0].clone()); } - InteractionMode::new(json_mode, can_prompt()).require_prompt( - "Workspace selection requires a real terminal. Pass `--workspace ` or run `alien workspaces set ` first.", - )?; + if InteractionMode::current(json_mode).is_machine() { + return Err(AlienError::new(ErrorData::WorkspaceSelectionRequired { + workspaces, + })); + } prompt_select("Select a workspace:", &workspaces) } diff --git a/crates/alien-cli/src/error.rs b/crates/alien-cli/src/error.rs index 377647e14..1fd1be51e 100644 --- a/crates/alien-cli/src/error.rs +++ b/crates/alien-cli/src/error.rs @@ -177,6 +177,19 @@ pub enum ErrorData { field: String, }, + /// Several workspaces exist but one can't be chosen without a terminal. + #[error( + code = "WORKSPACE_SELECTION_REQUIRED", + message = "Several workspaces are available; selecting one needs an interactive terminal", + hint = "Run `alien workspaces ls` to see them, then `alien workspaces set ` (or pass `--workspace `).", + retryable = "false", + internal = "false" + )] + WorkspaceSelectionRequired { + /// Names the caller can pass to `--workspace`, exposed under `context.workspaces`. + workspaces: Vec, + }, + /// Invalid project name specified. #[error( code = "INVALID_PROJECT_NAME", @@ -294,3 +307,28 @@ pub enum ErrorData { } pub type Result = alien_error::Result; + +#[cfg(test)] +mod tests { + use super::*; + use alien_error::AlienErrorData; + + #[test] + fn workspace_selection_required_exposes_names_in_context() { + let err = ErrorData::WorkspaceSelectionRequired { + workspaces: vec!["alpha".to_string(), "beta".to_string()], + }; + + assert_eq!(err.code(), "WORKSPACE_SELECTION_REQUIRED"); + + let context = err.context().expect("variant exposes a context payload"); + let names: Vec<&str> = context + .get("workspaces") + .and_then(|value| value.as_array()) + .expect("context.workspaces is an array") + .iter() + .filter_map(|value| value.as_str()) + .collect(); + assert_eq!(names, vec!["alpha", "beta"]); + } +} diff --git a/crates/alien-cli/src/lib.rs b/crates/alien-cli/src/lib.rs index 63e2525db..6efa97499 100644 --- a/crates/alien-cli/src/lib.rs +++ b/crates/alien-cli/src/lib.rs @@ -94,7 +94,7 @@ impl Cli { #[cfg(feature = "platform")] Some(Commands::Platform(PlatformCommand::Link(args))) => args.json, #[cfg(feature = "platform")] - Some(Commands::Platform(PlatformCommand::Login(args))) => args.json, + Some(Commands::Platform(PlatformCommand::Login(_))) => false, #[cfg(feature = "platform")] Some(Commands::Platform(PlatformCommand::Workspaces(args))) => args.json, #[cfg(feature = "platform")] diff --git a/crates/alien-cli/src/ui.rs b/crates/alien-cli/src/ui.rs index a830bb624..d2c9be609 100644 --- a/crates/alien-cli/src/ui.rs +++ b/crates/alien-cli/src/ui.rs @@ -1531,7 +1531,7 @@ fn build_resource_noun( } #[cfg(test)] -mod tests { +mod command_event_tests { use super::*; fn empty_command_state() -> CommandEventState {