Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 21 additions & 44 deletions crates/tower-cmd/src/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,36 +133,27 @@ pub async fn do_show(config: Config, cmd: &ArgMatches) {

output::table(headers, rows, Some(&app_response));
}
Err(err) => {
output::tower_error(err);
}
Err(err) => output::tower_error_and_die(err, "Fetching app details failed"),
}
}

pub async fn do_list_apps(config: Config) {
let resp = api::list_apps(&config).await;

match resp {
Ok(resp) => {
let items = resp
.apps
.iter()
.map(|app_summary| {
let app = &app_summary.app;
let desc = if app.short_description.is_empty() {
output::placeholder("No description")
} else {
app.short_description.to_string()
};
format!("{}\n{}", output::title(&app.name), desc)
})
.collect();
output::list(items, Some(&resp.apps));
}
Err(err) => {
output::tower_error(err);
}
}
let resp = output::with_spinner("Listing apps", api::list_apps(&config)).await;

let items = resp
.apps
.iter()
.map(|app_summary| {
let app = &app_summary.app;
let desc = if app.short_description.is_empty() {
output::placeholder("No description")
} else {
app.short_description.to_string()
};
format!("{}\n{}", output::title(&app.name), desc)
})
.collect();
output::list(items, Some(&resp.apps));
}

pub async fn do_create(config: Config, args: &ArgMatches) {
Expand All @@ -172,30 +163,16 @@ pub async fn do_create(config: Config, args: &ArgMatches) {

let description = args.get_one::<String>("description").unwrap();

let mut spinner = output::spinner("Creating app");
let app =
output::with_spinner("Creating app", api::create_app(&config, name, description)).await;

match api::create_app(&config, name, description).await {
Ok(app) => {
spinner.success();
output::success_with_data(&format!("App '{}' created", name), Some(app));
}
Err(err) => {
spinner.failure();
output::tower_error(err);
}
}
output::success_with_data(&format!("App '{}' created", name), Some(app));
}

pub async fn do_delete(config: Config, cmd: &ArgMatches) {
let name = extract_app_name("delete", cmd.subcommand());
let mut spinner = output::spinner("Deleting app");

if let Err(err) = api::delete_app(&config, &name).await {
spinner.failure();
output::tower_error(err);
} else {
spinner.success();
}
output::with_spinner("Deleting app", api::delete_app(&config, &name)).await;
}

/// Extract app name and run number from command
Expand Down
27 changes: 20 additions & 7 deletions crates/tower-cmd/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,21 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) {
let create_app = args.get_flag("create");
if let Err(err) = deploy_from_dir(config, dir, create_app).await {
match err {
crate::Error::ApiDeployError { source } => output::tower_error(source),
crate::Error::ApiDescribeAppError { source } => output::tower_error(source),
crate::Error::PackageError { source } => output::package_error(source),
crate::Error::TowerfileLoadFailed { source, .. } => output::config_error(source),
_ => output::error(&err.to_string()),
crate::Error::ApiDeployError { source } => {
output::tower_error_and_die(source, "Deploying app failed")
}
crate::Error::ApiDescribeAppError { source } => {
output::tower_error_and_die(source, "Fetching app details failed")
}
crate::Error::PackageError { source } => {
output::package_error(source);
std::process::exit(1);
}
crate::Error::TowerfileLoadFailed { source, .. } => {
output::config_error(source);
std::process::exit(1);
}
_ => output::die(&err.to_string()),
}
}
}
Expand All @@ -62,13 +72,16 @@ pub async fn deploy_from_dir(
let api_config = config.into();

// Add app existence check before proceeding
util::apps::ensure_app_exists(
if let Err(err) = util::apps::ensure_app_exists(
&api_config,
&towerfile.app.name,
&towerfile.app.description,
create_app,
)
.await?;
.await
{
return Err(crate::Error::ApiDescribeAppError { source: err });
}

let spec = PackageSpec::from_towerfile(&towerfile);
let mut spinner = output::spinner("Building package...");
Expand Down
45 changes: 18 additions & 27 deletions crates/tower-cmd/src/environments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,42 +24,33 @@ pub fn environments_cmd() -> Command {
}

pub async fn do_list(config: Config) {
let resp = api::list_environments(&config).await;
let resp = output::with_spinner("Listing environments", api::list_environments(&config)).await;

match resp {
Ok(resp) => {
let headers = vec!["Name"]
.into_iter()
.map(|h| h.yellow().to_string())
.collect();
let headers = vec!["Name"]
.into_iter()
.map(|h| h.yellow().to_string())
.collect();

let envs_data: Vec<Vec<String>> = resp
.environments
.iter()
.map(|env| vec![env.name.clone()])
.collect();
let envs_data: Vec<Vec<String>> = resp
.environments
.iter()
.map(|env| vec![env.name.clone()])
.collect();

// Display the table using the existing table function
output::table(headers, envs_data, Some(&resp.environments));
}
Err(err) => {
output::tower_error(err);
}
}
// Display the table using the existing table function
output::table(headers, envs_data, Some(&resp.environments));
}

pub async fn do_create(config: Config, args: &ArgMatches) {
let name = args.get_one::<String>("name").unwrap_or_else(|| {
output::die("Environment name (--name) is required");
});

let mut spinner = output::spinner("Creating environment");
output::with_spinner(
"Creating environment",
api::create_environment(&config, name),
)
.await;

if let Err(err) = api::create_environment(&config, name).await {
spinner.failure();
output::tower_error(err);
} else {
spinner.success();
output::success(&format!("Environment '{}' created", name));
}
output::success(&format!("Environment '{}' created", name));
}
19 changes: 8 additions & 11 deletions crates/tower-cmd/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,18 +687,15 @@ impl TowerService {
}
}
Err(e) => {
let error_text = if output.trim().is_empty() {
let api_error = Self::extract_api_error_message(&e);
if Self::is_deployment_error(&api_error) {
format!(
"App '{}' not deployed. Try running tower_deploy first.",
app_name
)
} else {
api_error
}
// Always extract the detailed API error message
let api_error = Self::extract_api_error_message(&e);
let error_text = if Self::is_deployment_error(&api_error) {
format!(
"App '{}' not deployed. Try running tower_deploy first.",
app_name
)
} else {
output
api_error
};
Self::error_result("Remote run failed", error_text)
}
Expand Down
87 changes: 87 additions & 0 deletions crates/tower-cmd/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ pub fn write(msg: &str) {
send_to_current_sender(clean_msg);
} else {
io::stdout().write_all(msg.as_bytes()).unwrap();
io::stdout().flush().ok();
}
}

Expand Down Expand Up @@ -319,6 +320,87 @@ pub fn tower_error<T>(err: ApiError<T>) {
}
}

/// Handles Tower API errors with context-specific authentication messages.
/// If the error is a 401 Unauthorized, provides a helpful message mentioning
/// the operation that failed and suggests running 'tower login'.
/// Always exits the process with error code 1.
pub fn tower_error_and_die<T>(err: ApiError<T>, operation: &str) -> ! {
// Check if this is an authentication error
if let ApiError::ResponseError(ref resp) = err {
if resp.status == StatusCode::UNAUTHORIZED {
die(&format!(
"{} because you are not logged into Tower. Please run 'tower login' first.",
operation
));
}
}

// Show the detailed error first
tower_error(err);
die(operation);
}

/// Runs an async operation with a spinner and proper error handling.
///
/// This helper provides consistent spinner behavior across all commands:
/// - Shows a spinner with "{operation}..." while the operation runs
/// - On success: stops the spinner with success indicator and returns the result
/// - On error: stops the spinner with failure indicator and shows auth-aware error message
///
/// # Examples
///
/// ```ignore
/// let envs = output::with_spinner(
/// "Listing environments",
/// api::list_environments(&config)
/// ).await;
/// ```
pub async fn with_spinner<F, T, E>(operation: &str, future: F) -> T
where
F: std::future::Future<Output = Result<T, ApiError<E>>>,
{
let spinner_msg = format!("{}...", operation);
let mut spinner = self::spinner(&spinner_msg);
match future.await {
Ok(result) => {
spinner.success();
result
}
Err(err) => {
spinner.failure();
let error_msg = format!("{} failed", operation);
tower_error_and_die(err, &error_msg);
}
}
}

/// Runs an async operation with a spinner, returning Result instead of exiting.
///
/// This is the MCP-safe version of with_spinner that returns errors instead of exiting.
/// Use this for operations that may be called from MCP or other contexts where
/// process exit is not acceptable. Returns the error without displaying it, allowing
/// the caller to decide how to handle and display the error.
///
/// Shows "{operation}..." during execution and stops the spinner on completion.
pub async fn try_with_spinner<F, T, E>(operation: &str, future: F) -> Result<T, ApiError<E>>
where
F: std::future::Future<Output = Result<T, ApiError<E>>>,
{
let spinner_msg = format!("{}...", operation);
let mut spinner = self::spinner(&spinner_msg);
match future.await {
Ok(result) => {
spinner.success();
Ok(result)
}
Err(err) => {
spinner.failure();
// Just return the error - let the caller decide how to handle it
Err(err)
}
}
}

pub fn table<T: Serialize>(headers: Vec<String>, data: Vec<Vec<String>>, json_data: Option<&T>) {
if get_output_mode().is_json() {
if let Some(data) = json_data {
Expand Down Expand Up @@ -442,8 +524,13 @@ pub fn newline() {
}

pub fn die(msg: &str) -> ! {
io::stdout().flush().ok();
io::stderr().flush().ok();
let line = format!("{} {}\n", "Error:".red(), msg);
write(&line);
// Flush output before exit to ensure "Error:" message is displayed
io::stdout().flush().ok();
io::stderr().flush().ok();
std::process::exit(1);
}

Expand Down
Loading