diff --git a/rust/src/application/commands/deploy/mod.rs b/rust/src/application/commands/deploy/mod.rs index 2fdedf0..cfe1274 100644 --- a/rust/src/application/commands/deploy/mod.rs +++ b/rust/src/application/commands/deploy/mod.rs @@ -227,7 +227,13 @@ pub(super) fn command() -> RuntimeCommandSpec { tap_deploy_err( &sender, - finalize_deploy_activation(&client, &sender, &application_id, &release_id).await, + activate_release(&client, &sender, &application_id, &release_id).await, + ) + .await?; + + tap_deploy_err( + &sender, + sync_manifest_metadata(&client, &sender, &application_id, &config).await, ) .await?; @@ -254,9 +260,9 @@ pub(super) fn command() -> RuntimeCommandSpec { ) } -/// Finalize a deploy by activating the release. Do not follow activation with -/// an `updateApplication` status mutation. -async fn finalize_deploy_activation( +/// Activate the release. The App Registry lifecycle owns the parent +/// application's status transition; deploy must not directly mutate it. +async fn activate_release( client: &ApplicationClient, sender: &StreamSender, application_id: &str, @@ -272,9 +278,40 @@ async fn finalize_deploy_activation( sender .send(json!({ "type": "step", "name": "release.activate", "status": "completed" })) .await; + Ok(()) } +/// Synchronize application-level manifest fields without changing lifecycle +/// status. Actions and subscriptions belong to the release and are handled by +/// `platform app release`. +async fn sync_manifest_metadata( + client: &ApplicationClient, + sender: &StreamSender, + application_id: &str, + config: &crate::config::Config, +) -> cli_engine::Result<()> { + sender + .send(json!({ "type": "step", "name": "application.sync", "status": "started" })) + .await; + client + .update_application(application_id, manifest_metadata_input(config)) + .await + .map_err(super::client_err)?; + sender + .send(json!({ "type": "step", "name": "application.sync", "status": "completed" })) + .await; + Ok(()) +} + +fn manifest_metadata_input(config: &crate::config::Config) -> Value { + json!({ + "url": config.url, + "proxyUrl": config.proxy_url, + "authorizationScopes": config.authorization_scopes, + }) +} + #[cfg(test)] mod tests { /// `deploy --follow` must end with exactly one terminal line. `StreamSender` @@ -383,4 +420,31 @@ mod tests { "first next action should enable on a store: {event}" ); } + + #[test] + fn manifest_metadata_input_syncs_all_application_fields_without_status() { + let config = crate::config::Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://app.example.com".to_owned(), + proxy_url: "https://api.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned(), "profile".to_owned()], + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + }; + + let input = super::manifest_metadata_input(&config); + + assert_eq!(input["url"], "https://app.example.com"); + assert_eq!(input["proxyUrl"], "https://api.example.com"); + assert_eq!( + input["authorizationScopes"], + serde_json::json!(["openid", "profile"]) + ); + assert!(input.get("status").is_none()); + } } diff --git a/rust/src/main.rs b/rust/src/main.rs index 105e6e4..b29c384 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -25,7 +25,7 @@ mod truncation; mod update; mod webhook; -use std::{process::ExitCode, sync::Arc}; +use std::{io::Write as _, process::ExitCode, sync::Arc}; use cli_engine::{BuildInfo, Cli, CliConfig, Module}; @@ -98,7 +98,30 @@ async fn main() -> ExitCode { .with_modules(all_modules()), ); - cli.execute().await + execute_without_stdout_lock(&cli).await +} + +/// Execute without holding stdout's global lock for the entire command. +/// +/// Streaming commands write their progress directly through Tokio's stdout. +/// `Cli::execute` keeps a `StdoutLock` alive until the command has returned, +/// which prevents that writer from acquiring stdout and leaves the command +/// waiting for its own stream to drain. Passing `Stdout`/`Stderr` directly +/// keeps the final envelope writes synchronized without blocking streaming +/// progress events. +async fn execute_without_stdout_lock(cli: &Cli) -> ExitCode { + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + match cli + .execute_from(std::env::args_os(), &mut stdout, &mut stderr) + .await + { + Ok(code) => code, + Err(err) => { + drop(writeln!(stderr, "{err}")); + ExitCode::from(1) + } + } } #[cfg(test)]