diff --git a/core/crates/solstone-core-sol-client-cli/src/help.rs b/core/crates/solstone-core-sol-client-cli/src/help.rs index b607dac6d..7aa74eb5c 100644 --- a/core/crates/solstone-core-sol-client-cli/src/help.rs +++ b/core/crates/solstone-core-sol-client-cli/src/help.rs @@ -87,6 +87,20 @@ pub fn render_sol_call_help(args: &[String]) -> Option { None } +#[must_use] +pub fn render_link_help(args: &[String]) -> Option { + if !args.is_empty() && !(args.len() == 1 && is_help(args[0].as_str())) { + return None; + } + let path = vec![String::from("link")]; + if !is_surface_group("sol-link", &path) { + return None; + } + Some(CommandOutput::success(render_surface_group_help( + "sol-link", "sol link", &path, + ))) +} + #[must_use] pub fn render_top_level_help(command: &str, args: &[String]) -> Option { if args.len() != 1 || !is_help(args[0].as_str()) { @@ -147,14 +161,19 @@ pub fn render_leaf_help(invocation: &str, entry: &InventoryEntry) -> String { #[must_use] pub fn render_group_help(path: &[String]) -> String { + render_surface_group_help("sol-call", &format!("sol call {}", path.join(" ")), path) +} + +#[must_use] +pub fn render_surface_group_help(surface: &str, invocation: &str, path: &[String]) -> String { let mut output = String::new(); push_line( &mut output, - &format!("Usage: sol call {} [args...]", path.join(" ")), + &format!("Usage: {invocation} [args...]"), ); output.push('\n'); push_line(&mut output, "Commands:"); - for child in immediate_children(path) { + for child in immediate_children(surface, path) { let mut line = format!(" {}", child.name); if let Some(help) = child.help { line.push_str(&format!("{:width$}{}", "", help, width = child.padding)); @@ -166,12 +185,16 @@ pub fn render_group_help(path: &[String]) -> String { #[must_use] pub fn is_sol_call_group(path: &[String]) -> bool { - if path.is_empty() || leaf_for_path("sol-call", path).is_some() { + is_surface_group("sol-call", path) +} + +fn is_surface_group(surface: &str, path: &[String]) -> bool { + if path.is_empty() || leaf_for_path(surface, path).is_some() { return false; } aggregate::entries() .iter() - .filter(|entry| entry.surface == "sol-call") + .filter(|entry| entry.surface == surface) .any(|entry| is_strict_prefix(path, entry.path)) } @@ -281,11 +304,11 @@ struct Child { padding: usize, } -fn immediate_children(path: &[String]) -> Vec { +fn immediate_children(surface: &str, path: &[String]) -> Vec { let mut names: Vec = Vec::new(); for entry in aggregate::entries() .iter() - .filter(|entry| entry.surface == "sol-call") + .filter(|entry| entry.surface == surface) { if entry.path.len() <= path.len() || !path_matches(path, entry.path) { continue; @@ -304,7 +327,7 @@ fn immediate_children(path: &[String]) -> Vec { .cloned() .chain(std::iter::once(name.clone())) .collect::>(); - let help = leaf_for_path("sol-call", &child_path).map(|entry| entry.help); + let help = leaf_for_path(surface, &child_path).map(|entry| entry.help); Child { padding: max_len.saturating_sub(name.len()) + 2, name, @@ -394,6 +417,34 @@ mod tests { assert_eq!(handler_output, output); } + #[test] + fn link_group_help_is_inventory_driven() { + let output = render_link_help(&[]).expect("bare link help"); + + assert_eq!(output.stderr, ""); + assert_eq!(output.exit, 0); + assert!( + output + .stdout + .contains("Usage: sol link [args...]") + ); + assert!(output.stdout.contains(" join")); + assert!( + output + .stdout + .contains("join a solstone with a short code or pair link") + ); + assert!(output.stdout.contains(" serve")); + assert!( + output + .stdout + .contains("serve a paired journal over the local link bridge") + ); + + let flag_output = render_link_help(&["--help".to_string()]).expect("flagged link help"); + assert_eq!(flag_output, output); + } + #[test] fn every_positive_inventory_leaf_renders_declared_metadata() { let mut seen = BTreeSet::new(); @@ -402,7 +453,7 @@ mod tests { for entry in aggregate::entries() { if !matches!( entry.surface, - "sol-call" | "sol-chat" | "sol-import" | "sol-notify" + "sol-call" | "sol-chat" | "sol-import" | "sol-link" | "sol-notify" ) { continue; } @@ -485,7 +536,7 @@ mod tests { !output.contains("Authority:"), "group help fabricated authority line for {group:?}" ); - for child in immediate_children(&group) { + for child in immediate_children("sol-call", &group) { assert!( output.contains(&format!(" {}", child.name)), "missing group child {child:?} in {group:?}" diff --git a/core/crates/solstone-core-sol/src/lib.rs b/core/crates/solstone-core-sol/src/lib.rs index 072f22990..e99730889 100644 --- a/core/crates/solstone-core-sol/src/lib.rs +++ b/core/crates/solstone-core-sol/src/lib.rs @@ -20,18 +20,27 @@ use solstone_core_journal::{ resolve_journal_path, }; use solstone_core_sol_client::command::{CommandContext, CommandOutput}; -use solstone_core_sol_client::port::read_convey_port; +use solstone_core_sol_client::port::{DEFAULT_CONVEY_PORT, read_convey_port}; use solstone_core_sol_client::resident::{ResidentHandler, ShutdownSignal}; use solstone_core_sol_client::seam::{ BuildIdentityProvider, ChatEventSource, ChatInput, ClientItemIdProvider, Clock, FileProvider, - HttpTransport, NotificationSink, NotificationSinkError, ProcessOutput, ProcessSpawner, + HttpTransport, LinkJoinPairingSeam, LinkServeRunner, NotificationSink, NotificationSinkError, + ProcessOutput, ProcessSpawner, +}; +#[cfg(target_os = "ios")] +use solstone_core_sol_client::seam::{ + LinkJoinDirectRequest, LinkJoinPairingError, LinkJoinPairingErrorKind, LinkJoinRelayRequest, + LinkServeError, LinkServeErrorKind, }; use solstone_core_sol_client::sse::SseDecoder; use solstone_core_sol_client::transport::UreqHttpTransport; use solstone_core_sol_client_cli::{ - DispatchSeams, Outcome, dispatch_sol_call_with_seams, dispatch_sol_chat_with_seams, - dispatch_sol_import_with_seams, dispatch_sol_notify_with_seams, evaluate_args, help, + DispatchSeams, LinkDispatch, LinkDispatchSeams, Outcome, dispatch_sol_call_with_seams, + dispatch_sol_chat_with_seams, dispatch_sol_import_with_seams, dispatch_sol_link_with_seams, + dispatch_sol_notify_with_seams, evaluate_args, help, }; +#[cfg(not(target_os = "ios"))] +use solstone_core_sol_link::{SplLinkJoinPairingSeam, SplLinkServeRunner}; mod generated; mod skills; @@ -51,7 +60,7 @@ const COMPAT_SENTINEL_ARMED: &str = "armed"; const COMPAT_ARGV0_MARKER_PREFIX: &str = "__solstone_native_argv0="; const COMPAT_RECURSION_ERROR: &str = "sol: compatibility dispatch recursion detected. Reinstall solstone and solstone-core."; -const TOP_LEVEL_COMPAT_COMMANDS: &[&str] = &["doctor", "check", "link"]; +const TOP_LEVEL_COMPAT_COMMANDS: &[&str] = &["doctor", "check"]; pub fn run(public_argv0: &str, args: Vec) -> ExitCode { run_with_stdin_provider(public_argv0, args, &RealStdinProvider) @@ -92,6 +101,9 @@ fn run_with_stdin_provider( [command, rest @ ..] if command == OsStr::new("import") => { run_top_level_native(public_argv0, &args, "import", rest, stdin_provider) } + [command, rest @ ..] if command == OsStr::new("link") => { + run_top_level_link(&args, rest, stdin_provider) + } [command, rest @ ..] if command == OsStr::new("notify") => { run_top_level_native(public_argv0, &args, "notify", rest, stdin_provider) } @@ -336,6 +348,108 @@ fn run_top_level_native( run_dispatched(public_argv0, all_args, command_args, stdin_provider) } +fn run_top_level_link( + all_args: &[OsString], + command_args: &[OsString], + stdin_provider: &dyn StdinProvider, +) -> ExitCode { + let args = match os_strings_to_strings(command_args) { + Some(args) => args, + None => return render_output(usage_error_output()), + }; + if let Some(output) = help::render_link_help(&args) { + return render_output(output); + } + let dispatch_args = match os_strings_to_strings(all_args) { + Some(args) => args, + None => return render_output(usage_error_output()), + }; + let today = Local::now().format("%Y%m%d").to_string(); + let journal_root = resolve_process_journal_path().ok().map(|line| line.path); + let port = journal_root + .as_ref() + .map_or(DEFAULT_CONVEY_PORT, read_convey_port); + let transport = UreqHttpTransport::new(port); + let env = env::vars().collect::>(); + let stdin = match stdin_provider.read_if_piped() { + Ok(Some(value)) => value, + Ok(None) => String::new(), + Err(error) => { + eprintln!("native sol stdin read failed: {error}"); + return ExitCode::from(EXIT_TEMPFAIL); + } + }; + let clock = SystemClock::default(); + let files = RealFileProvider; + let dispatch = dispatch_top_level_link_with_runtime_seams( + &dispatch_args, + TopLevelLinkRuntime { + env: &env, + stdin: &stdin, + today: &today, + transport: &transport, + clock: &clock, + files: &files, + journal_root: journal_root.as_deref(), + }, + ); + match dispatch { + LinkDispatch::Buffered(output) => render_output(output), + LinkDispatch::Resident { + handler, + args: resident_args, + } => { + let context = CommandContext { + args: &resident_args, + env: &env, + stdin: &stdin, + today: &today, + transport: &transport, + clock: Some(&clock), + chat_events: None, + files: Some(&files), + build_identity: None, + client_item_ids: None, + notification_sink: None, + link_pairing: Some(link_join_pairing_seam()), + link_serve: Some(link_serve_runner()), + journal_root: journal_root.as_deref(), + }; + run_resident_command(handler, context) + } + } +} + +struct TopLevelLinkRuntime<'a> { + env: &'a BTreeMap, + stdin: &'a str, + today: &'a str, + transport: &'a dyn HttpTransport, + clock: &'a dyn Clock, + files: &'a dyn FileProvider, + journal_root: Option<&'a Path>, +} + +fn dispatch_top_level_link_with_runtime_seams( + args: &[String], + runtime: TopLevelLinkRuntime<'_>, +) -> LinkDispatch { + dispatch_sol_link_with_seams( + args, + runtime.env, + runtime.stdin, + runtime.today, + LinkDispatchSeams { + transport: runtime.transport, + clock: Some(runtime.clock), + files: Some(runtime.files), + link_pairing: Some(link_join_pairing_seam()), + link_serve: Some(link_serve_runner()), + journal_root: runtime.journal_root, + }, + ) +} + fn plain_path_output(line: &JournalPathLine) -> CommandOutput { CommandOutput::success(format!("{}\n", line.path.display())) } @@ -555,6 +669,62 @@ fn run_dispatched( render_output(output) } +#[cfg(not(target_os = "ios"))] +static LINK_JOIN_PAIRING_SEAM: SplLinkJoinPairingSeam = SplLinkJoinPairingSeam; +#[cfg(not(target_os = "ios"))] +static LINK_SERVE_RUNNER: SplLinkServeRunner = SplLinkServeRunner; +#[cfg(target_os = "ios")] +static LINK_JOIN_PAIRING_SEAM: UnavailableLinkJoinPairingSeam = UnavailableLinkJoinPairingSeam; +#[cfg(target_os = "ios")] +static LINK_SERVE_RUNNER: UnavailableLinkServeRunner = UnavailableLinkServeRunner; + +fn link_join_pairing_seam() -> &'static dyn LinkJoinPairingSeam { + &LINK_JOIN_PAIRING_SEAM +} + +fn link_serve_runner() -> &'static dyn LinkServeRunner { + &LINK_SERVE_RUNNER +} + +#[cfg(target_os = "ios")] +#[derive(Debug, Default)] +struct UnavailableLinkJoinPairingSeam; + +#[cfg(target_os = "ios")] +impl LinkJoinPairingSeam for UnavailableLinkJoinPairingSeam { + fn pair_direct( + &self, + _request: LinkJoinDirectRequest, + ) -> Result { + Err(LinkJoinPairingError::new( + LinkJoinPairingErrorKind::RuntimeUnavailable, + )) + } + + fn pair_relay( + &self, + _request: LinkJoinRelayRequest, + ) -> Result { + Err(LinkJoinPairingError::new( + LinkJoinPairingErrorKind::RuntimeUnavailable, + )) + } +} + +#[cfg(target_os = "ios")] +#[derive(Debug, Default)] +struct UnavailableLinkServeRunner; + +#[cfg(target_os = "ios")] +impl LinkServeRunner for UnavailableLinkServeRunner { + fn start( + &self, + _request: solstone_core_sol_client::seam::LinkServeRequest, + ) -> Result, LinkServeError> { + Err(LinkServeError::new(LinkServeErrorKind::RuntimeUnavailable)) + } +} + fn delegate_to_compat(public_argv0: &str, all_args: &[OsString]) -> ExitCode { let existing_sentinel = env::var_os(COMPAT_SENTINEL); if let Err(output) = compat_env_preflight(existing_sentinel.as_deref()) { @@ -1213,6 +1383,44 @@ mod tests { assert!(output.contains(" journal\n")); } + #[test] + fn production_link_join_dispatch_supplies_pairing_seam() { + let env = BTreeMap::new(); + let transport = ScriptedHttpTransport::new(vec![]); + let clock = SystemClock::default(); + let files = RealFileProvider; + let args = ["link", "join", "--code", "not-a-code"] + .iter() + .map(|value| (*value).to_string()) + .collect::>(); + + match dispatch_top_level_link_with_runtime_seams( + &args, + TopLevelLinkRuntime { + env: &env, + stdin: "", + today: "20260727", + transport: &transport, + clock: &clock, + files: &files, + journal_root: None, + }, + ) { + LinkDispatch::Buffered(output) => { + assert_eq!(output.stdout, ""); + assert_eq!(output.exit, 1); + assert!(!output.stderr.contains("Link pairing seam is unavailable")); + assert!( + output + .stderr + .contains("Pair code did not match an accepted form") + ); + } + LinkDispatch::Resident { .. } => panic!("link join must stay buffered"), + } + transport.assert_done(); + } + #[test] fn project_root_resolution_returns_an_existing_directory() { assert!(