外部 CLI 补齐主机与凭据管理和目录同步,命令写法对齐 termark

- 新增 shellrs hosts / credentials 的 list、show、create、update、delete,create / update 从标准输入读 JSON,show 的输出可以直接交回 update
- 校验和文案同对话框(HostDraft::validated),分组路径不存在就逐级新建;改了地址、端口或用户时保存的密码跟着搬
- 秘密只写不读:密码、口令、私钥进钥匙串或 keys/,show 只说有没有保存
- hosts delete 遇到有打开标签的主机报 host_in_use,--force 才关标签;删凭据时用它的主机改回自己登录
- 改动经服务端队列交给 UI 线程逐条执行,超时只算排队;写库和钥匙串失败报 save_failed
- 新增 shellrs sync:本地目录同步到远程,没变的文件跳过,--delete 删远程多余项(不删 .filepart 和备份,本地读不了的目录不清理)
- 去掉顶层 shellrs list,统一用 shellrs hosts list;skill、手册、README 同步更新

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-07 14:11:40 +08:00
1 parent f7928fbeba
commit 7e32ac7033
31 files changed
+4367 -241

No files matched your search

+134 -11
View File
@@ -14,10 +14,11 @@ use std::{
use shellrs::app::cli_endpoint;
use shellrs::cli::{
CliBackend, CliError, CliServer, CliTarget, HostInfo, OpenLink, TransferCounters,
TransferSummary,
AuthChoice, CliBackend, CliChange, CliError, CliServer, CliTarget, HostDetails, HostInfo,
OpenLink, Reply, RouteDetails, TransferCounters, TransferSummary,
};
use shellrs::host::{AuthKind, Host, HostDraft, HostId, HostLogin};
use shellrs::host::{AuthKind, HostDraft, HostStore};
use shellrs::secrets::SecretRef;
use shellrs::ssh::ExecStream;
/// More than a pipe's buffer, so the app finishes writing before the
@@ -72,6 +73,31 @@ impl CliBackend for FakeBackend {
) -> Result<TransferSummary, CliError> {
Ok(TransferSummary::default())
}
fn sync(
&self,
_: &CliTarget,
source: &Path,
destination: &str,
delete: bool,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
self.commands
.lock()
.unwrap()
.push(format!("sync {} {destination} {delete}", source.display()));
Ok(TransferSummary {
files: 1,
bytes: 10,
skipped: 5,
deleted: 2,
..TransferSummary::default()
})
}
fn is_saved(&self, _: &SecretRef) -> bool {
false
}
}
/// Run the command against the app whose data lives in `data_dir`.
@@ -91,7 +117,7 @@ fn shellrs(data_dir: &Path, args: &[&str], stdin: &[u8]) -> Output {
#[test]
fn without_the_app_the_command_says_so_and_exits_255() {
let data_dir = tempfile::tempdir().unwrap();
let output = shellrs(data_dir.path(), &["list"], b"");
let output = shellrs(data_dir.path(), &["hosts", "list"], b"");
assert_eq!(output.status.code(), Some(255));
assert!(
String::from_utf8(output.stderr)
@@ -105,16 +131,21 @@ fn the_command_finds_the_app_through_its_data_directory() {
let data_dir = tempfile::tempdir().unwrap();
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(cli_endpoint(data_dir.path()), backend.clone()).unwrap();
let web = Host::new(
HostId(1),
HostDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Password, None),
);
server.set_targets(vec![CliTarget::new(&web, HostLogin::of(&web, None), None)]);
let mut store = HostStore::empty();
let web = store.insert_unnotified(HostDraft::new(
"web-01",
"10.0.1.12",
22,
"root",
AuthKind::Password,
None,
));
server.set_hosts(&store);
server.set_enabled(true);
let id = web.public_id.to_string();
let id = store.host(web).unwrap().public_id.to_string();
// A pipe for stdout means JSON.
let output = shellrs(data_dir.path(), &["list"], b"");
let output = shellrs(data_dir.path(), &["hosts", "list"], b"");
assert_eq!(output.status.code(), Some(0));
let hosts: Vec<HostInfo> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(hosts[0].id, id);
@@ -153,6 +184,31 @@ fn the_command_finds_the_app_through_its_data_directory() {
assert_eq!(output.status.code(), Some(0));
assert_eq!(output.stdout.len(), LARGE_OUTPUT);
// A folder's contents, from where the command runs.
let output = shellrs(
data_dir.path(),
&[
"sync",
&id,
data_dir.path().to_str().unwrap(),
"/srv/app",
"--delete",
],
b"",
);
assert_eq!(output.status.code(), Some(0));
let summary: TransferSummary = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!((summary.skipped, summary.deleted), (5, 2));
assert!(
backend
.commands
.lock()
.unwrap()
.last()
.unwrap()
.ends_with(" /srv/app true")
);
server.set_enabled(false);
let output = shellrs(data_dir.path(), &["exec", &id, "true"], b"");
assert_eq!(output.status.code(), Some(255));
@@ -233,3 +289,70 @@ fn shellrs_opened_with_a_link_hands_it_to_the_running_app() {
])
);
}
#[test]
fn a_host_described_on_stdin_reaches_the_app_and_comes_back_saved() {
let data_dir = tempfile::tempdir().unwrap();
let server = Arc::new(
CliServer::start(
cli_endpoint(data_dir.path()),
Arc::new(FakeBackend::default()),
)
.unwrap(),
);
server.set_enabled(true);
// The app's side: take the change up and answer with the host saved.
let app = std::thread::spawn({
let server = server.clone();
move || {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some((change, reply)) = server.take_change() {
let CliChange::CreateHost(fields) = change else {
panic!("not a new host: {change:?}");
};
let password = fields.password.clone().flatten();
reply.send(Ok(Reply::Host(HostDetails {
id: "Jwg5rHvXCxw89paM".into(),
name: fields.name.clone().unwrap_or_default(),
group: fields.group.clone().flatten(),
host: fields.host.clone().unwrap_or_default(),
port: fields.port.unwrap_or(22),
user: "root".into(),
auth: AuthChoice::Password,
credential: None,
password_saved: Some(password.is_some()),
route: RouteDetails::Direct,
notes: String::new(),
os: None,
temporary: false,
})));
return password.map(|password| password.expose().to_string());
}
assert!(Instant::now() < deadline, "the command sent nothing");
std::thread::sleep(Duration::from_millis(20));
}
}
});
// As Windows PowerShell pipes it: a BOM, CRLF, and its escapes.
let host = "\u{feff}{\"name\": \"\\u751f\\u4ea7 web\", \"address\": \"10.0.1.12\",\r\n \"group\": \"\u{751f}\u{4ea7}/web\", \"password\": \"s3cret\", \"id\": \"ignored\"}\r\n";
let output = shellrs(data_dir.path(), &["hosts", "create"], host.as_bytes());
assert_eq!(output.status.code(), Some(0), "{output:?}");
let saved: HostDetails = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(saved.name, "生产 web");
assert_eq!(saved.group.as_deref(), Some("生产/web"));
assert_eq!(saved.password_saved, Some(true));
assert_eq!(app.join().unwrap().as_deref(), Some("s3cret"));
// A field the command does not know is refused before anything is sent.
let output = shellrs(
data_dir.path(),
&["hosts", "create"],
br#"{"name": "web", "adress": "10.0.1.12"}"#,
);
assert_eq!(output.status.code(), Some(255));
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.starts_with("shellrs: [bad_request]"), "{stderr}");
assert!(stderr.contains("adress"), "{stderr}");
assert!(server.take_change().is_none());
}
+331
View File
@@ -0,0 +1,331 @@
//! The changes the external CLI asks for, as the workspace makes them:
//! hosts and credentials created, changed and deleted from `shellrs`.
//! The socket is not in it; `apply_cli_change` is where the server hands
//! each change over.
use shellrs::cli::{CliChange, CliError, CredentialFields, ErrorCode, HostFields, Reply, Secret};
use crate::support::*;
/// Make `change` as the server's poller would, and wait for its outcome.
fn apply(
cx: &mut TestAppContext,
handle: WindowHandle<Root>,
workspace: &Entity<Workspace>,
change: CliChange,
) -> Result<Reply, CliError> {
let outcome = in_frame(cx, handle, |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.apply_cli_change(change, window, cx)
})
});
let outcome = cx.foreground_executor.block_test(outcome);
cx.run_until_parked();
outcome
}
fn host_id(workspace: &Entity<Workspace>, id: u64, cx: &mut TestAppContext) -> String {
cx.update(|cx| {
workspace
.read(cx)
.store()
.read(cx)
.host(HostId(id))
.unwrap()
.public_id
.to_string()
})
}
fn store(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Entity<HostStore> {
cx.update(|cx| workspace.read(cx).store().clone())
}
#[gpui_kit::test]
fn a_host_from_the_cli_lands_in_the_tree_with_its_group_and_password(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let (handle, workspace) =
open_workspace_with_store(cx, HostStore::seed().with_secrets(secrets.clone()));
let fields = HostFields {
name: Some("api-01".into()),
host: Some("10.0.9.1".into()),
group: Some(Some("生产/接口".into())),
password: Some(Some(Secret::new("s3cret"))),
..HostFields::default()
};
let Ok(Reply::Host(created)) = apply(cx, handle, &workspace, CliChange::CreateHost(fields))
else {
panic!("not created");
};
assert_eq!(created.group.as_deref(), Some("生产/接口"));
assert_eq!(created.password_saved, Some(true));
let store = store(&workspace, cx);
let host = cx.update(|cx| {
let store = store.read(cx);
let host = store
.hosts()
.iter()
.find(|host| host.public_id.as_str() == created.id)
.unwrap();
let group = store.group(host.group.unwrap()).unwrap();
assert_eq!(group.name.as_ref(), "接口");
assert_eq!(group.parent, Some(GroupId(PRODUCTION)));
host.id
});
assert_eq!(
secrets
.get(&SecretRef::password("root", "10.0.9.1", 22))
.unwrap()
.as_deref()
.map(String::as_str),
Some("s3cret")
);
in_frame(cx, handle, |window, _| {
assert!(
window
.within("host-tree")
.try_find(("host-row", host.0))
.is_some()
);
});
// Checked as the host form checks it.
let nameless = HostFields {
host: Some("10.0.9.2".into()),
..HostFields::default()
};
let error = apply(cx, handle, &workspace, CliChange::CreateHost(nameless)).unwrap_err();
assert_eq!(
(error.code, error.message.as_str()),
(ErrorCode::BadRequest, "请输入名称")
);
}
#[gpui_kit::test]
fn a_host_moved_to_another_port_keeps_its_password_until_told_otherwise(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let before = SecretRef::password("postgres", "10.0.2.5", 22);
secrets.set(&before, "pw").unwrap();
let (handle, workspace) =
open_workspace_with_store(cx, HostStore::seed().with_secrets(secrets.clone()));
let db = host_id(&workspace, DB_01, cx);
let moved = HostFields {
port: Some(2222),
..HostFields::default()
};
let Ok(Reply::Host(changed)) = apply(
cx,
handle,
&workspace,
CliChange::UpdateHost {
host: db.clone(),
fields: moved,
},
) else {
panic!("not changed");
};
assert_eq!((changed.port, changed.password_saved), (2222, Some(true)));
let after = SecretRef::password("postgres", "10.0.2.5", 2222);
assert_eq!(
secrets.get(&after).unwrap().as_deref().map(String::as_str),
Some("pw")
);
// The entry it left goes, as no other host uses it.
assert!(secrets.get(&before).unwrap().is_none());
let forget = HostFields {
password: Some(None),
..HostFields::default()
};
let Ok(Reply::Host(changed)) = apply(
cx,
handle,
&workspace,
CliChange::UpdateHost {
host: db,
fields: forget,
},
) else {
panic!("not changed");
};
assert_eq!(changed.password_saved, Some(false));
assert!(secrets.get(&after).unwrap().is_none());
}
#[gpui_kit::test]
fn a_host_with_tabs_open_goes_only_with_force_and_takes_them_along(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let web = host_id(&workspace, WEB_01, cx);
let delete = |force| CliChange::DeleteHost {
host: web.clone(),
force,
};
let error = apply(cx, handle, &workspace, delete(false)).unwrap_err();
assert_eq!(error.code, ErrorCode::HostInUse);
let store = store(&workspace, cx);
assert!(cx.update(|cx| store.read(cx).host(HostId(WEB_01)).is_some()));
in_frame(cx, handle, |window, _| {
assert!(
window
.try_find(("terminal-tab", INITIAL_WEB_TERMINAL))
.is_some()
);
});
let Ok(Reply::HostDeleted(deleted)) = apply(cx, handle, &workspace, delete(true)) else {
panic!("not deleted");
};
assert_eq!(deleted.host.name, "web-01");
assert!(cx.update(|cx| store.read(cx).host(HostId(WEB_01)).is_none()));
in_frame(cx, handle, |window, _| {
assert!(
window
.try_find(("terminal-tab", INITIAL_WEB_TERMINAL))
.is_none()
);
});
// Nothing open, nothing to force.
let db = host_id(&workspace, DB_01, cx);
let deleted = apply(
cx,
handle,
&workspace,
CliChange::DeleteHost {
host: db,
force: false,
},
);
assert!(matches!(deleted, Ok(Reply::HostDeleted(_))));
}
#[gpui_kit::test]
fn a_host_not_saved_is_left_to_its_tabs(cx: &mut TestAppContext) {
let mut seeded = HostStore::seed();
let temporary = seeded.insert_temporary_unnotified(
HostDraft::new("临时", "10.0.0.3", 22, "root", AuthKind::Password, None),
None,
);
let (handle, workspace) = open_workspace_with_store(cx, seeded);
let id = cx.update(|cx| {
workspace
.read(cx)
.store()
.read(cx)
.host(temporary)
.unwrap()
.public_id
.to_string()
});
for change in [
CliChange::UpdateHost {
host: id.clone(),
fields: HostFields::default(),
},
CliChange::DeleteHost {
host: id.clone(),
force: true,
},
] {
let error = apply(cx, handle, &workspace, change).unwrap_err();
assert_eq!(error.code, ErrorCode::BadRequest);
assert!(
error.message.contains("没有保存的连接"),
"{}",
error.message
);
}
}
#[gpui_kit::test]
fn a_pasted_key_becomes_a_credential_shellrs_keeps(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let data = tempfile::tempdir().unwrap();
let (handle, workspace) = open_workspace_with_store(
cx,
HostStore::empty()
.with_secrets(secrets.clone())
.with_key_dir(data.path().join("keys")),
);
let key = GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap();
let text = key.encode("deploy", "correct horse").unwrap().to_string();
let fields = CredentialFields {
name: Some("部署".into()),
user: Some("deploy".into()),
private_key: Some(Secret::new(text.clone())),
passphrase: Some(Some(Secret::new("correct horse"))),
..CredentialFields::default()
};
let Ok(Reply::Credential(created)) =
apply(cx, handle, &workspace, CliChange::CreateCredential(fields))
else {
panic!("not created");
};
assert!(created.kept);
// Where it is kept is not said: the file is as good as the key.
assert_eq!(created.key_path, None);
assert_eq!(created.passphrase_saved, Some(true));
let store = store(&workspace, cx);
let path = cx.update(|cx| {
let store = store.read(cx);
let credential = &store.credentials()[0];
assert_eq!(credential.keychain_id.as_str(), created.id);
assert_eq!(credential.kind, CredentialKind::Key);
credential.key_path.clone().unwrap()
});
assert!(path.starts_with(data.path().join("keys").to_str().unwrap()));
assert_eq!(std::fs::read_to_string(path.as_ref()).unwrap(), text);
assert_eq!(
secrets
.get(&SecretRef::passphrase(path.as_ref()))
.unwrap()
.as_deref()
.map(String::as_str),
Some("correct horse")
);
}
#[gpui_kit::test]
fn deleting_a_credential_lets_its_hosts_log_in_on_their_own(cx: &mut TestAppContext) {
let mut seeded = HostStore::empty();
let credential = seeded.insert_credential_unnotified(CredentialDraft::new(
"运维",
CredentialKind::Password,
"deploy",
));
let host = seeded.insert_unnotified(
HostDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Password, None)
.with_credential(credential),
);
let id = seeded
.credential(credential)
.unwrap()
.keychain_id
.to_string();
let (handle, workspace) = open_workspace_with_store(cx, seeded);
let Ok(Reply::CredentialDeleted(deleted)) = apply(
cx,
handle,
&workspace,
CliChange::DeleteCredential { credential: id },
) else {
panic!("not deleted");
};
assert_eq!(deleted.credential.name, "运维");
let released: Vec<&str> = deleted
.released
.iter()
.map(|host| host.name.as_str())
.collect();
assert_eq!(released, ["db-01"]);
let store = store(&workspace, cx);
cx.update(|cx| {
let store = store.read(cx);
assert!(store.credentials().is_empty());
let host = store.host(host).unwrap();
assert_eq!((host.credential, host.auth), (None, AuthKind::Password));
assert_eq!(host.user.as_ref(), "deploy");
});
}
+1
View File
@@ -7,6 +7,7 @@
mod support;
mod cli;
mod connection;
mod credential;
mod docker;