diff --git a/CHANGELOG.md b/CHANGELOG.md index f60cb0d..2e760ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Remote** — `void remote status` reports `local_version` and `remote_version` so version skew between the client and the server binary is visible at a glance. + +### Fixed + +- **Remote** — Proxied write commands print a warning on stderr when the server's `void` is a different version than the local client, instead of surfacing confusing `unexpected argument` errors from the older remote binary. + ## [0.11.1] - 2026-08-20 ### Fixed diff --git a/crates/void-cli/src/cli.rs b/crates/void-cli/src/cli.rs index ada969c..93ed3b9 100644 --- a/crates/void-cli/src/cli.rs +++ b/crates/void-cli/src/cli.rs @@ -548,6 +548,40 @@ mod tests { assert!(matches!(cli.command, Some(Command::Forward(_)))); } + #[test] + fn parse_gmail_draft_create_accepts_cc_with_connection() { + // Regression for #66: --cc must not be rejected as a typo for --connection. + let cli = parse(&[ + "void", + "gmail", + "draft", + "create", + "--to", + "a@b.com", + "--cc", + "c@d.com", + "--subject", + "s", + "--body", + "b", + "--connection", + "me@example.com", + ]); + match cli.command { + Some(Command::Gmail(ref g)) => match &g.command { + commands::gmail::GmailCommand::Draft(d) => match &d.action { + commands::gmail::DraftAction::Create(a) => { + assert_eq!(a.cc.as_deref(), Some("c@d.com")); + assert_eq!(a.connection.as_deref(), Some("me@example.com")); + } + other => panic!("expected Create, got {other:?}"), + }, + other => panic!("expected Draft, got {other:?}"), + }, + other => panic!("expected Gmail, got {other:?}"), + } + } + // --- Unsupported connector forward rejection --- #[test] diff --git a/crates/void-core/src/store/mod.rs b/crates/void-core/src/store/mod.rs index a678785..2bd0869 100644 --- a/crates/void-core/src/store/mod.rs +++ b/crates/void-core/src/store/mod.rs @@ -302,6 +302,9 @@ impl ResolvedContext { .unwrap_or(false); let daemon_running = remote_daemon_running(&remote.ssh, &remote.remote_store_path); + let remote_version = ssh_check + .then(|| remote_void_version(&remote.ssh)) + .flatten(); Ok(serde_json::json!({ "mode": "remote", @@ -315,6 +318,8 @@ impl ResolvedContext { "ssh_reachable": ssh_check, "remote_daemon_running": daemon_running, "proxy_writes": remote.proxy_writes, + "local_version": normalize_void_version(env!("CARGO_PKG_VERSION")), + "remote_version": remote_version, })) } @@ -355,6 +360,12 @@ impl ResolvedContext { } }; + if let Some(warning) = + version_skew_message(targets.void_version.as_deref(), env!("CARGO_PKG_VERSION")) + { + eprintln!("{warning}"); + } + let store_path = remote.ssh.resolve_path_on_host(&remote.remote_store_path)?; let mut parts = vec![targets.void_bin.clone(), "--config".to_string()]; @@ -472,6 +483,43 @@ fn remote_daemon_running(ssh: &SshTarget, remote_store_path: &str) -> bool { .unwrap_or(false) } +fn remote_void_version(ssh: &SshTarget) -> Option { + let output = ssh + .run_remote(&format!( + "{REMOTE_PATH_PREFIX}; void --version 2>/dev/null | head -n1" + )) + .ok()?; + if !output.status.success() { + return None; + } + let raw = String::from_utf8(output.stdout).ok()?; + let version = normalize_void_version(&raw); + (!version.is_empty()).then(|| version.to_string()) +} + +/// Reduce `void --version` output to a bare version string. +/// +/// Accepts `void 0.11.1`, `0.11.1`, and trailing build metadata +/// (`void 0.11.1 (abc1234)`), so comparisons never trip on formatting. +fn normalize_void_version(raw: &str) -> &str { + let raw = raw.trim(); + let rest = raw.strip_prefix("void ").unwrap_or(raw); + rest.split_whitespace().next().unwrap_or("") +} + +/// Warning to emit when the remote `void` differs from the local one, if any. +fn version_skew_message(remote_version: Option<&str>, local_version: &str) -> Option { + let remote = normalize_void_version(remote_version?); + let local = normalize_void_version(local_version); + if remote.is_empty() || remote == local { + return None; + } + Some(format!( + "warning: remote void is {remote}, local is {local}. \ + Update the server binary or proxied flags may fail with confusing clap errors." + )) +} + fn shell_escape(arg: &str) -> String { if arg.is_empty() { return "''".to_string(); @@ -515,4 +563,23 @@ mod tests { let bin = "/Users/me/bin/void"; assert_eq!(shell_escape(bin), bin); } + + #[test] + fn normalize_void_version_strips_prefix() { + assert_eq!(normalize_void_version("void 0.11.1"), "0.11.1"); + assert_eq!(normalize_void_version("0.11.1"), "0.11.1"); + assert_eq!(normalize_void_version(" void 0.10.3 "), "0.10.3"); + assert_eq!(normalize_void_version("void 0.11.1 (abc1234)"), "0.11.1"); + assert_eq!(normalize_void_version(" "), ""); + } + + #[test] + fn version_skew_message_only_on_mismatch() { + assert_eq!(version_skew_message(None, "0.11.1"), None); + assert_eq!(version_skew_message(Some("void 0.11.1"), "0.11.1"), None); + assert_eq!(version_skew_message(Some(""), "0.11.1"), None); + let warning = version_skew_message(Some("void 0.10.3"), "0.11.1").expect("skew warning"); + assert!(warning.contains("remote void is 0.10.3")); + assert!(warning.contains("local is 0.11.1")); + } } diff --git a/crates/void-core/src/store/remote/ssh.rs b/crates/void-core/src/store/remote/ssh.rs index ed00091..20289a9 100644 --- a/crates/void-core/src/store/remote/ssh.rs +++ b/crates/void-core/src/store/remote/ssh.rs @@ -9,6 +9,8 @@ use super::REMOTE_PATH_PREFIX; pub struct RemoteProxyTargets { pub config_path: String, pub void_bin: String, + /// Remote `void --version` output (e.g. `void 0.11.1`), if resolvable. + pub void_version: Option, } #[derive(Debug, Clone)] @@ -64,7 +66,8 @@ impl SshTarget { "{REMOTE_PATH_PREFIX}; \ home=$(printf %s \"$HOME\"); \ bin=$(command -v void); \ - printf '%s\n%s\n' \"$home\" \"$bin\"" + ver=$(\"$bin\" --version 2>/dev/null | head -n1); \ + printf '%s\n%s\n%s\n' \"$home\" \"$bin\" \"$ver\"" ))?; if !output.status.success() { return Err(ConfigError::Remote( @@ -89,6 +92,11 @@ impl SshTarget { .into(), ) })?; + let void_version = lines + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); let resolved_config = if let Some(rest) = config_path.strip_prefix("~/") { format!("{home}/{rest}") @@ -101,6 +109,7 @@ impl SshTarget { Ok(RemoteProxyTargets { config_path: resolved_config, void_bin: void_bin.to_string(), + void_version, }) } diff --git a/docs/remote-store.md b/docs/remote-store.md index d97d4fa..8abe34a 100644 --- a/docs/remote-store.md +++ b/docs/remote-store.md @@ -59,13 +59,15 @@ All `[store.remote]` options: ## Inspecting and refreshing ```bash -# SSH connectivity, cache age, remote daemon state +# SSH connectivity, cache age, remote daemon state, client/server versions void remote status # Force-refresh config + DB snapshot void remote refresh ``` +`void remote status` also reports `local_version` (this client) and `remote_version` (the server's `void`, `null` when unreachable). When they differ, proxied write commands print a warning: an outdated server binary rejects flags the local client accepts. + Useful flags: - `--config ` — pick a different local client profile