Add optional command extensions
This commit is contained in:
@@ -165,6 +165,29 @@ in Dosh's host config:
|
|||||||
dosh import-ssh palav homelab
|
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
|
## Develop
|
||||||
|
|
||||||
Build:
|
Build:
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ dosh -L 8080:127.0.0.1:80 host
|
|||||||
dosh -R 9000:127.0.0.1:9000 host
|
dosh -R 9000:127.0.0.1:9000 host
|
||||||
dosh --session work host
|
dosh --session work host
|
||||||
dosh --view-only --session work host
|
dosh --view-only --session work host
|
||||||
|
dosh host tm
|
||||||
```
|
```
|
||||||
|
|
||||||
Compatibility expectations:
|
Compatibility expectations:
|
||||||
@@ -107,6 +108,8 @@ Must work in v1:
|
|||||||
- `dosh update`.
|
- `dosh update`.
|
||||||
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
|
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
|
||||||
- `dosh sessions host` for session visibility.
|
- `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:
|
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"]
|
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
|
||||||
set_env = {}
|
set_env = {}
|
||||||
forwardings = []
|
forwardings = []
|
||||||
|
|
||||||
|
[extensions.tm]
|
||||||
|
command = "tm {args}"
|
||||||
|
description = "Open an optional server-side tmux dashboard"
|
||||||
```
|
```
|
||||||
|
|
||||||
Server:
|
Server:
|
||||||
|
|||||||
@@ -101,6 +101,29 @@ dosh import-ssh palav homelab
|
|||||||
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
|
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
|
||||||
OpenSSH config parser.
|
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)
|
## Forwarding (Implemented)
|
||||||
|
|
||||||
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
|
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();
|
.unwrap_or_default();
|
||||||
let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone());
|
let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone());
|
||||||
let server = ssh_with_user(&raw_server, host.user.as_deref());
|
let server = ssh_with_user(&raw_server, host.user.as_deref());
|
||||||
let startup_command = startup_command(&args.command).or_else(|| {
|
let startup_command = resolved_startup_command(&args.command, &config, &host);
|
||||||
host.default_command
|
|
||||||
.as_deref()
|
|
||||||
.map(startup_command_from_string)
|
|
||||||
});
|
|
||||||
let local_forwards = parse_local_forwards(&args.local_forward)?;
|
let local_forwards = parse_local_forwards(&args.local_forward)?;
|
||||||
let remote_forwards = parse_remote_forwards(&args.remote_forward)?;
|
let remote_forwards = parse_remote_forwards(&args.remote_forward)?;
|
||||||
let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_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())
|
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> {
|
fn startup_command_from_string(command: &str) -> Vec<u8> {
|
||||||
let mut command = command.to_string();
|
let mut command = command.to_string();
|
||||||
command.push('\n');
|
command.push('\n');
|
||||||
@@ -4226,10 +4289,11 @@ mod tests {
|
|||||||
RemoteForward, SshConfig, StatusAction, auth_allows,
|
RemoteForward, SshConfig, StatusAction, auth_allows,
|
||||||
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward,
|
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,
|
parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until,
|
||||||
render_status_clear, render_status_overlay, requested_env, ssh_destination_host,
|
render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
|
||||||
ssh_username, ssh_with_user, startup_command, toml_bare_key_or_quoted,
|
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::native::EnvVar;
|
||||||
use dosh::protocol::{self, Frame, PacketKind};
|
use dosh::protocol::{self, Frame, PacketKind};
|
||||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
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]
|
#[test]
|
||||||
fn parses_local_forward_with_default_bind() {
|
fn parses_local_forward_with_default_bind() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ pub struct ClientConfig {
|
|||||||
pub send_env: Vec<String>,
|
pub send_env: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub set_env: HashMap<String, String>,
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -151,6 +156,22 @@ pub struct HostConfig {
|
|||||||
pub send_env: Option<Vec<String>>,
|
pub send_env: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub set_env: HashMap<String, String>,
|
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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -186,6 +207,7 @@ impl Default for ClientConfig {
|
|||||||
disconnect_status: true,
|
disconnect_status: true,
|
||||||
send_env: default_send_env(),
|
send_env: default_send_env(),
|
||||||
set_env: HashMap::new(),
|
set_env: HashMap::new(),
|
||||||
|
extensions: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user