diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index cf760af..1340e71 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "133d7b466c2f96c240f559722bc4b557aa94c091", - "release_name": "dryrun-133d7b466c2f", + "source_commit": "09993cb75187f1e8798d6429946085e167059e9f", + "release_name": "dryrun-09993cb75187", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index c0b77aa..4ffcf63 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -117,6 +117,8 @@ struct LoginArgs { #[arg(long)] browser: bool, #[arg(long)] + non_interactive: bool, + #[arg(long)] plan: bool, #[arg(long)] json: bool, @@ -285,6 +287,8 @@ struct RunArgs { #[arg(long)] local: bool, #[arg(long)] + non_interactive: bool, + #[arg(long)] json: bool, } @@ -837,6 +841,37 @@ fn auth_logout_report(args: AuthLogoutArgs, cwd: PathBuf) -> Result { logout_report(args, cwd, "auth logout") } +fn non_interactive_auth_machine_error(message: &str, next_actions: Vec<&'static str>) -> Value { + let mut machine_error = cli_error_summary_for_category("authentication", message); + if let Some(object) = machine_error.as_object_mut() { + object.insert("next_actions".to_owned(), json!(next_actions)); + object.insert("browser_opened".to_owned(), json!(false)); + } + machine_error +} + +fn non_interactive_browser_login_report(args: &LoginArgs) -> Value { + let message = + "browser login requires an interactive browser, but non-interactive mode is enabled"; + let next_actions = vec![ + "rerun without --non-interactive to open the browser", + "disasmer login --browser --plan", + "use DISASMER_AGENT_PUBLIC_KEY for automation", + ]; + json!({ + "command": "login", + "status": "authentication_required", + "coordinator": args.coordinator, + "non_interactive": true, + "browser_requested": true, + "browser_opened": false, + "safe_failure": true, + "message": message, + "next_actions": next_actions, + "machine_error": non_interactive_auth_machine_error(message, next_actions), + }) +} + fn logout_report(args: AuthLogoutArgs, cwd: PathBuf, command: &str) -> Result { let session_file = session_config_file(&cwd); let existed = session_file.exists(); @@ -2593,7 +2628,14 @@ fn run_cli() -> Result<()> { } Commands::Login(args) => { let json_output = args.json; - if args.complete_browser_code.is_some() { + if args.non_interactive + && args.browser + && !args.plan + && args.complete_browser_code.is_none() + { + let report = non_interactive_browser_login_report(&args); + emit_report(&report, json_output)?; + } else if args.complete_browser_code.is_some() { let report = execute_browser_login_completion(args)?; emit_report(&report, json_output)?; } else if args.browser && !args.plan { @@ -4297,7 +4339,39 @@ fn run_plan(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result }) } +fn non_interactive_run_requires_auth_report(args: RunArgs, cwd: PathBuf) -> Value { + let project = args.project.unwrap_or(cwd); + let entry = args.entry.unwrap_or_else(|| "build".to_owned()); + let message = "non-interactive run requires an authenticated human or agent session unless --local or --coordinator is explicit"; + let next_actions = vec![ + "disasmer login --browser", + "set DISASMER_AGENT_PUBLIC_KEY for automation", + "pass --local to run against local services", + "pass --coordinator for an explicit self-hosted coordinator", + ]; + json!({ + "command": "run", + "status": "authentication_required", + "project_root": project, + "entry": entry, + "non_interactive": true, + "browser_opened": false, + "safe_failure": true, + "message": message, + "next_actions": next_actions, + "private_website_required": false, + "machine_error": non_interactive_auth_machine_error(message, next_actions), + }) +} + fn run_report(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result { + if args.non_interactive + && !args.local + && args.coordinator.is_none() + && !session.is_authenticated() + { + return Ok(non_interactive_run_requires_auth_report(args, cwd)); + } let plan = run_plan(args, cwd, session)?; if should_execute_local_node(&plan) { return Ok(serde_json::to_value(execute_local_node_run(plan)?)?); @@ -5062,6 +5136,35 @@ mod tests { assert_eq!(plan.session, CliSession::Anonymous); } + #[test] + fn non_interactive_run_without_session_requires_explicit_auth_or_local() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run", "--non-interactive", "--json"]) + else { + panic!("wrong command"); + }; + let report = run_report(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap(); + + assert_eq!(report["command"], "run"); + assert_eq!(report["status"], "authentication_required"); + assert_eq!(report["non_interactive"], true); + assert_eq!(report["browser_opened"], false); + assert_eq!(report["private_website_required"], false); + assert_eq!(report["machine_error"]["category"], "authentication"); + assert_eq!(report["machine_error"]["stable_exit_code"], 20); + assert_eq!(report["machine_error"]["browser_opened"], false); + let next_actions = report["machine_error"]["next_actions"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect::>(); + assert!(next_actions.contains(&"disasmer login --browser")); + assert!(next_actions.contains(&"set DISASMER_AGENT_PUBLIC_KEY for automation")); + assert!(next_actions.contains(&"pass --local to run against local services")); + } + #[test] fn run_project_and_named_entry_are_respected() { let Cli { @@ -5157,6 +5260,7 @@ mod tests { project: None, coordinator: None, local: false, + non_interactive: true, json: false, }; let plan = run_plan( @@ -5233,6 +5337,7 @@ mod tests { project: Some(temp.path().to_path_buf()), coordinator: Some(format!("http://{addr}")), local: false, + non_interactive: true, json: false, }, PathBuf::from("/unused"), @@ -5310,6 +5415,7 @@ mod tests { project: Some(temp.path().to_path_buf()), coordinator: Some(format!("http://{addr}")), local: false, + non_interactive: false, json: false, }, PathBuf::from("/unused"), @@ -5322,6 +5428,7 @@ mod tests { project: Some(temp.path().to_path_buf()), coordinator: Some(format!("http://{addr}")), local: false, + non_interactive: false, json: false, }, PathBuf::from("/unused"), @@ -5433,6 +5540,34 @@ mod tests { assert!(flow.state.starts_with("sha256:")); } + #[test] + fn browser_login_non_interactive_fails_before_opening_browser() { + let Cli { + command: Commands::Login(args), + } = parse(&["disasmer", "login", "--browser", "--non-interactive"]) + else { + panic!("wrong command"); + }; + let report = non_interactive_browser_login_report(&args); + + assert_eq!(report["command"], "login"); + assert_eq!(report["status"], "authentication_required"); + assert_eq!(report["non_interactive"], true); + assert_eq!(report["browser_requested"], true); + assert_eq!(report["browser_opened"], false); + assert_eq!(report["machine_error"]["category"], "authentication"); + assert_eq!(report["machine_error"]["stable_exit_code"], 20); + assert_eq!(report["machine_error"]["browser_opened"], false); + let next_actions = report["machine_error"]["next_actions"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect::>(); + assert!(next_actions.contains(&"rerun without --non-interactive to open the browser")); + assert!(next_actions.contains(&"use DISASMER_AGENT_PUBLIC_KEY for automation")); + } + #[test] fn browser_login_completion_detects_raw_provider_token_fields() { assert!(contains_provider_token_field(&json!({ @@ -5849,6 +5984,7 @@ mod tests { &["disasmer", "auth", "status"], &["disasmer", "logout", "--yes"], &["disasmer", "auth", "logout", "--yes"], + &["disasmer", "login", "--browser", "--non-interactive"], &[ "disasmer", "key", @@ -5866,6 +6002,7 @@ mod tests { &["disasmer", "project", "select", "project"], &["disasmer", "inspect"], &["disasmer", "build"], + &["disasmer", "run", "--non-interactive"], &["disasmer", "node", "enroll"], &["disasmer", "node", "list"], &["disasmer", "node", "status"], @@ -5961,6 +6098,13 @@ mod tests { for args in [ &["disasmer", "doctor", "--json"][..], &["disasmer", "login", "--json"], + &[ + "disasmer", + "login", + "--browser", + "--non-interactive", + "--json", + ], &["disasmer", "logout", "--yes", "--json"], &["disasmer", "auth", "status", "--json"], &[ @@ -5986,6 +6130,7 @@ mod tests { &["disasmer", "build", "--json"], &["disasmer", "bundle", "inspect", "--json"], &["disasmer", "run", "--json"], + &["disasmer", "run", "--non-interactive", "--json"], &["disasmer", "node", "attach", "--json"], &["disasmer", "node", "enroll", "--json"], &["disasmer", "process", "status", "--json"], diff --git a/scripts/cli-error-exit-smoke.js b/scripts/cli-error-exit-smoke.js index c149eb2..56c284a 100755 --- a/scripts/cli-error-exit-smoke.js +++ b/scripts/cli-error-exit-smoke.js @@ -35,6 +35,33 @@ function runDisasmer(args) { } async function main() { + const nonInteractive = await runDisasmer([ + "run", + "build", + "--project", + project, + "--non-interactive", + "--json", + ]); + assert.strictEqual(nonInteractive.signal, null, nonInteractive.stderr); + assert.strictEqual(nonInteractive.code, 20, nonInteractive.stderr); + assert.doesNotMatch(nonInteractive.stderr, /Opening Disasmer browser login/); + const nonInteractiveReport = JSON.parse(nonInteractive.stdout); + assert.strictEqual(nonInteractiveReport.status, "authentication_required"); + assert.strictEqual(nonInteractiveReport.non_interactive, true); + assert.strictEqual(nonInteractiveReport.browser_opened, false); + assert.strictEqual(nonInteractiveReport.machine_error.category, "authentication"); + assert.strictEqual(nonInteractiveReport.machine_error.stable_exit_code, 20); + assert.strictEqual( + nonInteractiveReport.machine_error.process_exit_code_applied, + true + ); + assert( + nonInteractiveReport.machine_error.next_actions.includes( + "pass --local to run against local services" + ) + ); + let request = ""; const server = net.createServer((socket) => { socket.setEncoding("utf8"); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 9c57a1f..86446b9 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -141,6 +141,9 @@ for (const [name, pattern] of [ ["top-level version metadata", /#\[command\([\s\S]*name = "disasmer"[\s\S]*version[\s\S]*arg_required_else_help = true[\s\S]*\)\]/], ["top-level primary workflow help", /after_help = "Primary workflow:[\s\S]*disasmer login --browser[\s\S]*disasmer node attach --worker[\s\S]*Disasmer: Launch Virtual Process[\s\S]*Hosted account creation happens in the browser login flow\."/], ["top-level logout command", /enum Commands[\s\S]*Logout\(AuthLogoutArgs\)[\s\S]*Auth \{/], + ["login non-interactive flag", /struct LoginArgs[\s\S]*non_interactive: bool/], + ["run non-interactive flag", /struct RunArgs[\s\S]*non_interactive: bool/], + ["non-interactive auth report", /fn non_interactive_auth_machine_error[\s\S]*browser_opened[\s\S]*false/], ["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\)/], @@ -166,6 +169,8 @@ for (const [name, pattern] of [ for (const [name, pattern] of [ ["CLI parse coverage", /fn cli_first_mvp_command_surface_parses\(\)/], ["CLI primary workflow help coverage", /fn top_level_help_exposes_primary_workflow_without_auth\(\)/], + ["CLI non-interactive run auth coverage", /fn non_interactive_run_without_session_requires_explicit_auth_or_local\(\)/], + ["CLI non-interactive browser login coverage", /fn browser_login_non_interactive_fails_before_opening_browser\(\)/], ["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\(\)/], diff --git a/scripts/cli-login-smoke.js b/scripts/cli-login-smoke.js index efc0491..3b3ea2a 100644 --- a/scripts/cli-login-smoke.js +++ b/scripts/cli-login-smoke.js @@ -18,6 +18,21 @@ function disasmer(args) { ); } +function disasmerRaw(args, env = {}) { + return cp.spawnSync( + "cargo", + ["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args], + { + cwd: repo, + encoding: "utf8", + env: { + ...process.env, + ...env, + }, + } + ); +} + const device = disasmer(["login", "--coordinator", coordinator, "--json"]); assert.strictEqual(device.coordinator, coordinator); assert(device.human_flow.Device, "default human login should use device flow"); @@ -44,4 +59,25 @@ assert.match( assert.match(browser.human_flow.Browser.callback_path, /^http:\/\/127\.0\.0\.1:\d+\/callback$/); assert.match(browser.human_flow.Browser.state, /^sha256:[a-f0-9]{64}$/); +const nonInteractiveBrowser = disasmerRaw( + ["login", "--browser", "--non-interactive", "--coordinator", coordinator, "--json"], + { + DISASMER_BROWSER_OPEN_COMMAND: + "node -e 'require(\"fs\").writeFileSync(\"/tmp/disasmer-browser-should-not-open\", \"opened\")'", + } +); +assert.strictEqual(nonInteractiveBrowser.status, 20, nonInteractiveBrowser.stderr); +assert.doesNotMatch(nonInteractiveBrowser.stderr, /Opening Disasmer browser login/); +const nonInteractiveReport = JSON.parse(nonInteractiveBrowser.stdout); +assert.strictEqual(nonInteractiveReport.status, "authentication_required"); +assert.strictEqual(nonInteractiveReport.non_interactive, true); +assert.strictEqual(nonInteractiveReport.browser_opened, false); +assert.strictEqual(nonInteractiveReport.machine_error.category, "authentication"); +assert.strictEqual(nonInteractiveReport.machine_error.stable_exit_code, 20); +assert( + nonInteractiveReport.machine_error.next_actions.includes( + "rerun without --non-interactive to open the browser" + ) +); + console.log("CLI login smoke passed"); diff --git a/scripts/docs-smoke.js b/scripts/docs-smoke.js index 4af5666..a7665a5 100644 --- a/scripts/docs-smoke.js +++ b/scripts/docs-smoke.js @@ -5,7 +5,15 @@ const fs = require("fs"); const path = require("path"); const repo = path.resolve(__dirname, ".."); -const readme = fs.readFileSync(path.join(repo, "README.md"), "utf8"); +const readmePath = path.join(repo, "README.md"); +if (!fs.existsSync(readmePath)) { + if (fs.existsSync(path.join(repo, "DISASMER_PUBLIC_TREE.json"))) { + console.log("Docs smoke skipped: root markdown is filtered from this public tree"); + process.exit(0); + } + throw new Error("README.md is missing"); +} +const readme = fs.readFileSync(readmePath, "utf8"); const userFacingDocs = [ "README.md", "MVP.md", @@ -73,7 +81,7 @@ const requiredReadmePatterns = [ ["hosted community limit", /community tier does not provide arbitrary hosted native commands or hosted containers/], ["browser login", /disasmer login --browser/], ["public-key agents", /disasmer agent enroll --public-key/], - ["noninteractive agent CLI", /DISASMER_AGENT_PUBLIC_KEY= disasmer run build/], + ["noninteractive agent CLI", /DISASMER_AGENT_PUBLIC_KEY= disasmer run --non-interactive build/], ["agent key lifecycle", /register, list, rotate, and revoke an agent key/], ["capability auto-detect", /auto-detects OS, architecture/], ["capability override", /--cap /],