From 5386bb851f57c191b6ca2ec82c03b393d40c527d Mon Sep 17 00:00:00 2001 From: Michel Paulissen <862400+MichelPaulissen@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:11:24 +0200 Subject: [PATCH] Public dry run dryrun-0b6a82183be2 Source commit: 0b6a82183be25780b308c75442a9b5f602f727d4 --- DISASMER_PUBLIC_TREE.json | 4 +- crates/disasmer-cli/src/main.rs | 559 +++++++++++++++++++++++---- scripts/acceptance-public.sh | 1 + scripts/cli-first-contract-smoke.js | 27 ++ scripts/cli-install-smoke.js | 2 +- scripts/cli-local-run-smoke.js | 5 +- scripts/cli-login-smoke.js | 6 +- scripts/cli-output-mode-smoke.js | 77 ++++ scripts/docs-smoke.js | 4 + scripts/flagship-demo-smoke.js | 3 +- scripts/node-attach-smoke.js | 1 + scripts/public-release-dryrun-e2e.js | 4 +- scripts/verify-public-split.sh | 1 + scripts/vscode-extension-smoke.js | 1 + scripts/windows-runner-smoke.js | 3 +- vscode-extension/extension.js | 5 +- 16 files changed, 608 insertions(+), 95 deletions(-) create mode 100644 scripts/cli-output-mode-smoke.js diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index cb270cd..bc312c6 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "f1e9b7b784b4a4dd28799719681535e2a4ef44c9", - "release_name": "dryrun-f1e9b7b784b4", + "source_commit": "0b6a8218d2d6d25f6025f62264e9394b90bd4dfd", + "release_name": "dryrun-0b6a8218d2d6", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 5a16663..1189368 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -157,6 +157,8 @@ enum BundleCommands { #[derive(Clone, Debug, Parser)] struct AgentEnrollArgs { + #[arg(long)] + json: bool, #[arg(long = "public-key")] public_key: String, } @@ -231,6 +233,8 @@ struct ProjectSelectArgs { struct BundleInspectArgs { #[arg(long)] project: Option, + #[arg(long)] + json: bool, } #[derive(Clone, Debug, Parser)] @@ -239,6 +243,8 @@ struct BuildArgs { project: Option, #[arg(long)] output: Option, + #[arg(long)] + json: bool, } #[derive(Clone, Debug, Parser)] @@ -250,6 +256,8 @@ struct RunArgs { coordinator: Option, #[arg(long)] local: bool, + #[arg(long)] + json: bool, } #[derive(Clone, Debug, Subcommand)] @@ -277,6 +285,8 @@ struct AttachArgs { enrollment_grant: Option, #[arg(long = "public-key")] public_key: Option, + #[arg(long)] + json: bool, } #[derive(Clone, Debug, Parser)] @@ -414,6 +424,8 @@ struct ArtifactExportArgs { struct DapArgs { #[arg(long)] plan: bool, + #[arg(long)] + json: bool, #[arg(last = true)] args: Vec, } @@ -796,6 +808,7 @@ fn project_status_report(args: ProjectStatusArgs, cwd: PathBuf) -> Result let inspection = bundle_inspection( BundleInspectArgs { project: Some(cwd.clone()), + json: true, }, cwd.clone(), ) @@ -864,6 +877,7 @@ fn build_report(args: BuildArgs, cwd: PathBuf) -> Result { let inspection = bundle_inspection( BundleInspectArgs { project: args.project.clone(), + json: true, }, cwd, )?; @@ -1247,137 +1261,428 @@ fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result { })) } +fn emit_report(report: &T, json_output: bool) -> Result<()> { + let value = serde_json::to_value(report)?; + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("{}", human_report(&value)); + } + Ok(()) +} + +fn human_report(value: &Value) -> String { + let mut lines = Vec::new(); + let title = value + .get("command") + .and_then(Value::as_str) + .map(|command| format!("Disasmer {command}")) + .or_else(|| { + if value.get("human_flow").is_some() { + Some("Disasmer login".to_owned()) + } else if value.get("metadata").is_some() + && value.get("source_provider_manifest").is_some() + { + Some("Disasmer bundle inspect".to_owned()) + } else if value.get("entry").is_some() && value.get("session").is_some() { + Some("Disasmer run".to_owned()) + } else if value.get("capabilities").is_some() && value.get("node").is_some() { + Some("Disasmer node attach".to_owned()) + } else if value.get("public_key_fingerprint").is_some() { + Some("Disasmer agent enroll".to_owned()) + } else { + None + } + }) + .unwrap_or_else(|| "Disasmer report".to_owned()); + lines.push(title); + + push_string_field(&mut lines, value, "status", "status"); + push_string_field(&mut lines, value, "coordinator", "coordinator"); + push_string_field(&mut lines, value, "active_coordinator", "coordinator"); + push_string_field(&mut lines, value, "tenant", "tenant"); + push_string_field(&mut lines, value, "project", "project"); + push_string_field(&mut lines, value, "process", "process"); + push_string_field(&mut lines, value, "node", "node"); + push_string_field(&mut lines, value, "artifact", "artifact"); + push_string_field(&mut lines, value, "entry", "entry"); + push_string_field(&mut lines, value, "config_file", "config"); + push_string_field(&mut lines, value, "adapter", "adapter"); + + if let Some(project) = value.get("project").and_then(Value::as_str) { + if !lines + .iter() + .any(|line| line == &format!("project: {project}")) + { + lines.push(format!("project: {project}")); + } + } + if let Some(project_config) = value.get("project_config").or_else(|| value.get("project")) { + if project_config.is_object() { + push_nested_string_field(&mut lines, project_config, "tenant", "tenant"); + push_nested_string_field(&mut lines, project_config, "project", "project"); + push_nested_string_field(&mut lines, project_config, "coordinator", "coordinator"); + } + } + 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) { + let names = environments + .iter() + .filter_map(|env| env.get("name").and_then(Value::as_str)) + .collect::>(); + if !names.is_empty() { + lines.push(format!("environments: {}", names.join(", "))); + } + } + } + if let Some(bundle) = value.get("bundle") { + if let Some(metadata) = bundle.get("metadata") { + push_nested_string_field(&mut lines, metadata, "identity", "bundle"); + } + } + if let Some(flow) = value.get("human_flow") { + if let Some(device) = flow.get("Device") { + lines.push("flow: device".to_owned()); + push_nested_string_field(&mut lines, device, "verification_url", "verify"); + push_nested_string_field(&mut lines, device, "user_code", "code"); + } else if let Some(browser) = flow.get("Browser") { + lines.push("flow: browser".to_owned()); + push_nested_string_field(&mut lines, browser, "authorization_url", "open"); + push_nested_string_field(&mut lines, browser, "callback_path", "callback"); + } + } + if let Some(session) = value.get("session") { + lines.push(format!("session: {}", compact_json(session))); + } + if let Some(coordinator_selection) = value.get("coordinator") { + if coordinator_selection.is_object() { + lines.push(format!( + "coordinator: {}", + compact_json(coordinator_selection) + )); + } + } + if let Some(response) = value + .get("response") + .or_else(|| value.get("coordinator_response")) + { + if let Some(response_type) = response.get("type").and_then(Value::as_str) { + lines.push(format!("coordinator response: {response_type}")); + } + } + if let Some(events) = value + .pointer("/events/response/events") + .and_then(Value::as_array) + { + lines.push(format!("events: {}", events.len())); + } + if let Some(events) = value + .pointer("/coordinator_response/response/events") + .and_then(Value::as_array) + { + lines.push(format!("events: {}", events.len())); + } + if let Some(requires_confirmation) = value.get("requires_confirmation").and_then(Value::as_bool) + { + lines.push(format!( + "confirmation: {}", + if requires_confirmation { + "required" + } else { + "confirmed" + } + )); + } + if let Some(flag) = value + .get("private_website_required") + .and_then(Value::as_bool) + { + lines.push(format!("private website required: {flag}")); + } + if let Some(next_actions) = value.get("next_actions").and_then(Value::as_array) { + let actions = next_actions + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !actions.is_empty() { + lines.push(format!("next: {}", actions.join("; "))); + } + } + if let Some(auth) = value.get("auth").or_else(|| value.get("session")) { + if let Some(kind) = auth.get("kind").and_then(Value::as_str) { + lines.push(format!("auth: {kind}")); + } + if let Some(authenticated) = auth.get("authenticated").and_then(Value::as_bool) { + lines.push(format!("authenticated: {authenticated}")); + } + if let Some(expires) = auth.get("expires_at").and_then(Value::as_str) { + lines.push(format!("token expiry: {expires}")); + } else if let Some(posture) = auth.get("token_expiry_posture").and_then(Value::as_str) { + lines.push(format!("token expiry: {posture}")); + } else if auth.get("authenticated").is_some() { + lines.push("token expiry: unavailable".to_owned()); + } + } + if let Some(dependencies) = value.get("dependencies").and_then(Value::as_object) { + let mut entries = dependencies + .iter() + .map(|(name, available)| { + format!( + "{name}={}", + if available.as_bool().unwrap_or(false) { + "ok" + } else { + "missing" + } + ) + }) + .collect::>(); + entries.sort(); + if !entries.is_empty() { + lines.push(format!("dependencies: {}", entries.join(", "))); + } + } + if let Some(readiness) = value.get("node_readiness") { + push_nested_string_field(&mut lines, readiness, "os", "node os"); + push_nested_string_field(&mut lines, readiness, "arch", "node arch"); + if let Some(capabilities) = readiness.get("capabilities").and_then(Value::as_array) { + let caps = capabilities + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !caps.is_empty() { + lines.push(format!("node capabilities: {}", caps.join(", "))); + } + } + } + + lines.dedup(); + lines.join("\n") +} + +fn push_string_field(lines: &mut Vec, value: &Value, key: &str, label: &str) { + if let Some(text) = value.get(key).and_then(Value::as_str) { + lines.push(format!("{label}: {text}")); + } else if let Some(path) = value.get(key).and_then(|value| { + value + .as_object() + .and_then(|object| object.get("display")) + .and_then(Value::as_str) + }) { + lines.push(format!("{label}: {path}")); + } +} + +fn push_nested_string_field(lines: &mut Vec, value: &Value, key: &str, label: &str) { + if let Some(text) = value.get(key).and_then(Value::as_str) { + lines.push(format!("{label}: {text}")); + } +} + +fn compact_json(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "".to_owned()) +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Doctor(args) => { - println!( - "{}", - serde_json::to_string_pretty(&doctor_report(args, std::env::current_dir()?)?)? - ); + let json_output = args.scope.json; + let report = doctor_report(args, std::env::current_dir()?)?; + emit_report(&report, json_output)?; } Commands::Login(args) => { + let json_output = args.json; if args.complete_browser_code.is_some() { let report = execute_browser_login_completion(args)?; - println!("{}", serde_json::to_string_pretty(&report)?); + emit_report(&report, json_output)?; } else if args.browser && !args.plan { - let json_output = args.json; let report = execute_interactive_browser_login(args)?; if json_output { - println!("{}", serde_json::to_string_pretty(&report)?); + emit_report(&report, true)?; } else { print_browser_login_success(&report); } } else { let plan = login_plan(args); - println!("{}", serde_json::to_string_pretty(&plan)?); + emit_report(&plan, json_output)?; } } Commands::Auth { command } => { - let report = match command { - AuthCommands::Status(args) => auth_status_report(args, std::env::current_dir()?), - AuthCommands::Logout(args) => auth_logout_report(args, std::env::current_dir()?), - }?; - println!("{}", serde_json::to_string_pretty(&report)?); + let (report, json_output) = match command { + AuthCommands::Status(args) => { + let json_output = args.scope.json; + ( + auth_status_report(args, std::env::current_dir()?)?, + json_output, + ) + } + AuthCommands::Logout(args) => { + let json_output = args.scope.json; + ( + auth_logout_report(args, std::env::current_dir()?)?, + json_output, + ) + } + }; + emit_report(&report, json_output)?; } Commands::Agent { command: AgentCommands::Enroll(args), } => { + let json_output = args.json; let plan = agent_enrollment_plan(args); - println!("{}", serde_json::to_string_pretty(&plan)?); + emit_report(&plan, json_output)?; } Commands::Key { command } => { - let report = match command { - KeyCommands::Add(args) => key_add_report(args), - KeyCommands::List(args) => key_list_report(args), - KeyCommands::Revoke(args) => key_revoke_report(args), - }?; - println!("{}", serde_json::to_string_pretty(&report)?); + let (report, json_output) = match command { + KeyCommands::Add(args) => { + let json_output = args.scope.json; + (key_add_report(args)?, json_output) + } + KeyCommands::List(args) => { + let json_output = args.scope.json; + (key_list_report(args)?, json_output) + } + KeyCommands::Revoke(args) => { + let json_output = args.scope.json; + (key_revoke_report(args)?, json_output) + } + }; + emit_report(&report, json_output)?; } Commands::Project { command } => { let cwd = std::env::current_dir()?; - let report = match command { - ProjectCommands::Init(args) => project_init_report(args, cwd), - ProjectCommands::Status(args) => project_status_report(args, cwd), - ProjectCommands::List(args) => project_list_report(args, cwd), - ProjectCommands::Select(args) => project_select_report(args, cwd), - }?; - println!("{}", serde_json::to_string_pretty(&report)?); + let (report, json_output) = match command { + ProjectCommands::Init(args) => { + let json_output = args.scope.json; + (project_init_report(args, cwd)?, json_output) + } + ProjectCommands::Status(args) => { + let json_output = args.scope.json; + (project_status_report(args, cwd)?, json_output) + } + ProjectCommands::List(args) => { + let json_output = args.scope.json; + (project_list_report(args, cwd)?, json_output) + } + ProjectCommands::Select(args) => { + let json_output = args.scope.json; + (project_select_report(args, cwd)?, json_output) + } + }; + emit_report(&report, json_output)?; } Commands::Inspect(args) => { + let json_output = args.json; let inspection = bundle_inspection(args, std::env::current_dir()?)?; - println!("{}", serde_json::to_string_pretty(&inspection)?); + emit_report(&inspection, json_output)?; } Commands::Build(args) => { + let json_output = args.json; let report = build_report(args, std::env::current_dir()?)?; - println!("{}", serde_json::to_string_pretty(&report)?); + emit_report(&report, json_output)?; } Commands::Bundle { command: BundleCommands::Inspect(args), } => { + let json_output = args.json; let inspection = bundle_inspection(args, std::env::current_dir()?)?; - println!("{}", serde_json::to_string_pretty(&inspection)?); + emit_report(&inspection, json_output)?; } Commands::Run(args) => { + let json_output = args.json; let plan = run_plan(args, std::env::current_dir()?, session_from_env())?; if should_execute_local_node(&plan) { let report = execute_local_node_run(plan)?; - println!("{}", serde_json::to_string_pretty(&report)?); + emit_report(&report, json_output)?; } else { - println!("{}", serde_json::to_string_pretty(&plan)?); + emit_report(&plan, json_output)?; } } Commands::Node { command: NodeCommands::Attach(args), } => { + let json_output = args.json; if args.coordinator.is_some() { let report = execute_node_attach(args)?; - println!("{}", serde_json::to_string_pretty(&report)?); + emit_report(&report, json_output)?; } else { let plan = attach_plan(args); - println!("{}", serde_json::to_string_pretty(&plan)?); + emit_report(&plan, json_output)?; } } Commands::Node { command } => { - let report = match command { - NodeCommands::Enroll(args) => node_enroll_report(args), - NodeCommands::List(args) => node_list_report(args), - NodeCommands::Status(args) => node_status_report(args), - NodeCommands::Revoke(args) => node_revoke_report(args), + let (report, json_output) = match command { + NodeCommands::Enroll(args) => { + let json_output = args.scope.json; + (node_enroll_report(args)?, json_output) + } + NodeCommands::List(args) => { + let json_output = args.scope.json; + (node_list_report(args)?, json_output) + } + NodeCommands::Status(args) => { + let json_output = args.scope.json; + (node_status_report(args)?, json_output) + } + NodeCommands::Revoke(args) => { + let json_output = args.scope.json; + (node_revoke_report(args)?, json_output) + } NodeCommands::Attach(_) => unreachable!("node attach is handled above"), - }?; - println!("{}", serde_json::to_string_pretty(&report)?); + }; + emit_report(&report, json_output)?; } Commands::Process { command } => { - let report = match command { - ProcessCommands::Status(args) => process_status_report(args), - ProcessCommands::Restart(args) => process_restart_report(args), - ProcessCommands::Cancel(args) => process_cancel_report(args), - }?; - println!("{}", serde_json::to_string_pretty(&report)?); + let (report, json_output) = match command { + ProcessCommands::Status(args) => { + let json_output = args.scope.json; + (process_status_report(args)?, json_output) + } + ProcessCommands::Restart(args) => { + let json_output = args.scope.json; + (process_restart_report(args)?, json_output) + } + ProcessCommands::Cancel(args) => { + let json_output = args.scope.json; + (process_cancel_report(args)?, json_output) + } + }; + emit_report(&report, json_output)?; } Commands::Task { command: TaskCommands::List(args), } => { - println!( - "{}", - serde_json::to_string_pretty(&task_list_report(args)?)? - ); + let json_output = args.scope.json; + emit_report(&task_list_report(args)?, json_output)?; } Commands::Logs(args) => { - println!("{}", serde_json::to_string_pretty(&logs_report(args)?)?); + let json_output = args.scope.json; + emit_report(&logs_report(args)?, json_output)?; } Commands::Artifact { command } => { - let report = match command { - ArtifactCommands::List(args) => artifact_list_report(args), - ArtifactCommands::Download(args) => artifact_download_report(args), - ArtifactCommands::Export(args) => artifact_export_report(args), - }?; - println!("{}", serde_json::to_string_pretty(&report)?); + let (report, json_output) = match command { + ArtifactCommands::List(args) => { + let json_output = args.scope.json; + (artifact_list_report(args)?, json_output) + } + ArtifactCommands::Download(args) => { + let json_output = args.scope.json; + (artifact_download_report(args)?, json_output) + } + ArtifactCommands::Export(args) => { + let json_output = args.scope.json; + (artifact_export_report(args)?, json_output) + } + }; + emit_report(&report, json_output)?; } Commands::Dap(args) => { + let json_output = args.json; if args.plan { - println!("{}", serde_json::to_string_pretty(&dap_plan(args)?)?); + emit_report(&dap_plan(args)?, json_output)?; } else { return exec_dap(args); } @@ -1385,30 +1690,42 @@ fn main() -> Result<()> { Commands::Debug { command: DebugCommands::Attach(args), } => { - println!( - "{}", - serde_json::to_string_pretty(&debug_attach_report(args)?)? - ); + let json_output = args.scope.json; + emit_report(&debug_attach_report(args)?, json_output)?; } Commands::Quota { command: QuotaCommands::Status(args), } => { - println!( - "{}", - serde_json::to_string_pretty("a_status_report(args)?)? - ); + let json_output = args.scope.json; + emit_report("a_status_report(args)?, json_output)?; } Commands::Admin { command } => { - let report = match command { - AdminCommands::Status(args) => admin_status_report(args), - AdminCommands::Bootstrap(args) => { - admin_bootstrap_report(args, std::env::current_dir()?) + let (report, json_output) = match command { + AdminCommands::Status(args) => { + let json_output = args.scope.json; + (admin_status_report(args)?, json_output) } - AdminCommands::RevokeNode(args) => node_revoke_report(args), - AdminCommands::StopProcess(args) => process_cancel_report(args), - AdminCommands::SuspendTenant(args) => admin_suspend_tenant_report(args), - }?; - println!("{}", serde_json::to_string_pretty(&report)?); + AdminCommands::Bootstrap(args) => { + let json_output = args.scope.json; + ( + admin_bootstrap_report(args, std::env::current_dir()?)?, + json_output, + ) + } + AdminCommands::RevokeNode(args) => { + let json_output = args.scope.json; + (node_revoke_report(args)?, json_output) + } + AdminCommands::StopProcess(args) => { + let json_output = args.scope.json; + (process_cancel_report(args)?, json_output) + } + AdminCommands::SuspendTenant(args) => { + let json_output = args.scope.json; + (admin_suspend_tenant_report(args)?, json_output) + } + }; + emit_report(&report, json_output)?; } } Ok(()) @@ -1450,13 +1767,19 @@ fn auth_state_value() -> Value { "kind": "anonymous", "authenticated": false, "source": "environment", + "token_expiry_posture": "no_session", }), - CliSession::HumanSession => json!({ - "kind": "human", - "authenticated": true, - "source": "DISASMER_TOKEN", - "provider_tokens_exposed_to_nodes": false, - }), + CliSession::HumanSession => { + let expires_at = std::env::var("DISASMER_TOKEN_EXPIRES_AT").ok(); + json!({ + "kind": "human", + "authenticated": true, + "source": "DISASMER_TOKEN", + "provider_tokens_exposed_to_nodes": false, + "expires_at": expires_at, + "token_expiry_posture": if expires_at.is_some() { "expires_at" } else { "unknown_env_token" }, + }) + } CliSession::AgentPublicKey { public_key_fingerprint, browser_interaction_required, @@ -1466,6 +1789,7 @@ fn auth_state_value() -> Value { "source": "DISASMER_AGENT_PUBLIC_KEY", "public_key_fingerprint": public_key_fingerprint, "browser_interaction_required": browser_interaction_required, + "token_expiry_posture": "not_applicable_public_key", }), } } @@ -2549,6 +2873,7 @@ mod tests { project: None, coordinator: None, local: false, + json: false, }; let plan = run_plan( args, @@ -2747,6 +3072,7 @@ mod tests { let first = bundle_inspection( BundleInspectArgs { project: Some(temp.path().to_path_buf()), + json: false, }, PathBuf::from("/unused"), ) @@ -2759,6 +3085,7 @@ mod tests { let second = bundle_inspection( BundleInspectArgs { project: Some(temp.path().to_path_buf()), + json: false, }, PathBuf::from("/unused"), ) @@ -2777,6 +3104,7 @@ mod tests { let first = bundle_inspection( BundleInspectArgs { project: Some(first.path().to_path_buf()), + json: false, }, PathBuf::from("/unused"), ) @@ -2784,6 +3112,7 @@ mod tests { let second = bundle_inspection( BundleInspectArgs { project: Some(second.path().to_path_buf()), + json: false, }, PathBuf::from("/unused"), ) @@ -2926,6 +3255,71 @@ mod tests { } } + #[test] + fn cli_first_json_mode_parses_for_primary_commands() { + for args in [ + &["disasmer", "doctor", "--json"][..], + &["disasmer", "login", "--json"], + &["disasmer", "auth", "status", "--json"], + &[ + "disasmer", + "agent", + "enroll", + "--public-key", + "key", + "--json", + ], + &[ + "disasmer", + "key", + "add", + "--agent", + "agent", + "--public-key", + "key", + "--json", + ], + &["disasmer", "project", "init", "--yes", "--json"], + &["disasmer", "inspect", "--json"], + &["disasmer", "build", "--json"], + &["disasmer", "bundle", "inspect", "--json"], + &["disasmer", "run", "--json"], + &["disasmer", "node", "attach", "--json"], + &["disasmer", "node", "enroll", "--json"], + &["disasmer", "process", "status", "--json"], + &["disasmer", "task", "list", "--json"], + &["disasmer", "logs", "--json"], + &["disasmer", "artifact", "list", "--json"], + &["disasmer", "artifact", "download", "artifact", "--json"], + &[ + "disasmer", "artifact", "export", "artifact", "--to", "/tmp/out", "--json", + ], + &["disasmer", "dap", "--plan", "--json"], + &["disasmer", "debug", "attach", "--json"], + &["disasmer", "quota", "status", "--json"], + &["disasmer", "admin", "status", "--json"], + ] { + let _ = parse(args); + } + } + + #[test] + fn human_report_is_text_not_json() { + let report = json!({ + "command": "doctor", + "status": "ok", + "coordinator": "127.0.0.1:9443", + "next_actions": ["disasmer login --browser", "disasmer project init"], + }); + let human = human_report(&report); + + assert!(!human.trim_start().starts_with('{')); + assert!(human.contains("Disasmer doctor")); + assert!(human.contains("status: ok")); + assert!(human.contains("coordinator: 127.0.0.1:9443")); + assert!(human.contains("disasmer login --browser")); + } + #[test] fn project_init_select_and_status_use_local_project_config() { let temp = tempfile::tempdir().unwrap(); @@ -2984,6 +3378,7 @@ mod tests { BuildArgs { project: Some(temp.path().to_path_buf()), output: None, + json: false, }, PathBuf::from("/unused"), ) diff --git a/scripts/acceptance-public.sh b/scripts/acceptance-public.sh index 2b0016b..eef4478 100755 --- a/scripts/acceptance-public.sh +++ b/scripts/acceptance-public.sh @@ -37,6 +37,7 @@ cargo fmt --all --check cargo test --workspace cargo build --workspace --bins node scripts/docs-smoke.js +node scripts/cli-output-mode-smoke.js node scripts/cli-login-smoke.js node scripts/cli-browser-login-flow-smoke.js node scripts/cli-install-smoke.js diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 3a7f45f..4727ffa 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -32,6 +32,7 @@ function expect(source, name, pattern) { const criteria = read("cli_acceptance_criteria.md"); const cli = read("crates/disasmer-cli/src/main.rs"); +const outputModeSmoke = read("scripts/cli-output-mode-smoke.js"); expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m); expect( @@ -76,6 +77,8 @@ assert.deepStrictEqual( for (const [name, pattern] of [ ["top-level version metadata", /#\[command\(name = "disasmer", version, arg_required_else_help = true\)\]/], + ["human report renderer", /fn human_report\(value: &Value\) -> String/], + ["shared report emitter", /fn emit_report\(report: &T, json_output: bool\) -> Result<\(\)>/], ["doctor command", /Doctor\(DoctorArgs\)/], ["auth status command", /enum AuthCommands[\s\S]*Status\(AuthStatusArgs\)/], ["auth logout command", /enum AuthCommands[\s\S]*Logout\(AuthLogoutArgs\)/], @@ -99,6 +102,8 @@ for (const [name, pattern] of [ for (const [name, pattern] of [ ["CLI parse coverage", /fn cli_first_mvp_command_surface_parses\(\)/], ["CLI version coverage", /fn top_level_version_is_available\(\)/], + ["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/], + ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], ["build no full repo upload coverage", /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)/], ["safe coordinator-required plans", /fn node_enroll_and_process_commands_have_safe_plan_without_coordinator\(\)/], @@ -106,4 +111,26 @@ for (const [name, pattern] of [ expect(cli, name, pattern); } +for (const [name, pattern] of [ + ["agent --json flag", /struct AgentEnrollArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["bundle inspect --json flag", /struct BundleInspectArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["build --json flag", /struct BuildArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["run --json flag", /struct RunArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["node attach --json flag", /struct AttachArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["DAP plan --json flag", /struct DapArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], + ["shared scope --json flag", /struct CliScopeArgs[\s\S]*#\[arg\(long\)\]\s*json: bool/], +]) { + expect(cli, name, pattern); +} + +for (const [name, pattern] of [ + ["human default assertion", /default output should be human-readable text, not JSON/], + ["login JSON mode", /\["login", "--coordinator", "https:\/\/coord\.example\.test", "--json"\]/], + ["doctor human mode", /\["doctor"\]/], + ["bundle inspect JSON mode", /\["bundle", "inspect", "--project", project, "--json"\]/], + ["auth expiry posture", /token_expiry_posture[\s\S]*expires_at/], +]) { + expect(outputModeSmoke, name, pattern); +} + console.log("CLI-first contract smoke passed"); diff --git a/scripts/cli-install-smoke.js b/scripts/cli-install-smoke.js index 9037a08..8ddcaa8 100755 --- a/scripts/cli-install-smoke.js +++ b/scripts/cli-install-smoke.js @@ -42,7 +42,7 @@ try { const inspection = JSON.parse( cp.execFileSync( installedBin, - ["bundle", "inspect", "--project", project], + ["bundle", "inspect", "--project", project, "--json"], { cwd: repo, encoding: "utf8" } ) ); diff --git a/scripts/cli-local-run-smoke.js b/scripts/cli-local-run-smoke.js index f973037..d9ce170 100755 --- a/scripts/cli-local-run-smoke.js +++ b/scripts/cli-local-run-smoke.js @@ -115,7 +115,7 @@ function runCli(args, env = {}) { assert.strictEqual((await send(addr, { type: "ping" })).type, "pong"); const { pid: cliPid, report } = await runCli( - ["run", "--coordinator", `${addr.host}:${addr.port}`, "--project", project], + ["run", "--coordinator", `${addr.host}:${addr.port}`, "--project", project, "--json"], { DISASMER_AGENT_PUBLIC_KEY: agentPublicKey } ); assert(Number.isInteger(cliPid)); @@ -164,7 +164,8 @@ function runCli(args, env = {}) { "run", "--local", "--project", - project + project, + "--json", ]); assert(Number.isInteger(autoCliPid)); assert.strictEqual(autoReport.plan.entry, "build"); diff --git a/scripts/cli-login-smoke.js b/scripts/cli-login-smoke.js index 26bc78b..efc0491 100644 --- a/scripts/cli-login-smoke.js +++ b/scripts/cli-login-smoke.js @@ -18,7 +18,7 @@ function disasmer(args) { ); } -const device = disasmer(["login", "--coordinator", coordinator]); +const device = disasmer(["login", "--coordinator", coordinator, "--json"]); assert.strictEqual(device.coordinator, coordinator); assert(device.human_flow.Device, "default human login should use device flow"); assert.strictEqual(device.human_flow.Device.verification_url, `${coordinator}/auth/device`); @@ -27,14 +27,14 @@ assert.match(device.human_flow.Device.device_code, /^sha256:[a-f0-9]{64}$/); assert.strictEqual(device.human_flow.Device.expires_in_seconds, 900); assert.strictEqual(device.human_flow.Device.yields_long_lived_secret_directly, false); -const defaultDevice = disasmer(["login"]); +const defaultDevice = disasmer(["login", "--json"]); assert.strictEqual(defaultDevice.coordinator, defaultOperatorEndpoint); assert.strictEqual( defaultDevice.human_flow.Device.verification_url, `${defaultOperatorEndpoint}/auth/device` ); -const browser = disasmer(["login", "--browser", "--plan", "--coordinator", coordinator]); +const browser = disasmer(["login", "--browser", "--plan", "--coordinator", coordinator, "--json"]); assert.strictEqual(browser.coordinator, coordinator); assert(browser.human_flow.Browser, "browser login should be available for human users"); assert.match( diff --git a/scripts/cli-output-mode-smoke.js b/scripts/cli-output-mode-smoke.js new file mode 100644 index 0000000..711a07f --- /dev/null +++ b/scripts/cli-output-mode-smoke.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +const assert = require("assert"); +const cp = require("child_process"); +const path = require("path"); + +const repo = path.resolve(__dirname, ".."); +const project = path.join(repo, "examples/launch-build-demo"); + +function disasmer(args, env = {}) { + return cp.execFileSync( + "cargo", + ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + { + cwd: repo, + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + } + ); +} + +function json(args, env) { + return JSON.parse(disasmer(args, env)); +} + +function assertHuman(name, output, requiredPatterns) { + assert( + !output.trimStart().startsWith("{"), + `${name} default output should be human-readable text, not JSON` + ); + for (const pattern of requiredPatterns) { + assert.match(output, pattern, `${name} human output missing ${pattern}`); + } +} + +const loginHuman = disasmer(["login", "--coordinator", "https://coord.example.test"]); +assertHuman("login", loginHuman, [ + /Disasmer login/, + /flow: device/, + /verify: https:\/\/coord\.example\.test\/auth\/device/, +]); + +const loginJson = json(["login", "--coordinator", "https://coord.example.test", "--json"]); +assert.strictEqual(loginJson.coordinator, "https://coord.example.test"); +assert(loginJson.human_flow.Device); + +const doctorHuman = disasmer(["doctor"]); +assertHuman("doctor", doctorHuman, [ + /Disasmer doctor/, + /dependencies:/, + /auth:/, + /node capabilities:/, +]); + +const authJson = json(["auth", "status", "--json"], { + DISASMER_TOKEN: "token", + DISASMER_TOKEN_EXPIRES_AT: "2026-07-04T00:00:00Z", +}); +assert.strictEqual(authJson.session.kind, "human"); +assert.strictEqual(authJson.session.token_expiry_posture, "expires_at"); +assert.strictEqual(authJson.session.expires_at, "2026-07-04T00:00:00Z"); + +const inspectHuman = disasmer(["bundle", "inspect", "--project", project]); +assertHuman("bundle inspect", inspectHuman, [ + /Disasmer bundle inspect/, + /bundle: sha256:/, + /environments:/, +]); + +const inspectJson = json(["bundle", "inspect", "--project", project, "--json"]); +assert.strictEqual(inspectJson.project, project); +assert.match(inspectJson.metadata.identity, /^sha256:/); + +console.log("CLI output mode smoke passed"); diff --git a/scripts/docs-smoke.js b/scripts/docs-smoke.js index eaa6a93..03a9f88 100644 --- a/scripts/docs-smoke.js +++ b/scripts/docs-smoke.js @@ -158,6 +158,10 @@ for (const script of [publicAcceptance, publicSplit]) { script.includes("node scripts/cli-install-smoke.js"), "public acceptance gates must run cli-install-smoke.js" ); + assert( + script.includes("node scripts/cli-output-mode-smoke.js"), + "public acceptance gates must run cli-output-mode-smoke.js" + ); assert( script.includes("node scripts/cli-login-smoke.js"), "public acceptance gates must run cli-login-smoke.js" diff --git a/scripts/flagship-demo-smoke.js b/scripts/flagship-demo-smoke.js index 2361425..989bb98 100644 --- a/scripts/flagship-demo-smoke.js +++ b/scripts/flagship-demo-smoke.js @@ -36,7 +36,8 @@ const inspection = JSON.parse( "bundle", "inspect", "--project", - project + project, + "--json" ], { cwd: repo, encoding: "utf8" } ) diff --git a/scripts/node-attach-smoke.js b/scripts/node-attach-smoke.js index ba5328e..bb193bd 100755 --- a/scripts/node-attach-smoke.js +++ b/scripts/node-attach-smoke.js @@ -76,6 +76,7 @@ function runAttach(addr, grant) { grant, "--cap", "quic-direct", + "--json", ], { cwd: repo } ); diff --git a/scripts/public-release-dryrun-e2e.js b/scripts/public-release-dryrun-e2e.js index 1e3bf69..31fbdf2 100755 --- a/scripts/public-release-dryrun-e2e.js +++ b/scripts/public-release-dryrun-e2e.js @@ -883,7 +883,7 @@ async function main() { const disasmerNode = executable(installDir, "disasmer-node"); const disasmerDap = executable(installDir, "disasmer-debug-dap"); - const defaultLoginPlan = runJson(disasmer, ["login"], { cwd: checkout }); + const defaultLoginPlan = runJson(disasmer, ["login", "--json"], { cwd: checkout }); assert.strictEqual(defaultLoginPlan.coordinator, serviceEndpoint); const suffix = String(Date.now()); @@ -902,6 +902,7 @@ async function main() { [ "login", "--browser", + "--json", "--complete-browser-code", oidcCode, "--oidc-issuer-url", @@ -959,6 +960,7 @@ async function main() { `${cliNode}-public-key`, "--enrollment-grant", cliGrant.grant.grant_id, + "--json", ], { cwd: checkout } ); diff --git a/scripts/verify-public-split.sh b/scripts/verify-public-split.sh index a2bae52..80c9888 100755 --- a/scripts/verify-public-split.sh +++ b/scripts/verify-public-split.sh @@ -46,6 +46,7 @@ if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then (cd "$tmp_dir" && node scripts/publish-public-release-dryrun.js) fi (cd "$tmp_dir" && node scripts/docs-smoke.js) +(cd "$tmp_dir" && node scripts/cli-output-mode-smoke.js) (cd "$tmp_dir" && node scripts/cli-login-smoke.js) (cd "$tmp_dir" && node scripts/cli-browser-login-flow-smoke.js) (cd "$tmp_dir" && node scripts/cli-install-smoke.js) diff --git a/scripts/vscode-extension-smoke.js b/scripts/vscode-extension-smoke.js index e2268e8..fcafbb8 100755 --- a/scripts/vscode-extension-smoke.js +++ b/scripts/vscode-extension-smoke.js @@ -61,6 +61,7 @@ assert.deepStrictEqual(inspectCommand.args.slice(0, 8), [ assert(inspectCommand.args.includes("inspect")); assert(inspectCommand.args.includes("--project")); assert(inspectCommand.args.includes(root)); +assert(inspectCommand.args.includes("--json")); const refreshed = extension.refreshBundleBeforeLaunch(root, "/repo", (command, args, options) => { assert.strictEqual(command, "cargo"); diff --git a/scripts/windows-runner-smoke.js b/scripts/windows-runner-smoke.js index 230565f..ef7f78d 100755 --- a/scripts/windows-runner-smoke.js +++ b/scripts/windows-runner-smoke.js @@ -169,7 +169,8 @@ function runWindowsAttach(addr, grant) { "--enrollment-grant", grant, "--cap", - "windows-command-dev" + "windows-command-dev", + "--json" ], { cwd: repo } ); diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js index 3947607..3544d6b 100644 --- a/vscode-extension/extension.js +++ b/vscode-extension/extension.js @@ -87,7 +87,7 @@ function bundleInspectCommand(projectRoot, adapterWorkspace = path.resolve(__dir if (workspaceCli) { return { command: workspaceCli, - args: ["bundle", "inspect", "--project", projectRoot], + args: ["bundle", "inspect", "--project", projectRoot, "--json"], options: { cwd: projectRoot, encoding: "utf8" @@ -108,7 +108,8 @@ function bundleInspectCommand(projectRoot, adapterWorkspace = path.resolve(__dir "bundle", "inspect", "--project", - projectRoot + projectRoot, + "--json" ], options: { cwd: adapterWorkspace,