diff --git a/bb-cli/src/bb/apps.rs b/bb-cli/src/bb/apps.rs index d88811abf..f8bc41a5b 100644 --- a/bb-cli/src/bb/apps.rs +++ b/bb-cli/src/bb/apps.rs @@ -75,6 +75,59 @@ pub fn command() -> Command { "Read the control-plane contract, runtime metadata, and supported operations", ), )) + .subcommand(control_plane_args( + Command::new("list") + .about("List apps the current caller can manage") + .long_about( + "List Apps Platform apps the current caller owns or is approved to publish. \ + Deleted apps remain hidden unless explicitly included.", + ) + .arg( + Arg::new("scope") + .long("scope") + .value_name("SCOPE") + .value_parser(["manageable", "owned", "publisher"]) + .help("Filter by relationship to the app (control-plane default: manageable)"), + ) + .arg( + Arg::new("include-deleted") + .long("include-deleted") + .action(clap::ArgAction::SetTrue) + .help("Include logically deleted apps"), + ), + )) + .subcommand(control_plane_args( + Command::new("get") + .about("Get one manageable app and its recorded versions") + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bb apps list` or `bb apps create`"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ), + )) + .subcommand(control_plane_args( + Command::new("versions") + .about("List active and rollback-candidate versions for an app") + .arg( + Arg::new("app-id") + .value_name("APP_ID") + .required(true) + .help("App identifier returned by `bb apps list` or `bb apps create`"), + ) + .arg( + Arg::new("environment") + .long("environment") + .value_name("ENVIRONMENT") + .help("Optional Compose environment override"), + ), + )) .subcommand(control_plane_args( Command::new("create") .about("Plan and initialize an app") @@ -238,6 +291,9 @@ fn dispatch(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { runner::ensure_org_configured(config)?; match matches.subcommand() { Some(("contract", contract_matches)) => run_contract(config, contract_matches), + Some(("list", list_matches)) => run_list(config, list_matches), + Some(("get", get_matches)) => run_get(config, get_matches), + Some(("versions", versions_matches)) => run_versions(config, versions_matches), Some(("create", create_matches)) => run_create(config, create_matches), Some(("deploy", deploy_matches)) => run_deploy(config, deploy_matches), Some(("ready", ready_matches)) => run_ready(config, ready_matches), @@ -260,6 +316,34 @@ fn run_contract(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { print_json(&contract) } +fn run_list(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let scope = matches.get_one::("scope").map(String::as_str); + let include_deleted = matches.get_flag("include-deleted"); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.list_apps(&credential, scope, include_deleted)?; + print_json(&response) +} + +fn run_get(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let environment = matches.get_one::("environment").map(String::as_str); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.get_app(&credential, app_id, environment)?; + print_json(&response) +} + +fn run_versions(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { + let app_id = matches + .get_one::("app-id") + .context("expected app id")?; + let environment = matches.get_one::("environment").map(String::as_str); + let (client, credential) = control_plane_context(config, matches)?; + let response = client.versions(&credential, app_id, environment)?; + print_json(&response) +} + fn run_create(config: &SkillsConfig, matches: &ArgMatches) -> Result<()> { let (client, credential) = control_plane_context(config, matches)?; let request = PlanRequest { @@ -618,6 +702,48 @@ impl ControlPlaneClient { }) } + fn list_apps( + &self, + credential: &ComposeSessionCredential, + scope: Option<&str>, + include_deleted: bool, + ) -> Result { + let mut query = Vec::new(); + if let Some(scope) = scope { + query.push(("scope", scope.to_string())); + } + if include_deleted { + query.push(("include_deleted", "true".to_string())); + } + let url = self.apps_url(&query)?; + self.get_url(credential, url) + } + + fn get_app( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + environment: Option<&str>, + ) -> Result { + let query = environment + .map(|environment| vec![("environment", environment.to_string())]) + .unwrap_or_default(); + let url = self.app_url(app_id, &query)?; + self.get_url(credential, url) + } + + fn versions( + &self, + credential: &ComposeSessionCredential, + app_id: &str, + environment: Option<&str>, + ) -> Result { + let query = environment + .map(|environment| vec![("environment", environment.to_string())]) + .unwrap_or_default(); + self.get_app_resource(credential, app_id, "versions", &query) + } + fn initialize( &self, credential: &ComposeSessionCredential, @@ -691,6 +817,10 @@ impl ControlPlaneClient { query: &[(&str, String)], ) -> Result { let url = self.app_resource_url(app_id, resource, query)?; + self.get_url(credential, url) + } + + fn get_url(&self, credential: &ComposeSessionCredential, url: url::Url) -> Result { let path = request_path(&url); self.authorized_json_request(credential, "GET", &path, |authorization| { self.standard_request(self.client.get(url.clone()), authorization) @@ -708,25 +838,39 @@ impl ControlPlaneClient { self.app_resource_url(app_id, action, &[]) } + fn apps_url(&self, query: &[(&str, String)]) -> Result { + let mut url = self.endpoint("/v1/agent/apps")?; + if !query.is_empty() { + let mut pairs = url.query_pairs_mut(); + for (name, value) in query { + pairs.append_pair(name, value); + } + } + Ok(url) + } + + fn app_url(&self, app_id: &str, query: &[(&str, String)]) -> Result { + let mut url = self.apps_url(query)?; + url.path_segments_mut() + .map_err(|_| { + anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") + })? + .push(app_id); + Ok(url) + } + fn app_resource_url( &self, app_id: &str, resource: &str, query: &[(&str, String)], ) -> Result { - let mut url = self.endpoint("/v1/agent/apps")?; + let mut url = self.app_url(app_id, query)?; url.path_segments_mut() .map_err(|_| { anyhow::anyhow!("Apps Platform control-plane URL cannot contain path segments") })? - .push(app_id) .push(resource); - if !query.is_empty() { - let mut pairs = url.query_pairs_mut(); - for (name, value) in query { - pairs.append_pair(name, value); - } - } Ok(url) } @@ -1331,6 +1475,176 @@ mod tests { assert_process_control_plane(&control_requests[0], "GET", APPS_CONTRACT_PATH, credential); } + #[test] + fn bb_apps_list_process_sends_filters_and_preserves_inventory() { + let credential = "apps-e2e-only.list.session+credential"; + let inventory = json!({ + "ok": true, + "caller": "apps-user", + "scope": "publisher", + "captured_at": "2026-09-01T12:00:00Z", + "count": 1, + "apps": [{ + "app_id": "merchant-lookup", + "role": "publisher", + "status": "deleted", + "ready": false, + "active_version_id": "ver-123", + "last_published_by": "apps-user" + }] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(inventory.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "list", + "--scope", + "publisher", + "--include-deleted", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps list process command"); + assert!( + output.status.success(), + "stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse list process output"), + inventory + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_eq!(requests.len(), 1); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps?scope=publisher&include_deleted=true", + credential, + ); + assert_eq!(requests[0].body, Value::Null); + } + + #[test] + fn bb_apps_get_process_encodes_app_id_and_preserves_versions() { + let credential = "apps-e2e-only.get.session+credential"; + let app = json!({ + "ok": true, + "app": { + "app_id": "merchant/lookup app", + "environment": "staging", + "role": "owner", + "ready": true, + "route_revision": 9 + }, + "versions": [{ + "version_id": "ver-123", + "deployment_id": "dpl-123", + "active": true + }] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(app.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "get", + "merchant/lookup app", + "--environment", + "staging", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps get process command"); + assert!(output.status.success()); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse get process output"), + app + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps/merchant%2Flookup%20app?environment=staging", + credential, + ); + } + + #[test] + fn bb_apps_versions_process_preserves_rollback_candidates() { + let credential = "apps-e2e-only.versions.session+credential"; + let versions = json!({ + "ok": true, + "app_id": "merchant-lookup", + "environment": "staging", + "active_version_id": "ver-123", + "count": 2, + "versions": [ + {"version_id": "ver-123", "route_revision": 9, "active": true}, + {"version_id": "ver-122", "route_revision": 8, "active": false} + ] + }); + let auth_server = ProcessServer::start(vec![process_auth_response()]); + let control_plane = ProcessServer::start(vec![ProcessResponse::json(versions.clone())]); + let mut command = process_command( + &auth_server, + &control_plane, + &[ + "apps", + "versions", + "merchant-lookup", + "--environment", + "staging", + "--base-url", + APPROVED_TEST_BASE_URL, + "--client-version", + "0.2.0", + "--json", + ], + credential, + ); + + let output = command.output().expect("run Apps versions process command"); + assert!(output.status.success()); + assert_eq!( + serde_json::from_str::(&process_stdout(&output)) + .expect("parse versions process output"), + versions + ); + let auth_requests = auth_server.finish(); + let requests = control_plane.finish(); + assert_process_auth(&auth_requests[0], credential); + assert_process_control_plane( + &requests[0], + "GET", + "/v1/agent/apps/merchant-lookup/versions?environment=staging", + credential, + ); + } + #[test] fn bb_apps_create_process_runs_plan_and_initialize() { let credential = "apps-e2e-only.create.session+credential"; @@ -2058,6 +2372,96 @@ mod tests { server_thread.join().expect("join control-plane server"); } + #[test] + fn list_builds_each_supported_query_shape() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let expected_paths = [ + "/v1/agent/apps", + "/v1/agent/apps?scope=owned", + "/v1/agent/apps?include_deleted=true", + "/v1/agent/apps?scope=publisher&include_deleted=true", + ]; + let server_thread = thread::spawn(move || { + for (index, expected_path) in expected_paths.into_iter().enumerate() { + let request = server.recv().expect("receive list request"); + assert_eq!(request.method().as_str(), "GET"); + assert_eq!(request.url(), expected_path); + request + .respond( + Response::from_string(format!(r#"{{"request":{index}}}"#)).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to list request"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("list_query_session_credential_123456"); + + for (index, (scope, include_deleted)) in [ + (None, false), + (Some("owned"), false), + (None, true), + (Some("publisher"), true), + ] + .into_iter() + .enumerate() + { + let response = client + .list_apps(&credential, scope, include_deleted) + .expect("request list response"); + assert_eq!(response["request"], index); + } + + server_thread.join().expect("join control-plane server"); + } + + #[test] + fn get_and_versions_support_default_and_explicit_environments() { + let server = Server::http("127.0.0.1:0").expect("bind control-plane server"); + let base_url = format!("http://{}", server.server_addr()); + let expected_paths = [ + "/v1/agent/apps/app", + "/v1/agent/apps/app?environment=staging%2Fwest%3Fcell%3D1", + "/v1/agent/apps/app/versions", + "/v1/agent/apps/app/versions?environment=staging%2Fwest%3Fcell%3D1", + ]; + let server_thread = thread::spawn(move || { + for (index, expected_path) in expected_paths.into_iter().enumerate() { + let request = server.recv().expect("receive inspection request"); + assert_eq!(request.method().as_str(), "GET"); + assert_eq!(request.url(), expected_path); + request + .respond( + Response::from_string(format!(r#"{{"request":{index}}}"#)).with_header( + Header::from_bytes("Content-Type", "application/json") + .expect("build content type"), + ), + ) + .expect("respond to inspection request"); + } + }); + let client = test_control_plane_client(&base_url, Duration::from_secs(2)); + let credential = test_credential("inspection_environment_session_credential_123456"); + + let responses = [ + client.get_app(&credential, "app", None), + client.get_app(&credential, "app", Some("staging/west?cell=1")), + client.versions(&credential, "app", None), + client.versions(&credential, "app", Some("staging/west?cell=1")), + ]; + for (index, response) in responses.into_iter().enumerate() { + assert_eq!( + response.expect("request inspection response")["request"], + index + ); + } + + server_thread.join().expect("join control-plane server"); + } + #[test] fn debug_tail_lines_match_control_plane_bounds() { for invalid in ["0", "1001"] { @@ -2100,6 +2504,11 @@ mod tests { assert_eq!(url.path(), "/v1/agent/apps/app%2F..%2F..%2Fidentity/deploy"); + let app = client + .app_url("app/with space", &[]) + .expect("build app detail URL"); + assert_eq!(app.path(), "/v1/agent/apps/app%2Fwith%20space"); + let ready = client .app_resource_url( "app/with space", diff --git a/bb-cli/tests/bb_e2e.rs b/bb-cli/tests/bb_e2e.rs index 0852831c7..9bc17783c 100644 --- a/bb-cli/tests/bb_e2e.rs +++ b/bb-cli/tests/bb_e2e.rs @@ -3652,6 +3652,9 @@ fn bb_apps_help_distinguishes_external_and_internal_paths() { "Cloudflare-backed internal App Kit", "bb tools appkit", "separate internal Compose workflow", + "list", + "get", + "versions", "ready", "debug", ] { @@ -3662,6 +3665,48 @@ fn bb_apps_help_distinguishes_external_and_internal_paths() { } } +#[test] +fn bb_apps_inspection_help_exposes_filters_and_app_ids() { + let list = bb_command() + .args(["apps", "list", "--help"]) + .output() + .expect("run bb apps list help"); + let (list_stdout, list_stderr) = output_text(&list); + assert!(list.status.success(), "stderr was: {list_stderr}"); + for expected in [ + "--scope ", + "manageable", + "owned", + "publisher", + "--include-deleted", + "--base-url ", + ] { + assert!( + list_stdout.contains(expected), + "list help omitted {expected:?}: {list_stdout}" + ); + } + + for subcommand in ["get", "versions"] { + let output = bb_command() + .args(["apps", subcommand, "--help"]) + .output() + .unwrap_or_else(|error| panic!("run bb apps {subcommand} help: {error}")); + let (stdout, stderr) = output_text(&output); + assert!(output.status.success(), "stderr was: {stderr}"); + for expected in [ + "", + "--environment ", + "--base-url ", + ] { + assert!( + stdout.contains(expected), + "{subcommand} help omitted {expected:?}: {stdout}" + ); + } + } +} + #[test] fn bb_apps_ready_and_debug_help_expose_their_arguments() { let ready = bb_command()