- 数据库升到第 13 版:主机的登录、连接方式、代理和备注合进 hosts 一行,只有一对多的跳板、书签和端口转发单独成表;全部表改为 STRICT,并用 CHECK 写明约束;用凭据的主机不再冗余存用户名,凭据外键不再级联,在用的凭据删不掉 - 表名 sessions → hosts、session_jumps → host_jumps,session_id → host_id,地址列 host → address;schema 比对去掉排版,以后加字段可以直接 ADD COLUMN - 删除开发期的历史迁移代码:版本 1–12 的库直接拒绝打开、不动文件 - 代码里 Session / SessionId / SessionStore 等改为 Host*,模块 session/ → host/,Host.host → Host.address,菜单命令与界面元素 id 同步改名 - 外部 CLI 的参数、JSON 字段和错误码改为 host(host_not_found),协议版本升到 2 - CLAUDE.md、AGENTS.md、README 与 Agent skill 同步更新
147 lines
4.7 KiB
Rust
147 lines
4.7 KiB
Rust
//! The `shellrs-cli` program run as an agent runs it: a separate process
|
|
//! with piped stdio, finding the app through `SHELLRS_DATA_DIR`. A fake
|
|
//! backend stands in for SSH, so this runs on every platform, including the
|
|
//! Windows CI where it is the one test of the named pipe between processes.
|
|
|
|
use std::{
|
|
io::{self, Write as _},
|
|
path::Path,
|
|
process::{Command, Output, Stdio},
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
use shellrs::app::cli_endpoint;
|
|
use shellrs::cli::{
|
|
CliBackend, CliError, CliServer, CliTarget, HostInfo, TransferCounters, TransferSummary,
|
|
};
|
|
use shellrs::host::{AuthKind, Host, HostDraft, HostId, HostLogin};
|
|
use shellrs::ssh::ExecStream;
|
|
|
|
/// More than a pipe's buffer, so the app finishes writing before the
|
|
/// command has read it all.
|
|
const LARGE_OUTPUT: usize = 256 * 1024;
|
|
|
|
#[derive(Default)]
|
|
struct FakeBackend {
|
|
commands: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl CliBackend for FakeBackend {
|
|
fn exec(
|
|
&self,
|
|
_: &CliTarget,
|
|
command: &str,
|
|
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
|
|
) -> Result<i32, CliError> {
|
|
self.commands.lock().unwrap().push(command.to_string());
|
|
if command == "large" {
|
|
let line = [b'x'; 1023]
|
|
.iter()
|
|
.chain(b"\n")
|
|
.copied()
|
|
.collect::<Vec<_>>();
|
|
for _ in 0..LARGE_OUTPUT / line.len() {
|
|
output(ExecStream::Stdout, &line).unwrap();
|
|
}
|
|
return Ok(0);
|
|
}
|
|
output(ExecStream::Stdout, "输出\n".as_bytes()).unwrap();
|
|
output(ExecStream::Stderr, b"warning\n").unwrap();
|
|
Ok(7)
|
|
}
|
|
|
|
fn upload(
|
|
&self,
|
|
_: &CliTarget,
|
|
_: &Path,
|
|
_: &str,
|
|
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
|
|
) -> Result<TransferSummary, CliError> {
|
|
Ok(TransferSummary::default())
|
|
}
|
|
|
|
fn download(
|
|
&self,
|
|
_: &CliTarget,
|
|
_: &str,
|
|
_: &Path,
|
|
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
|
|
) -> Result<TransferSummary, CliError> {
|
|
Ok(TransferSummary::default())
|
|
}
|
|
}
|
|
|
|
/// Run the command against the app whose data lives in `data_dir`.
|
|
fn shellrs(data_dir: &Path, args: &[&str], stdin: &[u8]) -> Output {
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_shellrs-cli"))
|
|
.args(args)
|
|
.env("SHELLRS_DATA_DIR", data_dir)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
child.stdin.take().unwrap().write_all(stdin).unwrap();
|
|
child.wait_with_output().unwrap()
|
|
}
|
|
|
|
#[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"");
|
|
assert_eq!(output.status.code(), Some(255));
|
|
assert!(
|
|
String::from_utf8(output.stderr)
|
|
.unwrap()
|
|
.starts_with("shellrs: [not_running]")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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)]);
|
|
server.set_enabled(true);
|
|
let id = web.public_id.to_string();
|
|
|
|
// A pipe for stdout means JSON.
|
|
let output = shellrs(data_dir.path(), &["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);
|
|
|
|
// What PowerShell pipes in: a BOM and CRLF line ends.
|
|
let output = shellrs(
|
|
data_dir.path(),
|
|
&["exec", &id, "--stdin"],
|
|
"\u{feff}cd /srv\r\nls\r\n".as_bytes(),
|
|
);
|
|
assert_eq!(output.status.code(), Some(7));
|
|
assert_eq!(output.stdout, "输出\n".as_bytes());
|
|
assert_eq!(output.stderr, b"warning\n");
|
|
assert_eq!(
|
|
backend.commands.lock().unwrap().last().unwrap(),
|
|
"cd /srv\nls\n"
|
|
);
|
|
|
|
// Everything arrives, though the app is done long before.
|
|
let output = shellrs(data_dir.path(), &["exec", &id, "large"], b"");
|
|
assert_eq!(output.status.code(), Some(0));
|
|
assert_eq!(output.stdout.len(), LARGE_OUTPUT);
|
|
|
|
server.set_enabled(false);
|
|
let output = shellrs(data_dir.path(), &["exec", &id, "true"], b"");
|
|
assert_eq!(output.status.code(), Some(255));
|
|
assert!(
|
|
String::from_utf8(output.stderr)
|
|
.unwrap()
|
|
.starts_with("shellrs: [not_enabled]")
|
|
);
|
|
}
|