diff --git a/crates/config/src/towerfile.rs b/crates/config/src/towerfile.rs index 648bc00f..aa385e1a 100644 --- a/crates/config/src/towerfile.rs +++ b/crates/config/src/towerfile.rs @@ -28,8 +28,8 @@ pub struct App { #[serde(default)] pub schedule: String, - #[serde(default)] - pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, #[serde(default)] pub import_paths: Vec, @@ -58,7 +58,7 @@ impl Towerfile { script: String::from(""), source: vec![], schedule: String::from("0 0 * * *"), - description: String::from(""), + description: None, import_paths: vec![], }, } @@ -147,6 +147,7 @@ mod test { assert_eq!(towerfile.app.script, "./script.py"); assert_eq!(towerfile.app.source, vec!["*.py"]); assert_eq!(towerfile.app.schedule, "0 0 * * *"); + assert_eq!(towerfile.app.description, None); } #[test] @@ -163,6 +164,7 @@ mod test { assert_eq!(towerfile.app.script, "./script.py"); assert_eq!(towerfile.app.source, vec!["*.py"]); assert_eq!(towerfile.app.schedule, ""); + assert_eq!(towerfile.app.description, None); } #[test] @@ -316,6 +318,7 @@ default = "value2" assert_eq!(towerfile.app.name, reparsed.app.name); assert_eq!(towerfile.app.script, reparsed.app.script); assert_eq!(towerfile.app.source, reparsed.app.source); + assert_eq!(towerfile.app.description, reparsed.app.description); assert_eq!(towerfile.parameters.len(), reparsed.parameters.len()); assert_eq!(towerfile.parameters[0].name, reparsed.parameters[0].name); } diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 772bfb21..0078a15f 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -73,6 +73,7 @@ pub async fn create_app( create_app_params: tower_api::models::CreateAppParams { schema: None, name: name.to_string(), + // API create expects short_description; CLI/Towerfile expose "description". short_description: Some(description.to_string()), slug: None, is_externally_accessible: None, diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index cf3edcb7..15baadf7 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -43,6 +43,9 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { crate::Error::ApiDeployError { source } => { output::tower_error_and_die(source, "Deploying app failed") } + crate::Error::ApiCreateAppError { source } => { + output::tower_error_and_die(source, "Creating app failed") + } crate::Error::ApiDescribeAppError { source } => { output::tower_error_and_die(source, "Fetching app details failed") } @@ -72,16 +75,13 @@ pub async fn deploy_from_dir( let api_config = config.into(); // Add app existence check before proceeding - if let Err(err) = util::apps::ensure_app_exists( + util::apps::ensure_app_exists( &api_config, &towerfile.app.name, - &towerfile.app.description, + towerfile.app.description.as_deref(), create_app, ) - .await - { - return Err(crate::Error::ApiDescribeAppError { source: err }); - } + .await?; let spec = PackageSpec::from_towerfile(&towerfile); let mut spinner = output::spinner("Building package..."); diff --git a/crates/tower-cmd/src/error.rs b/crates/tower-cmd/src/error.rs index ff5ab381..777deec5 100644 --- a/crates/tower-cmd/src/error.rs +++ b/crates/tower-cmd/src/error.rs @@ -1,6 +1,6 @@ use snafu::prelude::*; use tower_api::apis::default_api::{ - DeployAppError, DescribeAppError, DescribeRunError, RunAppError, + CreateAppError, DeployAppError, DescribeAppError, DescribeRunError, RunAppError, }; use tower_telemetry::debug; @@ -91,6 +91,12 @@ pub enum Error { source: tower_api::apis::Error, }, + // API create app error + #[snafu(display("API create app error: {}", source))] + ApiCreateAppError { + source: tower_api::apis::Error, + }, + // API describe app error #[snafu(display("API describe app error: {}", source))] ApiDescribeAppError { @@ -173,6 +179,12 @@ impl From> for Error { } } +impl From> for Error { + fn from(source: tower_api::apis::Error) -> Self { + Self::ApiCreateAppError { source } + } +} + impl From> for Error { fn from(source: tower_api::apis::Error) -> Self { Self::ApiDescribeAppError { source } diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index af0a5b27..95d9440d 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -734,7 +734,7 @@ impl TowerService { towerfile.app.script = script; } if let Some(description) = request.description { - towerfile.app.description = description; + towerfile.app.description = Some(description); } if let Some(source) = request.source { towerfile.app.source = source; diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index 949785d1..38012bae 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -1,5 +1,4 @@ use crate::output; -use http::StatusCode; use promptly::prompt_default; use tower_api::apis::{ configuration::Configuration, @@ -10,9 +9,9 @@ use tower_api::models::CreateAppParams as CreateAppParamsModel; pub async fn ensure_app_exists( api_config: &Configuration, app_name: &str, - description: &str, + description: Option<&str>, create_app: bool, -) -> Result<(), tower_api::apis::Error> { +) -> Result<(), crate::Error> { // Try to describe the app first (with spinner) let mut spinner = output::spinner("Checking app..."); let describe_result = default_api::describe_app( @@ -27,7 +26,7 @@ pub async fn ensure_app_exists( ) .await; - // If the app exists, return Ok + // If the app exists, return Ok (description is create-only). if describe_result.is_ok() { spinner.success(); return Ok(()); @@ -49,7 +48,7 @@ pub async fn ensure_app_exists( // If it's not a 404 error, fail the spinner and return the error if !is_not_found { spinner.failure(); - return Err(err); + return Err(crate::Error::ApiDescribeAppError { source: err }); } // App not found - stop spinner before prompting user @@ -68,7 +67,7 @@ pub async fn ensure_app_exists( // If the user doesn't want to create the app, return the original error if !create_app { - return Err(err); + return Err(crate::Error::ApiDescribeAppError { source: err }); } // Try to create the app (with a new spinner) @@ -79,7 +78,8 @@ pub async fn ensure_app_exists( create_app_params: CreateAppParamsModel { schema: None, name: app_name.to_string(), - short_description: Some(description.to_string()), + // API create expects short_description; CLI/Towerfile expose "description". + short_description: description.map(|desc| desc.to_string()), slug: None, is_externally_accessible: None, subdomain: None, @@ -96,21 +96,9 @@ pub async fn ensure_app_exists( } Err(create_err) => { spinner.failure(); - // Convert any creation error to a response error - Err(tower_api::apis::Error::ResponseError( - tower_api::apis::ResponseContent { - tower_trace_id: "".to_string(), - status: match &create_err { - tower_api::apis::Error::ResponseError(resp) => resp.status, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }, - content: match &create_err { - tower_api::apis::Error::ResponseError(resp) => resp.content.clone(), - _ => create_err.to_string(), - }, - entity: None, - }, - )) + Err(crate::Error::ApiCreateAppError { + source: create_err, + }) } } } diff --git a/tests/integration/features/cli_app_management.feature b/tests/integration/features/cli_app_management.feature index 0add0a6d..fbadbe34 100644 --- a/tests/integration/features/cli_app_management.feature +++ b/tests/integration/features/cli_app_management.feature @@ -26,4 +26,11 @@ Feature: CLI App Management When I run "tower apps create --json --name test-cli-app-123 --description 'Test app'" via CLI Then the output should be valid JSON And the JSON should contain the created app information - And the app name should be "test-cli-app-123" \ No newline at end of file + And the app name should be "test-cli-app-123" + And the app description should be "Test app" + + Scenario: CLI deploy --create creates app with description from Towerfile + Given I have a valid Towerfile in the current directory + When I run "tower deploy --create" via CLI + And I run "tower apps show --json {app_name}" via CLI using created app name + Then the app description should be "A test app" diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index a491e491..53274330 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -333,3 +333,35 @@ def step_app_name_should_be(context, expected_name): assert ( actual_name == expected_name ), f"Expected app name '{expected_name}', got '{actual_name}'" + + +@step('the app description should be "{expected_description}"') +def step_app_description_should_be(context, expected_description): + """Verify app description matches expected value""" + data = json.loads(context.cli_output) + candidates = [] + + if "app" in data: + candidates.append(data["app"]) + if "data" in data and "app" in data["data"]: + candidates.append(data["data"]["app"]) + + if not candidates: + candidates.append(data) + + actual_description = None + for candidate in candidates: + if isinstance(candidate, dict): + if "short_description" in candidate: + actual_description = candidate["short_description"] + break + if "description" in candidate: + actual_description = candidate["description"] + break + + assert ( + actual_description is not None + ), f"Could not find app description in JSON response: {data}" + assert ( + actual_description == expected_description + ), f"Expected description '{expected_description}', got '{actual_description}'" diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index e597d0b4..2fc1565a 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -132,6 +132,10 @@ async def create_app(app_data: Dict[str, Any]): if app_name in mock_apps_db: return {"app": mock_apps_db[app_name]} + description = app_data.get("description") + if description is None: + description = app_data.get("short_description", "") + new_app = { "created_at": datetime.datetime.now().isoformat(), "health_status": "healthy", @@ -148,7 +152,7 @@ async def create_app(app_data: Dict[str, Any]): "running": 0, }, "schedule": None, - "short_description": app_data.get("short_description", ""), + "short_description": description or "", "status": "active", "subdomain": "", "version": None, @@ -171,6 +175,27 @@ async def describe_app(name: str, response: Response): return {"app": app_info, "runs": []} # Simplistic, no runs yet +@app.put("/v1/apps/{name}") +async def update_app(name: str, app_data: Dict[str, Any], response: Response): + app_info = mock_apps_db.get(name) + if not app_info: + response.status_code = 404 + return { + "$schema": "https://api.tower.dev/v1/schemas/ErrorModel.json", + "title": "Not Found", + "status": 404, + "detail": f"App '{name}' not found", + } + + if "description" in app_data: + app_info["short_description"] = app_data.get("description") or "" + elif "short_description" in app_data: + app_info["short_description"] = app_data.get("short_description") or "" + + mock_apps_db[name] = app_info + return {"app": app_info} + + @app.delete("/v1/apps/{name}") async def delete_app(name: str): if name not in mock_apps_db: