diff --git a/crates/treq-napi/src/dispatch.rs b/crates/treq-napi/src/dispatch.rs index 8bda4537..2dbb3716 100644 --- a/crates/treq-napi/src/dispatch.rs +++ b/crates/treq-napi/src/dispatch.rs @@ -634,6 +634,40 @@ pub fn dispatch(command: &str, args: Value) -> Result { serde_json::to_value(result).map_err(|e| e.to_string()) } + // ── Remote SSH ─────────────────────────────────────────────────── + "list_ssh_hosts" => { + let hosts = treq_lib::core::remote::list_configured_hosts()?; + serde_json::to_value(hosts).map_err(|e| e.to_string()) + } + + "check_ssh_host" => { + let host = get_str(&args, "host")?; + let readiness = treq_lib::core::remote::check_readiness(&host)?; + serde_json::to_value(readiness).map_err(|e| e.to_string()) + } + + "remote_probe_repo" => { + let host = get_str(&args, "host")?; + let path = get_str(&args, "path")?; + let probe = treq_lib::core::remote::probe_repo(&host, &path)?; + serde_json::to_value(probe).map_err(|e| e.to_string()) + } + + "remote_clone_repo" => { + let host = get_str(&args, "host")?; + let repo_url = get_str(&args, "repoUrl")?; + let destination = get_str(&args, "destination")?; + let repo = treq_lib::core::remote::clone_repo(&host, &repo_url, &destination)?; + serde_json::to_value(repo).map_err(|e| e.to_string()) + } + + "remote_open_repo" => { + let host = get_str(&args, "host")?; + let path = get_str(&args, "path")?; + let repo = treq_lib::core::remote::open_repo(&host, &path); + serde_json::to_value(repo).map_err(|e| e.to_string()) + } + // ── Tauri-runtime-only: silent no-ops ───────────────────────────── "pty_create_session" | "pty_session_exists" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2d8db383..7bbe1895 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod file_watcher; pub mod filesystem; pub mod pending_review; pub mod pty_commands; +pub mod remote; pub mod session; pub mod settings; pub mod workspace; @@ -18,6 +19,7 @@ pub use file_watcher::*; pub use filesystem::*; pub use pending_review::*; pub use pty_commands::*; +pub use remote::*; pub use session::*; pub use settings::*; pub use workspace::*; diff --git a/src-tauri/src/commands/pty_commands.rs b/src-tauri/src/commands/pty_commands.rs index ec489da3..47514b2b 100644 --- a/src-tauri/src/commands/pty_commands.rs +++ b/src-tauri/src/commands/pty_commands.rs @@ -10,6 +10,7 @@ pub fn pty_create_session( shell: Option, initial_command: Option, suppress_echo_for: Option, + remote_host: Option, ) -> Result<(), String> { log::debug!( "pty_create_session: session_id={}, working_dir={:?}, shell={:?}, initial_command_present={}, suppress_echo_for_present={}", @@ -23,10 +24,22 @@ pub fn pty_create_session( let sid = session_id.clone(); let event_name = format!("pty-data-{}", sid); - pty_manager.create_session( + let (shell, shell_args, working_dir, initial_command) = if let Some(host) = remote_host { + let (program, args) = crate::core::remote::build_ssh_shell_command( + &host, + working_dir.as_deref(), + initial_command.as_deref(), + )?; + (Some(program), args, None, None) + } else { + (shell, Vec::new(), working_dir, initial_command) + }; + + let result = pty_manager.create_session( session_id, working_dir, shell, + shell_args, initial_command, suppress_echo_for, Box::new(move |data| { @@ -39,7 +52,8 @@ pub fn pty_create_session( ); } }), - ) + ); + result } #[tauri::command] diff --git a/src-tauri/src/commands/remote.rs b/src-tauri/src/commands/remote.rs new file mode 100644 index 00000000..3bd95b5f --- /dev/null +++ b/src-tauri/src/commands/remote.rs @@ -0,0 +1,51 @@ +use crate::core::remote::{self, RemoteReadiness, RemoteRepoProbe, RemoteRepository, SshHost}; + +#[tauri::command] +pub fn list_ssh_hosts() -> Result, String> { + remote::list_configured_hosts() +} + +#[tauri::command] +pub async fn check_ssh_host(host: String) -> Result { + tauri::async_runtime::spawn_blocking(move || remote::check_readiness(&host)) + .await + .map_err(|e| format!("Failed to join check_ssh_host task: {e}"))? +} + +#[tauri::command] +pub async fn remote_probe_repo(host: String, path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || remote::probe_repo(&host, &path)) + .await + .map_err(|e| format!("Failed to join remote_probe_repo task: {e}"))? +} + +#[tauri::command] +pub async fn remote_clone_repo( + host: String, + repo_url: String, + destination: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || remote::clone_repo(&host, &repo_url, &destination)) + .await + .map_err(|e| format!("Failed to join remote_clone_repo task: {e}"))? +} + +#[tauri::command] +pub fn remote_open_repo(host: String, path: String) -> Result { + Ok(remote::open_repo(&host, &path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_open_repo_returns_remote_identity() { + let repo = remote_open_repo("devbox".to_string(), "/srv/project".to_string()).unwrap(); + + assert_eq!(repo.host, "devbox"); + assert_eq!(repo.path, "/srv/project"); + assert_eq!(repo.display_name, "devbox:project"); + assert_eq!(repo.repo_uri, "ssh://devbox/srv/project"); + } +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index a0f62307..31c98793 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,6 +1,7 @@ pub mod app; pub mod changes; pub mod commits; +pub mod remote; pub mod repo; pub mod workspaces; pub use app::*; diff --git a/src-tauri/src/core/remote.rs b/src-tauri/src/core/remote.rs new file mode 100644 index 00000000..2bb5d487 --- /dev/null +++ b/src-tauri/src/core/remote.rs @@ -0,0 +1,303 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SshHost { + pub alias: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteReadinessCheck { + pub name: String, + pub available: bool, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteReadiness { + pub host: String, + pub connected: bool, + pub checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteRepoProbe { + pub host: String, + pub path: String, + pub exists: bool, + pub is_repo: bool, + pub needs_clone: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteRepository { + pub host: String, + pub path: String, + pub display_name: String, + pub repo_uri: String, +} + +pub fn parse_ssh_config_hosts(contents: &str) -> Vec { + let mut aliases = BTreeSet::new(); + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let mut parts = trimmed.split_whitespace(); + let Some(keyword) = parts.next() else { + continue; + }; + if !keyword.eq_ignore_ascii_case("host") { + continue; + } + for alias in parts { + if alias == "*" || alias.contains('*') || alias.contains('?') || alias.starts_with('!') + { + continue; + } + aliases.insert(alias.to_string()); + } + } + aliases.into_iter().map(|alias| SshHost { alias }).collect() +} + +pub fn list_configured_hosts() -> Result, String> { + list_configured_hosts_from_paths(ssh_config_paths()) +} + +pub fn list_configured_hosts_from_paths(paths: Vec) -> Result, String> { + let mut all = BTreeSet::new(); + for path in paths { + if let Ok(contents) = fs::read_to_string(path) { + for host in parse_ssh_config_hosts(&contents) { + all.insert(host.alias); + } + } + } + Ok(all.into_iter().map(|alias| SshHost { alias }).collect()) +} + +pub fn check_readiness(host: &str) -> Result { + validate_host_alias(host)?; + let script = "set -u; for c in treq jj git; do command -v $c >/dev/null 2>&1 && echo $c:ok || echo $c:missing; done; for c in claude codex cursor-agent cursor; do command -v $c >/dev/null 2>&1 && echo agent:$c:ok; done"; + let output = ssh_output(host, script)?; + let stdout = String::from_utf8_lossy(&output.stdout); + let mut checks = Vec::new(); + for line in stdout.lines() { + let parts: Vec<_> = line.split(':').collect(); + match parts.as_slice() { + [name, "ok"] => checks.push(RemoteReadinessCheck { + name: (*name).to_string(), + available: true, + detail: "available".to_string(), + }), + [name, "missing"] => checks.push(RemoteReadinessCheck { + name: (*name).to_string(), + available: false, + detail: "missing from PATH".to_string(), + }), + ["agent", name, "ok"] => checks.push(RemoteReadinessCheck { + name: format!("agent:{name}"), + available: true, + detail: "available".to_string(), + }), + _ => {} + } + } + Ok(RemoteReadiness { + host: host.to_string(), + connected: output.status.success(), + checks, + }) +} + +pub fn probe_repo(host: &str, path: &str) -> Result { + validate_host_alias(host)?; + validate_remote_path(path)?; + let quoted = shell_quote(path); + let script = format!("if [ -d {quoted} ]; then echo exists; if [ -d {quoted}/.jj ] || [ -d {quoted}/.git ]; then echo repo; fi; else echo missing; fi"); + let output = ssh_output(host, &script)?; + let stdout = String::from_utf8_lossy(&output.stdout); + let exists = stdout.lines().any(|line| line == "exists"); + let is_repo = stdout.lines().any(|line| line == "repo"); + Ok(RemoteRepoProbe { + host: host.to_string(), + path: path.to_string(), + exists, + is_repo, + needs_clone: !is_repo, + }) +} + +pub fn clone_repo( + host: &str, + repo_url: &str, + destination: &str, +) -> Result { + validate_host_alias(host)?; + validate_remote_path(destination)?; + if repo_url.trim().is_empty() { + return Err("Repository URL is required".to_string()); + } + let quoted_url = shell_quote(repo_url); + let quoted_destination = shell_quote(destination); + let script = format!("git clone {quoted_url} {quoted_destination} && cd {quoted_destination} && treq st >/dev/null 2>&1 || true"); + let output = ssh_output(host, &script)?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(open_repo(host, destination)) +} + +pub fn open_repo(host: &str, path: &str) -> RemoteRepository { + let display_name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(path) + .to_string(); + RemoteRepository { + host: host.to_string(), + path: path.to_string(), + display_name: format!("{host}:{display_name}"), + repo_uri: format!("ssh://{host}{path}"), + } +} + +pub fn build_ssh_shell_command( + host: &str, + working_dir: Option<&str>, + initial_command: Option<&str>, +) -> Result<(String, Vec), String> { + validate_host_alias(host)?; + let mut remote_command = String::new(); + if let Some(dir) = working_dir { + validate_remote_path(dir)?; + remote_command.push_str("cd "); + remote_command.push_str(&shell_quote(dir)); + remote_command.push_str(" && "); + } + remote_command.push_str("${SHELL:-/bin/sh} -l"); + if let Some(command) = initial_command { + if !command.trim().is_empty() { + remote_command.push_str(" -c "); + remote_command.push_str(&shell_quote(command)); + } + } + Ok(( + "ssh".to_string(), + vec![host.to_string(), "-t".to_string(), remote_command], + )) +} + +fn ssh_config_paths() -> Vec { + let mut paths = Vec::new(); + if let Ok(home) = std::env::var("HOME") { + paths.push(PathBuf::from(home).join(".ssh").join("config")); + } + paths.push(PathBuf::from("/etc/ssh/ssh_config")); + paths +} + +fn ssh_output(host: &str, script: &str) -> Result { + Command::new("ssh") + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("ConnectTimeout=10") + .arg(host) + .arg(script) + .output() + .map_err(|e| format!("Failed to run ssh: {e}")) +} + +pub fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn validate_host_alias(host: &str) -> Result<(), String> { + if host.trim().is_empty() { + return Err("SSH host is required".to_string()); + } + if host + .chars() + .any(|c| c.is_whitespace() || matches!(c, ';' | '&' | '|' | '`' | '$' | '<' | '>')) + { + return Err("SSH host must be a host alias from ssh config".to_string()); + } + Ok(()) +} + +fn validate_remote_path(path: &str) -> Result<(), String> { + if path.trim().is_empty() { + return Err("Remote path is required".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ssh_hosts_ignoring_patterns() { + let hosts = parse_ssh_config_hosts("\nHost prod bastion\n HostName example.com\nHost *\nHost !blocked *.internal test?\nHost dev\n"); + let aliases: Vec<_> = hosts.into_iter().map(|h| h.alias).collect(); + assert_eq!(aliases, vec!["bastion", "dev", "prod"]); + } + + #[test] + fn lists_configured_hosts_from_multiple_files() { + let temp_dir = tempfile::tempdir().unwrap(); + let user_config = temp_dir.path().join("user_config"); + let system_config = temp_dir.path().join("system_config"); + std::fs::write(&user_config, "Host dev prod\n User test\n").unwrap(); + std::fs::write(&system_config, "Host prod staging *.ignored\n").unwrap(); + + let hosts = list_configured_hosts_from_paths(vec![user_config, system_config]).unwrap(); + let aliases: Vec<_> = hosts.into_iter().map(|host| host.alias).collect(); + + assert_eq!(aliases, vec!["dev", "prod", "staging"]); + } + + #[test] + fn probe_repo_rejects_empty_path_before_ssh() { + let error = probe_repo("devbox", "").unwrap_err(); + assert_eq!(error, "Remote path is required"); + } + + #[test] + fn clone_repo_rejects_empty_url_before_ssh() { + let error = clone_repo("devbox", "", "/srv/project").unwrap_err(); + assert_eq!(error, "Repository URL is required"); + } + + #[test] + fn quotes_remote_paths_with_single_quotes() { + assert_eq!(shell_quote("/tmp/a b/it's"), "'/tmp/a b/it'\\''s'"); + } + + #[test] + fn builds_ssh_shell_command_with_working_dir() { + let (program, args) = build_ssh_shell_command("devbox", Some("/srv/my app"), None).unwrap(); + assert_eq!(program, "ssh"); + assert_eq!(args[0], "devbox"); + assert!(args[2].contains("cd '/srv/my app'")); + } + + #[test] + fn rejects_unsafe_host_aliases() { + assert!(build_ssh_shell_command("dev; rm -rf /", None, None).is_err()); + } + + #[test] + fn opens_repo_identity_for_remote_path() { + let repo = open_repo("devbox", "/srv/project"); + assert_eq!(repo.display_name, "devbox:project"); + assert_eq!(repo.repo_uri, "ssh://devbox/srv/project"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9b678748..d1b8738e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -214,9 +214,14 @@ pub fn run() { .accelerator("CmdOrCtrl+Shift+O") .build(app)?; + let open_ssh_item = MenuItemBuilder::with_id("open_ssh", "Open via SSH...") + .accelerator("CmdOrCtrl+Alt+O") + .build(app)?; + let file_menu = SubmenuBuilder::new(app, "File") .item(&open_item) .item(&open_new_window_item) + .item(&open_ssh_item) .build()?; // Edit menu with native shortcuts @@ -325,9 +330,14 @@ pub fn run() { .accelerator("CmdOrCtrl+Shift+O") .build(app)?; + let open_ssh_item = MenuItemBuilder::with_id("open_ssh", "Open via SSH...") + .accelerator("CmdOrCtrl+Alt+O") + .build(app)?; + let file_menu = SubmenuBuilder::new(app, "File") .item(&open_item) .item(&open_new_window_item) + .item(&open_ssh_item) .build()?; // Go menu items @@ -401,6 +411,7 @@ pub fn run() { "settings" => emit_to_focused(app, "navigate-to-settings", ()), "open" => emit_to_focused(app, "menu-open-repository", ()), "open_new_window" => emit_to_focused(app, "menu-open-in-new-window", ()), + "open_ssh" => emit_to_focused(app, "menu-open-ssh", ()), "open_web_inspector" => { #[cfg(debug_assertions)] @@ -498,6 +509,11 @@ pub fn run() { commands::load_pending_review, commands::save_pending_review, commands::clear_pending_review, + commands::list_ssh_hosts, + commands::check_ssh_host, + commands::remote_probe_repo, + commands::remote_clone_repo, + commands::remote_open_repo, commands::set_window_repo_path, commands::get_window_repo_path, commands::rebase_home_repo_branch, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index ada02863..83559632 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -133,6 +133,7 @@ impl PtyManager { session_id: String, working_dir: Option, shell: Option, + shell_args: Vec, initial_command: Option, suppress_echo_for: Option, callback: Box, @@ -159,6 +160,9 @@ impl PtyManager { }); let mut cmd = CommandBuilder::new(&shell_cmd); + for arg in shell_args { + cmd.arg(arg); + } if let Some(dir) = working_dir { cmd.cwd(dir); } diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 352c1c36..7e04509b 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -38,7 +38,11 @@ import { getWorkspaces, initRepo, listRepoBranches, + listSshHosts, listWorkspaceStatuses, + remoteCloneRepo, + remoteOpenRepo, + remoteProbeRepo, selectFolder, setSessionModel, setSetting, @@ -360,6 +364,60 @@ export const Dashboard: React.FC = ({ }, }); + const rememberRemoteHost = useCallback(async (host: string) => { + const raw = await getSetting("remote_ssh_recent_hosts").catch(() => null); + const hosts = raw + ? (JSON.parse(raw) as string[]).filter((item) => item !== host) + : []; + await setSetting( + "remote_ssh_recent_hosts", + JSON.stringify([host, ...hosts].slice(0, 10)), + ); + }, []); + + const handleOpenRemoteSsh = useCallback(async () => { + const configuredHosts = await listSshHosts().catch(() => []); + const recentRaw = await getSetting("remote_ssh_recent_hosts").catch( + () => null, + ); + const recentHosts = recentRaw ? (JSON.parse(recentRaw) as string[]) : []; + const suggestedHost = recentHosts[0] ?? configuredHosts[0]?.alias ?? ""; + const host = window.prompt("SSH host alias", suggestedHost)?.trim(); + if (!host) return; + + const remotePath = window + .prompt("Remote repository directory", "~/src/project") + ?.trim(); + if (!remotePath) return; + + try { + const probe = await remoteProbeRepo(host, remotePath); + let remoteRepo = probe.is_repo + ? await remoteOpenRepo(host, remotePath) + : null; + if (!remoteRepo) { + const repoUrl = window + .prompt("No repository was found there. Git URL to clone") + ?.trim(); + if (!repoUrl) return; + remoteRepo = await remoteCloneRepo(host, repoUrl, remotePath); + } + await rememberRemoteHost(host); + await setSetting("last_opened_remote_repo", JSON.stringify(remoteRepo)); + addToast({ + title: "Remote SSH repository ready", + description: `${remoteRepo.display_name} is ready for SSH terminal sessions.`, + type: "success", + }); + } catch (error) { + addToast({ + title: "Remote SSH failed", + description: error instanceof Error ? error.message : String(error), + type: "error", + }); + } + }, [addToast, rememberRemoteHost, listSshHosts]); + const handleOpenRepository = useCallback(async () => { const selected = await selectFolder(); if (!selected) return; @@ -417,6 +475,7 @@ export const Dashboard: React.FC = ({ }), // Menu open repository listen("menu-open-repository", () => handleOpenRepository()), + listen("menu-open-ssh", () => handleOpenRemoteSsh()), // Menu factory reset listen("menu-factory-reset", async () => { const confirmed = await ask( @@ -527,6 +586,7 @@ export const Dashboard: React.FC = ({ queryClient, deleteWorkspaceMutation, handleOpenRepository, + handleOpenRemoteSsh, ]); // Note: Git merge functionality removed - using JJ now @@ -954,7 +1014,10 @@ export const Dashboard: React.FC = ({ ); return !repoPath ? ( - + ) : (
Promise; + onOpenRemoteSsh?: () => Promise; } -export const Onboarding: React.FC = ({ onOpenRepo }) => ( +export const Onboarding: React.FC = ({ + onOpenRepo, + onOpenRemoteSsh, +}) => (
@@ -28,6 +32,16 @@ export const Onboarding: React.FC = ({ onOpenRepo }) => ( + {onOpenRemoteSsh && ( + + )}
diff --git a/src/lib/api-extra.ts b/src/lib/api-extra.ts index a390c10a..c0abdf3c 100644 --- a/src/lib/api-extra.ts +++ b/src/lib/api-extra.ts @@ -18,6 +18,7 @@ export const ptyCreateSession = ( shell?: string, initialCommand?: string, suppressEchoFor?: string, + remoteHost?: string, ): Promise => invoke("pty_create_session", { sessionId, @@ -25,6 +26,7 @@ export const ptyCreateSession = ( shell, initialCommand, suppressEchoFor, + remoteHost: remoteHost ?? null, }); export const ptyWrite = (sessionId: string, data: string): Promise => diff --git a/src/lib/api-types.ts b/src/lib/api-types.ts index 85e2e3f2..3a009120 100644 --- a/src/lib/api-types.ts +++ b/src/lib/api-types.ts @@ -365,3 +365,34 @@ export interface ConflictRegion { line_map: number[]; comparison: ConflictComparisonView; } + +export interface SshHost { + alias: string; +} + +export interface RemoteReadinessCheck { + name: string; + available: boolean; + detail: string; +} + +export interface RemoteReadiness { + host: string; + connected: boolean; + checks: RemoteReadinessCheck[]; +} + +export interface RemoteRepoProbe { + host: string; + path: string; + exists: boolean; + is_repo: boolean; + needs_clone: boolean; +} + +export interface RemoteRepository { + host: string; + path: string; + display_name: string; + repo_uri: string; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index da7fca02..da0ef804 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -20,6 +20,10 @@ import type { Workspace, WorkspaceSidebarStatus, WorkspaceStatus, + SshHost, + RemoteReadiness, + RemoteRepoProbe, + RemoteRepository, } from "./api-types"; import { invoke } from "@tauri-apps/api/core"; @@ -438,3 +442,25 @@ export const dryRunHomeRepoRebase = ( }); // PTY API + +export const listSshHosts = (): Promise => invoke("list_ssh_hosts"); + +export const checkSshHost = (host: string): Promise => + invoke("check_ssh_host", { host }); + +export const remoteProbeRepo = ( + host: string, + path: string, +): Promise => invoke("remote_probe_repo", { host, path }); + +export const remoteCloneRepo = ( + host: string, + repoUrl: string, + destination: string, +): Promise => + invoke("remote_clone_repo", { host, repoUrl, destination }); + +export const remoteOpenRepo = ( + host: string, + path: string, +): Promise => invoke("remote_open_repo", { host, path }); diff --git a/test/integration/remote-ssh.test.ts b/test/integration/remote-ssh.test.ts new file mode 100644 index 00000000..620482cb --- /dev/null +++ b/test/integration/remote-ssh.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + listSshHosts, + remoteOpenRepo, + remoteProbeRepo, +} from "../../src/lib/api"; + +describe("remote SSH integration", () => { + it("opens a remote repository identity through the NAPI dispatcher", async () => { + const repo = await remoteOpenRepo("devbox", "/srv/project"); + + expect(repo).toEqual({ + host: "devbox", + path: "/srv/project", + display_name: "devbox:project", + repo_uri: "ssh://devbox/srv/project", + }); + }); + + it("rejects unsafe SSH host aliases before opening a connection", async () => { + await expect( + remoteProbeRepo("devbox; rm -rf /", "/srv/project"), + ).rejects.toThrow("SSH host must be a host alias from ssh config"); + }); + + it("lists SSH hosts as an array even when no user config is present", async () => { + await expect(listSshHosts()).resolves.toEqual(expect.any(Array)); + }); +}); diff --git a/web/docs/how-to/remote-ssh-workspaces.md b/web/docs/how-to/remote-ssh-workspaces.md new file mode 100644 index 00000000..4e6aac95 --- /dev/null +++ b/web/docs/how-to/remote-ssh-workspaces.md @@ -0,0 +1,48 @@ +--- +sidebar_position: 5 +--- + +# Remote SSH Workspaces + +Treq can connect to a remote development node through your existing SSH configuration so you can prepare a repository directory, clone a repository when needed, and run remote terminal or agent sessions from the client UI. + +## Prerequisites + +Treq does **not** install software on remote nodes. Before opening a remote workspace, install and configure these tools yourself on the remote host: + +- Treq CLI or remote helper available on `PATH` +- `jj` for workspace and commit operations +- `git` for cloning repositories and interacting with Git remotes +- Any coding agents you want to run remotely, such as Claude Code, Codex, or Cursor tooling + +On the client, configure SSH in `~/.ssh/config`. Host aliases, identity files, and `ProxyJump` entries should live there. If `ssh my-host` works in a terminal, Treq can use the same host alias. + +## Open a remote repository + +1. Choose **File → Open via SSH...** or select **Open via SSH** from the onboarding screen. +2. Enter an SSH host alias from your SSH config. +3. Enter the remote repository directory. +4. If the directory does not contain a repository, enter a Git URL so Treq can clone it into that directory. + +Treq stores recent SSH hosts locally so you can reconnect quickly later. + +## Proxy jumps and tunnels + +Use normal SSH config for proxy jumps and tunnels. For example: + +```ssh-config +Host devbox + HostName devbox.internal + User you + ProxyJump bastion + LocalForward 8080 127.0.0.1:8080 +``` + +Treq invokes SSH through the configured host alias and does not need to know the proxy details. + +## Troubleshooting + +- **Connection fails**: verify `ssh ` works outside Treq. +- **Missing tool**: install the missing tool on the remote node and ensure it is on `PATH` for login shells. +- **Clone fails**: confirm the remote node has network access and credentials for the Git URL. +- **Wrong directory**: use an absolute remote path when `~` expansion is not available in your shell. diff --git a/web/sidebars.ts b/web/sidebars.ts index 6a0980ea..32e87e59 100644 --- a/web/sidebars.ts +++ b/web/sidebars.ts @@ -32,7 +32,7 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Guides', link: {type: 'doc', id: 'guides/index'}, - items: [], + items: ['how-to/remote-ssh-workspaces'], }, { type: 'category',