diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 5ad40bd..906e5f1 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "ef0246f094d8e779e21138dda21f7ee1c4734b72", - "release_name": "dryrun-ef0246f094d8", + "source_commit": "57e56a24e60a3990d91c199548b96b6c1f0fe962", + "release_name": "dryrun-57e56a24e60a", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 8a95d8b..a70de24 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1128,7 +1128,6 @@ fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result { config_file.display() ); } - write_project_config(&cwd, &config)?; let coordinator_response = if let Some(coordinator) = &args.scope.coordinator { let mut session = JsonLineSession::connect(coordinator)?; Some(session.request(json!({ @@ -1141,8 +1140,12 @@ fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result { } else { None }; + write_project_config(&cwd, &config)?; Ok(json!({ "command": "project init", + "source": if args.scope.coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" }, + "private_website_required": false, + "project_config_written": true, "project_config": config, "config_file": config_file, "coordinator_response": coordinator_response, @@ -1208,17 +1211,32 @@ fn project_list_report(args: ProjectListArgs, cwd: PathBuf) -> Result { "tenant": args.scope.tenant, "actor_user": args.scope.user, }))?; + let projects = response + .get("projects") + .cloned() + .unwrap_or_else(|| json!([])); + let project_count = projects.as_array().map(Vec::len).unwrap_or(0); return Ok(json!({ "command": "project list", + "source": "public_coordinator_api", "coordinator": coordinator, + "tenant": args.scope.tenant, + "user": args.scope.user, + "projects": projects, + "project_count": project_count, + "private_website_required": false, "response": response, "coordinator_session_requests": session.requests(), })); } + let projects = read_project_config(&cwd)?.into_iter().collect::>(); + let project_count = projects.len(); Ok(json!({ "command": "project list", "source": "local_project_config", - "projects": read_project_config(&cwd)?.into_iter().collect::>(), + "projects": projects, + "project_count": project_count, + "private_website_required": false, })) } @@ -1229,7 +1247,6 @@ fn project_select_report(args: ProjectSelectArgs, cwd: PathBuf) -> Result user: args.scope.user.clone(), coordinator: args.scope.coordinator.clone(), }; - write_project_config(&cwd, &config)?; let coordinator_response = if let Some(coordinator) = &args.scope.coordinator { let mut session = JsonLineSession::connect(coordinator)?; Some(session.request(json!({ @@ -1241,8 +1258,24 @@ fn project_select_report(args: ProjectSelectArgs, cwd: PathBuf) -> Result } else { None }; + write_project_config(&cwd, &config)?; + let selected_project = coordinator_response + .as_ref() + .and_then(|response| response.get("project")) + .cloned() + .unwrap_or_else(|| { + json!({ + "id": config.project.clone(), + "tenant": config.tenant.clone(), + "name": config.project.clone(), + }) + }); Ok(json!({ "command": "project select", + "source": if args.scope.coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" }, + "selected_project": selected_project, + "project_config_written": true, + "private_website_required": false, "project_config": config, "coordinator_response": coordinator_response, })) @@ -6766,6 +6799,119 @@ mod tests { assert_eq!(status["attached_nodes"]["checked"], false); } + #[test] + fn project_list_and_select_use_public_api_without_website() { + let temp = tempfile::tempdir().unwrap(); + write_project_config( + temp.path(), + &ProjectConfig { + tenant: "tenant-live".to_owned(), + project: "project-original".to_owned(), + user: "user-live".to_owned(), + coordinator: None, + }, + ) + .unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + for index in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""actor_user":"user-live""#)); + match index { + 0 => { + assert!(line.contains(r#""type":"list_projects""#)); + stream + .write_all( + br#"{"type":"projects","projects":[{"id":"project-a","tenant":"tenant-live","name":"Project A"}],"actor":"user-live"}"#, + ) + .unwrap(); + } + 1 => { + assert!(line.contains(r#""type":"select_project""#)); + assert!(line.contains(r#""project":"project-a""#)); + stream + .write_all( + br#"{"type":"project_selected","project":{"id":"project-a","tenant":"tenant-live","name":"Project A"},"actor":"user-live"}"#, + ) + .unwrap(); + } + 2 => { + assert!(line.contains(r#""type":"select_project""#)); + assert!(line.contains(r#""project":"project-b""#)); + stream + .write_all( + br#"{"type":"error","message":"project is outside the signed-in tenant scope"}"#, + ) + .unwrap(); + } + _ => unreachable!(), + } + stream.write_all(b"\n").unwrap(); + } + }); + + let scope = CliScopeArgs { + coordinator: Some(addr), + tenant: "tenant-live".to_owned(), + project: "project".to_owned(), + user: "user-live".to_owned(), + json: false, + }; + let list = project_list_report( + ProjectListArgs { + scope: scope.clone(), + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(list["command"], "project list"); + assert_eq!(list["source"], "public_coordinator_api"); + assert_eq!(list["project_count"], 1); + assert_eq!(list["projects"][0]["id"], "project-a"); + assert_eq!(list["private_website_required"], false); + assert_eq!(list["coordinator_session_requests"], 1); + + let selected = project_select_report( + ProjectSelectArgs { + scope: scope.clone(), + selected_project: "project-a".to_owned(), + }, + temp.path().to_path_buf(), + ) + .unwrap(); + assert_eq!(selected["command"], "project select"); + assert_eq!(selected["source"], "public_coordinator_api"); + assert_eq!(selected["selected_project"]["id"], "project-a"); + assert_eq!(selected["project_config_written"], true); + assert_eq!(selected["private_website_required"], false); + assert_eq!( + read_project_config(temp.path()).unwrap().unwrap().project, + "project-a" + ); + + let rejected = project_select_report( + ProjectSelectArgs { + scope, + selected_project: "project-b".to_owned(), + }, + temp.path().to_path_buf(), + ) + .unwrap_err(); + server.join().unwrap(); + + assert!(rejected.to_string().contains("tenant scope")); + assert_eq!( + read_project_config(temp.path()).unwrap().unwrap().project, + "project-a" + ); + } + #[test] fn project_status_queries_public_coordinator_state() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 0cf53dd..bb4bfc3 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -191,6 +191,7 @@ for (const [name, pattern] of [ ["doctor unchecked reachability coverage", /fn doctor_reports_unchecked_coordinator_reachability_without_config\(\)/], ["doctor ping reachability coverage", /fn doctor_pings_configured_coordinator\(\)/], ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], + ["project public API list/select coverage", /fn project_list_and_select_use_public_api_without_website\(\)/], ["project coordinator status coverage", /fn project_status_queries_public_coordinator_state\(\)/], ["run coordinator active-process coverage", /fn run_contacts_configured_coordinator_and_reports_active_process_conflicts\(\)/], ["CLI error classifier coverage", /fn cli_error_classifier_distinguishes_mvp_failure_categories\(\)/], @@ -309,6 +310,16 @@ expect( "CLI exposes task placement reasons", /fn task_summaries[\s\S]*node_placement[\s\S]*reasons[\s\S]*explanation_available/ ); +expect( + cli, + "CLI project list/select report public API boundary", + /fn project_list_report[\s\S]*public_coordinator_api[\s\S]*private_website_required[\s\S]*fn project_select_report[\s\S]*project_config_written[\s\S]*private_website_required/ +); +expect( + cli, + "CLI project select writes config after coordinator response", + /let coordinator_response = if let Some\(coordinator\)[\s\S]*session\.request\(json!\(\{[\s\S]*"type": "select_project"[\s\S]*\}\)\)\?[\s\S]*write_project_config\(&cwd, &config\)\?/ +); expect( cli, "CLI renders task placement reasons",