diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 0f82e11..73a8f4e 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "4a860d72efe34b376c4b08762555bee3fe3105f9", - "release_name": "dryrun-4a860d72efe3", + "source_commit": "b25bfc668c9ac086a09be0d69b2ca1e6a6ddae13", + "release_name": "dryrun-b25bfc668c9a", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 660250a..0031af7 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1276,11 +1276,16 @@ fn key_revoke_report(args: KeyRevokeArgs) -> Result { } fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result { + let tenant = args.scope.tenant.clone(); + let project = args.new_project.clone(); + let user = args.scope.user.clone(); + let coordinator = args.scope.coordinator.clone(); + let name = args.name.clone(); let config = ProjectConfig { - tenant: args.scope.tenant.clone(), - project: args.new_project.clone(), - user: args.scope.user.clone(), - coordinator: args.scope.coordinator.clone(), + tenant: tenant.clone(), + project: project.clone(), + user: user.clone(), + coordinator: coordinator.clone(), }; let config_file = project_config_file(&cwd); if config_file.exists() && !args.yes { @@ -1289,26 +1294,63 @@ fn project_init_report(args: ProjectInitArgs, cwd: PathBuf) -> Result { config_file.display() ); } - let coordinator_response = if let Some(coordinator) = &args.scope.coordinator { + let mut coordinator_session_requests = 0; + let coordinator_response = if let Some(coordinator) = &coordinator { let mut session = JsonLineSession::connect(coordinator)?; - Some(session.request(json!({ + let response = session.request(json!({ "type": "create_project", - "tenant": args.scope.tenant, - "actor_user": args.scope.user, - "project": args.new_project, - "name": args.name, - }))?) + "tenant": tenant, + "actor_user": user, + "project": project, + "name": name, + }))?; + coordinator_session_requests = session.requests(); + Some(response) } else { None }; write_project_config(&cwd, &config)?; + let created_or_linked_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": args.name.clone(), + }) + }); Ok(json!({ "command": "project init", - "source": if args.scope.coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" }, + "source": if coordinator.is_some() { "public_coordinator_api" } else { "local_project_config" }, "private_website_required": false, "project_config_written": true, + "project_config_write_after_coordinator_acceptance": coordinator.is_some(), + "coordinator_create_before_local_write": coordinator.is_some(), + "coordinator_session_requests": coordinator_session_requests, + "created_or_linked_project": created_or_linked_project, + "current_directory_link": { + "cwd": cwd, + "config_file": config_file, + "config_format": "disasmer_project_config_v1", + "links_current_directory": true, + "writes_current_directory_only": true, + "private_website_required": false, + }, + "safe_defaults": { + "tenant": config.tenant.clone(), + "project": config.project.clone(), + "user": config.user.clone(), + "coordinator": config.coordinator.clone(), + "project_name": args.name.clone(), + "default_project_id_used": args.new_project == "project", + "default_project_name_used": args.name == "Disasmer Project", + "browser_interaction_required": false, + "private_website_required": false, + }, "project_config": config, - "config_file": config_file, + "config_file": project_config_file(&cwd), "coordinator_response": coordinator_response, })) } @@ -2567,6 +2609,16 @@ fn human_report(value: &Value) -> String { push_nested_string_field(&mut lines, project_config, "coordinator", "coordinator"); } } + if let Some(link) = value.get("current_directory_link") { + if link + .get("links_current_directory") + .and_then(Value::as_bool) + .unwrap_or(false) + { + lines.push("current directory linked: true".to_owned()); + } + push_nested_string_field(&mut lines, link, "config_file", "current directory config"); + } if let Some(metadata) = value.get("metadata") { push_nested_string_field(&mut lines, metadata, "identity", "bundle"); if let Some(environments) = metadata.get("environments").and_then(Value::as_array) { @@ -8128,6 +8180,27 @@ mod tests { ) .unwrap(); assert_eq!(init["command"], "project init"); + assert_eq!(init["source"], "local_project_config"); + assert_eq!(init["project_config_written"], true); + assert_eq!( + init["current_directory_link"]["config_format"], + "disasmer_project_config_v1" + ); + assert_eq!( + init["current_directory_link"]["links_current_directory"], + true + ); + assert_eq!( + init["current_directory_link"]["writes_current_directory_only"], + true + ); + assert_eq!(init["safe_defaults"]["project"], "project-a"); + assert_eq!(init["safe_defaults"]["tenant"], "tenant"); + assert_eq!(init["safe_defaults"]["browser_interaction_required"], false); + assert_eq!(init["coordinator_create_before_local_write"], false); + let rendered = human_report(&init); + assert!(rendered.contains("current directory linked: true")); + assert!(rendered.contains("current directory config:")); let config = read_project_config(temp.path()).unwrap().unwrap(); assert_eq!(config.project, "project-a"); @@ -8173,6 +8246,107 @@ mod tests { assert_eq!(status["attached_nodes"]["checked"], false); } + #[test] + fn project_init_uses_public_create_before_writing_local_config() { + let temp_success = tempfile::tempdir().unwrap(); + let temp_rejected = tempfile::tempdir().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..2 { + 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#""type":"create_project""#)); + assert!(line.contains(r#""tenant":"tenant-live""#)); + assert!(line.contains(r#""actor_user":"user-live""#)); + match index { + 0 => { + assert!(line.contains(r#""project":"project-created""#)); + stream + .write_all( + br#"{"type":"project_created","project":{"id":"project-created","tenant":"tenant-live","name":"Created Project"},"actor":"user-live"}"#, + ) + .unwrap(); + } + 1 => { + assert!(line.contains(r#""project":"foreign-project""#)); + stream + .write_all( + br#"{"type":"error","message":"project id 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: "ignored".to_owned(), + user: "user-live".to_owned(), + json: false, + }; + let created = project_init_report( + ProjectInitArgs { + scope: scope.clone(), + new_project: "project-created".to_owned(), + name: "Created Project".to_owned(), + yes: true, + }, + temp_success.path().to_path_buf(), + ) + .unwrap(); + + assert_eq!(created["command"], "project init"); + assert_eq!(created["source"], "public_coordinator_api"); + assert_eq!(created["coordinator_create_before_local_write"], true); + assert_eq!( + created["project_config_write_after_coordinator_acceptance"], + true + ); + assert_eq!(created["coordinator_session_requests"], 1); + assert_eq!( + created["created_or_linked_project"]["id"], + "project-created" + ); + assert_eq!( + created["current_directory_link"]["links_current_directory"], + true + ); + assert_eq!( + created["safe_defaults"]["browser_interaction_required"], + false + ); + assert_eq!(created["private_website_required"], false); + assert_eq!( + read_project_config(temp_success.path()) + .unwrap() + .unwrap() + .project, + "project-created" + ); + + let rejected = project_init_report( + ProjectInitArgs { + scope, + new_project: "foreign-project".to_owned(), + name: "Foreign Project".to_owned(), + yes: true, + }, + temp_rejected.path().to_path_buf(), + ) + .unwrap_err(); + server.join().unwrap(); + + assert!(rejected.to_string().contains("tenant scope")); + assert!(read_project_config(temp_rejected.path()).unwrap().is_none()); + } + #[test] fn project_list_and_select_use_public_api_without_website() { let temp = tempfile::tempdir().unwrap(); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index f1f18f7..964acf4 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -239,6 +239,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 init public create coverage", /fn project_init_uses_public_create_before_writing_local_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\(\)/], @@ -393,6 +394,21 @@ expect( "CLI exposes task placement reasons", /fn task_summaries[\s\S]*node_placement[\s\S]*reasons[\s\S]*explanation_available/ ); +expect( + cli, + "CLI project init report current-directory link", + /fn project_init_report[\s\S]*current_directory_link[\s\S]*links_current_directory[\s\S]*safe_defaults[\s\S]*coordinator_create_before_local_write/ +); +expect( + cli, + "CLI project init writes config after public create", + /let coordinator_response = if let Some\(coordinator\)[\s\S]*"type": "create_project"[\s\S]*coordinator_session_requests = session\.requests\(\)[\s\S]*write_project_config\(&cwd, &config\)\?/ +); +expect( + cli, + "CLI project init renders current-directory link", + /current_directory_link[\s\S]*current directory linked: true[\s\S]*current directory config/ +); expect( cli, "CLI project list/select report public API boundary",