From deca6b8b1aa856b460360f708d1c6b364f160183 Mon Sep 17 00:00:00 2001 From: HeapSmasher <98077186+las7@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:02:06 +0000 Subject: [PATCH 1/2] Document Homebrew and guided CLI setup Greentree-Change-Id: 555f4d5a8b45767414ac9e47f69c19fb --- README.md | 40 +++++++++++++++++++++++++++++++--------- greentree.yaml | 14 ++++++++++++++ install.sh | 8 ++++---- 3 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 greentree.yaml diff --git a/README.md b/README.md index 4403700..decd0ca 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,16 @@ machine underneath, so a paused workspace resumes mid-session. ## Install +With Homebrew on Apple silicon macOS or x86_64/arm64 Linux: + +```sh +brew install --cask reachpad/tap/reachpad +``` + +Or with the checksum-verifying installer: + ```sh -curl -fsSL https://raw.githubusercontent.com/Reachpad/reachpad-cli/main/install.sh | sh +curl -fsSL https://reachpad.dev/install | sh ``` Linux x86_64/arm64 (musl, static) and macOS arm64. The script fetches the @@ -14,19 +22,19 @@ latest release from this repository, verifies its checksum against SHA256SUMS, and installs to `~/.local/bin/reachpad` (override with `REACHPAD_INSTALL_DIR`). -## Sign in +## Get started -Run the browser-based WorkOS login: +Run Reachpad: ```sh -reachpad auth login +reachpad ``` -The CLI shows a short code and opens WorkOS hosted sign-in. WorkOS handles the -account login and any required MFA or SSO. After approval, Reachpad exchanges -the short-lived WorkOS token once and saves a user-scoped Reachpad credential -and the production endpoint with mode 0600. No password or authentication -factor is entered into Reachpad. +On first use, the CLI shows a short code, opens WorkOS hosted sign-in, and then +lists your workspaces. WorkOS handles the account login and any required MFA or +SSO. After approval, Reachpad exchanges the short-lived WorkOS token once and +saves a user-scoped Reachpad credential and the production endpoint with mode +0600. No password or authentication factor is entered into Reachpad. On a remote machine without a usable browser, run `reachpad auth login --no-browser` and open the displayed URL on another device. The manual @@ -41,6 +49,20 @@ reachpad ws create --name scratch reachpad attach ``` +Useful maintenance commands: + +```sh +reachpad doctor +reachpad update +reachpad completions bash +reachpad completions zsh +reachpad completions fish +``` + +`reachpad update` respects how Reachpad was installed: Homebrew installs are +directed to `brew upgrade --cask reachpad`, while installer-managed binaries +are updated in place after the release checksum is verified. + Docs: [reachpad.dev/docs/cli](https://reachpad.dev/docs/cli) ## Source and provenance diff --git a/greentree.yaml b/greentree.yaml new file mode 100644 index 0000000..7d4f68d --- /dev/null +++ b/greentree.yaml @@ -0,0 +1,14 @@ +version: 1 +checks: + format: + run: cargo fmt --all -- --check + required_for_publish: true + watch: false + test: + run: cargo test --workspace + required_for_publish: true + watch: false + installer: + run: shellcheck install.sh && sh -n install.sh + required_for_publish: true + watch: false diff --git a/install.sh b/install.sh index ec330de..6001966 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,7 @@ # $REACHPAD_INSTALL_DIR). POSIX sh: this runs on machines we know nothing # about, so no bashisms. # -# curl -fsSL https://raw.githubusercontent.com/Reachpad/reachpad-cli/main/install.sh | sh +# curl -fsSL https://reachpad.dev/install | sh # # Nothing here needs root, and the script refuses to guess: an unsupported # platform is an error naming the platform, never a wrong binary. @@ -60,6 +60,6 @@ case ":$PATH:" in echo " export PATH=\"$INSTALL_DIR:\$PATH\"" ;; esac -echo "next: sign in through WorkOS:" -echo " reachpad auth login" -echo "on a remote machine, use 'reachpad auth login --no-browser' and open the displayed URL elsewhere" +echo "next: start Reachpad:" +echo " reachpad" +echo "the guided first run signs in through WorkOS and lists your workspaces" From 0e28ecf1ac76c62ff09864632e210834083ca725 Mon Sep 17 00:00:00 2001 From: HeapSmasher <98077186+las7@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:24:23 +0000 Subject: [PATCH 2/2] Sync CLI v0.2.0 into installation docs Greentree-Change-Id: 4152e54963f87500f6d81db5f4c82862 --- Cargo.lock | 18 ++- Cargo.toml | 3 +- reachpad/Cargo.toml | 1 + reachpad/src/api.rs | 7 +- reachpad/src/cli.rs | 67 ++++++++++- reachpad/src/cli_auth.rs | 19 +++- reachpad/src/commands.rs | 151 ++++++++++++++++++++++-- reachpad/src/doctor.rs | 221 ++++++++++++++++++++++++++++++++++++ reachpad/src/lib.rs | 4 + reachpad/src/self_update.rs | 151 ++++++++++++++++++++++++ reachpad/src/tokenfile.rs | 28 +++++ 11 files changed, 647 insertions(+), 23 deletions(-) create mode 100644 reachpad/src/doctor.rs create mode 100644 reachpad/src/self_update.rs diff --git a/Cargo.lock b/Cargo.lock index 8230957..43d2cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ dependencies = [ [[package]] name = "authz" -version = "0.1.1" +version = "0.2.0" dependencies = [ "biscuit-auth", "blake3", @@ -338,6 +338,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -1296,7 +1305,7 @@ dependencies = [ [[package]] name = "proto" -version = "0.1.1" +version = "0.2.0" dependencies = [ "bytes", "proptest", @@ -1500,7 +1509,7 @@ dependencies = [ [[package]] name = "reach" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "authz", @@ -1509,6 +1518,7 @@ dependencies = [ "blake3", "bytes", "clap", + "clap_complete", "futures", "prost 0.13.5", "proto", @@ -1581,7 +1591,7 @@ dependencies = [ [[package]] name = "runtime" -version = "0.1.1" +version = "0.2.0" dependencies = [ "thiserror 2.0.19", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 85e0707..876d704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ resolver = "2" members = ["reachpad", "crates/proto", "crates/authz", "crates/runtime"] [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2021" rust-version = "1.85" license = "UNLICENSED" @@ -24,6 +24,7 @@ biscuit-auth = "5" blake3 = "1" bytes = "1" clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4" futures = "0.3" hex = "0.4" proptest = "1" diff --git a/reachpad/Cargo.toml b/reachpad/Cargo.toml index dcace8b..c585650 100644 --- a/reachpad/Cargo.toml +++ b/reachpad/Cargo.toml @@ -27,6 +27,7 @@ thiserror.workspace = true tokio.workspace = true tracing.workspace = true clap.workspace = true +clap_complete.workspace = true serde.workspace = true serde_json.workspace = true base64.workspace = true diff --git a/reachpad/src/api.rs b/reachpad/src/api.rs index 749cbad..b95b55d 100644 --- a/reachpad/src/api.rs +++ b/reachpad/src/api.rs @@ -814,9 +814,10 @@ impl Client { )) } - /// POST /v1/workspaces/:id/archive. Owner-only, and it destroys nothing: - /// the chain and the log stay, the workspace stops counting against - /// `max_workspaces` (I13). Returns when it was archived. + /// POST /v1/workspaces/:id/archive. Owner-only. The workspace stops + /// counting against `max_workspaces` and nothing is deleted immediately; + /// archived state follows ADR-0070's managed-retention boundary. Returns + /// when it was archived. pub async fn archive(&self, workspace: &str, biscuit_b64: &str) -> Result { let body = self .post( diff --git a/reachpad/src/cli.rs b/reachpad/src/cli.rs index c7a9e3e..bc32df8 100644 --- a/reachpad/src/cli.rs +++ b/reachpad/src/cli.rs @@ -112,6 +112,16 @@ impl Cli { #[derive(Subcommand, Debug)] pub enum Command { + /// Check the installation, saved login, endpoints, and connectivity. + Doctor, + /// Install the latest release, or print the package-manager upgrade command. + Update, + /// Generate a shell completion script on stdout. + Completions { + /// Shell whose completion syntax should be generated. + #[arg(value_enum)] + shell: CompletionShell, + }, /// Show the account's compute-credit balance. Credits, /// Workspace lifecycle (create / attach / release). @@ -185,6 +195,13 @@ pub enum Command { Token(TokenCommand), } +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum CompletionShell { + Bash, + Zsh, + Fish, +} + #[derive(Subcommand, Debug)] pub enum KeyCommand { /// Mint an API key (POST /v1/api-keys). The value is shown ONCE and is @@ -391,9 +408,9 @@ pub enum WsCommand { }, /// Archive a workspace (POST /v1/workspaces/:id/archive). /// - /// Frees the entitlement slot it holds. Nothing is deleted: the snapshot - /// chain and the event log are untouched (I4, I5) — the workspace simply - /// stops counting as live and can no longer be attached. + /// Frees the entitlement slot it holds and stops compute use. Nothing is + /// deleted immediately, but archived state follows managed retention + /// rather than a permanent-backup promise (ADR-0070). Archive { /// Workspace id. id: String, @@ -570,6 +587,50 @@ mod tests { assert_eq!(operator_token.as_deref(), Some("-")); } + #[test] + fn bare_reachpad_is_reserved_for_first_run_onboarding() { + let cli = parse(&["reachpad"]); + assert!(cli.command.is_none()); + } + + #[test] + fn maintenance_commands_and_completion_shells_parse() { + assert!(matches!( + parse(&["reachpad", "doctor"]).command, + Some(Command::Doctor) + )); + assert!(matches!( + parse(&["reachpad", "update"]).command, + Some(Command::Update) + )); + assert!(matches!( + parse(&["reachpad", "completions", "zsh"]).command, + Some(Command::Completions { + shell: CompletionShell::Zsh + }) + )); + assert!(Cli::try_parse_from(["reachpad", "completions", "nushell"]).is_err()); + } + + #[test] + fn every_supported_shell_generates_a_reachpad_script() { + use clap::CommandFactory as _; + + for generator in [ + clap_complete::Shell::Bash, + clap_complete::Shell::Zsh, + clap_complete::Shell::Fish, + ] { + let mut output = Vec::new(); + clap_complete::generate(generator, &mut Cli::command(), "reachpad", &mut output); + let script = String::from_utf8(output).unwrap(); + assert!( + script.contains("reachpad"), + "empty script for {generator:?}" + ); + } + } + #[test] fn quic_hub_url_and_dev_pin_parse() { let cli = parse(&[ diff --git a/reachpad/src/cli_auth.rs b/reachpad/src/cli_auth.rs index a410160..3b47ce0 100644 --- a/reachpad/src/cli_auth.rs +++ b/reachpad/src/cli_auth.rs @@ -306,8 +306,23 @@ pub async fn complete_device_authorization( /// flow, so a remote machine with no desktop works without port forwarding. pub fn open_browser(url: &str) -> bool { #[cfg(target_os = "linux")] - if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() { - return false; + { + // Reachpad workspaces expose the owner's real browser through this + // host command even though the remote shell has no desktop display. + // Other Linux machines fall through to their ordinary desktop opener. + if std::process::Command::new("devbox-browser-open") + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok() + { + return true; + } + if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() { + return false; + } } #[cfg(target_os = "linux")] let mut command = std::process::Command::new("xdg-open"); diff --git a/reachpad/src/commands.rs b/reachpad/src/commands.rs index 7bfad8a..3fee590 100644 --- a/reachpad/src/commands.rs +++ b/reachpad/src/commands.rs @@ -4,7 +4,9 @@ use anyhow::Context; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; +use clap::CommandFactory as _; use clap::Parser as _; +use std::io::IsTerminal as _; use crate::api::Client; use crate::attach; @@ -42,25 +44,49 @@ pub async fn run(argv: Vec) -> anyhow::Result { // only the missing default that makes the next `reachpad ws list` work. let token_path = cli.token_path(); if cli.endpoint.is_none() { - if let Some(saved) = tokenfile::read_connection_config(&token_path)? { - crate::cli_auth::validate_connection_urls(&saved.controld, &saved.hub) - .context("saved Reachpad connection configuration is unsafe")?; - if cli.controld == crate::cli::DEFAULT_CONTROLD { - cli.controld = saved.controld; + match tokenfile::read_connection_config(&token_path) { + Ok(Some(saved)) => { + crate::cli_auth::validate_connection_urls(&saved.controld, &saved.hub) + .context("saved Reachpad connection configuration is unsafe")?; + if cli.controld == crate::cli::DEFAULT_CONTROLD { + cli.controld = saved.controld; + } + if cli.hub == crate::cli::DEFAULT_HUB { + cli.hub = saved.hub; + } } - if cli.hub == crate::cli::DEFAULT_HUB { - cli.hub = saved.hub; + Ok(None) => {} + Err(_) if matches!(cli.command.as_ref(), Some(Command::Doctor)) => { + // Doctor reports the malformed file by name. Letting the + // ordinary startup path fail here would make the diagnostic + // command unable to diagnose the state it exists for. } + Err(error) => return Err(error), } } tracing::info!(mode = cfg.mode().as_str(), "reachpad ready"); let Some(command) = cli.command.take() else { - anyhow::bail!("no command given (try `reachpad --help`)"); + let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); + return run_onboarding(&cli, token_path, interactive).await; }; match command { + Command::Doctor => { + return crate::doctor::run(&cli.controld, &cli.hub, cli.trust(), &cli.token_path()) + .await; + } + Command::Update => return crate::self_update::run(), + Command::Completions { shell } => { + let generator = match shell { + crate::cli::CompletionShell::Bash => clap_complete::Shell::Bash, + crate::cli::CompletionShell::Zsh => clap_complete::Shell::Zsh, + crate::cli::CompletionShell::Fish => clap_complete::Shell::Fish, + }; + let mut stdout = std::io::stdout(); + clap_complete::generate(generator, &mut Cli::command(), "reachpad", &mut stdout); + } Command::Credits => { let credential = tokenfile::read_operator_token(&cli.token_path())?; let client = Client::with_trust(&cli.controld, cli.trust()); @@ -189,7 +215,7 @@ pub async fn run(argv: Vec) -> anyhow::Result { ); } Command::Auth(auth) => { - run_auth(&cli.controld, cli.trust(), cli.token_path(), auth).await?; + run_auth(&cli.controld, &cli.hub, cli.trust(), cli.token_path(), auth).await?; } Command::Key(key) => { run_key(&cli.controld, cli.trust(), cli.token_path(), key).await?; @@ -202,6 +228,75 @@ pub async fn run(argv: Vec) -> anyhow::Result { Ok(0) } +#[derive(Debug, PartialEq, Eq)] +enum OnboardingAction { + RefuseNonInteractive, + SignInThenList, + List, +} + +fn onboarding_action(interactive: bool, operator_token_exists: bool) -> OnboardingAction { + match (interactive, operator_token_exists) { + (false, _) => OnboardingAction::RefuseNonInteractive, + (true, false) => OnboardingAction::SignInThenList, + (true, true) => OnboardingAction::List, + } +} + +async fn run_onboarding( + cli: &Cli, + token_path: std::path::PathBuf, + interactive: bool, +) -> anyhow::Result { + let action = onboarding_action(interactive, tokenfile::operator_token_exists(&token_path)?); + if action == OnboardingAction::RefuseNonInteractive { + anyhow::bail!("no command given (try `reachpad --help`)"); + } + + let mut controld = cli.controld.clone(); + if action == OnboardingAction::SignInThenList { + eprintln!("No saved Reachpad sign-in was found. Starting browser sign-in."); + run_auth( + &controld, + &cli.hub, + cli.trust(), + token_path.clone(), + crate::cli::AuthCommand::Login { + operator_token: None, + account_url: crate::cli_auth::DEFAULT_ACCOUNT_URL.to_owned(), + no_browser: false, + }, + ) + .await?; + + let saved = tokenfile::read_connection_config(&token_path)? + .context("browser sign-in did not save the Reachpad endpoints")?; + crate::cli_auth::validate_connection_urls(&saved.controld, &saved.hub) + .context("browser sign-in saved unsafe Reachpad endpoints")?; + controld = saved.controld; + } + + println!(); + println!("Your workspaces:"); + run_ws( + &controld, + cli.trust(), + token_path, + WsCommand::List { + user: None, + principal: "dev-principal".to_owned(), + idp_assertion: None, + }, + ) + .await?; + println!(); + println!("Next commands:"); + println!(" Create a workspace: reachpad ws create --name "); + println!(" Open a workspace: reachpad ws token "); + println!(" reachpad attach "); + Ok(0) +} + impl Cli { fn token_path(&self) -> std::path::PathBuf { self.token_file @@ -235,6 +330,7 @@ fn wall_now_ms() -> u64 { /// `reachpad auth …` — the operator-credential half of ADR-0034. async fn run_auth( controld: &str, + hub: &str, trust: crate::transport::TlsTrust, token_path: std::path::PathBuf, cmd: crate::cli::AuthCommand, @@ -260,7 +356,16 @@ async fn run_auth( // Exchange BEFORE saving: a credential that does not work is // not one worth keeping on disk, and the failure names why. let session = client.operator_session(&credential).await?; + crate::cli_auth::validate_connection_urls(controld, hub) + .context("refusing to save unsafe Reachpad endpoints")?; tokenfile::write_operator_token(&token_path, &credential)?; + tokenfile::write_connection_config( + &token_path, + &tokenfile::ConnectionConfig { + controld: controld.to_owned(), + hub: hub.to_owned(), + }, + )?; println!( "logged in: user={} principal={}", session.user_id, session.principal_id @@ -269,6 +374,10 @@ async fn run_auth( " operator credential saved: {} (0600)", tokenfile::operator_path(&token_path).display() ); + println!( + " endpoints saved: {} (0600)", + tokenfile::connection_path(&token_path).display() + ); println!( " identity token valid until {} (ms since epoch); renew with `reachpad auth session`", session.expires_at_ms @@ -688,7 +797,7 @@ async fn run_ws( let presented = tokenfile::read_token(&token_path)?; let at = client.archive(&id, &presented).await?; println!("archived: workspace={id} archived_at_ms={at}"); - println!(" nothing was deleted: the snapshot chain and the event log remain"); + println!(" no data is deleted immediately; archived state follows managed retention"); } WsCommand::Token { id, @@ -800,3 +909,25 @@ fn print_inspection(i: &inspect::Inspection) { } } } + +#[cfg(test)] +mod onboarding_tests { + use super::*; + + #[test] + fn bare_command_only_onboards_an_interactive_terminal() { + assert_eq!( + onboarding_action(false, false), + OnboardingAction::RefuseNonInteractive + ); + assert_eq!( + onboarding_action(false, true), + OnboardingAction::RefuseNonInteractive + ); + assert_eq!( + onboarding_action(true, false), + OnboardingAction::SignInThenList + ); + assert_eq!(onboarding_action(true, true), OnboardingAction::List); + } +} diff --git a/reachpad/src/doctor.rs b/reachpad/src/doctor.rs new file mode 100644 index 0000000..4323fc7 --- /dev/null +++ b/reachpad/src/doctor.rs @@ -0,0 +1,221 @@ +//! Read-only diagnostics for the local CLI installation and account session. + +use std::path::Path; + +use crate::api::Client; +use crate::self_update::{install_source, InstallSource}; +use crate::{cli_auth, tokenfile, transport::TlsTrust}; + +struct Report { + failures: usize, +} + +impl Report { + fn ok(&self, name: &str, detail: impl std::fmt::Display) { + println!("[ok] {name}: {detail}"); + } + + fn fail(&mut self, name: &str, detail: impl std::fmt::Display) { + self.failures += 1; + println!("[fail] {name}: {detail}"); + } +} + +pub async fn run( + controld: &str, + hub: &str, + trust: TlsTrust, + token_path: &Path, +) -> anyhow::Result { + let mut report = Report { failures: 0 }; + let executable = std::env::current_exe(); + + report.ok("version", env!("CARGO_PKG_VERSION")); + match &executable { + Ok(path) => { + let source = match install_source(path) { + InstallSource::Homebrew => "Homebrew", + InstallSource::Development => "Cargo development build", + InstallSource::Native => "native installer", + }; + report.ok("binary", format!("{} ({source})", path.display())); + if executable_is_on_path(path) { + report.ok("PATH", "reachpad resolves to this binary"); + } else { + report.fail( + "PATH", + format!( + "{} is not the reachpad resolved through PATH", + path.display() + ), + ); + } + if install_source(path) == InstallSource::Native && !command_on_path("curl") { + report.fail("updater", "curl is required by `reachpad update`"); + } else { + report.ok("updater", "installation source has an update path"); + } + } + Err(error) => report.fail("binary", error), + } + + let endpoints_safe = match cli_auth::validate_connection_urls(controld, hub) { + Err(error) => { + report.fail("endpoints", error); + false + } + Ok(()) => { + report.ok("control endpoint", controld); + report.ok("workspace endpoint", hub); + true + } + }; + + let operator_path = tokenfile::operator_path(token_path); + let credential = match tokenfile::read_operator_token(token_path) { + Ok(credential) => { + match private_file(&operator_path) { + Ok(()) => report.ok( + "credential file", + format!("{} has mode 0600", operator_path.display()), + ), + Err(error) => report.fail("credential file", error), + } + Some(credential) + } + Err(error) => { + report.fail("saved login", error); + None + } + }; + + let connection_path = tokenfile::connection_path(token_path); + match tokenfile::read_connection_config(token_path) { + Ok(Some(saved)) => { + match private_file(&connection_path) { + Ok(()) => report.ok( + "endpoint file", + format!("{} has mode 0600", connection_path.display()), + ), + Err(error) => report.fail("endpoint file", error), + } + if let Err(error) = cli_auth::validate_connection_urls(&saved.controld, &saved.hub) { + report.fail("saved endpoints", error); + } else { + report.ok("saved endpoints", "configuration is safe"); + } + } + Ok(None) => report.ok( + "endpoint file", + "not present; command-line endpoints are active", + ), + Err(error) => report.fail("endpoint file", error), + } + + if let Some(credential) = credential.filter(|_| endpoints_safe) { + let client = Client::with_trust(controld, trust); + match client.operator_session(&credential).await { + Ok(session) => report.ok( + "account", + format!( + "authenticated as user={} principal={}", + session.user_id, session.principal_id + ), + ), + Err(error) => report.fail("account", error), + } + } + + if report.failures == 0 { + println!("doctor: all checks passed"); + Ok(0) + } else { + println!("doctor: {} check(s) failed", report.failures); + Ok(1) + } +} + +fn command_on_path(name: &str) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path).any(|directory| directory.join(name).is_file()) +} + +fn executable_is_on_path(executable: &Path) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + executable_is_on_paths(executable, std::env::split_paths(&path)) +} + +fn executable_is_on_paths( + executable: &Path, + paths: impl Iterator, +) -> bool { + let Ok(expected) = executable.canonicalize() else { + return false; + }; + paths.into_iter().any(|directory| { + directory + .join("reachpad") + .canonicalize() + .is_ok_and(|candidate| candidate == expected) + }) +} + +#[cfg(unix)] +fn private_file(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt as _; + + let metadata = + std::fs::metadata(path).map_err(|error| format!("{}: {error}", path.display()))?; + let mode = metadata.permissions().mode() & 0o777; + if mode == tokenfile::FILE_MODE { + Ok(()) + } else { + Err(format!( + "{} has mode {mode:04o}; run `chmod 600 {}`", + path.display(), + path.display() + )) + } +} + +#[cfg(not(unix))] +fn private_file(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_resolution_follows_symlinks_to_the_running_binary() { + let dir = std::env::temp_dir().join(format!("reach-doctor-path-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let binary = dir.join("actual-reachpad"); + std::fs::write(&binary, b"binary").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&binary, dir.join("reachpad")).unwrap(); + + assert!(executable_is_on_paths(&binary, [dir.clone()].into_iter())); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn credential_permission_check_requires_exactly_0600() { + use std::os::unix::fs::PermissionsExt as _; + + let path = std::env::temp_dir().join(format!("reach-doctor-mode-{}", std::process::id())); + std::fs::write(&path, b"credential").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(private_file(&path).is_ok()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(private_file(&path).is_err()); + let _ = std::fs::remove_file(path); + } +} diff --git a/reachpad/src/lib.rs b/reachpad/src/lib.rs index 6fe9267..d631dcd 100644 --- a/reachpad/src/lib.rs +++ b/reachpad/src/lib.rs @@ -34,14 +34,18 @@ //! - [`attach`] — the interactive PTY session (ADR-0033): raw mode, resize, //! ctrl-c passthrough, clean detach. The felt test of the whole system. //! - [`commands`] — command dispatch; all printing lives here. +//! - [`doctor`] — local installation, credential, and connectivity checks. +//! - [`self_update`] — package-manager-aware native updates. pub mod api; pub mod attach; pub mod cli; pub mod cli_auth; pub mod commands; +pub mod doctor; pub mod http_min; pub mod inspect; +pub mod self_update; pub mod tail; pub mod tokenfile; pub mod transport; diff --git a/reachpad/src/self_update.rs b/reachpad/src/self_update.rs new file mode 100644 index 0000000..ffbc724 --- /dev/null +++ b/reachpad/src/self_update.rs @@ -0,0 +1,151 @@ +//! Native release updates. Homebrew owns files it installs, so its update +//! path remains Homebrew rather than a second writer racing the cask. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::Context; + +const INSTALLER_URL: &str = "https://reachpad.dev/install"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InstallSource { + Homebrew, + Development, + Native, +} + +pub fn install_source(executable: &Path) -> InstallSource { + let components: Vec<_> = executable + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect(); + if components + .iter() + .any(|component| component == "Caskroom" || component == "Cellar") + { + return InstallSource::Homebrew; + } + if components + .windows(2) + .any(|pair| pair[0] == "target" && (pair[1] == "debug" || pair[1] == "release")) + { + return InstallSource::Development; + } + InstallSource::Native +} + +pub fn run() -> anyhow::Result { + let executable = std::env::current_exe().context("locating the running reachpad binary")?; + match install_source(&executable) { + InstallSource::Homebrew => { + println!("Reachpad is installed by Homebrew."); + println!("Run: brew upgrade --cask reachpad"); + return Ok(0); + } + InstallSource::Development => { + println!("Reachpad is running from a Cargo target directory."); + println!("Rebuild this checkout with: cargo build -p reach"); + return Ok(0); + } + InstallSource::Native => {} + } + + let install_dir = executable + .parent() + .context("the running reachpad binary has no parent directory")?; + anyhow::ensure!( + executable.file_name().and_then(|name| name.to_str()) == Some("reachpad"), + "refusing to update a binary not named reachpad at {}", + executable.display() + ); + + let scratch = create_scratch_dir()?; + let installer = scratch.join("install.sh"); + let result = run_native_update(&installer, install_dir); + let _ = std::fs::remove_file(&installer); + let _ = std::fs::remove_dir(&scratch); + result?; + + println!("Reachpad update completed in {}.", install_dir.display()); + println!("Run `reachpad --version` to confirm the installed version."); + Ok(0) +} + +fn create_scratch_dir() -> anyhow::Result { + let base = std::env::temp_dir(); + for sequence in 0..100_u32 { + let path = base.join(format!("reachpad-update-{}-{sequence}", std::process::id())); + match std::fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("creating update directory {}", path.display())); + } + } + } + anyhow::bail!("could not reserve a temporary directory for the update") +} + +fn run_native_update(installer: &Path, install_dir: &Path) -> anyhow::Result<()> { + let downloaded = Command::new("curl") + .args([ + "--proto", + "=https", + "--tlsv1.2", + "--fail", + "--silent", + "--show-error", + "--location", + "--output", + ]) + .arg(installer) + .arg(INSTALLER_URL) + .stdin(Stdio::null()) + .status() + .context("running curl; install curl or update through Homebrew")?; + anyhow::ensure!( + downloaded.success(), + "downloading {INSTALLER_URL} failed with {downloaded}" + ); + + let installed = Command::new("sh") + .arg(installer) + .env("REACHPAD_INSTALL_DIR", install_dir) + .stdin(Stdio::null()) + .status() + .context("running the Reachpad installer")?; + anyhow::ensure!( + installed.success(), + "the Reachpad installer exited with {installed}" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_manager_and_development_paths_are_not_self_modified() { + assert_eq!( + install_source(Path::new("/opt/homebrew/Caskroom/reachpad/0.1.1/reachpad")), + InstallSource::Homebrew + ); + assert_eq!( + install_source(Path::new( + "/home/linuxbrew/.linuxbrew/Cellar/reachpad/1/bin/reachpad" + )), + InstallSource::Homebrew + ); + assert_eq!( + install_source(Path::new("/work/reachpad/target/debug/reachpad")), + InstallSource::Development + ); + assert_eq!( + install_source(Path::new("/home/user/.local/bin/reachpad")), + InstallSource::Native + ); + } +} diff --git a/reachpad/src/tokenfile.rs b/reachpad/src/tokenfile.rs index 55a80ff..fd01a67 100644 --- a/reachpad/src/tokenfile.rs +++ b/reachpad/src/tokenfile.rs @@ -52,6 +52,17 @@ pub fn operator_path(token_path: &Path) -> PathBuf { sidecar(token_path, ".operator") } +/// Whether this credential set already has an operator credential. +/// +/// Existence is enough here. An empty or unreadable file is deliberately not +/// treated as signed out, because replacing a damaged credential without +/// naming the damage would hide the state the user needs to repair. +pub fn operator_token_exists(token_path: &Path) -> anyhow::Result { + let path = operator_path(token_path); + path.try_exists() + .with_context(|| format!("checking operator credential {}", path.display())) +} + /// Sidecar path for attach state, next to the token file. pub fn state_path(token_path: &Path) -> PathBuf { sidecar(token_path, ".state.json") @@ -306,6 +317,23 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn operator_token_existence_distinguishes_first_run_from_damage() { + let dir = scratch_dir("operator-exists"); + let path = dir.join("token"); + assert!(!operator_token_exists(&path).unwrap()); + + write_operator_token(&path, "rpop1.id.secret").unwrap(); + assert!(operator_token_exists(&path).unwrap()); + + std::fs::write(operator_path(&path), []).unwrap(); + assert!( + operator_token_exists(&path).unwrap(), + "an empty credential is damaged, not a new installation" + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn attach_state_round_trips_per_workspace_and_is_0600() { let dir = scratch_dir("state");