4 Commits

26 changed files with 563 additions and 832 deletions
Generated
-12
View File
@@ -836,15 +836,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -2299,14 +2290,11 @@ dependencies = [
"clap",
"crossterm",
"dirs",
"hex",
"hmac",
"indexmap",
"ratatui",
"reqwest",
"serde",
"serde_json",
"sha2",
"tempfile",
"toml",
"whoami",
-3
View File
@@ -11,14 +11,11 @@ base64 = "0.22"
clap = { version = "4.5", features = ["derive"] }
crossterm = "0.29"
dirs = "6"
hex = "0.4"
hmac = "0.12"
indexmap = { version = "2", features = ["serde"] }
ratatui = { version = "0.30", features = ["crossterm_0_29"] }
reqwest = { version = "0.13.3", default-features = false, features = ["blocking", "json", "rustls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tempfile = "3.27"
toml = "0.9"
whoami = "1.6"
+1 -1
View File
@@ -9,7 +9,7 @@ A personal SSH and shell connection manager with a terminal user interface (TUI)
- **Import from `~/.ssh/config`** — One command to import all existing hosts
- **Local shell scan** — Auto-detects available shells from `/etc/shells`
- **Quick select** — Press `Ctrl+Q` then a number key (19) to connect instantly
- **Encrypted cloud sync** — Push/pull config via GitHub Gist, WebDAV, or S3 with AES-256-GCM encryption
- **Encrypted cloud sync** — Push/pull config via GitHub Gist or WebDAV with AES-256-GCM encryption
- **CLI subcommands** — Connect, import, sync, and diagnostics without launching the TUI
## Installation
+1
View File
@@ -1,2 +1,3 @@
[tools]
rust = "latest"
rust-analyzer = "latest"
+1 -5
View File
@@ -91,11 +91,7 @@ impl FormNav for CredFormState {
impl TextEditing for CredFormState {
fn active_text(&self) -> &str {
match self.active {
CredFormField::Name => &self.name,
CredFormField::Value => &self.value,
_ => "",
}
self.field_value(self.active)
}
fn active_text_mut(&mut self) -> Option<&mut String> {
+10 -37
View File
@@ -220,19 +220,7 @@ impl FormNav for FormState {
impl TextEditing for FormState {
fn active_text(&self) -> &str {
match self.active {
FormField::Name => &self.name,
FormField::Host => &self.host,
FormField::Port => &self.port,
FormField::User => &self.user,
FormField::CredId => &self.auth_ref,
FormField::Secret => &self.secret,
FormField::Command => &self.command,
FormField::SyncArgs => &self.sync_args,
FormField::LocalArgs => &self.local_args,
FormField::Tags => &self.tags,
_ => "",
}
self.field_value(self.active)
}
fn active_text_mut(&mut self) -> Option<&mut String> {
@@ -292,15 +280,11 @@ impl App {
};
let removed = self.config.connections.shift_remove(&name);
if let Some(profile) = removed {
let ts = crate::config::now_epoch_secs();
self.config.deleted.insert(name.clone(), ts);
if let Some(auth_ref) = profile.auth_ref() {
let still_used = self
.config
.connections
.values()
.any(|p| p.auth_ref() == Some(auth_ref));
if !still_used {
self.config.credentials.entries.shift_remove(auth_ref);
}
self.config.prune_credential_if_unused(auth_ref, None);
}
self.config.save()?;
self.session.home.selected = self
@@ -367,6 +351,7 @@ impl App {
source,
added_order,
usage_count,
modified_at: crate::config::now_epoch_secs(),
kind: ConnectionType::Shell {
shell_name: shell_name_for_command(&self.session.form.command),
auth_ref: None,
@@ -389,6 +374,7 @@ impl App {
source,
added_order,
usage_count,
modified_at: crate::config::now_epoch_secs(),
kind: ConnectionType::Ssh {
host,
port,
@@ -430,7 +416,9 @@ impl App {
fn save_form_credential(&mut self, name: &str, auth_ref: &str, old_auth_ref: Option<String>) {
if self.session.form.is_shell {
self.remove_unused_old_credential(name, old_auth_ref);
if let Some(old) = old_auth_ref {
self.config.prune_credential_if_unused(&old, Some(name));
}
} else if !self.session.form.secret.is_empty() {
let secret = resolve_secret(&self.session.form.secret);
let entry = match self.session.form.auth_kind {
@@ -443,21 +431,6 @@ impl App {
.insert(auth_ref.to_string(), entry);
}
}
fn remove_unused_old_credential(&mut self, editing_name: &str, old_auth_ref: Option<String>) {
let Some(old_auth_ref) = old_auth_ref else {
return;
};
let still_used = self
.config
.connections
.iter()
.filter(|(conn_name, _)| conn_name.as_str() != editing_name)
.any(|(_, profile)| profile.auth_ref() == Some(old_auth_ref.as_str()));
if !still_used {
self.config.credentials.entries.shift_remove(&old_auth_ref);
}
}
}
fn parse_tags(raw: &str) -> Vec<String> {
+3 -30
View File
@@ -1,6 +1,5 @@
use anyhow::Result;
use crate::config::SyncBackend;
use super::{App, Mode};
impl App {
@@ -13,16 +12,6 @@ impl App {
self.session.mode = Mode::ActionMenu;
}
pub fn enter_combined_import(&mut self) -> Result<()> {
self.session.import.candidates = crate::import::load_candidates(&self.config)?;
self.session.import.selected = vec![false; self.session.import.candidates.len()];
self.session.import.shell_candidates = self.config.local_shell_candidates();
self.session.import.shell_selected = vec![false; self.session.import.shell_candidates.len()];
self.session.import.cursor = 0;
self.session.mode = Mode::ImportSelector;
Ok(())
}
pub fn enter_quick_select(&mut self) {
if self.entries().is_empty() {
self.toast("no connections available", false);
@@ -63,26 +52,10 @@ impl App {
Ok(())
}
pub fn push_sync_with_toast(&mut self) {
let result = match self.config.settings.backend {
SyncBackend::Gist => crate::sync::gist::push(&mut self.config).map(|id| format!("pushed ({id})")),
SyncBackend::Webdav => crate::sync::webdav::push(&mut self.config).map(|_| "pushed".to_string()),
SyncBackend::S3 => crate::sync::s3::push(&mut self.config).map(|_| "pushed".to_string()),
};
pub fn sync_with_toast(&mut self) {
let result = crate::sync::run_sync(&mut self.config);
match result {
Ok(msg) => self.toast(msg, true),
Err(err) => self.toast(err.to_string(), false),
}
}
pub fn pull_sync_with_toast(&mut self) {
let result = match self.config.settings.backend {
SyncBackend::Gist => crate::sync::gist::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
SyncBackend::Webdav => crate::sync::webdav::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
SyncBackend::S3 => crate::sync::s3::pull_with_strategy(&mut self.config, crate::sync::PullStrategy::Merge),
};
match result {
Ok(count) => self.toast(format!("pulled {count} items"), true),
Ok(report) => self.toast(report.to_string(), true),
Err(err) => self.toast(err.to_string(), false),
}
}
+4
View File
@@ -203,6 +203,7 @@ mod tests {
source: crate::config::ConnectionSource::Manual,
added_order: order,
usage_count: usage,
modified_at: 0,
kind: crate::config::ConnectionType::Ssh {
host: "h".into(),
port: 22,
@@ -224,6 +225,7 @@ mod tests {
source: crate::config::ConnectionSource::Manual,
added_order: 1,
usage_count: 0,
modified_at: 0,
kind: ConnectionType::Ssh {
host: "example.com".into(),
port: 22,
@@ -245,6 +247,7 @@ mod tests {
source: crate::config::ConnectionSource::Manual,
added_order: 1,
usage_count: 0,
modified_at: 0,
kind: ConnectionType::Ssh {
host: "example.com".into(),
port: 22,
@@ -265,6 +268,7 @@ mod tests {
source: crate::config::ConnectionSource::Manual,
added_order: 1,
usage_count: 0,
modified_at: 0,
kind: ConnectionType::Ssh {
host: "h".into(),
port: 22,
+21 -88
View File
@@ -1,24 +1,26 @@
use super::{FormNav, TextEditing};
use crate::config::SyncBackend;
/// Trim whitespace and return None if the result is empty.
fn trimmed_opt(s: &str) -> Option<String> {
let s = s.trim().to_string();
if s.is_empty() { None } else { Some(s) }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsField {
SyncPassword,
Backend,
SyncOnStart,
GistId,
WebdavUrl,
WebdavUser,
WebdavPassword,
S3Endpoint,
S3Bucket,
S3AccessKey,
S3SecretKey,
SyncUsage,
}
impl SettingsField {
pub fn visible_fields(backend: SyncBackend) -> Vec<SettingsField> {
let mut fields = vec![SettingsField::SyncPassword, SettingsField::Backend];
let mut fields = vec![SettingsField::SyncPassword, SettingsField::Backend, SettingsField::SyncOnStart];
match backend {
SyncBackend::Gist => fields.push(SettingsField::GistId),
SyncBackend::Webdav => {
@@ -28,16 +30,7 @@ impl SettingsField {
SettingsField::WebdavPassword,
]);
}
SyncBackend::S3 => {
fields.extend_from_slice(&[
SettingsField::S3Endpoint,
SettingsField::S3Bucket,
SettingsField::S3AccessKey,
SettingsField::S3SecretKey,
]);
}
}
fields.push(SettingsField::SyncUsage);
fields
}
@@ -45,20 +38,16 @@ impl SettingsField {
match self {
Self::SyncPassword => "Encrypt Pwd",
Self::Backend => "Backend",
Self::SyncOnStart => "Auto Sync",
Self::GistId => "Gist ID",
Self::WebdavUrl => "URL",
Self::WebdavUser => "Username",
Self::WebdavPassword => "Password",
Self::S3Endpoint => "Endpoint",
Self::S3Bucket => "Bucket",
Self::S3AccessKey => "Access Key",
Self::S3SecretKey => "Secret Key",
Self::SyncUsage => "Sync Usage",
}
}
}
pub fn is_toggle(self) -> bool {
matches!(self, Self::Backend | Self::SyncUsage)
matches!(self, Self::Backend | Self::SyncOnStart)
}
pub fn is_text(self) -> bool {
@@ -70,15 +59,11 @@ impl SettingsField {
pub struct SettingsState {
pub password: String,
pub backend: SyncBackend,
pub sync_on_start: bool,
pub gist_id: String,
pub webdav_url: String,
pub webdav_user: String,
pub webdav_password: String,
pub s3_endpoint: String,
pub s3_bucket: String,
pub s3_access_key: String,
pub s3_secret_key: String,
pub sync_usage: bool,
pub active: SettingsField,
pub cursor: usize,
}
@@ -91,10 +76,6 @@ impl SettingsState {
SettingsField::WebdavUrl => &self.webdav_url,
SettingsField::WebdavUser => &self.webdav_user,
SettingsField::WebdavPassword => &self.webdav_password,
SettingsField::S3Endpoint => &self.s3_endpoint,
SettingsField::S3Bucket => &self.s3_bucket,
SettingsField::S3AccessKey => &self.s3_access_key,
SettingsField::S3SecretKey => &self.s3_secret_key,
_ => "",
}
}
@@ -140,15 +121,11 @@ impl Default for SettingsState {
Self {
password: String::new(),
backend: SyncBackend::Gist,
sync_on_start: false,
gist_id: String::new(),
webdav_url: String::new(),
webdav_user: String::new(),
webdav_password: String::new(),
s3_endpoint: String::new(),
s3_bucket: String::new(),
s3_access_key: String::new(),
s3_secret_key: String::new(),
sync_usage: false,
active: SettingsField::SyncPassword,
cursor: 0,
}
@@ -157,18 +134,7 @@ impl Default for SettingsState {
impl TextEditing for SettingsState {
fn active_text(&self) -> &str {
match self.active {
SettingsField::SyncPassword => &self.password,
SettingsField::GistId => &self.gist_id,
SettingsField::WebdavUrl => &self.webdav_url,
SettingsField::WebdavUser => &self.webdav_user,
SettingsField::WebdavPassword => &self.webdav_password,
SettingsField::S3Endpoint => &self.s3_endpoint,
SettingsField::S3Bucket => &self.s3_bucket,
SettingsField::S3AccessKey => &self.s3_access_key,
SettingsField::S3SecretKey => &self.s3_secret_key,
_ => "",
}
self.field_text(self.active)
}
fn active_text_mut(&mut self) -> Option<&mut String> {
@@ -178,10 +144,6 @@ impl TextEditing for SettingsState {
SettingsField::WebdavUrl => Some(&mut self.webdav_url),
SettingsField::WebdavUser => Some(&mut self.webdav_user),
SettingsField::WebdavPassword => Some(&mut self.webdav_password),
SettingsField::S3Endpoint => Some(&mut self.s3_endpoint),
SettingsField::S3Bucket => Some(&mut self.s3_bucket),
SettingsField::S3AccessKey => Some(&mut self.s3_access_key),
SettingsField::S3SecretKey => Some(&mut self.s3_secret_key),
_ => None,
}
}
@@ -210,6 +172,7 @@ impl App {
.clone()
.unwrap_or_default();
self.session.settings.backend = self.config.settings.backend;
self.session.settings.sync_on_start = self.config.settings.sync_on_start;
self.session.settings.gist_id = self.config.settings.gist_id.clone().unwrap_or_default();
self.session.settings.webdav_url =
self.config.settings.webdav_url.clone().unwrap_or_default();
@@ -217,49 +180,19 @@ impl App {
self.config.settings.webdav_user.clone().unwrap_or_default();
self.session.settings.webdav_password =
self.config.settings.webdav_password.clone().unwrap_or_default();
self.session.settings.s3_endpoint =
self.config.settings.s3_endpoint.clone().unwrap_or_default();
self.session.settings.s3_bucket =
self.config.settings.s3_bucket.clone().unwrap_or_default();
self.session.settings.s3_access_key =
self.config.settings.s3_access_key.clone().unwrap_or_default();
self.session.settings.s3_secret_key =
self.config.settings.s3_secret_key.clone().unwrap_or_default();
self.session.settings.sync_usage = self.config.settings.sync_usage_count;
self.session.settings.active = SettingsField::SyncPassword;
self.session.settings.cursor = char_len(self.session.settings.active_text());
self.session.mode = Mode::Settings;
}
pub fn save_settings(&mut self) -> Result<()> {
let pw = self.session.settings.password.trim().to_string();
self.config.settings.sync_password = if pw.is_empty() { None } else { Some(pw) };
self.config.settings.sync_password = trimmed_opt(&self.session.settings.password);
self.config.settings.backend = self.session.settings.backend;
let gist = self.session.settings.gist_id.trim().to_string();
self.config.settings.gist_id = if gist.is_empty() { None } else { Some(gist) };
let url = self.session.settings.webdav_url.trim().to_string();
self.config.settings.webdav_url = if url.is_empty() { None } else { Some(url) };
let user = self.session.settings.webdav_user.trim().to_string();
self.config.settings.webdav_user = if user.is_empty() { None } else { Some(user) };
let wd_pw = self.session.settings.webdav_password.trim().to_string();
self.config.settings.webdav_password = if wd_pw.is_empty() {
None
} else {
Some(wd_pw)
};
let s3_ep = self.session.settings.s3_endpoint.trim().to_string();
self.config.settings.s3_endpoint = if s3_ep.is_empty() { None } else { Some(s3_ep) };
let s3_bk = self.session.settings.s3_bucket.trim().to_string();
self.config.settings.s3_bucket = if s3_bk.is_empty() { None } else { Some(s3_bk) };
let s3_ak = self.session.settings.s3_access_key.trim().to_string();
self.config.settings.s3_access_key = if s3_ak.is_empty() { None } else { Some(s3_ak) };
let s3_sk = self.session.settings.s3_secret_key.trim().to_string();
self.config.settings.s3_secret_key = if s3_sk.is_empty() {
None
} else {
Some(s3_sk)
};
self.config.settings.sync_usage_count = self.session.settings.sync_usage;
self.config.settings.sync_on_start = self.session.settings.sync_on_start;
self.config.settings.gist_id = trimmed_opt(&self.session.settings.gist_id);
self.config.settings.webdav_url = trimmed_opt(&self.session.settings.webdav_url);
self.config.settings.webdav_user = trimmed_opt(&self.session.settings.webdav_user);
self.config.settings.webdav_password = trimmed_opt(&self.session.settings.webdav_password);
self.config.save()?;
self.session.mode = Mode::Home;
Ok(())
+7 -56
View File
@@ -1,5 +1,5 @@
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, SyncBackend, config_path, find_binary};
use crate::sync::{self, gist, s3, webdav};
use crate::sync;
use crate::{connection, import, ui};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
@@ -23,31 +23,12 @@ enum Command {
name: String,
},
Import,
Sync {
#[command(subcommand)]
command: SyncCommand,
},
Sync,
Doctor {
name: Option<String>,
},
ConfigPath,
}
#[derive(Debug, Subcommand)]
pub enum SyncCommand {
Push,
Pull {
#[arg(long, value_enum, default_value_t = PullStrategy::Merge)]
strategy: PullStrategy,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum PullStrategy {
Merge,
Overwrite,
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command.unwrap_or(Command::Tui) {
@@ -63,7 +44,7 @@ pub fn run() -> Result<()> {
println!("imported {count} connections");
Ok(())
}
Command::Sync { command } => run_sync(command),
Command::Sync => run_sync(),
Command::Doctor { name } => doctor(name),
Command::ConfigPath => {
println!("{}", config_path()?.display());
@@ -72,36 +53,10 @@ pub fn run() -> Result<()> {
}
}
fn run_sync(command: SyncCommand) -> Result<()> {
fn run_sync() -> Result<()> {
let mut cfg = SshellConfig::load()?;
let strat = |s: PullStrategy| match s {
PullStrategy::Merge => sync::PullStrategy::Merge,
PullStrategy::Overwrite => sync::PullStrategy::Overwrite,
};
match command {
SyncCommand::Push => match cfg.settings.backend {
SyncBackend::Gist => {
let id = gist::push(&mut cfg)?;
println!("pushed ({id})");
}
SyncBackend::Webdav => {
webdav::push(&mut cfg)?;
println!("pushed");
}
SyncBackend::S3 => {
s3::push(&mut cfg)?;
println!("pushed");
}
},
SyncCommand::Pull { strategy } => {
let count = match cfg.settings.backend {
SyncBackend::Gist => gist::pull_with_strategy(&mut cfg, strat(strategy))?,
SyncBackend::Webdav => webdav::pull_with_strategy(&mut cfg, strat(strategy))?,
SyncBackend::S3 => s3::pull_with_strategy(&mut cfg, strat(strategy))?,
};
println!("pulled {count} items");
}
}
let report = sync::run_sync(&mut cfg)?;
println!("{report}");
Ok(())
}
@@ -124,7 +79,6 @@ fn doctor(name: Option<String>) -> Result<()> {
match cfg.settings.backend {
SyncBackend::Gist => "gist",
SyncBackend::Webdav => "webdav",
SyncBackend::S3 => "s3",
}
);
println!(
@@ -186,13 +140,10 @@ fn check_connection(
}
ConnectionType::Shell {
command,
sync_args,
local_args,
..
} => {
println!("type: shell");
let mut merged_args = sync_args.clone();
merged_args.extend(local_args.clone());
let merged_args = profile.merged_shell_args();
println!("command: {command} {}", merged_args.join(" "));
}
}
+39 -7
View File
@@ -18,6 +18,8 @@ pub struct SshellConfig {
pub connections: IndexMap<String, ConnectionProfile>,
#[serde(default)]
pub credentials: CredentialStore,
#[serde(default)]
pub deleted: IndexMap<String, u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -49,7 +51,6 @@ pub enum SyncBackend {
#[default]
Gist,
Webdav,
S3,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -60,13 +61,9 @@ pub struct Settings {
pub webdav_url: Option<String>,
pub webdav_user: Option<String>,
pub webdav_password: Option<String>,
pub s3_endpoint: Option<String>,
pub s3_bucket: Option<String>,
pub s3_access_key: Option<String>,
pub s3_secret_key: Option<String>,
#[serde(default)]
pub sync_usage_count: bool,
pub sync_password: Option<String>,
#[serde(default)]
pub sync_on_start: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -78,6 +75,7 @@ pub struct ConnectionProfile {
pub source: ConnectionSource,
pub added_order: u64,
pub usage_count: u64,
pub modified_at: u64,
#[serde(flatten)]
pub kind: ConnectionType,
}
@@ -136,6 +134,7 @@ impl Default for SshellConfig {
settings: Settings::default(),
connections: IndexMap::new(),
credentials: CredentialStore::default(),
deleted: IndexMap::new(),
}
}
}
@@ -200,6 +199,19 @@ impl SshellConfig {
.unwrap_or(0)
+ 1
}
/// Remove a credential if it is no longer referenced by any connection.
/// `exclude` is an optional connection name to skip during the check (e.g. the one being renamed).
pub fn prune_credential_if_unused(&mut self, auth_ref: &str, exclude: Option<&str>) {
let still_used = self
.connections
.iter()
.filter(|(name, _)| Some(name.as_str()) != exclude)
.any(|(_, profile)| profile.auth_ref() == Some(auth_ref));
if !still_used {
self.credentials.entries.shift_remove(auth_ref);
}
}
}
impl CredentialEntry {
@@ -236,6 +248,19 @@ impl ConnectionProfile {
ConnectionType::Shell { sync, .. } => *sync,
}
}
/// For Shell connections, returns the merged sync_args + local_args.
/// Returns an empty vec for SSH connections.
pub fn merged_shell_args(&self) -> Vec<String> {
match &self.kind {
ConnectionType::Shell { sync_args, local_args, .. } => {
let mut out = sync_args.clone();
out.extend(local_args.iter().cloned());
out
}
ConnectionType::Ssh { .. } => Vec::new(),
}
}
}
pub fn expand_user_path(value: &str) -> PathBuf {
@@ -298,3 +323,10 @@ fn default_shell_sync() -> bool {
fn default_ssh_sync() -> bool {
true
}
pub fn now_epoch_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
+1
View File
@@ -108,6 +108,7 @@ impl super::SshellConfig {
source: ConnectionSource::Scanned,
added_order: self.next_added_order(),
usage_count: 0,
modified_at: 0,
kind: ConnectionType::Shell {
shell_name: candidate.name.clone(),
auth_ref: None,
+34 -6
View File
@@ -1,9 +1,38 @@
use crate::config::{ConnectionType, CredentialEntry, SshellConfig};
use crate::config::{ConnectionType, CredentialEntry, SshellConfig, find_binary};
use anyhow::{Context, Result, bail};
use std::io::Write;
use std::process::Command;
use tempfile::NamedTempFile;
fn require_binary(name: &str) -> Result<()> {
if find_binary(name).is_some() {
return Ok(());
}
let hint = match name {
"ssh" => "\n\
sshell requires `ssh` to connect via SSH.\n\
\n\
Install it with:\n\
macOS: pre-installed (or: xcode-select --install)\n\
Debian: sudo apt install openssh-client\n\
Arch: sudo pacman -S openssh\n\
Fedora: sudo dnf install openssh-clients\n\
Windows: Settings → Apps → Optional Features → OpenSSH Client"
,
"sshpass" => "\n\
Password-based SSH login requires `sshpass`.\n\
Consider switching to private-key auth instead, or install it:\n\
macOS: brew install hudochenkov/sshpass/sshpass\n\
Debian: sudo apt install sshpass\n\
Arch: sudo pacman -S sshpass\n\
Fedora: sudo dnf install sshpass\n\
Windows: not available — use private-key auth"
,
_ => "",
};
bail!("command not found: `{name}`{hint}");
}
pub fn connect(name: &str, cfg: &SshellConfig) -> Result<()> {
let profile = cfg
.connections
@@ -20,12 +49,9 @@ pub fn connect(name: &str, cfg: &SshellConfig) -> Result<()> {
} => connect_ssh(cfg, host, *port, user, auth_ref),
ConnectionType::Shell {
command,
sync_args,
local_args,
..
} => {
let mut merged_args = sync_args.clone();
merged_args.extend(local_args.clone());
let merged_args = profile.merged_shell_args();
exec_shell(command, &merged_args)
}
}
@@ -41,8 +67,10 @@ fn connect_ssh(
if auth_ref.is_empty() {
bail!("this connection has no credential; edit it and set password or private key");
}
require_binary("ssh")?;
match cfg.credential(auth_ref) {
Some(CredentialEntry::Password { value, .. }) => {
require_binary("sshpass")?;
let args = vec![
"ssh".to_string(),
"-o".to_string(),
@@ -87,7 +115,7 @@ fn run_sshpass(args: &[String], password: &str) -> Result<()> {
.args(args)
.env("SSHPASS", password)
.status()
.context("failed to run sshpass — is it installed?")?;
.context("failed to run sshpass")?;
std::process::exit(status.code().unwrap_or(1));
}
+1
View File
@@ -116,6 +116,7 @@ pub fn import_candidates(cfg: &mut SshellConfig, candidates: &[ImportCandidate])
source: ConnectionSource::Imported,
added_order: cfg.next_added_order(),
usage_count: 0,
modified_at: crate::config::now_epoch_secs(),
kind: ConnectionType::Ssh {
host: item.host.clone(),
port: item.port,
+250 -70
View File
@@ -1,20 +1,242 @@
mod crypto;
pub mod gist;
pub mod s3;
pub mod webdav;
use crate::config::SyncBackend;
use crate::config::ConnectionType;
use crate::config::{ConnectionSource, CredentialStore, SshellConfig};
use anyhow::{Context, Result};
use indexmap::IndexMap;
pub(crate) const GIST_TOKEN_REF: &str = "__gist_token";
#[derive(Debug, Clone, Copy)]
pub enum PullStrategy {
Merge,
Overwrite,
// ── Sync report ──────────────────────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct SyncReport {
pub pulled: usize,
pub updated: usize,
pub pushed: usize,
pub deleted: usize,
pub skipped: usize,
}
impl std::fmt::Display for SyncReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut parts = Vec::new();
if self.pulled > 0 {
parts.push(format!("{} pulled", self.pulled));
}
if self.updated > 0 {
parts.push(format!("{} updated", self.updated));
}
if self.pushed > 0 {
parts.push(format!("{} pushed", self.pushed));
}
if self.deleted > 0 {
parts.push(format!("{} deleted", self.deleted));
}
if self.skipped > 0 {
parts.push(format!("{} skipped", self.skipped));
}
if parts.is_empty() {
write!(f, "already up to date")
} else {
write!(f, "sync: {}", parts.join(", "))
}
}
}
// ── Remote payload ───────────────────────────────────────────────
struct RemotePayload {
connections: IndexMap<String, crate::config::ConnectionProfile>,
credentials: CredentialStore,
deleted: IndexMap<String, u64>,
}
fn parse_remote_payload(
remote: toml::Value,
sync_password: Option<&str>,
) -> Result<RemotePayload> {
let mut connections = IndexMap::new();
if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) {
for (name, profile_val) in conns {
if let Ok(profile) = profile_val
.clone()
.try_into::<crate::config::ConnectionProfile>()
{
connections.insert(name.clone(), profile);
}
}
}
let credentials =
if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) {
let pw = sync_password
.context("sync_password not set; needed to decrypt remote credentials")?;
crypto::decrypt_credentials(enc, pw)?
} else if let Some(creds_val) = remote.get("credentials") {
creds_val
.clone()
.try_into::<CredentialStore>()
.unwrap_or_default()
} else {
CredentialStore::default()
};
let mut deleted = IndexMap::new();
if let Some(del) = remote.get("deleted").and_then(|v| v.as_table()) {
for (name, ts_val) in del {
if let Some(ts) = ts_val.as_integer() {
deleted.insert(name.clone(), ts as u64);
}
}
}
Ok(RemotePayload {
connections,
credentials,
deleted,
})
}
// ── Bidirectional merge ──────────────────────────────────────────
pub(crate) fn bidirectional_merge(
cfg: &mut SshellConfig,
remote: toml::Value,
) -> Result<SyncReport> {
let mut report = SyncReport::default();
let remote_payload = parse_remote_payload(remote, cfg.settings.sync_password.as_deref())?;
// 1. Merge remote connections into local
for (name, remote_profile) in &remote_payload.connections {
match cfg.connections.get_mut(name) {
None => {
// Only remote has it → pull
let mut p = remote_profile.clone();
if localize_shell_profile(cfg, name, &mut p) {
cfg.connections.insert(name.clone(), p);
report.pulled += 1;
} else {
report.skipped += 1;
}
}
Some(local_profile)
if remote_profile.modified_at > local_profile.modified_at =>
{
// Remote is newer → update local (preserve local-only fields)
let mut p = remote_profile.clone();
p.local_tags = local_profile.local_tags.clone();
p.usage_count = local_profile.usage_count;
p.added_order = local_profile.added_order;
p.source = local_profile.source;
// Preserve local_args for shell connections
if let (
ConnectionType::Shell { local_args, .. },
ConnectionType::Shell {
local_args: remote_local_args,
..
},
) = (&local_profile.kind, &p.kind)
{
// Keep existing local_args from the remote profile's
// localization step
let _ = (local_args, remote_local_args);
}
if let ConnectionType::Shell { .. } = &p.kind {
// Re-localize the shell profile for this machine
let preserved = (
p.local_tags.clone(),
p.usage_count,
p.added_order,
p.source,
);
if localize_shell_profile(cfg, name, &mut p) {
p.local_tags = preserved.0;
p.usage_count = preserved.1;
p.added_order = preserved.2;
p.source = preserved.3;
*cfg.connections.get_mut(name).unwrap() = p;
report.updated += 1;
} else {
report.skipped += 1;
}
} else {
*cfg.connections.get_mut(name).unwrap() = p;
report.updated += 1;
}
}
_ => {
// Local is newer or equal → nothing to do
}
}
}
// 2. Count connections to push (local has newer or remote doesn't have)
for (name, local_profile) in &cfg.connections {
if !local_profile.sync() {
continue;
}
match remote_payload.connections.get(name) {
None => report.pushed += 1,
Some(remote_p) if local_profile.modified_at > remote_p.modified_at => {
report.pushed += 1
}
_ => {}
}
}
// 3. Process remote tombstones
for (name, tombstone_ts) in &remote_payload.deleted {
if let Some(local_profile) = cfg.connections.get(name)
&& *tombstone_ts > local_profile.modified_at
{
// Remote deletion is newer → delete locally
let removed = cfg.connections.shift_remove(name);
if let Some(profile) = removed
&& let Some(auth_ref) = profile.auth_ref()
{
cfg.prune_credential_if_unused(auth_ref, None);
}
report.deleted += 1;
}
// else: local is newer or not present → local edit wins over deletion
}
// 4. Merge credentials: bring in remote credentials for pulled/updated connections
for (name, remote_profile) in &remote_payload.connections {
let local_is_newer = cfg
.connections
.get(name)
.is_some_and(|lp| lp.modified_at >= remote_profile.modified_at);
if !local_is_newer {
// We accepted the remote version of this connection → bring its credential too
if let Some(auth_ref) = remote_profile.auth_ref()
&& let Some(credential) = remote_payload.credentials.entries.get(auth_ref)
{
cfg.credentials
.entries
.insert(auth_ref.to_string(), credential.clone());
}
}
}
// 5. Prune local tombstones that both sides agree are gone
cfg.deleted.retain(|name, _| {
// Keep tombstone if remote still has this connection (need to propagate deletion)
remote_payload.connections.contains_key(name)
});
// NOTE: caller is responsible for cfg.save() after successful upload
Ok(report)
}
// ── Helpers ──────────────────────────────────────────────────────
pub(crate) fn localize_shell_profile(
cfg: &SshellConfig,
name: &str,
@@ -60,9 +282,6 @@ pub(crate) fn build_sync_payload(
sync_password: Option<&str>,
) -> Result<toml::Value> {
let mut payload = cfg.clone();
payload.settings.sync_password = None;
payload.settings.webdav_password = None;
payload.settings.s3_secret_key = None;
let synced_refs: Vec<String> = payload
.connections
@@ -90,14 +309,10 @@ pub(crate) fn build_sync_payload(
"version".to_string(),
toml::Value::Integer(payload.version as i64),
);
table.insert("settings".to_string(), to_toml_value(&payload.settings)?);
let mut conns = toml::map::Map::new();
for (name, profile) in &mut payload.connections {
profile.local_tags.clear();
if !payload.settings.sync_usage_count {
profile.usage_count = 0;
}
profile.usage_count = 0;
match &mut profile.kind {
ConnectionType::Shell {
auth_ref,
@@ -137,68 +352,33 @@ pub(crate) fn build_sync_payload(
);
}
// Include tombstones
if !payload.deleted.is_empty() {
let mut del_map = toml::map::Map::new();
for (name, ts) in &payload.deleted {
del_map.insert(name.clone(), toml::Value::Integer(*ts as i64));
}
table.insert("deleted".to_string(), toml::Value::Table(del_map));
}
Ok(toml::Value::Table(table))
}
pub(crate) fn merge_remote(
cfg: &mut SshellConfig,
remote: toml::Value,
strategy: PullStrategy,
) -> Result<usize> {
let mut count = 0;
if let Some(conns) = remote.get("connections").and_then(|v| v.as_table()) {
for (name, profile_val) in conns {
let should_insert = match strategy {
PullStrategy::Merge => !cfg.connections.contains_key(name),
PullStrategy::Overwrite => true,
};
if should_insert
&& let Ok(mut profile) = profile_val
.clone()
.try_into::<crate::config::ConnectionProfile>()
&& localize_shell_profile(cfg, name, &mut profile)
{
cfg.connections.insert(name.clone(), profile);
count += 1;
}
}
}
let remote_credentials =
if let Some(enc) = remote.get("credentials_encrypted").and_then(|v| v.as_str()) {
let sync_password = cfg
.settings
.sync_password
.as_deref()
.context("sync_password not set; needed to decrypt remote credentials")?;
Some(crypto::decrypt_credentials(enc, sync_password)?)
} else if let Some(creds_val) = remote.get("credentials") {
creds_val.clone().try_into::<CredentialStore>().ok()
} else {
None
};
if let Some(remote_creds) = remote_credentials {
for (name, credential) in remote_creds.entries {
if name == GIST_TOKEN_REF {
continue;
}
let should_insert = match strategy {
PullStrategy::Merge => !cfg.credentials.entries.contains_key(&name),
PullStrategy::Overwrite => true,
};
if should_insert {
cfg.credentials.entries.insert(name, credential);
count += 1;
}
}
}
cfg.save()?;
Ok(count)
pub(crate) fn count_synced(cfg: &SshellConfig) -> usize {
cfg.connections
.iter()
.filter(|(_, p)| p.sync())
.count()
}
pub(crate) fn to_toml_value<T: serde::Serialize>(val: &T) -> Result<toml::Value> {
toml::Value::try_from(val).map_err(|e| anyhow::anyhow!("toml conversion failed: {e}"))
}
/// Run sync using the configured backend.
pub fn run_sync(cfg: &mut crate::config::SshellConfig) -> Result<SyncReport> {
match cfg.settings.backend {
SyncBackend::Gist => gist::sync(cfg),
SyncBackend::Webdav => webdav::sync(cfg),
}
}
+52 -36
View File
@@ -1,14 +1,52 @@
use crate::config::{CredentialEntry, SshellConfig};
use super::{GIST_TOKEN_REF, PullStrategy, build_sync_payload, merge_remote};
use super::{GIST_TOKEN_REF, SyncReport, build_sync_payload, bidirectional_merge, count_synced};
use anyhow::{Context, Result, bail};
use reqwest::blocking::Client;
use serde_json::json;
const FILE_NAME: &str = "sshell-config.toml";
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
pub fn sync(cfg: &mut SshellConfig) -> Result<SyncReport> {
let token = gist_token(cfg)?;
// Step 1: Download remote if gist_id exists
let remote_payload = if let Some(id) = &cfg.settings.gist_id {
let client = Client::new();
let response = client
.get(format!("https://api.github.com/gists/{id}"))
.bearer_auth(&token)
.header("User-Agent", "sshell")
.send()?;
if response.status().is_success() {
let value: serde_json::Value = response.json()?;
if let Some(content) = value["files"][FILE_NAME]["content"].as_str() {
Some(
toml::from_str(content).with_context(|| "failed to parse remote config")?,
)
} else {
None
}
} else {
None
}
} else {
None
};
// Step 2: Snapshot before merge so we can rollback on upload failure
let snapshot = cfg.clone();
// Step 3: Bidirectional merge (modifies cfg in memory only)
let report = if let Some(remote) = remote_payload {
bidirectional_merge(cfg, remote)?
} else {
SyncReport {
pushed: count_synced(cfg),
..Default::default()
}
};
// Step 4: Upload merged payload
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
let content = toml::to_string_pretty(&payload)?;
let body = json!({
@@ -35,43 +73,21 @@ pub fn push(cfg: &mut SshellConfig) -> Result<String> {
};
if !response.status().is_success() {
bail!("sync push failed: {}", response.status());
// Rollback in-memory state
*cfg = snapshot;
bail!("sync upload failed: {}", response.status());
}
// Save gist_id if this was a first-time creation
if cfg.settings.gist_id.is_none() {
let value: serde_json::Value = response.json()?;
if let Some(id) = value["id"].as_str() {
cfg.settings.gist_id = Some(id.to_string());
}
}
let value: serde_json::Value = response.json()?;
let id = value["id"]
.as_str()
.context("sync response did not include id")?
.to_string();
cfg.settings.gist_id = Some(id.clone());
cfg.save()?;
Ok(id)
}
pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result<usize> {
let token = gist_token(cfg)?;
let id = cfg
.settings
.gist_id
.clone()
.context("gist id not configured")?;
let client = Client::new();
let response = client
.get(format!("https://api.github.com/gists/{id}"))
.bearer_auth(token)
.header("User-Agent", "sshell")
.send()?;
if !response.status().is_success() {
bail!("sync pull failed: {}", response.status());
}
let value: serde_json::Value = response.json()?;
let content = value["files"][FILE_NAME]["content"]
.as_str()
.context("remote file not found")?;
let remote: toml::Value =
toml::from_str(content).with_context(|| "failed to parse remote config")?;
merge_remote(cfg, remote, strategy)
Ok(report)
}
fn gist_token(cfg: &SshellConfig) -> Result<String> {
-313
View File
@@ -1,313 +0,0 @@
use crate::config::SshellConfig;
use super::{PullStrategy, build_sync_payload, merge_remote};
use anyhow::{Context, Result, bail};
use hmac::{Hmac, Mac};
use reqwest::blocking::Client;
use reqwest::header::{CONTENT_TYPE, HOST};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
const FILE_NAME: &str = "sshell-config.toml";
const SERVICE: &str = "s3";
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?;
let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?;
let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?;
let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?;
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
let body = toml::to_string_pretty(&payload)?;
let body_bytes = body.as_bytes();
let path = format!("/{bucket}/{FILE_NAME}");
let host = endpoint_host(endpoint);
let now = chrono_now();
let region = region_from_endpoint(endpoint);
let payload_hash = hex_hash(body_bytes);
let (auth_header, amz_date) = sign(
access_key,
secret_key,
&region,
&SigningRequest {
method: "PUT",
host: &host,
path: &path,
query: &[],
payload_hash: &payload_hash,
timestamp: &now,
},
);
let url = format!("https://{host}{path}");
let client = Client::new();
let response = client
.put(&url)
.header(HOST, host.clone())
.header(CONTENT_TYPE, "application/octet-stream")
.header("x-amz-content-sha256", &payload_hash)
.header("x-amz-date", &amz_date)
.header("Authorization", &auth_header)
.body(body)
.send()?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().unwrap_or_default();
bail!("sync push failed: {status} {body}");
}
Ok(url)
}
pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result<usize> {
let endpoint = cfg.settings.s3_endpoint.as_deref().context("s3_endpoint not set")?;
let bucket = cfg.settings.s3_bucket.as_deref().context("s3_bucket not set")?;
let access_key = cfg.settings.s3_access_key.as_deref().context("s3_access_key not set")?;
let secret_key = cfg.settings.s3_secret_key.as_deref().context("s3_secret_key not set")?;
let path = format!("/{bucket}/{FILE_NAME}");
let host = endpoint_host(endpoint);
let now = chrono_now();
let region = region_from_endpoint(endpoint);
let payload_hash = hex_hash(b"");
let (auth_header, amz_date) = sign(
access_key,
secret_key,
&region,
&SigningRequest {
method: "GET",
host: &host,
path: &path,
query: &[],
payload_hash: &payload_hash,
timestamp: &now,
},
);
let url = format!("https://{host}{path}");
let client = Client::new();
let response = client
.get(&url)
.header(HOST, host.clone())
.header("x-amz-content-sha256", &payload_hash)
.header("x-amz-date", &amz_date)
.header("Authorization", &auth_header)
.send()?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
bail!("sync pull failed: remote file not found");
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().unwrap_or_default();
bail!("sync pull failed: {status} {body}");
}
let content = response.text()?;
let remote: toml::Value =
toml::from_str(&content).with_context(|| "failed to parse remote config")?;
merge_remote(cfg, remote, strategy)
}
// ── AWS Signature V4 ───────────────────────────────────────────
struct SigningRequest<'a> {
method: &'a str,
host: &'a str,
path: &'a str,
query: &'a [(&'a str, &'a str)],
payload_hash: &'a str,
timestamp: &'a str,
}
fn sign(
access_key: &str,
secret_key: &str,
region: &str,
req: &SigningRequest<'_>,
) -> (String, String) {
let date = &req.timestamp[..8];
let amz_date = req.timestamp.to_string();
let content_type_val = if req.method == "PUT" {
"application/octet-stream"
} else {
""
};
// Canonical headers (sorted by key)
let mut headers: Vec<(&str, String)> = vec![
("content-type", content_type_val.to_string()),
("host", req.host.to_string()),
("x-amz-content-sha256", req.payload_hash.to_string()),
("x-amz-date", amz_date.clone()),
];
headers.sort_by_key(|(k, _)| *k);
let signed_headers: String = headers.iter().map(|(k, _)| *k).collect::<Vec<_>>().join(";");
let canonical_headers: String = headers
.iter()
.map(|(k, v)| format!("{k}:{v}\n"))
.collect();
let canonical_querystring = req.query
.iter()
.map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
.collect::<Vec<_>>()
.join("&");
let canonical_request = format!(
"{method}\n{path}\n{qs}\n{headers}\n{signed}\n{hash}",
method = req.method,
path = req.path,
qs = canonical_querystring,
headers = canonical_headers,
signed = signed_headers,
hash = req.payload_hash,
);
let credential_scope = format!("{date}/{region}/{SERVICE}/aws4_request");
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hash}",
timestamp = req.timestamp,
scope = credential_scope,
hash = hex_hash(canonical_request.as_bytes()),
);
let signing_key = derive_signing_key(secret_key, date, region);
let signature = hex_hmac(&signing_key, string_to_sign.as_bytes());
let auth = format!(
"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}"
);
(auth, amz_date)
}
fn derive_signing_key(secret_key: &str, date: &str, region: &str) -> Vec<u8> {
let k_date = hmac_bytes(format!("AWS4{secret_key}").as_bytes(), date.as_bytes());
let k_region = hmac_bytes(&k_date, region.as_bytes());
let k_service = hmac_bytes(&k_region, SERVICE.as_bytes());
hmac_bytes(&k_service, b"aws4_request")
}
fn hmac_bytes(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key len");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn hex_hmac(key: &[u8], data: &[u8]) -> String {
hex::encode(hmac_bytes(key, data))
}
fn hex_hash(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
fn url_encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'~' | b'.' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn endpoint_host(endpoint: &str) -> String {
let s = endpoint
.strip_prefix("https://")
.or_else(|| endpoint.strip_prefix("http://"))
.unwrap_or(endpoint);
s.trim_end_matches('/').to_string()
}
fn region_from_endpoint(endpoint: &str) -> String {
if endpoint.contains("r2.cloudflarestorage.com") {
return "auto".to_string();
}
let host = endpoint_host(endpoint);
let parts: Vec<&str> = host.split('.').collect();
for (i, part) in parts.iter().enumerate() {
if *part == "s3" && i + 1 < parts.len() {
return parts[i + 1].to_string();
}
}
"us-east-1".to_string()
}
fn chrono_now() -> String {
let now = std::time::SystemTime::now();
let duration = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
// Simple UTC time formatting without chrono dependency
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
// Calculate year/month/day from days since epoch
let (year, month, day) = days_to_date(days);
format!(
"{year:04}{month:02}{day:02}T{hours:02}{minutes:02}{seconds:02}Z"
)
}
fn days_to_date(mut days: u64) -> (u64, u64, u64) {
let mut year = 1970u64;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let leap = is_leap(year);
let month_days = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 0u64;
for (i, &md) in month_days.iter().enumerate() {
if days < md {
month = i as u64 + 1;
break;
}
days -= md;
}
if month == 0 {
month = 12;
}
(year, month, days + 1)
}
fn is_leap(year: u64) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
+43 -44
View File
@@ -1,75 +1,74 @@
use crate::config::SshellConfig;
use super::{PullStrategy, build_sync_payload, merge_remote};
use super::{SyncReport, bidirectional_merge, build_sync_payload, count_synced};
use anyhow::{Context, Result, bail};
use reqwest::blocking::Client;
use reqwest::header::{ACCEPT, CONTENT_TYPE};
const FILE_NAME: &str = "sshell-config.toml";
pub fn push(cfg: &mut SshellConfig) -> Result<String> {
pub fn sync(cfg: &mut SshellConfig) -> Result<SyncReport> {
let url = webdav_file_url(cfg)?;
let user = cfg
.settings
.webdav_user
.as_deref()
.clone()
.context("webdav_user not set")?;
let password = cfg
.settings
.webdav_password
.as_deref()
.clone()
.context("webdav_password not set")?;
let client = Client::new();
// Step 1: Download remote
let remote_payload = {
let response = client
.get(&url)
.basic_auth(&user, Some(&password))
.header(ACCEPT, "*/*")
.send()?;
if response.status().is_success() {
let content = response.text()?;
Some(
toml::from_str(&content)
.with_context(|| "failed to parse remote config")?,
)
} else {
None
}
};
// Step 2: Snapshot before merge so we can rollback on upload failure
let snapshot = cfg.clone();
// Step 3: Bidirectional merge (modifies cfg in memory only)
let report = if let Some(remote) = remote_payload {
bidirectional_merge(cfg, remote)?
} else {
SyncReport {
pushed: count_synced(cfg),
..Default::default()
}
};
// Step 4: Upload merged payload
let payload = build_sync_payload(cfg, cfg.settings.sync_password.as_deref())?;
let content = toml::to_string_pretty(&payload)?;
let client = Client::new();
let response = client
.put(&url)
.basic_auth(user, Some(password))
.basic_auth(&user, Some(&password))
.header(CONTENT_TYPE, "text/plain")
.header(ACCEPT, "*/*")
.body(content)
.send()?;
if !response.status().is_success() {
bail!("sync push failed: {}", response.status());
// Rollback in-memory state
*cfg = snapshot;
bail!("sync upload failed: {}", response.status());
}
Ok(url)
}
pub fn pull_with_strategy(cfg: &mut SshellConfig, strategy: PullStrategy) -> Result<usize> {
let url = webdav_file_url(cfg)?;
let user = cfg
.settings
.webdav_user
.as_deref()
.context("webdav_user not set")?;
let password = cfg
.settings
.webdav_password
.as_deref()
.context("webdav_password not set")?;
let client = Client::new();
let response = client
.get(&url)
.basic_auth(user, Some(password))
.header(ACCEPT, "*/*")
.send()?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
bail!("sync pull failed: remote file not found");
}
if !response.status().is_success() {
bail!("sync pull failed: {}", response.status());
}
let content = response.text()?;
let remote: toml::Value =
toml::from_str(&content).with_context(|| "failed to parse remote config")?;
merge_remote(cfg, remote, strategy)
cfg.save()?;
Ok(report)
}
fn webdav_file_url(cfg: &SshellConfig) -> Result<String> {
+30 -21
View File
@@ -75,32 +75,41 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut crate::app::App) {
});
use view::View;
let (title, hints): (&str, Vec<_>) = match app.session.mode {
Mode::Home => (view::HomeListView.title(), view::HomeListView.hints()),
Mode::ActionMenu => (view::ActionMenuView.title(), view::ActionMenuView.hints()),
Mode::Search => (view::SearchView.title(), view::SearchView.hints()),
Mode::QuickSelect => (view::QuickSelectView.title(), view::QuickSelectView.hints()),
Mode::DeleteConfirm => (view::DeleteConfirmView.title(), view::DeleteConfirmView.hints()),
Mode::Form => (view::FormView.title(), view::FormView.hints()),
Mode::ImportSelector => (view::ImportView.title(), view::ImportView.hints()),
Mode::Credentials => (view::CredListView.title(), view::CredListView.hints()),
Mode::CredForm => (view::CredFormView.title(), view::CredFormView.hints()),
Mode::Settings => (view::SettingsView.title(), view::SettingsView.hints()),
let (title, hints): (&str, Vec<_>) = {
let v: &dyn View = match app.session.mode {
Mode::Home => &view::HomeListView,
Mode::ActionMenu => &view::ActionMenuView,
Mode::Search => &view::SearchView,
Mode::QuickSelect => &view::QuickSelectView,
Mode::DeleteConfirm => &view::DeleteConfirmView,
Mode::Form => &view::FormView,
Mode::ImportSelector => &view::ImportView,
Mode::Credentials => &view::CredListView,
Mode::CredForm => &view::CredFormView,
Mode::Settings => &view::SettingsView,
};
(v.title(), v.hints())
};
draw_header(frame, app, title, shell[0]);
match app.session.mode {
Mode::Home => view::HomeListView.draw(frame, app, content),
Mode::ActionMenu => view::HomeListView.draw(frame, app, content),
Mode::Search => view::SearchView.draw(frame, app, content),
Mode::QuickSelect => view::QuickSelectView.draw(frame, app, content),
Mode::DeleteConfirm => view::DeleteConfirmView.draw(frame, app, content),
Mode::Form => view::FormView.draw(frame, app, content),
Mode::ImportSelector => view::ImportView.draw(frame, app, content),
Mode::Credentials => view::CredListView.draw(frame, app, content),
Mode::CredForm => view::CredFormView.draw(frame, app, content),
Mode::Settings => view::SettingsView.draw(frame, app, content),
Mode::Home | Mode::ActionMenu | Mode::DeleteConfirm => {
view::HomeListView.draw(frame, app, content)
}
_ => {
let v: &dyn View = match app.session.mode {
Mode::Search => &view::SearchView,
Mode::QuickSelect => &view::QuickSelectView,
Mode::Form => &view::FormView,
Mode::ImportSelector => &view::ImportView,
Mode::Credentials => &view::CredListView,
Mode::CredForm => &view::CredFormView,
Mode::Settings => &view::SettingsView,
_ => unreachable!(),
};
v.draw(frame, app, content);
}
}
draw_help(frame, &hints, shell[2]);
+11 -4
View File
@@ -25,6 +25,13 @@ pub fn run() -> Result<()> {
let mut terminal = Terminal::new(backend)?;
let mut app = App::load()?;
if app.config.settings.sync_on_start {
let result = crate::sync::run_sync(&mut app.config);
if let Err(err) = result {
app.toast(err.to_string(), false);
}
}
spawn_latency_probes(&app);
loop {
@@ -116,10 +123,10 @@ fn spawn_latency_probes(app: &App) {
// Re-check under lock to avoid duplicate spawns
{
let cache = app.session.latency.lock().unwrap();
if let Some(entry) = cache.get(&key) {
if now.duration_since(entry.checked_at) < stale_duration {
continue;
}
if let Some(entry) = cache.get(&key)
&& now.duration_since(entry.checked_at) < stale_duration
{
continue;
}
}
// Mark as "in-flight" by inserting a fresh entry
+1 -8
View File
@@ -1,6 +1,6 @@
use crate::app::App;
use crate::config::SyncBackend;
use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, PURPLE, TEXT};
use crate::ui::{ACCENT, BG, BLUE, DIM_BORDER, GREEN, MUTED, ORANGE, PANEL, TEXT};
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Style, Stylize},
@@ -31,13 +31,6 @@ pub fn draw_header(frame: &mut ratatui::Frame<'_>, app: &App, title: &str, area:
("webdav not set", MUTED)
}
}
SyncBackend::S3 => {
if app.config.settings.s3_endpoint.is_some() {
("s3 ready", PURPLE)
} else {
("s3 not set", MUTED)
}
}
};
let mut spans: Vec<Span<'static>> = vec![
+32 -5
View File
@@ -7,12 +7,14 @@ mod import;
mod settings;
use crate::app::{App, FormAction, FormNav};
use crate::ui::BLUE;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
widgets::Row,
style::{Modifier, Style},
widgets::{Cell, Row},
};
/// A View represents a full screen that handles both rendering and key events.
@@ -38,17 +40,30 @@ pub use settings::SettingsView;
/// Scroll a 1:1 row list so the selected index stays visible.
/// `area_height` includes the block borders and table header.
pub fn scroll_rows<'a>(rows: Vec<Row<'a>>, selected: usize, area_height: u16) -> Vec<Row<'a>> {
scroll_indexed_rows(rows, &[selected], 0, area_height)
}
/// Scroll a row list that mixes data rows with non-data rows (e.g. section headers).
/// `entry_row` maps each data index to its visual row index in `rows`.
/// `selected` is the currently selected data index.
pub fn scroll_indexed_rows<'a>(
rows: Vec<Row<'a>>,
entry_row: &[usize],
selected: usize,
area_height: u16,
) -> Vec<Row<'a>> {
let visible = area_height.saturating_sub(3) as usize; // 2 borders + 1 header
let total = rows.len();
if visible == 0 || total <= visible {
if visible == 0 || total <= visible || entry_row.is_empty() {
return rows;
}
let scroll = if selected < visible / 2 {
let sel_row = entry_row[selected.min(entry_row.len() - 1)];
let scroll = if sel_row < visible / 2 {
0
} else if selected + visible / 2 >= total {
} else if sel_row + visible / 2 >= total {
total.saturating_sub(visible)
} else {
selected - visible / 2
sel_row - visible / 2
};
rows.into_iter().skip(scroll).take(visible).collect()
}
@@ -79,3 +94,15 @@ pub fn handle_form_nav<F: FormNav>(form: &mut F, key: KeyEvent) -> Option<FormAc
_ => None,
}
}
/// A section header row with a label and count, used in table views.
pub fn section_row(label: &str, count: usize) -> Row<'static> {
Row::new([
Cell::from(format!(" {label} ({count})"))
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
Cell::from(""),
Cell::from(""),
Cell::from(""),
])
.height(1)
}
+5 -7
View File
@@ -16,8 +16,7 @@ use ratatui::{
const ACTIONS: &[(&str, &str)] = &[
("Import", "scan shells & import SSH config"),
("Push Sync", "upload to cloud"),
("Pull Sync", "download from cloud"),
("Sync", "bidirectional cloud sync"),
("Credentials", "manage passwords & keys"),
("Settings", "preferences & sync config"),
];
@@ -94,11 +93,10 @@ impl View for ActionMenuView {
ListAction::Select => {
app.session.mode = Mode::Home;
match app.session.action_menu.cursor {
0 => app.enter_combined_import()?,
1 => app.push_sync_with_toast(),
2 => app.pull_sync_with_toast(),
3 => app.enter_credentials(),
4 => app.enter_settings(),
0 => app.enter_import_selector()?,
1 => app.sync_with_toast(),
2 => app.enter_credentials(),
3 => app.enter_settings(),
_ => {}
}
}
+4 -38
View File
@@ -6,6 +6,7 @@ use crate::ui::component::{badge_span, draw_input, panel, tag_badge};
use crate::ui::{ACCENT, BLUE, GREEN, MUTED, PANEL_ALT, PURPLE, RED, SELECTED_BG, TEXT, YELLOW};
use super::View;
use super::{section_row, scroll_indexed_rows};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@@ -162,21 +163,7 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
}
// Scroll to keep selected entry visible
let visible = area.height.saturating_sub(3) as usize; // 2 borders + 1 header
if visible > 0 && !entry_row.is_empty() {
let sel_row = entry_row[app.session.home.selected.min(entry_row.len() - 1)];
let total = rows.len();
if total > visible {
let scroll = if sel_row < visible / 2 {
0
} else if sel_row + visible / 2 >= total {
total.saturating_sub(visible)
} else {
sel_row - visible / 2
};
rows = rows.into_iter().skip(scroll).take(visible).collect();
}
}
rows = scroll_indexed_rows(rows, &entry_row, app.session.home.selected, area.height);
let title = if app.session.mode == Mode::QuickSelect {
"Connections - Quick Select"
@@ -203,17 +190,6 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
frame.render_widget(table, area);
}
fn section_row(label: &str, count: usize) -> Row<'static> {
Row::new([
Cell::from(format!(" {label} ({count})"))
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
Cell::from(""),
Cell::from(""),
Cell::from(""),
])
.height(1)
}
fn connection_row(
app: &App,
idx: usize,
@@ -252,11 +228,9 @@ fn connection_row(
}
ConnectionType::Shell {
command,
sync_args,
local_args,
..
} => {
let merged_args = shell_args(sync_args, local_args);
let merged_args = profile.merged_shell_args();
if merged_args.is_empty() {
command.clone()
} else {
@@ -378,14 +352,12 @@ pub fn draw_detail_panel(frame: &mut Frame<'_>, app: &App, area: Rect) {
shell_name,
auth_ref,
command,
sync_args,
local_args,
sync,
..
} => {
lines.push(detail_text("Shell", shell_name));
lines.push(detail_text("Command", command));
let merged_args = shell_args(sync_args, local_args);
let merged_args = profile.merged_shell_args();
if !merged_args.is_empty() {
lines.push(detail_text("Args", &merged_args.join(" ")));
}
@@ -437,12 +409,6 @@ fn detail_text(label: &str, value: &str) -> Line<'static> {
])
}
fn shell_args(sync_args: &[String], local_args: &[String]) -> Vec<String> {
let mut out = sync_args.to_vec();
out.extend(local_args.iter().cloned());
out
}
fn detail_line(label: &str, spans: Vec<Span<'static>>) -> Line<'static> {
let mut out = vec![Span::styled(
format!(" {:<11}", label),
+2 -26
View File
@@ -3,6 +3,7 @@ use crate::ui::component::{ListAction, handle_list_nav, panel, panel_with_subtit
use crate::ui::{BLUE, GREEN, MUTED, SELECTED_BG, TEXT};
use super::View;
use super::{section_row, scroll_indexed_rows};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
@@ -134,21 +135,7 @@ fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
}
// Scroll to keep selected entry visible
let visible = area.height.saturating_sub(3) as usize;
if visible > 0 && !entry_row.is_empty() {
let sel_row = entry_row[app.session.import.cursor.min(entry_row.len() - 1)];
let total_rows = rows.len();
if total_rows > visible {
let scroll = if sel_row < visible / 2 {
0
} else if sel_row + visible / 2 >= total_rows {
total_rows.saturating_sub(visible)
} else {
sel_row - visible / 2
};
rows = rows.into_iter().skip(scroll).take(visible).collect();
}
}
rows = scroll_indexed_rows(rows, &entry_row, app.session.import.cursor, area.height);
let table = Table::new(
rows,
@@ -172,17 +159,6 @@ fn draw_import(frame: &mut Frame<'_>, app: &App, area: Rect) {
frame.render_widget(table, area);
}
fn section_row(label: &str, count: usize) -> Row<'static> {
Row::new([
Cell::from(format!(" {label} ({count})"))
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
Cell::from(""),
Cell::from(""),
Cell::from(""),
])
.height(1)
}
// ── Key handling ───────────────────────────────────────────────
fn handle_import(app: &mut App, key: KeyEvent) -> Result<()> {
+10 -15
View File
@@ -1,7 +1,7 @@
use crate::app::{App, FormAction, Mode, SettingsField, SettingsState, char_len};
use crate::config::SyncBackend;
use crate::ui::component::{FormRow, badge_span};
use crate::ui::{ACCENT, GREEN, ORANGE, PURPLE, RED};
use crate::ui::{ACCENT, GREEN, MUTED, ORANGE};
use super::{View, handle_form_nav};
@@ -31,7 +31,7 @@ impl View for SettingsView {
let active = settings.active == field;
let label = field.label().to_string();
if i == 2 || matches!(field, SettingsField::SyncUsage) {
if i == 2 {
rows.push(FormRow::Separator);
}
@@ -40,15 +40,12 @@ impl View for SettingsView {
SettingsField::Backend => match settings.backend {
SyncBackend::Gist => badge_span("Gist", ACCENT),
SyncBackend::Webdav => badge_span("WebDAV", ORANGE),
SyncBackend::S3 => badge_span("S3", PURPLE),
},
SettingsField::SyncUsage => {
if settings.sync_usage {
badge_span("Yes", GREEN)
} else {
badge_span("No", RED)
}
}
SettingsField::SyncOnStart => if settings.sync_on_start {
badge_span("on", GREEN)
} else {
badge_span("off", MUTED)
},
_ => unreachable!(),
};
rows.push(FormRow::Toggle {
@@ -62,7 +59,6 @@ impl View for SettingsView {
field,
SettingsField::SyncPassword
| SettingsField::WebdavPassword
| SettingsField::S3SecretKey
);
let (display, secret_cursor) = if is_secret {
if raw.is_empty() {
@@ -126,13 +122,12 @@ fn settings_toggle(settings: &mut SettingsState) {
SettingsField::Backend => {
settings.backend = match settings.backend {
SyncBackend::Gist => SyncBackend::Webdav,
SyncBackend::Webdav => SyncBackend::S3,
SyncBackend::S3 => SyncBackend::Gist,
SyncBackend::Webdav => SyncBackend::Gist,
};
settings.ensure_active_visible();
}
SettingsField::SyncUsage => {
settings.sync_usage = !settings.sync_usage;
SettingsField::SyncOnStart => {
settings.sync_on_start = !settings.sync_on_start;
}
_ => {}
}