From b88b8e239ad531c6813fa5eb9377b8d174033281 Mon Sep 17 00:00:00 2001 From: Ben Lovell Date: Tue, 2 Jun 2026 14:14:23 +0200 Subject: [PATCH 1/2] chore: bump nixpkgs for darwin binary cache coverage --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 0bfec00f..d72b06ec 100644 --- a/flake.lock +++ b/flake.lock @@ -63,11 +63,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1776548001, - "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", + "lastModified": 1780243769, + "narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", + "rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", "type": "github" }, "original": { From 64123afe394f705255485f5b0d3405ab40b65219 Mon Sep 17 00:00:00 2001 From: Ben Lovell Date: Tue, 2 Jun 2026 14:14:23 +0200 Subject: [PATCH 2/2] fix: keep --json output parseable on stdout --- crates/tower-cmd/src/output.rs | 19 +++++++ crates/tower-cmd/src/run.rs | 23 ++++----- crates/tower-cmd/src/schedules.rs | 2 +- crates/tower-cmd/src/teams.rs | 5 +- crates/tower-cmd/src/version.rs | 7 ++- .../features/cli_json_output.feature | 31 ++++++++++++ tests/integration/features/steps/cli_steps.py | 29 +++++++++++ tests/mock-api-server/main.py | 50 +++++++++++++++---- 8 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 tests/integration/features/cli_json_output.feature diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index 1fa99fc4..29bacd8f 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -487,6 +487,25 @@ pub fn list(items: Vec, 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(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); } diff --git a/crates/tower-cmd/src/run.rs b/crates/tower-cmd/src/run.rs index 3a3cfa27..3a5e6d3d 100644 --- a/crates/tower-cmd/src/run.rs +++ b/crates/tower-cmd/src/run.rs @@ -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 { @@ -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(()) } diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs index 3fafd1d9..7acc75ce 100644 --- a/crates/tower-cmd/src/schedules.rs +++ b/crates/tower-cmd/src/schedules.rs @@ -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; } diff --git a/crates/tower-cmd/src/teams.rs b/crates/tower-cmd/src/teams.rs index 59ae9636..7e180a70 100644 --- a/crates/tower-cmd/src/teams.rs +++ b/crates/tower-cmd/src/teams.rs @@ -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(); } diff --git a/crates/tower-cmd/src/version.rs b/crates/tower-cmd/src/version.rs index 53252088..92682dcb 100644 --- a/crates/tower-cmd/src/version.rs +++ b/crates/tower-cmd/src/version.rs @@ -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 }), + ); } diff --git a/tests/integration/features/cli_json_output.feature b/tests/integration/features/cli_json_output.feature new file mode 100644 index 00000000..ffb7777a --- /dev/null +++ b/tests/integration/features/cli_json_output.feature @@ -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" diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index c4a38eb0..24105289 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -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""" @@ -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""" diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index ba386f7a..ce477eec 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -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"}, }, }