diff --git a/README.md b/README.md index e3154dd..ccc4355 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ Both the `open` and `recent` commands share the same set of launch arguments: - `--command`: Specify which editor command to use (e.g., "code", "code-insiders", "cursor") - `--behavior`: Set the launch behavior ("detect", "force-container", "force-classic") - `--config`: Override the path to the dev container config file, or pass a config name to resolve from the config directory +- `--remote-host`: Open the given path on a remote SSH host alias configured for VS Code Remote SSH - Additional arguments can be passed to the editor executable by specifying them after `--` The `recent` command additionally supports: @@ -245,6 +246,8 @@ vscli open /path/to/project # open vscode in the specified directory The default behavior tries to detect whether the project is a [dev container](https://containers.dev/) project. If it is, it will launch the dev container instead - if not it will launch vscode normally. +These behaviors apply to local workspaces. Remote SSH workspaces always open as remote folders; `--behavior detect` and `--behavior force-container` are not supported with `--remote-host`. + You can change the launch behavior using the `--behavior` flag: ```sh @@ -261,6 +264,7 @@ You can specify which editor command to use with the `--command` flag: vscli open --command cursor . # open using cursor editor vscli open --command code . # open using vscode (default) vscli open --command code-insiders . # open using vscode insiders +vscli open --remote-host my-ec2 /home/ec2-user/app # open a remote folder over SSH ``` Additional arguments can be passed to the editor executable, by specifying them after `--`: @@ -281,6 +285,7 @@ vscli recent --command cursor # open the selected project with vscli recent --behavior force-container # force open the selected project in a dev container vscli recent --command cursor --behavior detect # open with cursor and detect if dev container should be used vscli recent --config .devcontainer/custom.json # open with a specific dev container config +vscli recent --remote-host my-ec2 # reopen the selected project on a remembered remote host vscli recent -- --disable-gpu # pass additional arguments to the editor vscli recent --hide-instructions # hide the keybinding instructions from the UI vscli recent --hide-info # hide additional information like strategy, command, args and dev container path @@ -306,11 +311,25 @@ vscli config copy rust-dev # copy into the current direct vscli config copy rust-dev ~/projects/my-app # copy into another project directory ``` +#### Remote SSH Hosts + +If you already use VS Code Remote SSH, you can point `vscli` at a remote host alias and remote path: + +```sh +vscli open --remote-host my-ec2 /home/ec2-user/app +vscli recent --remote-host my-ec2 +``` + +This opens the workspace using a `vscode-remote://ssh-remote+...` folder URI and stores the remote host in `recent` history so you can reopen it from the UI later. + +`vscli` does not manage dev containers on remote SSH hosts. Remote workspaces are opened as SSH folders; if the remote folder contains a `.devcontainer` setup, VS Code Dev Containers may offer to reopen it in a container afterward. + #### Environment Variables | Variable | Description | | --- | --- | | `VSCLI_CONFIG_DIR` | Override the config directory (default: `~/.local/share/vscli/configs`) | | `VSCLI_EDITOR` | Editor command for `config ui` and `container ui` (default: `code`) | +| `VSCLI_REMOTE_HOST` | Default remote SSH host alias for `open` and `recent` | | `HISTORY_PATH` | Override the history file path | | `DRY_RUN` | Enable dry-run mode | diff --git a/src/history.rs b/src/history.rs index 8a2e9fd..06e2155 100644 --- a/src/history.rs +++ b/src/history.rs @@ -28,6 +28,9 @@ pub struct Entry { pub config_name: Option, /// The path to the vscode workspace pub workspace_path: PathBuf, + /// The remote SSH host alias, if the workspace was opened remotely. + #[serde(default)] + pub remote_host: Option, /// The path to the dev container config, if it exists pub config_path: Option, /// The launch behavior @@ -41,6 +44,7 @@ pub struct Entry { impl PartialEq for Entry { fn eq(&self, other: &Self) -> bool { self.workspace_path == other.workspace_path + && self.remote_host == other.remote_host && self.config_path == other.config_path && self.behavior == other.behavior } @@ -225,3 +229,59 @@ impl Tracker { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::{Entry, History, Tracker}; + use crate::launch::{Behavior, ContainerStrategy}; + use chrono::Utc; + use std::ffi::OsString; + use std::path::PathBuf; + + fn unique_test_path(name: &str) -> PathBuf { + let unique = format!( + "vscli-history-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + std::env::temp_dir().join(unique).join("history.json") + } + + #[test] + fn tracker_store_and_load_preserve_remote_host_entries() { + let path = unique_test_path("remote-host"); + let mut tracker = Tracker { + path: path.clone(), + history: History::default(), + }; + + tracker.history.upsert(Entry { + workspace_name: "workspace".to_string(), + dev_container_name: None, + config_name: None, + workspace_path: PathBuf::from("/home/dev/workspace"), + remote_host: Some("vscli-remote-test".to_string()), + config_path: None, + behavior: Behavior { + strategy: ContainerStrategy::ForceClassic, + args: vec![OsString::from("--reuse-window")], + command: "code".to_string(), + }, + last_opened: Utc::now(), + }); + + tracker.store().unwrap(); + + let loaded = Tracker::load(path).unwrap(); + let entries = loaded.history.into_entries(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].remote_host.as_deref(), Some("vscli-remote-test")); + assert_eq!( + entries[0].behavior.strategy, + ContainerStrategy::ForceClassic + ); + } +} diff --git a/src/launch.rs b/src/launch.rs index fbdaf17..7efd685 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -150,6 +150,17 @@ impl Setup { ) -> Result> { let editor_name = format_editor_name(&self.behavior.command); + if self.workspace.remote_host.is_some() { + info!("Opening remote workspace over SSH with {editor_name}..."); + + self.workspace.open_classic( + self.behavior.args, + self.dry_run, + &self.behavior.command, + )?; + return Ok(None); + } + match self.behavior.strategy { ContainerStrategy::Detect => { let dev_container = self.detect(config)?; diff --git a/src/main.rs b/src/main.rs index d5d9e55..6fed365 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ mod workspace; use chrono::Utc; use clap::Parser; -use color_eyre::eyre::{Result, WrapErr}; +use color_eyre::eyre::{Result, WrapErr, bail}; use log::trace; use std::io::Write; use std::path::{Path, PathBuf}; @@ -27,8 +27,8 @@ use crate::config_store::ConfigStore; use crate::history::{Entry, Tracker}; use crate::{ - launch::{Behavior, Setup}, - opts::Opts, + launch::{Behavior, ContainerStrategy, Setup}, + opts::{LaunchArgs, Opts}, workspace::Workspace, }; @@ -69,12 +69,175 @@ fn workspace_root_from_config( current = parent; }; let path_abs = std::fs::canonicalize(path_arg).unwrap_or(path_arg.to_path_buf()); - let sub = if path_abs.starts_with(&root) && path_abs != root { - path_abs.strip_prefix(&root).ok().map(Path::to_path_buf) + if path_abs.starts_with(&root) { + let sub = if path_abs == root { + None + } else { + path_abs.strip_prefix(&root).ok().map(Path::to_path_buf) + }; + Ok((root, sub)) } else { - None + Ok((path_abs, None)) + } +} + +fn resolve_strategy_for_remote( + remote_host: Option<&str>, + strategy: Option, +) -> Result { + if remote_host.is_some() { + match strategy { + None | Some(ContainerStrategy::ForceClassic) => Ok(ContainerStrategy::ForceClassic), + Some(ContainerStrategy::Detect) => { + bail!("--behavior detect is not supported with --remote-host.") + } + Some(ContainerStrategy::ForceContainer) => { + bail!("--behavior force-container is not supported with --remote-host.") + } + } + } else { + Ok(strategy.unwrap_or_default()) + } +} + +fn normalize_recent_strategy_for_remote( + remote_host: Option<&str>, + cli_strategy: Option, + stored_strategy: ContainerStrategy, +) -> Result { + if remote_host.is_none() { + return Ok(cli_strategy.unwrap_or(stored_strategy)); + } + + match cli_strategy { + Some(strategy) => resolve_strategy_for_remote(remote_host, Some(strategy)), + None => Ok(ContainerStrategy::ForceClassic), + } +} + +fn ensure_remote_has_no_config(remote_host: Option<&str>, config: Option<&Path>) -> Result<()> { + if remote_host.is_some() && config.is_some() { + bail!( + "--config cannot be combined with --remote-host; point vscli at the remote workspace path instead." + ); + } + + Ok(()) +} + +fn open_workspace( + path: &Path, + launch: LaunchArgs, + tracker: &mut Tracker, + config_store: &ConfigStore, + dry_run: bool, +) -> Result<()> { + let resolved_config = resolve_launch_config(launch.config.as_ref(), config_store)?; + ensure_remote_has_no_config(launch.remote_host.as_deref(), resolved_config.as_deref())?; + let config_name = resolved_config + .as_ref() + .and_then(|p| config_store::config_name_from_path(p, config_store)); + + let (workspace_path, subfolder) = if let Some(ref config) = resolved_config { + workspace_root_from_config(config, path)? + } else { + (path.to_path_buf(), None) + }; + + let ws = if let Some(remote_host) = launch.remote_host.clone() { + Workspace::from_remote_path(&workspace_path, remote_host)? + } else { + Workspace::from_path(&workspace_path)? }; - Ok((root, sub)) + let ws_name = ws.name.clone(); + let tracked_workspace_path = ws.path.clone(); + let remote_host = ws.remote_host.clone(); + + let behavior = Behavior { + strategy: resolve_strategy_for_remote(ws.remote_host.as_deref(), launch.behavior)?, + args: launch.args, + command: launch.command.unwrap_or_else(|| "code".to_string()), + }; + let setup = Setup::new(ws, behavior.clone(), dry_run); + let dev_container = setup.launch(resolved_config, subfolder.as_deref())?; + + tracker.history.upsert(Entry { + workspace_name: ws_name, + dev_container_name: dev_container.as_ref().and_then(|dc| dc.name.clone()), + config_name, + workspace_path: tracked_workspace_path, + remote_host, + config_path: dev_container.map(|dc| dc.config_path), + behavior, + last_opened: Utc::now(), + }); + + Ok(()) +} + +fn reopen_recent( + launch: LaunchArgs, + tracker: &mut Tracker, + config_store: &ConfigStore, + dry_run: bool, + hide_instructions: bool, + hide_info: bool, +) -> Result<()> { + let res = ui::start(tracker, hide_instructions, hide_info)?; + if let Some((id, mut entry)) = res { + let remote_host = launch.remote_host.clone().or(entry.remote_host.clone()); + let ws = if let Some(remote_host) = remote_host.clone() { + Workspace::from_remote_path(&entry.workspace_path, remote_host)? + } else { + Workspace::from_path(&entry.workspace_path)? + }; + let ws_name = ws.name.clone(); + let tracked_workspace_path = ws.path.clone(); + + if let Some(cmd) = launch.command { + entry.behavior.command = cmd; + } + let cli_strategy = launch.behavior; + if !launch.args.is_empty() { + entry.behavior.args = launch.args; + } + + entry.behavior.strategy = normalize_recent_strategy_for_remote( + remote_host.as_deref(), + cli_strategy, + entry.behavior.strategy, + )?; + + let resolved_config = if launch.config.is_some() { + resolve_launch_config(launch.config.as_ref(), config_store)? + } else { + entry.config_path.clone() + }; + ensure_remote_has_no_config(remote_host.as_deref(), resolved_config.as_deref())?; + + let config_name = resolved_config + .as_ref() + .and_then(|p| config_store::config_name_from_path(p, config_store)); + + let setup = Setup::new(ws, entry.behavior.clone(), dry_run); + let dev_container = setup.launch(resolved_config, None)?; + + tracker.history.update( + id, + Entry { + workspace_name: ws_name, + dev_container_name: dev_container.as_ref().and_then(|dc| dc.name.clone()), + config_name, + workspace_path: tracked_workspace_path, + remote_host, + config_path: dev_container.map(|dc| dc.config_path), + behavior: entry.behavior.clone(), + last_opened: Utc::now(), + }, + ); + } + + Ok(()) } fn main() -> Result<()> { @@ -95,38 +258,7 @@ fn main() -> Result<()> { match opts.command { opts::Commands::Open { path, launch } => { let mut tracker = load_tracker(opts.history_path)?; - - let resolved_config = resolve_launch_config(launch.config.as_ref(), &config_store)?; - let config_name = resolved_config - .as_ref() - .and_then(|p| config_store::config_name_from_path(p, &config_store)); - - let (workspace_path, subfolder) = if let Some(ref config) = resolved_config { - workspace_root_from_config(config, &path)? - } else { - (path.clone(), None) - }; - - let ws = Workspace::from_path(&workspace_path)?; - let ws_name = ws.name.clone(); - - let behavior = Behavior { - strategy: launch.behavior.unwrap_or_default(), - args: launch.args, - command: launch.command.unwrap_or_else(|| "code".to_string()), - }; - let setup = Setup::new(ws, behavior.clone(), opts.dry_run); - let dev_container = setup.launch(resolved_config, subfolder.as_deref())?; - - tracker.history.upsert(Entry { - workspace_name: ws_name, - dev_container_name: dev_container.as_ref().and_then(|dc| dc.name.clone()), - config_name, - workspace_path: workspace_path.canonicalize()?, - config_path: dev_container.map(|dc| dc.config_path), - behavior, - last_opened: Utc::now(), - }); + open_workspace(&path, launch, &mut tracker, &config_store, opts.dry_run)?; tracker.store()?; } opts::Commands::Recent { @@ -135,47 +267,14 @@ fn main() -> Result<()> { hide_info, } => { let mut tracker = load_tracker(opts.history_path)?; - let res = ui::start(&mut tracker, hide_instructions, hide_info)?; - if let Some((id, mut entry)) = res { - let ws = Workspace::from_path(&entry.workspace_path)?; - let ws_name = ws.name.clone(); - - if let Some(cmd) = launch.command { - entry.behavior.command = cmd; - } - if let Some(beh) = launch.behavior { - entry.behavior.strategy = beh; - } - if !launch.args.is_empty() { - entry.behavior.args = launch.args; - } - - let resolved_config = if launch.config.is_some() { - resolve_launch_config(launch.config.as_ref(), &config_store)? - } else { - entry.config_path.clone() - }; - - let config_name = resolved_config - .as_ref() - .and_then(|p| config_store::config_name_from_path(p, &config_store)); - - let setup = Setup::new(ws, entry.behavior.clone(), opts.dry_run); - let dev_container = setup.launch(resolved_config, None)?; - - tracker.history.update( - id, - Entry { - workspace_name: ws_name, - dev_container_name: dev_container.as_ref().and_then(|dc| dc.name.clone()), - config_name, - workspace_path: entry.workspace_path.clone(), - config_path: dev_container.map(|dc| dc.config_path), - behavior: entry.behavior.clone(), - last_opened: Utc::now(), - }, - ); - } + reopen_recent( + launch, + &mut tracker, + &config_store, + opts.dry_run, + hide_instructions, + hide_info, + )?; tracker.store()?; } opts::Commands::Config { action } => { @@ -222,3 +321,97 @@ fn log_format( writeln!(buf, "{}: {}", colored_level, record.args()) } } + +#[cfg(test)] +mod tests { + use super::{ + normalize_recent_strategy_for_remote, resolve_strategy_for_remote, + workspace_root_from_config, + }; + use crate::launch::ContainerStrategy; + use std::path::{Path, PathBuf}; + + fn unique_test_dir(name: &str) -> PathBuf { + let unique = format!( + "vscli-main-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + std::env::temp_dir().join(unique) + } + + #[test] + fn preserves_project_path_for_external_config() { + let root = unique_test_dir("external-config"); + let config = root + .join("configs") + .join("rust-dev") + .join(".devcontainer") + .join("devcontainer.json"); + let project = root.join("projects").join("my-app"); + + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(&config, "{}\n").unwrap(); + + let (workspace, subfolder) = workspace_root_from_config(&config, &project).unwrap(); + + assert_eq!(workspace, project.canonicalize().unwrap()); + assert_eq!(subfolder, None); + } + + #[test] + fn derives_subfolder_when_path_is_inside_config_workspace() { + let root = unique_test_dir("subfolder"); + let workspace = root.join("workspace"); + let config = workspace.join(".devcontainer").join("devcontainer.json"); + let project = workspace.join("packages").join("api"); + + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(&config, "{}\n").unwrap(); + + let (resolved_workspace, subfolder) = + workspace_root_from_config(&config, &project).unwrap(); + + assert_eq!(resolved_workspace, workspace.canonicalize().unwrap()); + assert_eq!(subfolder.as_deref(), Some(Path::new("packages/api"))); + } + + #[test] + fn remote_workspaces_default_to_force_classic() { + let strategy = resolve_strategy_for_remote(Some("remote-test"), None).unwrap(); + assert_eq!(strategy, ContainerStrategy::ForceClassic); + } + + #[test] + fn remote_workspaces_reject_detect_behavior() { + let err = resolve_strategy_for_remote(Some("remote-test"), Some(ContainerStrategy::Detect)) + .unwrap_err(); + assert!(err.to_string().contains("not supported with --remote-host")); + } + + #[test] + fn remote_workspaces_reject_force_container_behavior() { + let err = resolve_strategy_for_remote( + Some("remote-test"), + Some(ContainerStrategy::ForceContainer), + ) + .unwrap_err(); + assert!(err.to_string().contains("not supported with --remote-host")); + } + + #[test] + fn recent_remote_reopen_coerces_stored_detect_to_force_classic() { + let strategy = normalize_recent_strategy_for_remote( + Some("remote-test"), + None, + ContainerStrategy::Detect, + ) + .unwrap(); + assert_eq!(strategy, ContainerStrategy::ForceClassic); + } +} diff --git a/src/opts.rs b/src/opts.rs index 40be306..cf16f9c 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -50,6 +50,10 @@ pub(crate) struct LaunchArgs { #[arg(long, env)] pub config: Option, + /// Open the workspace on a remote SSH host. + #[arg(long, env = "VSCLI_REMOTE_HOST")] + pub remote_host: Option, + /// Additional arguments to pass to the editor #[arg(value_parser, env)] pub args: Vec, diff --git a/src/workspace.rs b/src/workspace.rs index 503b0cb..137cb76 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -87,6 +87,8 @@ pub struct Workspace { pub path: PathBuf, /// The name of the workspace. pub name: String, + /// The remote SSH host alias, if this workspace is remote. + pub remote_host: Option, } impl Workspace { @@ -104,16 +106,29 @@ impl Workspace { trace!("Canonicalized path: {path_log}"); // get workspace name (either directory or file name) - let workspace_name = path - .file_name() - .ok_or_else(|| eyre!("Error getting workspace from path"))? - .to_string_lossy() - .into_owned(); + let workspace_name = workspace_name(&path)?; trace!("Workspace name: {workspace_name}"); let ws = Workspace { path, name: workspace_name, + remote_host: None, + }; + trace!("{ws:?}"); + Ok(ws) + } + + /// Creates a new remote `Workspace` from the given absolute path and SSH host alias. + pub fn from_remote_path(path: &Path, remote_host: String) -> Result { + if !path.is_absolute() { + bail!("Remote path must be absolute: {}", path.display()); + } + + let workspace_name = workspace_name(path)?; + let ws = Workspace { + path: path.to_path_buf(), + name: workspace_name, + remote_host: Some(remote_host), }; trace!("{ws:?}"); Ok(ws) @@ -185,6 +200,14 @@ impl Workspace { bail!("Specifying `--folder-uri` is not possible while using vscli."); } + if self.remote_host.is_some() { + let uri = self.remote_folder_uri(subfolder); + args.push(OsString::from("--folder-uri")); + args.push(OsString::from(uri)); + return exec_code(args, dry_run, command) + .wrap_err_with(|| "Error opening vscode using remote SSH..."); + } + let mut container_folder: String = dev_container.workspace_path_in_container.clone(); if let Some(sub) = subfolder { let sub_str = sub.to_string_lossy().replace('\\', "/"); @@ -276,10 +299,63 @@ impl Workspace { trace!("path: {}", self.path.display()); trace!("args: {args:?}"); + if args.iter().any(|arg| arg == "--folder-uri") { + bail!("Specifying `--folder-uri` is not possible while using vscli."); + } + + if self.remote_host.is_some() { + let uri = self.remote_folder_uri(None); + args.push(OsString::from("--folder-uri")); + args.push(OsString::from(uri)); + return exec_code(args, dry_run, command) + .wrap_err_with(|| "Error opening vscode using remote SSH..."); + } + args.insert(0, self.path.as_os_str().to_owned()); exec_code(args, dry_run, command) .wrap_err_with(|| "Error opening vscode the classic way...") } + + fn remote_folder_uri(&self, subfolder: Option<&Path>) -> String { + let host = self + .remote_host + .as_deref() + .expect("remote folder URI requires remote host"); + let remote_path = remote_workspace_path(&self.path, subfolder); + format!("vscode-remote://ssh-remote+{host}{remote_path}") + } +} + +fn workspace_name(path: &Path) -> Result { + if let Some(name) = path.file_name() { + return Ok(name.to_string_lossy().into_owned()); + } + + let display = path.display().to_string(); + if display.is_empty() { + Err(eyre!("Error getting workspace from path")) + } else { + Ok(display) + } +} + +fn remote_workspace_path(path: &Path, subfolder: Option<&Path>) -> String { + let mut remote_path = path.to_string_lossy().replace('\\', "/"); + if !remote_path.starts_with('/') { + remote_path.insert(0, '/'); + } + + if let Some(subfolder) = subfolder { + let subfolder = subfolder.to_string_lossy().replace('\\', "/"); + if !subfolder.is_empty() && subfolder != "." { + if !remote_path.ends_with('/') { + remote_path.push('/'); + } + remote_path.push_str(subfolder.trim_start_matches('/')); + } + } + + remote_path } /// Executes the vscode executable with the given arguments on Unix. @@ -382,4 +458,35 @@ mod tests { let result = DevContainer::substitute_variables(folder, "my-project"); assert_eq!(result, "/custom/path"); } + + #[test] + fn test_remote_workspace_path_appends_subfolder() { + let path = remote_workspace_path( + Path::new("/home/dev/workspace"), + Some(Path::new("apps/api")), + ); + assert_eq!(path, "/home/dev/workspace/apps/api"); + } + + #[test] + fn test_remote_workspace_path_normalizes_windows_separators() { + let path = remote_workspace_path( + Path::new("/home/dev/workspace"), + Some(Path::new("apps\\api")), + ); + assert_eq!(path, "/home/dev/workspace/apps/api"); + } + + #[test] + fn test_remote_workspace_uri_uses_ssh_remote_scheme() { + let ws = Workspace::from_remote_path( + Path::new("/home/dev/workspace"), + "remote-test".to_string(), + ) + .unwrap(); + assert_eq!( + ws.remote_folder_uri(Some(Path::new("packages/api"))), + "vscode-remote://ssh-remote+remote-test/home/dev/workspace/packages/api" + ); + } } diff --git a/tests/remote-host/Dockerfile b/tests/remote-host/Dockerfile new file mode 100644 index 0000000..553e3f2 --- /dev/null +++ b/tests/remote-host/Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu:24.04 + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server sudo \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/run/sshd + +RUN useradd -m -s /bin/bash dev \ + && mkdir -p /home/dev/workspace/.devcontainer \ + && printf '{\n "name": "remote-test",\n "image": "mcr.microsoft.com/devcontainers/base:ubuntu"\n}\n' > /home/dev/workspace/.devcontainer/devcontainer.json \ + && chown -R dev:dev /home/dev + +COPY tests/remote-host/entrypoint.sh /usr/local/bin/vscli-remote-host-entrypoint +RUN chmod +x /usr/local/bin/vscli-remote-host-entrypoint + +EXPOSE 22 + +ENTRYPOINT ["/usr/local/bin/vscli-remote-host-entrypoint"] diff --git a/tests/remote-host/docker-compose.yml b/tests/remote-host/docker-compose.yml new file mode 100644 index 0000000..fca7898 --- /dev/null +++ b/tests/remote-host/docker-compose.yml @@ -0,0 +1,9 @@ +services: + remote-host: + build: + context: ../.. + dockerfile: tests/remote-host/Dockerfile + environment: + AUTHORIZED_KEY: ${AUTHORIZED_KEY:?set AUTHORIZED_KEY to an SSH public key} + ports: + - "127.0.0.1:2222:22" diff --git a/tests/remote-host/entrypoint.sh b/tests/remote-host/entrypoint.sh new file mode 100644 index 0000000..6fa8741 --- /dev/null +++ b/tests/remote-host/entrypoint.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p /home/dev/.ssh +printf '%s\n' "$AUTHORIZED_KEY" > /home/dev/.ssh/authorized_keys +chown -R dev:dev /home/dev/.ssh +chmod 700 /home/dev/.ssh +chmod 600 /home/dev/.ssh/authorized_keys + +exec /usr/sbin/sshd -D -e diff --git a/tests/remote-host/smoke.sh b/tests/remote-host/smoke.sh new file mode 100644 index 0000000..3f025e7 --- /dev/null +++ b/tests/remote-host/smoke.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +TEST_DIR="$ROOT_DIR/tests/remote-host" +TMP_DIR="$(mktemp -d)" +KEY_PATH="$TMP_DIR/id_ed25519" +SSH_CONFIG="$TMP_DIR/ssh_config" +DOCKER_CONFIG_DIR="$TMP_DIR/docker-config" +FAKE_EDITOR="$TMP_DIR/fake-editor.sh" +EDITOR_LOG="$TMP_DIR/editor-args.log" + +cleanup() { + AUTHORIZED_KEY="${AUTHORIZED_KEY:-}" docker compose -f "$TEST_DIR/docker-compose.yml" down -v >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +mkdir -p "$DOCKER_CONFIG_DIR" +export DOCKER_CONFIG="$DOCKER_CONFIG_DIR" + +ssh-keygen -q -t ed25519 -N "" -f "$KEY_PATH" >/dev/null +AUTHORIZED_KEY="$(cat "$KEY_PATH.pub")" +export AUTHORIZED_KEY + +cat > "$SSH_CONFIG" < "$FAKE_EDITOR" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$VSCLI_EDITOR_LOG" +EOF +chmod +x "$FAKE_EDITOR" + +docker compose -f "$TEST_DIR/docker-compose.yml" up -d --build + +for _ in $(seq 1 30); do + if ssh -F "$SSH_CONFIG" vscli-remote-test 'test -f /home/dev/workspace/.devcontainer/devcontainer.json'; then + break + fi + sleep 1 +done + +ssh -F "$SSH_CONFIG" vscli-remote-test 'test -f /home/dev/workspace/.devcontainer/devcontainer.json' +VSCLI_EDITOR_LOG="$EDITOR_LOG" cargo run -- open --remote-host vscli-remote-test /home/dev/workspace --command "$FAKE_EDITOR" + +grep -Fx -- '--folder-uri' "$EDITOR_LOG" +grep -Fx -- 'vscode-remote://ssh-remote+vscli-remote-test/home/dev/workspace' "$EDITOR_LOG"