diff --git a/README.md b/README.md index fb036bb8..e950d1d4 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,22 @@ Get this running locally in a few minutes. curl -fsSL https://raw.githubusercontent.com/Robdel12/OrbitDock/main/orbitdock-server/install.sh | bash ``` -The installer sets up the binary, shell `PATH`, data directory, database, Claude hooks, and background service. +The installer sets up the binary, shell `PATH`, data directory, and database. It then asks whether +you want to install Claude hooks and whether you want OrbitDock running as a background service. -### 2. Verify it started +### 2. Start or verify the server ```bash orbitdock status orbitdock doctor ``` +If you skipped the background service during install, start OrbitDock manually with: + +```bash +orbitdock start +``` + `doctor` runs a full diagnostic — database, hooks, encryption key, disk space, port availability, and more. If something's wrong, it'll tell you. @@ -79,7 +86,7 @@ The app auto-connects to the local server. **Codex** — Open Settings → CODEX CLI → Sign in with ChatGPT (or use API key auth mode). -**Claude Code** — Install the [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code/overview) and log in. The installer already set up hooks, but if you skipped that step: `orbitdock install-hooks`. +**Claude Code** — Install the [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code/overview) and log in. If you skipped hook setup during install, run `orbitdock install-hooks`. ### 5. Create a session @@ -96,14 +103,14 @@ Running on a VPS, Raspberry Pi, NAS, or another machine: ```bash # On the server -orbitdock setup --remote --server-url https://your-server.example.com:4000 +orbitdock remote-setup # On your developer machine orbitdock install-hooks --server-url https://your-server.example.com:4000 ``` -`install-hooks` prompts for the token that `setup --remote` prints and stores it encrypted for local hook forwarding. -For the app, add the same server URL and token in Settings → Servers. +`remote-setup` guides secure exposure, creates a fresh auth token, and tells you the exact next commands +for pairing clients and forwarding hooks. For the app, add the same server URL and token in Settings → Servers. See [DEPLOYMENT.md](docs/DEPLOYMENT.md) for Cloudflare tunnels, TLS, reverse proxies, and Raspberry Pi notes. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index bc5cdade..77986a5d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -14,10 +14,11 @@ curl -fsSL https://raw.githubusercontent.com/Robdel12/OrbitDock/main/orbitdock-s ```bash orbitdock setup --local # localhost only -orbitdock setup --remote # generates auth token, binds 0.0.0.0 +orbitdock remote-setup # secure remote exposure onboarding ``` -If you're exposing the server through a tunnel, reverse proxy, or public hostname, pass `--server-url https://...` in remote mode so the printed instructions use the URL your other machines will actually reach. +`setup` is for local machine bootstrap. Use `remote-setup` when you want to expose an existing install +securely to other machines. ## Deployment Topologies @@ -42,8 +43,7 @@ Run the server on a VPS, connect from your dev machine. **On the server:** ```bash -orbitdock setup --remote --server-url https://your-server.example.com:4000 -# Copy the auth token when it is printed. OrbitDock only shows it once. +orbitdock remote-setup ``` **On your dev machine** (hooks only — no local server): @@ -53,6 +53,7 @@ orbitdock install-hooks \ --server-url https://your-server.example.com:4000 ``` +`remote-setup` prints the exact client URL and auth token instructions after it configures the server side. `install-hooks` will prompt for the token and store it encrypted in `~/.orbitdock/hook-forward.json`. For non-interactive setup, pass `--auth-token ` or set `ORBITDOCK_AUTH_TOKEN`. diff --git a/orbitdock-server/README.md b/orbitdock-server/README.md index 320989ec..9e6a9ac3 100644 --- a/orbitdock-server/README.md +++ b/orbitdock-server/README.md @@ -19,17 +19,23 @@ The installer downloads a prebuilt binary for macOS, Linux x86_64, and Linux aar - Installs `orbitdock` to `~/.orbitdock/bin/` - Ensures `~/.orbitdock/bin` is on shell `PATH` - Runs `orbitdock init` -- Runs `orbitdock install-hooks` -- Runs `orbitdock install-service --enable` +- Prompts before installing Claude Code hooks +- Prompts before installing the background service Optional flags: - `--skip-hooks` skip Claude hook setup - `--skip-service` skip launchd/systemd install +- `--enable-service` install and start the background service without prompting - `--server-url ` hooks-only mode for a remote server (skips service install) - `--auth-token ` remote server auth token for non-interactive hooks-only install - `--version ` install a specific release tag (for example `v1.2.3`) - `--force-source` skip prebuilt download and build from source with Cargo +- `-y, --yes` accept installer defaults without prompting + +On interactive runs, the installer asks before editing `~/.claude/settings.json` and before +installing a launchd/systemd service. On non-interactive runs, it keeps the install lightweight by +default and only installs the service when you pass `--enable-service`. ### Standalone Setup @@ -53,14 +59,13 @@ That's it. Codex direct sessions work immediately — the server embeds codex-co For a dev server or headless machine: ```bash -# Interactive setup (generates token, binds 0.0.0.0) -orbitdock setup --remote --server-url https://your-server.example.com:4000 - -# Or manually: -orbitdock generate-token -orbitdock start --bind 0.0.0.0:4000 +# Secure remote onboarding +orbitdock remote-setup ``` +`remote-setup` guides exposure mode, creates a fresh auth token, optionally reconfigures the background +service, and prints the exact next commands for pairing clients and forwarding hooks. + Connect a remote developer machine (hooks only — no local server needed): ```bash @@ -147,6 +152,7 @@ orbitdock [--data-dir PATH] |---------|-------------| | `start` | Start the server (also the default when you omit the subcommand) | | `setup` | Interactive wizard (init + hooks + token + service) | +| `remote-setup` | Guide secure remote exposure for an existing install | | `init` | Create data directory and run migrations | | `ensure-path` | Persist the server binary directory on your shell `PATH` | | `install-hooks` | Merge OrbitDock hooks into `~/.claude/settings.json` | diff --git a/orbitdock-server/crates/server/src/cmd_ensure_path.rs b/orbitdock-server/crates/server/src/cmd_ensure_path.rs index 24f2b2e7..119e5bcb 100644 --- a/orbitdock-server/crates/server/src/cmd_ensure_path.rs +++ b/orbitdock-server/crates/server/src/cmd_ensure_path.rs @@ -19,6 +19,7 @@ enum ShellKind { } pub fn run() -> anyhow::Result<()> { + let installer_mode = installer_mode(); let binary_path = std::env::current_exe().context("failed to resolve current executable")?; let bin_dir = binary_path.parent().ok_or_else(|| { anyhow!( @@ -53,7 +54,12 @@ pub fn run() -> anyhow::Result<()> { bin_dir.display(), profile_path.display() ); - println!(" Restart your terminal, or run:"); + if installer_mode { + println!(" New terminals will pick it up automatically."); + println!(" For this shell only, run:"); + } else { + println!(" Restart your terminal, or run:"); + } match shell_kind { ShellKind::Fish => println!(" fish_add_path {}", quote_for_shell(bin_dir)), _ => println!( @@ -66,6 +72,10 @@ pub fn run() -> anyhow::Result<()> { Ok(()) } +fn installer_mode() -> bool { + std::env::var_os("ORBITDOCK_INSTALLER_MODE").is_some() +} + fn detect_shell_kind(shell_env: Option<&str>) -> ShellKind { let raw = shell_env.unwrap_or("/bin/bash"); let name = Path::new(raw) diff --git a/orbitdock-server/crates/server/src/cmd_init.rs b/orbitdock-server/crates/server/src/cmd_init.rs index 9471dac7..95206880 100644 --- a/orbitdock-server/crates/server/src/cmd_init.rs +++ b/orbitdock-server/crates/server/src/cmd_init.rs @@ -9,6 +9,7 @@ use crate::migration_runner; use crate::paths; pub fn run(data_dir: &Path, _server_url: &str) -> anyhow::Result<()> { + let installer_mode = installer_mode(); println!(); // 1. Create directory structure @@ -31,24 +32,30 @@ pub fn run(data_dir: &Path, _server_url: &str) -> anyhow::Result<()> { // 3. Detect Tailscale let ts_ip = detect_tailscale_ip(); - println!(); - if let Some(ip) = &ts_ip { - println!(" Tailscale detected! Your IP: {}", ip); - println!(" For remote access (secure by default):"); - println!(" orbitdock generate-token"); - println!(" orbitdock start --bind 0.0.0.0:4000"); + if !installer_mode { println!(); - } + if let Some(ip) = &ts_ip { + println!(" Tailscale detected! Your IP: {}", ip); + println!(" For remote access (secure by default):"); + println!(" orbitdock generate-token"); + println!(" orbitdock start --bind 0.0.0.0:4000"); + println!(); + } - println!(" Next steps:"); - println!(" 1. Install Claude Code hooks: orbitdock install-hooks"); - println!(" 2. Start the server: orbitdock start"); - println!(" 3. Install as a service: orbitdock install-service --enable"); - println!(); + println!(" Next steps:"); + println!(" 1. Install Claude Code hooks: orbitdock install-hooks"); + println!(" 2. Start the server: orbitdock start"); + println!(" 3. Install as a service: orbitdock install-service --enable"); + println!(); + } Ok(()) } +fn installer_mode() -> bool { + std::env::var_os("ORBITDOCK_INSTALLER_MODE").is_some() +} + fn detect_tailscale_ip() -> Option { let output = std::process::Command::new("tailscale") .args(["status", "--json"]) diff --git a/orbitdock-server/crates/server/src/cmd_install_hooks.rs b/orbitdock-server/crates/server/src/cmd_install_hooks.rs index 5a515c48..84ac1840 100644 --- a/orbitdock-server/crates/server/src/cmd_install_hooks.rs +++ b/orbitdock-server/crates/server/src/cmd_install_hooks.rs @@ -40,6 +40,7 @@ pub fn run( server_url: Option<&str>, auth_token: Option<&str>, ) -> anyhow::Result<()> { + let installer_mode = installer_mode(); let settings_file = settings_path.map(PathBuf::from).unwrap_or_else(|| { dirs::home_dir() .expect("HOME not found") @@ -144,11 +145,13 @@ pub fn run( if settings_file.exists() { let backup = settings_file.with_extension("json.bak"); std::fs::copy(&settings_file, &backup)?; - println!( - " Backed up {} → {}", - settings_file.display(), - backup.display() - ); + if !installer_mode { + println!( + " Backed up {} → {}", + settings_file.display(), + backup.display() + ); + } } // Ensure parent dir exists @@ -161,25 +164,29 @@ pub fn run( std::fs::write(&settings_file, formatted)?; println!(); - if !added.is_empty() { - println!(" Added {} hook(s):", added.len()); - for h in &added { - println!(" + {}", h); + if installer_mode { + println!(" Claude Code hooks ready in {}", settings_file.display()); + } else { + if !added.is_empty() { + println!(" Added {} hook(s):", added.len()); + for h in &added { + println!(" + {}", h); + } } - } - if !updated.is_empty() { - println!(" Updated {} hook(s):", updated.len()); - for h in &updated { - println!(" ~ {}", h); + if !updated.is_empty() { + println!(" Updated {} hook(s):", updated.len()); + for h in &updated { + println!(" ~ {}", h); + } } + println!(); + println!(" Settings written to {}", settings_file.display()); } - println!(); - println!(" Settings written to {}", settings_file.display()); println!( " Hook transport config: {}", transport_config_path.display() ); - match resolved_auth_token { + match resolved_auth_token.as_deref() { Some(_) => println!(" Hook auth token: configured"), None if should_prompt_for_auth_token(target_url) => { println!(" Hook auth token: not configured"); @@ -187,15 +194,22 @@ pub fn run( " Remote requests may be rejected until you rerun `orbitdock install-hooks` with a token." ); } - None => println!(" Hook auth token: not configured"), + None if !installer_mode => println!(" Hook auth token: not configured"), + None => {} + } + if !installer_mode { + println!(" Hook forward binary: {}", resolve_hook_binary_path()); + println!(" Spool directory: {}", paths::spool_dir().display()); } - println!(" Hook forward binary: {}", resolve_hook_binary_path()); - println!(" Spool directory: {}", paths::spool_dir().display()); println!(); Ok(()) } +fn installer_mode() -> bool { + std::env::var_os("ORBITDOCK_INSTALLER_MODE").is_some() +} + fn resolve_auth_token( server_url: &str, auth_token: Option<&str>, diff --git a/orbitdock-server/crates/server/src/cmd_remote_setup.rs b/orbitdock-server/crates/server/src/cmd_remote_setup.rs new file mode 100644 index 00000000..241041e5 --- /dev/null +++ b/orbitdock-server/crates/server/src/cmd_remote_setup.rs @@ -0,0 +1,462 @@ +//! `orbitdock remote-setup` — guide secure remote exposure for an existing install. + +use std::fs; +use std::io::{self, BufRead, Write}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use crate::{auth_tokens, cmd_install_hooks, cmd_install_service, cmd_status}; + +const LOCAL_BIND_ADDR: &str = "127.0.0.1:4000"; +const REMOTE_BIND_ADDR: &str = "0.0.0.0:4000"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExposureMode { + Cloudflare, + Tailscale, + ReverseProxy, + Direct, +} + +impl ExposureMode { + fn label(self) -> &'static str { + match self { + Self::Cloudflare => "Cloudflare Tunnel", + Self::Tailscale => "Tailscale", + Self::ReverseProxy => "Existing HTTPS reverse proxy", + Self::Direct => "Direct bind / LAN / public IP", + } + } + + fn desired_bind(self) -> SocketAddr { + match self { + Self::Cloudflare | Self::ReverseProxy => LOCAL_BIND_ADDR.parse().unwrap(), + Self::Tailscale | Self::Direct => REMOTE_BIND_ADDR.parse().unwrap(), + } + } + + fn default_public_url(self) -> Option { + match self { + Self::Tailscale => detect_tailscale_url(), + Self::Direct => Some("http://your-server.example.com:4000".to_string()), + Self::Cloudflare | Self::ReverseProxy => None, + } + } + + fn public_url_prompt(self) -> Option<&'static str> { + match self { + Self::Cloudflare => { + Some("Public HTTPS URL for clients (leave blank if you'll create the tunnel later)") + } + Self::ReverseProxy => { + Some("Public HTTPS URL for clients (leave blank if you'll finish the proxy later)") + } + Self::Tailscale => None, + Self::Direct => Some("Reachable URL for clients (leave blank to use a placeholder)"), + } + } +} + +#[derive(Debug)] +struct ServiceState { + path: PathBuf, + installed: bool, + bind: Option, +} + +pub fn run(data_dir: &Path) -> anyhow::Result<()> { + println!(); + println!(" OrbitDock Remote Setup"); + println!(" ======================"); + println!(); + + let service_state = detect_service_state()?; + let active_tokens = auth_tokens::active_token_count().unwrap_or(0); + + println!(" Current state:"); + if service_state.installed { + if let Some(bind) = service_state.bind { + println!( + " Service: {} (bind {})", + service_state.path.display(), + bind + ); + } else { + println!( + " Service: {} (installed, bind unknown)", + service_state.path.display() + ); + } + } else { + println!(" Service: not installed"); + } + println!(" Auth tokens: {} active", active_tokens); + println!(); + + let exposure = prompt_exposure_mode()?; + let desired_bind = exposure.desired_bind(); + let public_url = resolve_public_url(exposure)?; + let configure_service = prompt_service_choice(&service_state, desired_bind)?; + let configure_local_hooks = + prompt_yes_no("Will Claude Code run on this machine too? [y/N]", false)?; + + println!(); + println!(" Generating a fresh auth token for this remote setup..."); + let token = cmd_status::create_token(data_dir)?; + println!(" Token: {}", token); + println!(" Copy it now and store it somewhere secure."); + println!(" (Stored hashed in the database; OrbitDock will not print it again.)"); + + if configure_service { + println!(); + println!( + " Configuring background service for {} (bind {})...", + exposure.label(), + desired_bind + ); + cmd_install_service::run(data_dir, desired_bind, true, None)?; + } else if service_state.installed { + println!(); + println!(" Leaving the existing background service unchanged."); + } else { + println!(); + println!(" Background service not installed."); + } + + if configure_local_hooks { + println!(); + println!(" Configuring local Claude Code hooks for http://127.0.0.1:4000..."); + std::env::set_var("ORBITDOCK_INSTALLER_MODE", "1"); + let hook_result = + cmd_install_hooks::run(None, Some("http://127.0.0.1:4000"), Some(token.as_str())); + std::env::remove_var("ORBITDOCK_INSTALLER_MODE"); + hook_result?; + } else { + println!(); + println!(" Local Claude Code hooks were left unchanged."); + } + + print_summary( + exposure, + desired_bind, + service_state, + configure_service, + configure_local_hooks, + public_url.as_deref(), + ); + + Ok(()) +} + +fn prompt_exposure_mode() -> anyhow::Result { + println!(" How should other devices reach this server?"); + println!(); + println!(" 1) Cloudflare Tunnel (recommended)"); + println!(" 2) Tailscale"); + println!(" 3) Existing HTTPS reverse proxy"); + println!(" 4) Direct bind / LAN / public IP (advanced)"); + println!(); + + loop { + print!(" Choice [1]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + + match input.trim() { + "" | "1" | "cloudflare" => return Ok(ExposureMode::Cloudflare), + "2" | "tailscale" => return Ok(ExposureMode::Tailscale), + "3" | "proxy" | "reverse-proxy" => return Ok(ExposureMode::ReverseProxy), + "4" | "direct" | "lan" => return Ok(ExposureMode::Direct), + _ => println!(" Please choose 1, 2, 3, or 4."), + } + } +} + +fn prompt_service_choice( + service_state: &ServiceState, + desired_bind: SocketAddr, +) -> anyhow::Result { + println!(); + if service_state.installed { + let current = service_state + .bind + .map(|bind| bind.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + return prompt_yes_no( + &format!( + "Update the existing background service (current bind {}, desired bind {})? [Y/n]", + current, desired_bind + ), + true, + ); + } + + prompt_yes_no( + "Install OrbitDock as a background service on this machine? [Y/n]", + true, + ) +} + +fn prompt_yes_no(prompt: &str, default_yes: bool) -> anyhow::Result { + loop { + print!(" {} ", prompt); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + let trimmed = input.trim(); + + if trimmed.is_empty() { + return Ok(default_yes); + } + + match trimmed.to_ascii_lowercase().as_str() { + "y" | "yes" => return Ok(true), + "n" | "no" => return Ok(false), + _ => println!(" Please answer y or n."), + } + } +} + +fn resolve_public_url(exposure: ExposureMode) -> anyhow::Result> { + let default_url = exposure.default_public_url(); + let Some(prompt) = exposure.public_url_prompt() else { + return Ok(default_url); + }; + + println!(); + print!(" {}: ", prompt); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + let trimmed = input.trim(); + + if trimmed.is_empty() { + return Ok(default_url); + } + + let default_scheme = match exposure { + ExposureMode::Cloudflare | ExposureMode::ReverseProxy => "https", + ExposureMode::Tailscale | ExposureMode::Direct => "http", + }; + + Ok(Some(normalize_public_url(trimmed, default_scheme))) +} + +fn normalize_public_url(value: &str, default_scheme: &str) -> String { + let trimmed = value.trim().trim_end_matches('/'); + if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("{default_scheme}://{trimmed}") + } +} + +fn detect_service_state() -> anyhow::Result { + let path = service_file_path(); + if !path.exists() { + return Ok(ServiceState { + path, + installed: false, + bind: None, + }); + } + + let content = fs::read_to_string(&path)?; + let bind = if cfg!(target_os = "macos") { + parse_launchd_bind(&content) + } else { + parse_systemd_bind(&content) + }; + + Ok(ServiceState { + path, + installed: true, + bind, + }) +} + +fn service_file_path() -> PathBuf { + let home = dirs::home_dir().expect("HOME not found"); + if cfg!(target_os = "macos") { + home.join("Library/LaunchAgents/com.orbitdock.server.plist") + } else { + home.join(".config/systemd/user/orbitdock-server.service") + } +} + +fn parse_launchd_bind(content: &str) -> Option { + let lines = content.lines().collect::>(); + for window in lines.windows(3) { + if window[0].contains("--bind") { + let raw = strip_string_tag(window[1])?; + return raw.parse().ok(); + } + } + None +} + +fn parse_systemd_bind(content: &str) -> Option { + let exec_line = content + .lines() + .find(|line| line.trim_start().starts_with("ExecStart="))?; + let bind_flag = exec_line.find("--bind")?; + let after_flag = &exec_line[bind_flag + "--bind".len()..]; + let bind_value = after_flag.split_whitespace().next()?; + bind_value.parse().ok() +} + +fn strip_string_tag(line: &str) -> Option<&str> { + let trimmed = line.trim(); + let start = trimmed.strip_prefix("")?; + start.strip_suffix("") +} + +fn detect_tailscale_url() -> Option { + let output = std::process::Command::new("tailscale") + .args(["status", "--json"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + let self_node = json.get("Self")?; + let addrs = self_node.get("TailscaleIPs")?.as_array()?; + let ip = addrs + .iter() + .find(|a| a.as_str().map(|s| !s.contains(':')).unwrap_or(false)) + .or_else(|| addrs.first())? + .as_str()?; + + Some(format!("http://{}:4000", ip)) +} + +fn print_summary( + exposure: ExposureMode, + desired_bind: SocketAddr, + previous_service_state: ServiceState, + service_configured: bool, + local_hooks_configured: bool, + public_url: Option<&str>, +) { + println!(); + println!(" Remote setup complete!"); + println!(" ─────────────────────"); + println!(); + println!(" Exposure: {}", exposure.label()); + println!(" Bind: {}", desired_bind); + + if service_configured { + println!(" Service: configured for {}", desired_bind); + } else if previous_service_state.installed { + let current = previous_service_state + .bind + .map(|bind| bind.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + println!(" Service: left unchanged ({})", current); + } else { + println!(" Service: not installed"); + } + + println!( + " Hooks: {}", + if local_hooks_configured { + "local Claude Code hooks configured" + } else { + "local Claude Code hooks left unchanged" + } + ); + println!(); + + match exposure { + ExposureMode::Cloudflare => { + println!(" Recommended next step:"); + println!(" Put Cloudflare in front of http://127.0.0.1:4000"); + println!( + " `orbitdock tunnel` is fine for quick testing, but not the long-term setup." + ); + } + ExposureMode::ReverseProxy => { + println!(" Recommended next step:"); + println!(" Point your reverse proxy at http://127.0.0.1:4000"); + } + ExposureMode::Tailscale => { + println!(" Recommended next step:"); + println!( + " Confirm your Tailscale device can reach {}", + desired_bind + ); + } + ExposureMode::Direct => { + println!(" Recommended next step:"); + println!(" Lock down firewall/network exposure before connecting clients."); + } + } + println!(); + + let url = public_url.unwrap_or(match exposure { + ExposureMode::Cloudflare | ExposureMode::ReverseProxy => "https://your-server.example.com", + ExposureMode::Tailscale => "http://100.x.y.z:4000", + ExposureMode::Direct => "http://your-server.example.com:4000", + }); + + println!(" Connect the macOS/iOS app:"); + println!(" Server URL: {}", url); + println!(" Auth token: use the token printed above"); + println!(); + println!(" Pair with QR / connection URL:"); + println!(" orbitdock pair --tunnel-url {}", url); + println!(); + println!(" Connect another developer machine (hooks only):"); + println!(" orbitdock install-hooks --server-url {}", url); + println!(" # Enter the auth token when prompted."); + println!(); + + if !service_configured && !previous_service_state.installed { + println!(" Start manually when you're ready:"); + println!(" orbitdock start --bind {}", desired_bind); + println!(); + } else if !service_configured && previous_service_state.bind != Some(desired_bind) { + println!(" Your current background service is still using a different bind."); + println!(" To use this remote exposure mode without changing the service:"); + println!(" orbitdock start --bind {}", desired_bind); + println!(); + } +} + +#[cfg(test)] +mod tests { + use super::{normalize_public_url, parse_launchd_bind, parse_systemd_bind}; + + #[test] + fn normalize_public_url_adds_https_scheme() { + assert_eq!( + normalize_public_url("orbitdock.example.com:4000/", "https"), + "https://orbitdock.example.com:4000" + ); + } + + #[test] + fn parse_launchd_bind_reads_bind_address() { + let content = r#" + start + --bind + 127.0.0.1:4000 + "#; + let bind = parse_launchd_bind(content).expect("bind"); + assert_eq!(bind.to_string(), "127.0.0.1:4000"); + } + + #[test] + fn parse_systemd_bind_reads_bind_address() { + let content = r#"ExecStart=/Users/test/.orbitdock/bin/orbitdock start --bind 0.0.0.0:4000 --data-dir /Users/test/.orbitdock"#; + let bind = parse_systemd_bind(content).expect("bind"); + assert_eq!(bind.to_string(), "0.0.0.0:4000"); + } +} diff --git a/orbitdock-server/crates/server/src/main.rs b/orbitdock-server/crates/server/src/main.rs index 3f85f777..0f8bcba6 100644 --- a/orbitdock-server/crates/server/src/main.rs +++ b/orbitdock-server/crates/server/src/main.rs @@ -14,6 +14,7 @@ mod cmd_init; mod cmd_install_hooks; mod cmd_install_service; mod cmd_pair; +mod cmd_remote_setup; mod cmd_setup; mod cmd_status; mod cmd_tunnel; @@ -246,6 +247,9 @@ enum Command { skip_hooks: bool, }, + /// Guide secure remote exposure for an existing install + RemoteSetup, + /// Expose the server via Cloudflare Tunnel Tunnel { /// Local server port to tunnel @@ -440,6 +444,9 @@ fn main() -> anyhow::Result<()> { }, ); } + Some(Command::RemoteSetup) => { + return cmd_remote_setup::run(&data_dir); + } _ => {} } diff --git a/orbitdock-server/install.sh b/orbitdock-server/install.sh index 20943ea0..4a143f53 100755 --- a/orbitdock-server/install.sh +++ b/orbitdock-server/install.sh @@ -27,6 +27,8 @@ VERSION="${ORBITDOCK_SERVER_VERSION:-latest}" INSTALL_ROOT="${ORBITDOCK_INSTALL_ROOT:-$HOME/.orbitdock}" SKIP_HOOKS="${ORBITDOCK_SKIP_HOOKS:-0}" SKIP_SERVICE="${ORBITDOCK_SKIP_SERVICE:-0}" +ENABLE_SERVICE="${ORBITDOCK_ENABLE_SERVICE:-0}" +ASSUME_DEFAULTS=0 FORCE_SOURCE="${ORBITDOCK_FORCE_SOURCE:-0}" SERVER_URL="" AUTH_TOKEN="${ORBITDOCK_AUTH_TOKEN:-}" @@ -58,6 +60,10 @@ while [[ $# -gt 0 ]]; do SKIP_SERVICE=1 shift ;; + --enable-service) + ENABLE_SERVICE=1 + shift + ;; --version) VERSION="$2" shift 2 @@ -70,6 +76,10 @@ while [[ $# -gt 0 ]]; do FORCE_SOURCE=1 shift ;; + -y|--yes) + ASSUME_DEFAULTS=1 + shift + ;; -h|--help) echo "Usage: install.sh [OPTIONS]" echo "" @@ -78,8 +88,10 @@ while [[ $# -gt 0 ]]; do echo " --auth-token Remote server auth token (or set ORBITDOCK_AUTH_TOKEN)" echo " --skip-hooks Skip Claude hook installation" echo " --skip-service Skip system service installation" + echo " --enable-service Install and start the background service without prompting" echo " --version Install specific version tag (default: latest)" echo " --force-source Build from source instead of downloading a prebuilt binary" + echo " -y, --yes Accept installer defaults without prompting" echo " -h, --help Show this help" exit 0 ;; @@ -107,6 +119,93 @@ normalize_tag() { fi } +can_prompt() { + [[ "$ASSUME_DEFAULTS" != "1" ]] && [[ -r /dev/tty ]] && [[ -w /dev/tty ]] +} + +prompt_yes_no() { + local prompt="$1" + local default_answer="$2" + local reply + + if ! can_prompt; then + return 2 + fi + + while true; do + printf " %s " "$prompt" > /dev/tty + if ! IFS= read -r reply < /dev/tty; then + return 2 + fi + + if [[ -z "$reply" ]]; then + reply="$default_answer" + fi + + case "$reply" in + y|Y|yes|YES|Yes) return 0 ;; + n|N|no|NO|No) return 1 ;; + *) + printf " Please answer y or n.\n" > /dev/tty + ;; + esac + done +} + +select_install_options() { + INSTALL_HOOKS=1 + INSTALL_SERVICE=0 + + if [[ "$SKIP_HOOKS" == "1" ]]; then + INSTALL_HOOKS=0 + elif can_prompt; then + echo "" > /dev/tty + if [[ -n "$SERVER_URL" ]]; then + printf " This install can configure Claude Code to forward events to %s.\n" "$SERVER_URL" > /dev/tty + printf " You can skip this for now and run it later.\n\n" > /dev/tty + if prompt_yes_no "Install Claude Code hooks for this remote server now? [Y/n]" "Y"; then + INSTALL_HOOKS=1 + else + INSTALL_HOOKS=0 + fi + else + printf " Local setup can also configure Claude Code hooks and a background service.\n" > /dev/tty + printf " Both can be done later if you'd rather keep this install lightweight.\n\n" > /dev/tty + if prompt_yes_no "Install Claude Code hooks into ~/.claude/settings.json now? [Y/n]" "Y"; then + INSTALL_HOOKS=1 + else + INSTALL_HOOKS=0 + fi + fi + fi + + if [[ -n "$SERVER_URL" || "$SKIP_SERVICE" == "1" ]]; then + INSTALL_SERVICE=0 + elif [[ "$ENABLE_SERVICE" == "1" ]]; then + INSTALL_SERVICE=1 + elif can_prompt; then + if prompt_yes_no "Install and start OrbitDock as a background service? [y/N]" "N"; then + INSTALL_SERVICE=1 + else + INSTALL_SERVICE=0 + fi + fi +} + +wait_for_local_health() { + local attempt=1 + + while [[ "$attempt" -le 20 ]]; do + if "$SERVER_BIN" health >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + attempt=$((attempt + 1)) + done + + return 1 +} + asset_names_for_platform() { local os arch os="$(uname -s)" @@ -348,7 +447,7 @@ ensure_in_path_legacy() { } if "$SERVER_BIN" --help 2>/dev/null | grep -q "ensure-path"; then - if ! "$SERVER_BIN" ensure-path; then + if ! ORBITDOCK_INSTALLER_MODE=1 "$SERVER_BIN" ensure-path; then warn "orbitdock-server ensure-path failed; falling back to legacy PATH setup." ensure_in_path_legacy USED_LEGACY_PATH_SETUP=1 @@ -360,53 +459,102 @@ else fi # ── Setup ───────────────────────────────────────────────────────────────── -info "Running initial setup..." +select_install_options + +info "Initializing OrbitDock..." if [[ -n "$SERVER_URL" ]]; then - "$SERVER_BIN" init --server-url "$SERVER_URL" + ORBITDOCK_INSTALLER_MODE=1 "$SERVER_BIN" init --server-url "$SERVER_URL" else - "$SERVER_BIN" init + ORBITDOCK_INSTALLER_MODE=1 "$SERVER_BIN" init fi -if [[ "$SKIP_HOOKS" == "1" ]]; then - warn "Skipping Claude hook installation (--skip-hooks)." -else +HOOKS_INSTALLED=0 +if [[ "$INSTALL_HOOKS" == "1" ]]; then + info "Installing Claude Code hooks..." if [[ -n "$SERVER_URL" ]]; then if [[ -n "$AUTH_TOKEN" ]]; then - "$SERVER_BIN" install-hooks --server-url "$SERVER_URL" --auth-token "$AUTH_TOKEN" + ORBITDOCK_INSTALLER_MODE=1 "$SERVER_BIN" install-hooks --server-url "$SERVER_URL" --auth-token "$AUTH_TOKEN" else - "$SERVER_BIN" install-hooks --server-url "$SERVER_URL" + ORBITDOCK_INSTALLER_MODE=1 "$SERVER_BIN" install-hooks --server-url "$SERVER_URL" fi else - "$SERVER_BIN" install-hooks + ORBITDOCK_INSTALLER_MODE=1 "$SERVER_BIN" install-hooks fi + HOOKS_INSTALLED=1 +else + info "Skipping Claude Code hooks for now." fi -if [[ "$SKIP_SERVICE" == "1" ]]; then +SERVICE_ENABLED=0 +HEALTH_OK=0 +if [[ "$INSTALL_SERVICE" == "1" ]]; then + info "Installing and starting the background service..." + ORBITDOCK_INSTALLER_MODE=1 "$SERVER_BIN" install-service --enable + SERVICE_ENABLED=1 + if [[ -z "$SERVER_URL" ]] && wait_for_local_health; then + HEALTH_OK=1 + fi +elif [[ "$SKIP_SERVICE" == "1" ]]; then if [[ -n "$SERVER_URL" ]]; then info "Skipping service install (hooks-only mode for remote server)." else warn "Skipping service installation (--skip-service)." fi else - "$SERVER_BIN" install-service --enable + info "Background service not installed." fi # ── Summary ─────────────────────────────────────────────────────────────── echo "" echo -e "${BOLD}Installation complete!${RESET}" echo "" -ok "Binary: $SERVER_BIN" +ok "Binary installed: $SERVER_BIN" if [[ -n "$SERVER_URL" ]]; then - ok "Hooks pointing to: $SERVER_URL" + if [[ "$HOOKS_INSTALLED" == "1" ]]; then + ok "Claude Code hooks will forward to: $SERVER_URL" + else + warn "Claude Code hooks were not installed." + echo " Install them later with:" + echo " orbitdock install-hooks --server-url $SERVER_URL" + fi echo "" - echo " Your local Claude Code sessions will report to the remote server." - echo " No local server is running — events are forwarded to $SERVER_URL." + echo " No local server is running on this machine." + echo " Local Claude Code events will be forwarded to $SERVER_URL once hooks are installed." else - ok "Health endpoint: http://127.0.0.1:4000/health" + if [[ "$HOOKS_INSTALLED" == "1" ]]; then + ok "Claude Code hooks installed" + else + info "Claude Code hooks not installed" + echo " Install them later with:" + echo " orbitdock install-hooks" + fi + echo "" + if [[ "$SERVICE_ENABLED" == "1" && "$HEALTH_OK" == "1" ]]; then + ok "Local server is running: http://127.0.0.1:4000/health" + echo "" + echo " Next:" + echo " orbitdock status" + echo " orbitdock doctor" + elif [[ "$SERVICE_ENABLED" == "1" ]]; then + warn "Background service installed, but the server is not healthy yet." + echo "" + echo " Check:" + echo " orbitdock status" + echo " orbitdock doctor" + else + info "Background service not installed" + echo "" + echo " Start it when you're ready:" + echo " orbitdock start" + echo "" + echo " Or install it as a service later:" + echo " orbitdock install-service --enable" + fi echo "" - echo " Verify with: orbitdock status" + echo " Want secure remote access later?" + echo " orbitdock remote-setup" fi if [[ "$USED_LEGACY_PATH_SETUP" == "1" && "$NEEDS_PATH_RELOAD" == "1" ]]; then