Add optional command extensions
This commit is contained in:
@@ -165,6 +165,29 @@ in Dosh's host config:
|
||||
dosh import-ssh palav homelab
|
||||
```
|
||||
|
||||
Optional command extensions are just config-side startup shortcuts; Dosh has no
|
||||
compile-time dependency on the tools they run. For example, to make a separately
|
||||
installed server-side `tm` dashboard easy to open:
|
||||
|
||||
```toml
|
||||
# ~/.config/dosh/client.toml
|
||||
[extensions.tm]
|
||||
command = "tm {args}"
|
||||
description = "Open the server-side tmux dashboard"
|
||||
```
|
||||
|
||||
Then `dosh palav tm` sends `tm`, and `dosh palav tm dosh` sends `tm 'dosh'`.
|
||||
Remove that table to remove the integration. Hosts can override or opt out:
|
||||
|
||||
```toml
|
||||
# ~/.config/dosh/hosts.toml
|
||||
[palav.extensions.tm]
|
||||
command = "/opt/tm/bin/tm {args}"
|
||||
|
||||
[other-host.extensions.tm]
|
||||
disabled = true
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
Build:
|
||||
|
||||
@@ -51,6 +51,7 @@ dosh -L 8080:127.0.0.1:80 host
|
||||
dosh -R 9000:127.0.0.1:9000 host
|
||||
dosh --session work host
|
||||
dosh --view-only --session work host
|
||||
dosh host tm
|
||||
```
|
||||
|
||||
Compatibility expectations:
|
||||
@@ -107,6 +108,8 @@ Must work in v1:
|
||||
- `dosh update`.
|
||||
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
|
||||
- `dosh sessions host` for session visibility.
|
||||
- Optional command extensions such as `dosh host tm` for companion tools that are
|
||||
installed separately from Dosh.
|
||||
|
||||
Should work in v1 if it does not compromise the transport schedule:
|
||||
|
||||
@@ -556,6 +559,10 @@ forward_agent = false
|
||||
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
|
||||
set_env = {}
|
||||
forwardings = []
|
||||
|
||||
[extensions.tm]
|
||||
command = "tm {args}"
|
||||
description = "Open an optional server-side tmux dashboard"
|
||||
```
|
||||
|
||||
Server:
|
||||
|
||||
@@ -101,6 +101,29 @@ dosh import-ssh palav homelab
|
||||
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
|
||||
OpenSSH config parser.
|
||||
|
||||
## Optional Command Extensions
|
||||
|
||||
Dosh can expose companion tools without taking a dependency on them. Command
|
||||
extensions live in client or host config and expand only the first trailing word:
|
||||
|
||||
```toml
|
||||
[extensions.tm]
|
||||
command = "tm {args}"
|
||||
description = "Open the server-side tmux dashboard"
|
||||
```
|
||||
|
||||
With that config, `dosh palav tm` runs `tm` in the remote Dosh shell and
|
||||
`dosh palav tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
|
||||
Host config can override a global extension, or disable it:
|
||||
|
||||
```toml
|
||||
[palav.extensions.tm]
|
||||
command = "/opt/tm/bin/tm {args}"
|
||||
|
||||
[other-host.extensions.tm]
|
||||
disabled = true
|
||||
```
|
||||
|
||||
## Forwarding (Implemented)
|
||||
|
||||
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
|
||||
|
||||
+166
-8
@@ -212,11 +212,7 @@ async fn main() -> Result<()> {
|
||||
.unwrap_or_default();
|
||||
let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone());
|
||||
let server = ssh_with_user(&raw_server, host.user.as_deref());
|
||||
let startup_command = startup_command(&args.command).or_else(|| {
|
||||
host.default_command
|
||||
.as_deref()
|
||||
.map(startup_command_from_string)
|
||||
});
|
||||
let startup_command = resolved_startup_command(&args.command, &config, &host);
|
||||
let local_forwards = parse_local_forwards(&args.local_forward)?;
|
||||
let remote_forwards = parse_remote_forwards(&args.remote_forward)?;
|
||||
let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_forward)?;
|
||||
@@ -776,6 +772,73 @@ fn startup_command(args: &[String]) -> Option<Vec<u8>> {
|
||||
Some(command.into_bytes())
|
||||
}
|
||||
|
||||
fn resolved_startup_command(
|
||||
args: &[String],
|
||||
config: &dosh::config::ClientConfig,
|
||||
host: &dosh::config::HostConfig,
|
||||
) -> Option<Vec<u8>> {
|
||||
if args.is_empty() {
|
||||
return host
|
||||
.default_command
|
||||
.as_deref()
|
||||
.map(startup_command_from_string);
|
||||
}
|
||||
match resolve_command_extension(args, config, host) {
|
||||
ExtensionResolution::Command(command) => Some(startup_command_from_string(&command)),
|
||||
ExtensionResolution::Disabled => None,
|
||||
ExtensionResolution::NoMatch => startup_command(args),
|
||||
}
|
||||
}
|
||||
|
||||
enum ExtensionResolution {
|
||||
Command(String),
|
||||
Disabled,
|
||||
NoMatch,
|
||||
}
|
||||
|
||||
fn resolve_command_extension(
|
||||
args: &[String],
|
||||
config: &dosh::config::ClientConfig,
|
||||
host: &dosh::config::HostConfig,
|
||||
) -> ExtensionResolution {
|
||||
let Some(name) = args.first() else {
|
||||
return ExtensionResolution::NoMatch;
|
||||
};
|
||||
if let Some(extension) = host.extensions.get(name) {
|
||||
if extension.disabled {
|
||||
return ExtensionResolution::Disabled;
|
||||
}
|
||||
if let Some(command) = extension.command.as_deref() {
|
||||
return ExtensionResolution::Command(expand_command_extension(command, &args[1..]));
|
||||
}
|
||||
}
|
||||
if let Some(extension) = config.extensions.get(name) {
|
||||
if extension.disabled {
|
||||
return ExtensionResolution::Disabled;
|
||||
}
|
||||
if let Some(command) = extension.command.as_deref() {
|
||||
return ExtensionResolution::Command(expand_command_extension(command, &args[1..]));
|
||||
}
|
||||
}
|
||||
ExtensionResolution::NoMatch
|
||||
}
|
||||
|
||||
fn expand_command_extension(template: &str, args: &[String]) -> String {
|
||||
let quoted_args = args
|
||||
.iter()
|
||||
.map(|arg| shell_word(arg))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if template.contains("{args}") {
|
||||
return template.replace("{args}", "ed_args);
|
||||
}
|
||||
if args.is_empty() {
|
||||
template.to_string()
|
||||
} else {
|
||||
format!("{template} {quoted_args}")
|
||||
}
|
||||
}
|
||||
|
||||
fn startup_command_from_string(command: &str) -> Vec<u8> {
|
||||
let mut command = command.to_string();
|
||||
command.push('\n');
|
||||
@@ -4226,10 +4289,11 @@ mod tests {
|
||||
RemoteForward, SshConfig, StatusAction, auth_allows,
|
||||
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward,
|
||||
parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until,
|
||||
render_status_clear, render_status_overlay, requested_env, ssh_destination_host,
|
||||
ssh_username, ssh_with_user, startup_command, toml_bare_key_or_quoted,
|
||||
render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
|
||||
ssh_destination_host, ssh_username, ssh_with_user, startup_command,
|
||||
toml_bare_key_or_quoted,
|
||||
};
|
||||
use dosh::config::{ClientConfig, HostConfig};
|
||||
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
|
||||
use dosh::native::EnvVar;
|
||||
use dosh::protocol::{self, Frame, PacketKind};
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
@@ -4775,6 +4839,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_startup_command_expands_global_extension() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: Some("tm {args}".to_string()),
|
||||
description: Some("server tmux dashboard".to_string()),
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
let host = HostConfig::default();
|
||||
|
||||
assert_eq!(
|
||||
resolved_startup_command(&["tm".into(), "work space".into()], &config, &host),
|
||||
Some(b"tm 'work space'\n".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_startup_command_uses_host_extension_override() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: Some("tm {args}".to_string()),
|
||||
description: None,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
let mut host = HostConfig::default();
|
||||
host.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: Some("/opt/tm/bin/tm --remote {args}".to_string()),
|
||||
description: None,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host),
|
||||
Some(b"/opt/tm/bin/tm --remote 'dosh'\n".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_startup_command_host_can_disable_global_extension() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: Some("tm {args}".to_string()),
|
||||
description: None,
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
let mut host = HostConfig::default();
|
||||
host.extensions.insert(
|
||||
"tm".to_string(),
|
||||
CommandExtension {
|
||||
command: None,
|
||||
description: None,
|
||||
disabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_startup_command_keeps_raw_command_fallback_and_default_command() {
|
||||
let config = ClientConfig::default();
|
||||
let mut host = HostConfig {
|
||||
default_command: Some("tm".to_string()),
|
||||
..HostConfig::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolved_startup_command(&["echo".into(), "ok".into()], &config, &host),
|
||||
Some(b"echo ok\n".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_startup_command(&[], &config, &host),
|
||||
Some(b"tm\n".to_vec())
|
||||
);
|
||||
|
||||
host.default_command = None;
|
||||
assert_eq!(resolved_startup_command(&[], &config, &host), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_local_forward_with_default_bind() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -136,6 +136,11 @@ pub struct ClientConfig {
|
||||
pub send_env: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub set_env: HashMap<String, String>,
|
||||
/// Optional client-side command extensions. These are just shell command
|
||||
/// templates expanded into the remote session's startup input; Dosh does not
|
||||
/// depend on the tools they name.
|
||||
#[serde(default)]
|
||||
pub extensions: HashMap<String, CommandExtension>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -151,6 +156,22 @@ pub struct HostConfig {
|
||||
pub send_env: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub set_env: HashMap<String, String>,
|
||||
/// Per-host extension overrides. A host can replace a global extension or set
|
||||
/// `disabled = true` to opt out of it.
|
||||
#[serde(default)]
|
||||
pub extensions: HashMap<String, CommandExtension>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CommandExtension {
|
||||
/// Remote shell command template. `{args}` expands to shell-quoted extra
|
||||
/// words after the extension name. When absent, extra args are appended.
|
||||
#[serde(default)]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -186,6 +207,7 @@ impl Default for ClientConfig {
|
||||
disconnect_status: true,
|
||||
send_env: default_send_env(),
|
||||
set_env: HashMap::new(),
|
||||
extensions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user