外部 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:
1 parent
f7928fbeba
commit
7e32ac7033
31 files changed
+4367
-241
No files matched your search
+134
-11
@@ -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
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
mod support;
|
||||
|
||||
mod cli;
|
||||
mod connection;
|
||||
mod credential;
|
||||
mod docker;
|
||||
|
||||
Reference in new issue
Block a user