Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -259,6 +260,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 `--`:
Expand Down Expand Up @@ -297,11 +299,24 @@ vscli open --config rust-dev ~/projects/my-app # open any project with the "ru
vscli open --config rust-dev ~/projects/other # reuse the same config for a different project
```

#### 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 open --remote-host my-ec2 --behavior force-container /home/ec2-user/app
vscli recent --remote-host my-ec2
```

This opens the workspace using a `vscode-remote://ssh-remote+...` folder URI. If the remote folder contains a `.devcontainer` setup, VS Code Dev Containers can reopen it in a container on that remote host.

#### 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 |
4 changes: 4 additions & 0 deletions src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pub struct Entry {
pub config_name: Option<String>,
/// 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<String>,
/// The path to the dev container config, if it exists
pub config_path: Option<PathBuf>,
/// The launch behavior
Expand All @@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions src/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,26 @@ impl Setup {
) -> Result<Option<DevContainer>> {
let editor_name = format_editor_name(&self.behavior.command);

if self.workspace.remote_host.is_some() {
match self.behavior.strategy {
ContainerStrategy::ForceContainer => {
info!(
"Opening remote workspace over SSH with {editor_name}; use VS Code Dev Containers on the remote host to reopen in a container..."
);
}
_ => {
info!("Opening remote workspace over SSH with {editor_name}...");
}
}

self.workspace.open_classic(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
self.behavior.args,
self.dry_run,
&self.behavior.command,
)?;
return Ok(None);
}

match self.behavior.strategy {
ContainerStrategy::Detect => {
let dev_container = self.detect(config)?;
Expand Down
207 changes: 132 additions & 75 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -28,7 +28,7 @@ use crate::history::{Entry, Tracker};

use crate::{
launch::{Behavior, Setup},
opts::Opts,
opts::{LaunchArgs, Opts},
workspace::Workspace,
};

Expand Down Expand Up @@ -77,6 +77,127 @@ fn workspace_root_from_config(
Ok((root, sub))
}

fn open_workspace(
path: &Path,
launch: LaunchArgs,
tracker: &mut Tracker,
config_store: &ConfigStore,
dry_run: bool,
) -> Result<()> {
if launch.remote_host.is_some() && launch.config.is_some() {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
bail!(
"--config cannot be combined with --remote-host; point vscli at the remote workspace path instead."
);
}

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.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)?
};
let ws_name = ws.name.clone();
let tracked_workspace_path = ws.path.clone();
let remote_host = ws.remote_host.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(), 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 {
if launch.remote_host.is_some() && launch.config.is_some() {
bail!(
"--config cannot be combined with --remote-host; point vscli at the remote workspace path instead."
);
}

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;
}
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(), 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<()> {
color_eyre::install()?;

Expand All @@ -95,38 +216,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 {
Expand All @@ -135,47 +225,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 } => {
Expand Down
4 changes: 4 additions & 0 deletions src/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ pub(crate) struct LaunchArgs {
#[arg(long, env)]
pub config: Option<PathBuf>,

/// Open the workspace on a remote SSH host.
#[arg(long, env = "VSCLI_REMOTE_HOST")]
pub remote_host: Option<String>,

/// Additional arguments to pass to the editor
#[arg(value_parser, env)]
pub args: Vec<OsString>,
Expand Down
Loading