Skip to content
Open
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
9 changes: 6 additions & 3 deletions crates/config/src/towerfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

#[serde(default)]
pub import_paths: Vec<PathBuf>,
Expand Down Expand Up @@ -58,7 +58,7 @@ impl Towerfile {
script: String::from(""),
source: vec![],
schedule: String::from("0 0 * * *"),
description: String::from(""),
description: None,
import_paths: vec![],
},
}
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions crates/tower-cmd/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 6 additions & 6 deletions crates/tower-cmd/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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...");
Expand Down
14 changes: 13 additions & 1 deletion crates/tower-cmd/src/error.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -91,6 +91,12 @@ pub enum Error {
source: tower_api::apis::Error<DeployAppError>,
},

// API create app error
#[snafu(display("API create app error: {}", source))]
ApiCreateAppError {
source: tower_api::apis::Error<CreateAppError>,
},

// API describe app error
#[snafu(display("API describe app error: {}", source))]
ApiDescribeAppError {
Expand Down Expand Up @@ -173,6 +179,12 @@ impl From<tower_api::apis::Error<DeployAppError>> for Error {
}
}

impl From<tower_api::apis::Error<CreateAppError>> for Error {
fn from(source: tower_api::apis::Error<CreateAppError>) -> Self {
Self::ApiCreateAppError { source }
}
}

impl From<tower_api::apis::Error<DescribeAppError>> for Error {
fn from(source: tower_api::apis::Error<DescribeAppError>) -> Self {
Self::ApiDescribeAppError { source }
Expand Down
2 changes: 1 addition & 1 deletion crates/tower-cmd/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 10 additions & 22 deletions crates/tower-cmd/src/util/apps.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use crate::output;
use http::StatusCode;
use promptly::prompt_default;
use tower_api::apis::{
configuration::Configuration,
Expand All @@ -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<default_api::DescribeAppError>> {
) -> 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(
Expand All @@ -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(());
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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,
})
}
}
}
9 changes: 8 additions & 1 deletion tests/integration/features/cli_app_management.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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"
32 changes: 32 additions & 0 deletions tests/integration/features/steps/cli_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"
27 changes: 26 additions & 1 deletion tests/mock-api-server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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:
Expand Down