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
19 changes: 19 additions & 0 deletions crates/tower-cmd/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,25 @@ pub fn list<T: Serialize>(items: Vec<String>, json_data: Option<&T>) {
}
}

/// Writes a human-readable rendering of some data, or the data itself as JSON when
/// in JSON mode. Use this when a command's output is data that has both a plain text
/// and a JSON representation, mirroring `table` and `list`.
pub fn text<T: Serialize>(msg: &str, json_data: &T) {
if get_output_mode().is_json() {
json(json_data);
} else {
write(msg);
}
}

/// Writes presentation-only text that accompanies human-formatted output, like table
/// legends or hints. Suppressed in JSON mode so stdout stays machine-parseable.
pub fn note(msg: &str) {
if !get_output_mode().is_json() {
write(msg);
}
}

pub fn banner() {
write(&BANNER_TEXT);
}
Expand Down
23 changes: 11 additions & 12 deletions crates/tower-cmd/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,14 @@ pub async fn do_run(config: Config, args: &ArgMatches) {
tower_api::apis::Error::ResponseError(resp) if resp.status == reqwest::StatusCode::NOT_FOUND
);
if is_not_found {
output::error("App not found. It may not exist or hasn't been deployed yet.");
output::write("\nTo fix this:\n");
output::write(" 1. Check your app exists: tower apps list\n");
output::write(" 2. Deploy your app: tower deploy\n");
output::write(" 3. Then run it: tower run\n");
output::error(concat!(
"App not found. It may not exist or hasn't been deployed yet.\n",
"\n",
"To fix this:\n",
" 1. Check your app exists: tower apps list\n",
" 2. Deploy your app: tower deploy\n",
" 3. Then run it: tower run"
));
std::process::exit(1);
}
if let Error::ApiRunError { source } = e {
Expand Down Expand Up @@ -374,14 +377,10 @@ pub async fn do_run_remote(
do_follow_run(config, &run).await?;
} else {
let line = format!(
"Run #{} for app `{}` has been scheduled",
run.number, app_slug
"Run #{} for app `{}` has been scheduled\n See more: {}",
run.number, app_slug, run.dollar_link
);
output::success(&line);

let link_line = format!(" See more: {}", run.dollar_link);
output::write(&link_line);
output::newline();
output::success_with_data(&line, Some(&run));
}
Ok(())
}
Expand Down
2 changes: 1 addition & 1 deletion crates/tower-cmd/src/schedules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub async fn do_list(config: Config, args: &ArgMatches) {
.await;

if schedules.is_empty() {
output::write("No schedules found.\n");
output::text("No schedules found.\n", &schedules);
return;
}

Expand Down
5 changes: 4 additions & 1 deletion crates/tower-cmd/src/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ async fn do_list_via_session(config: &Config) {
output::newline();

// Add a legend for the asterisk
println!("{}", "* indicates currently active team".dimmed());
output::note(&format!(
"{}\n",
"* indicates currently active team".dimmed()
));
output::newline();
}

Expand Down
7 changes: 5 additions & 2 deletions crates/tower-cmd/src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ pub fn version_cmd() -> Command {
}

pub async fn do_version() {
let line = format!("v{}\n", tower_version::current_version());
output::write(&line);
let version = tower_version::current_version();
output::text(
&format!("v{}\n", version),
&serde_json::json!({ "version": version }),
);
}
6 changes: 3 additions & 3 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions tests/integration/features/cli_json_output.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Feature: CLI JSON Output
As a developer scripting against the Tower CLI
I want --json output to be valid JSON on stdout
So that I can pipe it into jq or parse it programmatically

Scenario: Detached run with JSON mode emits parseable output with the run link
Given I have a valid Towerfile in the current directory
When I run "tower deploy --create" via CLI
And I run "tower run --detached --json" via CLI
Then the output should be valid JSON
And the output should show "See more"

Scenario: Run of an unknown app with JSON mode emits a parseable error
Given I have a simple hello world application
When I run "tower run --json" via CLI
Then the output should be valid JSON
And the output should show "tower deploy"

Scenario: Empty schedules list with JSON mode emits an empty JSON array
When I run "tower schedules list --json" via CLI
Then the output should be valid JSON
And the JSON should be an empty array

Scenario: Teams list with JSON mode emits parseable output
When I run "tower teams list --json" via CLI with a temporary session
Then the output should be valid JSON

Scenario: Version with JSON mode emits parseable output
When I run "tower version --json" via CLI
Then the output should be valid JSON
And the output should show "version"
29 changes: 29 additions & 0 deletions tests/integration/features/steps/cli_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,28 @@ def step_run_cli_command_with_api_key_and_input(context, command, input):
return run_command_with_env(context, command, test_env, input=input)


@step('I run "{command}" via CLI with a temporary session')
def step_run_cli_command_with_temp_session(context, command):
"""Run a Tower CLI command with HOME pointing at a copy of the test session.

Use this for commands that rewrite session.json (e.g. anything that refreshes
the session), so they don't modify the tracked test-home fixture.
"""
test_env = os.environ.copy()
test_env["FORCE_COLOR"] = "1"
test_env["CLICOLOR_FORCE"] = "1"
test_env["TOWER_URL"] = context.tower_url

test_home = Path(__file__).parent.parent.parent / "test-home"
session_src = test_home / ".config" / "tower" / "session.json"
session_dst_dir = Path(context.temp_dir) / ".config" / "tower"
session_dst_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(session_src, session_dst_dir / "session.json")
test_env["HOME"] = context.temp_dir

return run_command_with_env(context, command, test_env)


@step("no session.json should exist in the temp home")
def step_no_session_json(context):
"""Verify that API key auth did not create a session.json file"""
Expand Down Expand Up @@ -346,6 +368,13 @@ def step_output_should_be_valid_json(context):
)


@step("the JSON should be an empty array")
def step_json_should_be_empty_array(context):
"""Verify JSON output is an empty array"""
data = parse_cli_json(context)
assert data == [], f"Expected an empty JSON array, got: {data}"


@step("the JSON should contain app information")
def step_json_should_contain_app_info(context):
"""Verify JSON contains app-related information"""
Expand Down
50 changes: 41 additions & 9 deletions tests/mock-api-server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,16 +559,48 @@ async def get_session():

@app.post("/v1/session/refresh")
async def refresh_session(refresh_params: Dict[str, Any] = None):
"""Mock endpoint for refreshing session."""
"""
Mock endpoint for refreshing session.

IMPORTANT: This response format must match the OpenAPI-generated models in:
- crates/tower-api/src/models/refresh_session_response.rs
- crates/tower-api/src/models/session.rs
- crates/tower-api/src/models/team.rs
"""
return {
"user": {"id": "mock_user_id", "email": "test@example.com"},
"teams": [
{"name": "default", "type": "user", "token": {"jwt": "mock_jwt_token"}}
],
"active_team": {
"name": "default",
"type": "user",
"token": {"jwt": "mock_jwt_token"},
"refreshed_at": "2023-01-01T00:00:00Z",
"session": {
"featurebase_identity": {
"company_hash": "mock_company_hash",
"user_hash": "mock_user_hash",
},
"user": {
"company": "Mock Company",
"country": "US",
"created_at": "2023-01-01T00:00:00Z",
"email": "test@example.com",
"first_name": "Test",
"is_alerts_enabled": True,
"is_confirmed": True,
"is_invitation_claimed": True,
"is_subscribed_to_changelog": False,
"last_name": "User",
"profile_photo_url": "https://example.com/photo.jpg",
"promo_code": "",
},
"teams": [
{
"name": "test-team",
"type": "personal",
"execution_region": "us-east-1",
"organization": "",
"token": {
"access_token": "mock_access_token",
"jwt": "mock_jwt_token",
},
}
],
"token": {"access_token": "mock_access_token", "jwt": "mock_jwt_token"},
},
}

Expand Down
Loading