Skip to content

v0.3.70 release#327

Open
bradhe wants to merge 5 commits into
mainfrom
develop
Open

v0.3.70 release#327
bradhe wants to merge 5 commits into
mainfrom
develop

Conversation

@bradhe

@bradhe bradhe commented Jul 14, 2026

Copy link
Copy Markdown
Contributor
  • Use ARM runners for building ARM binaries
  • Add a Rust cache to all jobs to make them go aster
  • Add a beta label to CLI output
  • Send X-Tower-Idempotency-Key on deployment

Summary by CodeRabbit

  • New Features
    • Added deploy idempotency controls: --idempotency-key, --no-idempotency-key, and auto-use of the current git commit SHA when the tree is clean.
    • Repeated deploys with the same idempotency key now reuse an existing version and show a “version was reused” hint.
    • Introduced one-time beta notices for Storage catalogs, including updated help labeling.
  • Documentation
    • Updated tower deploy CLI reference with the new idempotency options.
  • Bug Fixes
    • Improved consistency of command output behavior across human and JSON modes, including reliable one-time beta notice claiming.

jo-sm and others added 4 commits July 9, 2026 18:48
* feat: send X-Tower-Idempotency-Key on deploy

Wire up the deploy command to send an idempotency key so consecutive
deploys of unchanged source (e.g. to staging then production) collapse to
a single AppVersion server-side instead of creating a new version each time.

- Auto-populate the key from the git HEAD SHA when the working tree is
  clean; omit it on a dirty tree so provenance is never misrepresented.
- Add --idempotency-key to override detection (useful for CI building
  outside a checkout) and --no-idempotency-key to opt out on a clean tree.
- Print a hint when the server reuses an existing version so the user
  understands why no new version was created.
- Apply the same git auto-detection to the MCP deploy tool.

* test: add deploy idempotency key integration coverage

BDD regression tests for the X-Tower-Idempotency-Key deploy behavior:
explicit --idempotency-key, --no-idempotency-key opt-out, git auto-detect
on a clean tree, header omission on a dirty tree, and the reuse hint on a
repeat deploy with the same key.

The mock API server now records the idempotency key seen on each deploy
and reuses a stored (backdated) version when the same key recurs, exposing
both via test-only inspection endpoints.

* style: black-format deploy idempotency test files

* chore: Cleanup to how the reuse hint is presented

* chore: Don't really need to check output mode any longer

* fix(test): match reuse-hint assertion to the new CLI wording

Commit 560872b changed the deploy reuse hint from 'Reusing version ...'
to 'No changes since commit ...' and updated the Rust unit test, but the
behave step still asserted the old copy, failing the integration suite.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR replaces global CLI output state with Out, adds persistent Storage beta notices, introduces deploy idempotency and reuse detection, updates MCP wiring, and adds Rust caching plus native ARM binary build jobs.

Changes

CLI output and command wiring

Layer / File(s) Summary
Instance-based output abstraction
crates/tower-cmd/src/output.rs, crates/tower-cmd/src/lib.rs, crates/tower-cmd/src/{apps,environments,package,run,schedules,secrets,session,teams,version}.rs
Output formats, writers, spinners, errors, and MCP capture are owned by Out and passed through command handlers.
MCP output integration
crates/tower-cmd/src/mcp.rs
MCP transports explicitly select notification behavior and execute tools with per-operation output handles.

Storage beta notices

Layer / File(s) Summary
Persistent notice claims
crates/config/...
Notice IDs are validated and claimed atomically through persistent files, with concurrency and filesystem tests.
Storage catalog integration
crates/tower-cmd/src/beta.rs, crates/tower-cmd/src/catalogs.rs
Storage help is labeled beta and catalog operations trigger a gated one-time notice.

Deploy idempotency

Layer / File(s) Summary
Key resolution and deployment transport
crates/tower-cmd/src/deploy.rs, crates/tower-cmd/src/util/{git,deploy}.rs, crates/tower-cmd/src/mcp.rs
Deploy flags resolve explicit, disabled, or clean-git-derived keys and send them as X-Tower-Idempotency-Key.
Reuse detection and validation
crates/tower-cmd/src/deploy.rs, tests/mock-api-server/*, tests/integration/features/*, plugin/skills/tower/SKILL.md
Matching older responses produce reuse hints; mock API behavior, feature scenarios, helpers, and command documentation cover the new flow.

Binary build workflow

Layer / File(s) Summary
Rust caching and native ARM builds
.github/workflows/build-binaries.yml
Rust setup and caching are added across platforms, Linux uses manylinux 2_28, and native ARM jobs replace cross-emulated ARM builds.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant DeployPipeline
  participant API
  CLI->>DeployPipeline: resolve idempotency key
  DeployPipeline->>API: upload package with optional key
  API-->>DeployPipeline: return new or cached version
  DeployPipeline-->>CLI: display result or reuse hint
Loading

Possibly related PRs

Suggested reviewers: socksy, konstantinoscs, codingcyclist, giray123

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is related to the changeset, but it is too generic to describe the main change clearly. Rename it to a concise, specific summary of the primary change, such as ARM runners, beta CLI labels, and deploy idempotency support.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
.github/workflows/build-binaries.yml (1)

373-383: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a native container step instead of nested docker-run-action.

GitHub-hosted ubuntu-24.04-arm runners already provide Docker; running addnab/docker-run-action to apk add rust and test the wheel duplicates functionality the runner already offers (flagged by zizmor as superfluous-actions). A container: step (or a plain run: with docker run) would be simpler and avoid an extra third-party action dependency. This mirrors the pre-existing musllinux job's pattern, so it's a pre-existing style choice being replicated rather than a new regression.

♻️ Illustrative alternative using a job-level container
  musllinux-arm-test:
    needs: musllinux-arm
    runs-on: ubuntu-24.04-arm
    container:
      image: python:${{ matrix.python-version }}-alpine
    steps:
      - run: |
          apk add rust
          python -m venv .venv
          .venv/bin/pip3 install dist/*.whl --force-reinstall
          .venv/bin/${{ env.EXECUTABLE_NAME }} --help
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build-binaries.yml around lines 373 - 383, Replace the
addnab/docker-run-action step named “Test wheel” for the aarch64 musllinux
target with a native container-based or plain docker run approach, matching the
existing musllinux job pattern. Preserve the Alpine image, workspace access,
Rust installation, virtualenv setup, wheel installation, and executable help
check while removing the third-party action dependency.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/tower-cmd/src/beta.rs`:
- Line 26: Update the user-facing STORAGE_BETA_MESSAGE constant to correct the
misspelled “featues” to “features,” preserving the rest of the beta notice
unchanged.

In `@crates/tower-cmd/src/deploy.rs`:
- Around line 253-258: Update the reuse hint message in the deployment flow to
avoid assuming sent_key is a git commit; use neutral wording that identifies it
as the key from the last deploy while preserving the key value and deployment
date.

In `@crates/tower-cmd/src/output.rs`:
- Around line 118-128: Update the non-JSON branch of muted so the dimmed message
passed to write includes a terminating newline, while preserving the existing
JSON behavior and styling.

---

Nitpick comments:
In @.github/workflows/build-binaries.yml:
- Around line 373-383: Replace the addnab/docker-run-action step named “Test
wheel” for the aarch64 musllinux target with a native container-based or plain
docker run approach, matching the existing musllinux job pattern. Preserve the
Alpine image, workspace access, Rust installation, virtualenv setup, wheel
installation, and executable help check while removing the third-party action
dependency.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9fa4df15-12cb-45a3-ab2b-2826f04efd03

📥 Commits

Reviewing files that changed from the base of the PR and between 8dd7b04 and e64b782.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • .github/workflows/build-binaries.yml
  • crates/config/Cargo.toml
  • crates/config/src/lib.rs
  • crates/config/src/session.rs
  • crates/tower-cmd/src/beta.rs
  • crates/tower-cmd/src/catalogs.rs
  • crates/tower-cmd/src/deploy.rs
  • crates/tower-cmd/src/lib.rs
  • crates/tower-cmd/src/mcp.rs
  • crates/tower-cmd/src/output.rs
  • crates/tower-cmd/src/util/deploy.rs
  • crates/tower-cmd/src/util/git.rs
  • crates/tower-cmd/src/util/mod.rs
  • plugin/skills/tower/SKILL.md
  • tests/integration/features/cli_deploy_idempotency.feature
  • tests/integration/features/steps/cli_steps.py
  • tests/mock-api-server/main.py

}
}

pub(crate) const STORAGE_BETA_MESSAGE: &str = "Tower Storage is in beta. Core functionality is stable, but some featues and interfaces might change before general availability.";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the typo in the beta notice.

The user-facing message says “featues”; change it to “features” before release.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/tower-cmd/src/beta.rs` at line 26, Update the user-facing
STORAGE_BETA_MESSAGE constant to correct the misspelled “featues” to “features,”
preserving the rest of the beta notice unchanged.

Comment on lines +253 to +258
Some(format!(
"No changes since commit {} (deployed on {})",
sent_key,
util::dates::format(created_at),
))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reuse hint wording assumes the key is a git commit.

"No changes since commit {sent_key} ..." is accurate when the key was auto-detected from git, but misleading when the user supplied an arbitrary value via --idempotency-key. Consider more neutral wording, e.g. "No changes since the last deploy with key {sent_key}", or branch the message based on whether the key came from git.

💬 Suggested tweak
     Some(format!(
-        "No changes since commit {} (deployed on {})",
+        "No changes since the last deploy with key `{}` (deployed on {})",
         sent_key,
         util::dates::format(created_at),
     ))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Some(format!(
"No changes since commit {} (deployed on {})",
sent_key,
util::dates::format(created_at),
))
}
Some(format!(
"No changes since the last deploy with key `{}` (deployed on {})",
sent_key,
util::dates::format(created_at),
))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/tower-cmd/src/deploy.rs` around lines 253 - 258, Update the reuse hint
message in the deployment flow to avoid assuming sent_key is a git commit; use
neutral wording that identifies it as the key from the last deploy while
preserving the key value and deployment date.

Comment thread crates/tower-cmd/src/output.rs Outdated
* refactor(cli): replace the output globals with an explicit `Out`

Where CLI output went was controlled by two mutable globals: an OUTPUT_MODE and a CURRENT_SENDER that the MCP server set and cleared around each tool call. Every output:: function read them to choose between stdout, JSON, and the MCP peer, so a command never said where its own output went.

Pass the destination in as a value instead: commands take an Out and the writing functions become methods on it. The CLI builds one over stdout; an MCP tool builds one whose writer forwards each line to that call's channel. The sender and the notification flag live on the value, not in globals.

* chore: rustfmt util/git.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
crates/tower-cmd/src/apps.rs (1)

407-412: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate error message across fatal/non-fatal branches.

out.error(&format!("Failed to stream run logs: {}", err)) is emitted identically in both the fatal (return) and non-fatal (continue+backoff) branches. Hoisting it above the if avoids the duplication.

♻️ Proposed refactor
             Err(err) => {
+                out.error(&format!("Failed to stream run logs: {}", err));
                 if is_fatal_stream_error(&err) {
-                    out.error(&format!("Failed to stream run logs: {}", err));
                     return;
                 }
-                out.error(&format!("Failed to stream run logs: {}", err));
                 sleep(backoff).await;
                 backoff = next_backoff(backoff);
                 continue;
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/tower-cmd/src/apps.rs` around lines 407 - 412, In the error handling
branch for the stream log operation, hoist the shared `out.error` call above the
`is_fatal_stream_error(&err)` check so the message is emitted once. Preserve the
fatal branch’s immediate return and the non-fatal branch’s existing
continue/backoff behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/tower-cmd/src/apps.rs`:
- Around line 120-148: Update do_logs so the non-follow describe_run_logs call
does not discard Err results; route failures through the module’s established
output error-handling path, while preserving the existing log-line iteration for
successful responses and the follow_logs branch.

In `@crates/tower-cmd/src/mcp.rs`:
- Around line 814-817: Update the deployment flow around deploy_from_dir and
do_deploy_package to preserve the returned deployment outcome instead of sending
output to Out::sink(). Propagate the reuse result through the MCP tool response
so reused deployments include the idempotency reuse hint rather than always
returning the generic success message.

In `@crates/tower-cmd/src/output.rs`:
- Around line 191-353: Update package_error, config_error, and
tower_error_and_die so --json requests emit exactly one structured error through
the Format/Out JSON path before exiting, rather than human-readable text or
multiple documents. Route usage errors occurring before Out initialization to
stderr, while preserving existing human-readable behavior for non-JSON output
and authentication-specific messaging.

---

Nitpick comments:
In `@crates/tower-cmd/src/apps.rs`:
- Around line 407-412: In the error handling branch for the stream log
operation, hoist the shared `out.error` call above the
`is_fatal_stream_error(&err)` check so the message is emitted once. Preserve the
fatal branch’s immediate return and the non-fatal branch’s existing
continue/backoff behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0ddf2a17-c402-4b7f-b64b-456bfc8d6f8c

📥 Commits

Reviewing files that changed from the base of the PR and between e64b782 and 1b5dfd6.

📒 Files selected for processing (19)
  • crates/tower-cmd/src/apps.rs
  • crates/tower-cmd/src/beta.rs
  • crates/tower-cmd/src/catalogs.rs
  • crates/tower-cmd/src/deploy.rs
  • crates/tower-cmd/src/environments.rs
  • crates/tower-cmd/src/lib.rs
  • crates/tower-cmd/src/mcp.rs
  • crates/tower-cmd/src/output.rs
  • crates/tower-cmd/src/package.rs
  • crates/tower-cmd/src/run.rs
  • crates/tower-cmd/src/schedules.rs
  • crates/tower-cmd/src/secrets.rs
  • crates/tower-cmd/src/session.rs
  • crates/tower-cmd/src/teams.rs
  • crates/tower-cmd/src/util/apps.rs
  • crates/tower-cmd/src/util/cmd.rs
  • crates/tower-cmd/src/util/deploy.rs
  • crates/tower-cmd/src/util/git.rs
  • crates/tower-cmd/src/version.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/tower-cmd/src/util/deploy.rs
  • crates/tower-cmd/src/beta.rs
  • crates/tower-cmd/src/util/git.rs
  • crates/tower-cmd/src/deploy.rs

Comment on lines +120 to 148
pub async fn do_logs(out: &output::Out, config: Config, cmd: &ArgMatches) {
let app_name_raw = cmd
.get_one::<String>("app_name")
.expect("app_name is required");
let (name, seq) = if let Some((name, num_str)) = app_name_raw.split_once('#') {
let num = num_str
.parse::<i64>()
.unwrap_or_else(|_| output::die("Run number must be a number"));
.unwrap_or_else(|_| out.die("Run number must be a number"));
(name.to_string(), num)
} else {
let num = match cmd.get_one::<i64>("run_number").copied() {
Some(n) => n,
None => latest_run_number(&config, app_name_raw).await,
None => latest_run_number(out, &config, app_name_raw).await,
};
(app_name_raw.clone(), num)
};
let follow = cmd.get_one::<bool>("follow").copied().unwrap_or(false);

if follow {
follow_logs(config, name, seq).await;
follow_logs(out, config, name, seq).await;
return;
}

if let Ok(resp) = api::describe_run_logs(&config, &name, seq).await {
for line in resp.log_lines {
output::remote_log_event(&line);
out.remote_log_event(&line);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file structure and find relevant functions
ast-grep outline crates/tower-cmd/src/apps.rs --view expanded || true

echo '--- grep for key functions / patterns ---'
rg -n "do_logs|follow_logs|latest_run_number|tower_error_and_die|if let Ok\\(resp\\)" crates/tower-cmd/src/apps.rs

echo '--- relevant sections around do_logs ---'
sed -n '110,170p' crates/tower-cmd/src/apps.rs

echo '--- relevant sections around latest_run_number ---'
sed -n '250,340p' crates/tower-cmd/src/apps.rs

echo '--- relevant sections around follow_logs ---'
sed -n '340,430p' crates/tower-cmd/src/apps.rs

Repository: tower/tower-cli

Length of output: 12864


Surface describe_run_logs errors in do_logs.

The non---follow branch drops Err from api::describe_run_logs, so a fetch failure exits cleanly with no output. Use the same error-handling path as the rest of this module.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/tower-cmd/src/apps.rs` around lines 120 - 148, Update do_logs so the
non-follow describe_run_logs call does not discard Err results; route failures
through the module’s established output error-handling path, while preserving
the existing log-line iteration for successful responses and the follow_logs
branch.

Comment on lines +814 to +817
// The tool builds its own result message, so the deploy's own progress is discarded.
let out = crate::output::Out::sink();
match deploy::deploy_from_dir(
&out,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the deployment reuse result.

Line 815 discards deploy_from_dir output, including deploy::do_deploy_package’s idempotency reuse hint. MCP therefore always returns “Deploy completed successfully” even when the server reused an existing version. Capture or return the deployment outcome and include it in the tool result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/tower-cmd/src/mcp.rs` around lines 814 - 817, Update the deployment
flow around deploy_from_dir and do_deploy_package to preserve the returned
deployment outcome instead of sending output to Out::sink(). Propagate the reuse
result through the MCP tool response so reused deployments include the
idempotency reuse hint rather than always returning the generic success message.

Comment on lines +191 to 353
pub fn package_error(&self, err: tower_package::Error) {
let msg = match err {
tower_package::Error::NoManifest => "No manifest was found".to_string(),
tower_package::Error::InvalidManifest => {
"Invalid manifest was found or created".to_string()
}
tower_package::Error::InvalidPath => {
"There was a problem determining exactly where your Towerfile was stored on disk"
.to_string()
}
tower_package::Error::InvalidGlob { message } => {
format!("Invalid file glob pattern: {}", message)
}
tower_package::Error::InvalidTowerfile { message } => {
format!("Invalid Towerfile: {}", message)
}
tower_package::Error::MissingTowerfile => {
"No Towerfile was found in the target directory".to_string()
}
tower_package::Error::MissingRequiredAppField { field } => {
format!("Missing required app field `{}` in Towerfile", field)
}
tower_package::Error::Io { source } => format!("IO error: {}", source),
tower_package::Error::MissingScript { script } => {
format!("Script '{}' not found. Check that the 'script' field in your Towerfile points to a file that exists in your project.", script)
}
};

pub fn title(text: &str) -> String {
text.bold().green().to_string()
}
let line = format!("{} {}\n", "Package error:".red(), msg);
self.write(&line);
}

pub fn placeholder(text: &str) -> String {
text.white().dimmed().italic().to_string()
}
pub fn config_error(&self, err: config::Error) {
let msg = match err {
config::Error::ConfigDirNotFound => "Config directory not found".to_string(),
config::Error::NoHomeDir => "No home directory found".to_string(),
config::Error::Io { ref source } => format!("IO error: {}", source),
config::Error::NoSession => "No session".to_string(),
config::Error::TeamNotFound { ref team_name } => {
format!("Team with name `{}` not found!", team_name)
}
config::Error::UnknownDescribeSessionValue { value: _ } => {
"An error occured while describing the session associated with the JWT you provided. Maybe your CLI is out of date?".to_string()
}
config::Error::DescribeSessionError { ref err } => {
format!("An error occured while describing the session associated with the JWT you provided: {}", err)
}
};

pub fn paragraph(msg: &str) -> String {
msg.chars()
.collect::<Vec<char>>()
.chunks(78)
.map(|c| c.iter().collect::<String>())
.map(|li| format!(" {}", li))
.collect::<Vec<String>>()
.join("\n")
}
let line = format!("{} {}\n", "Config error:".red(), msg);
self.write(&line);
}

pub fn config_error(err: config::Error) {
let msg = match err {
config::Error::ConfigDirNotFound => "Config directory not found".to_string(),
config::Error::NoHomeDir => "No home directory found".to_string(),
config::Error::Io { ref source } => format!("IO error: {}", source),
config::Error::NoSession => "No session".to_string(),
config::Error::TeamNotFound { ref team_name } => {
format!("Team with name `{}` not found!", team_name)
}
config::Error::UnknownDescribeSessionValue { value: _ } => {
"An error occured while describing the session associated with the JWT you provided. Maybe your CLI is out of date?".to_string()
}
config::Error::DescribeSessionError { ref err } => {
format!("An error occured while describing the session associated with the JWT you provided: {}", err)
// Outputs both the model.detail and the model.errors fields in a human readable format.
fn output_full_error_details(&self, model: &ErrorModel) {
// Show the main detail message if available
if let Some(detail) = &model.detail {
self.write(&format!("\n{}\n", "Error details:".yellow()));
self.write(&format!("{}\n", detail.red()));
}

// Show any additional error details from the errors field
if let Some(errors) = &model.errors {
if !errors.is_empty() {
if model.detail.is_none() {
self.write(&format!("\n{}\n", "Error details:".yellow()));
}
for error in errors {
let msg = format!(
" • {}",
error.message.as_deref().unwrap_or("Unknown error")
);
self.write(&format!("{}\n", msg.red()));
}
}
}
};
}

let line = format!("{} {}\n", "Config error:".red(), msg);
write(&line);
}
fn output_response_content_error<T>(&self, err: ResponseContent<T>) {
// Attempt to deserialize the error content into an ErrorModel.
let error_model = match serde_json::from_str::<ErrorModel>(&err.content) {
Ok(model) => {
debug!("Error model (status: {}): {:?}", err.status, model);
model
}
Err(e) => {
debug!("Failed to parse error content as JSON: {}", e);
debug!("Raw error content: {}", err.content);
// Show the raw error content if JSON parsing fails
self.write(&format!("\n{}\n", "API Error:".yellow()));
self.write(&format!("{}\n", err.content.red()));
return;
}
};

pub fn write(msg: &str) {
if get_output_mode().is_mcp() {
let clean_msg = msg.trim_end().to_string();
send_to_current_sender(clean_msg);
} else {
write_to_stdout(msg);
match err.status {
StatusCode::CONFLICT => {
self.output_full_error_details(&error_model);
}
StatusCode::UNPROCESSABLE_ENTITY => {
self.output_full_error_details(&error_model);
}
StatusCode::INTERNAL_SERVER_ERROR => {
self.error(
"The Tower API encountered an internal error. Maybe try again later on.",
);
}
StatusCode::NOT_FOUND => {
self.output_full_error_details(&error_model);
}
StatusCode::UNAUTHORIZED => {
self.error(
"You aren't authorized to do that! Are you logged in? Run `tower login` to login.",
);
}
_ => {
if error_model.detail.is_none() && error_model.errors.is_none() {
self.error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit.");
}
self.output_full_error_details(&error_model);
}
}
}
}

pub fn error(msg: &str) {
if get_output_mode().is_json() {
let response = serde_json::json!({
"result": "error",
"message": msg
});
json(&response);
} else {
let line = format!("{} {}\n", "Oh no!".red(), msg);
write(&line);
fn tower_error<T>(&self, err: ApiError<T>) {
match err {
ApiError::ResponseError(resp) => {
self.output_response_content_error(resp);
}
ApiError::Reqwest(e) => {
debug!("Reqwest error: {:?}", e);
self.error("The Tower CLI wasn't able to talk to the Tower API! Are you offline? Try again later.");
}
ApiError::Serde(e) => {
debug!("Serde error: {:?}", e);
self.error("The Tower API returned something that the Tower CLI didn't understand. Maybe you need to upgrade Tower CLI?");
}
ApiError::Io(e) => {
debug!("Io error: {:?}", e);
self.error("An error happened while talking to the Tower API. You can try that again in a bit.");
}
}
}
}

pub fn runtime_error(err: tower_runtime::errors::Error) {
let line = format!("{} {}\n", "Runtime Error:".red(), err.to_string());
write(&line);
}
/// 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>(&self, err: ApiError<T>, operation: &str) -> ! {
// Check if this is an authentication error
if let ApiError::ResponseError(ref resp) = err {
if resp.status == StatusCode::UNAUTHORIZED {
self.die(&format!(
"{} because you are not logged into Tower. Please run 'tower login' first.",
operation
));
}
}

// Outputs both the model.detail and the model.errors fields in a human readable format.
pub fn output_full_error_details(model: &ErrorModel) {
// Show the main detail message if available
if let Some(detail) = &model.detail {
write(&format!("\n{}\n", "Error details:".yellow()));
write(&format!("{}\n", detail.red()));
// Show the detailed error first
self.tower_error(err);
self.die(operation);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve a single parseable JSON error response.

These paths bypass Format, so API/package/config failures under --json emit human text or multiple documents. Render one structured error before exiting; send pre-Out usage errors to stderr.

Also applies to: 423-431, 603-611

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/tower-cmd/src/output.rs` around lines 191 - 353, Update package_error,
config_error, and tower_error_and_die so --json requests emit exactly one
structured error through the Format/Out JSON path before exiting, rather than
human-readable text or multiple documents. Route usage errors occurring before
Out initialization to stderr, while preserving existing human-readable behavior
for non-JSON output and authentication-specific messaging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants