dc9e13e6a1
Agents need a way to exercise multiple peers without launching the Tauri GUI.
Add `lanspread-peer-cli` as a workspace crate that starts the core peer runtime,
reads JSON commands from stdin, and writes result, event, and error records as
JSONL on stdout.
The harness supports status, peer listing, game listing, direct connect,
set-game-dir, download, install, uninstall, wait-peers, and shutdown commands.
It can seed tiny fixture archives that use a fixture unpacker, or delegate real
archives to an external `unrar` program when one is supplied.
Add a Dockerfile, `.dockerignore`, and `just` recipes for building the binary,
building the image, and running named harness containers with state and games
mounted under `target/peer-cli/`. The documentation now lists the crate and the
new test harness commands in the project map, with a crate-local README for the
JSONL protocol.
This commit depends on the non-GUI peer hooks introduced in the previous commit:
startup options, local-ready events, direct connects, snapshots, and explicit
post-download install policy. It does not add old-peer compatibility paths.
Test Plan:
- `git diff --check`
- `just fmt`
- `just clippy`
- `just test`
- `just peer-cli-build`
- Not run: `just peer-cli-image` requires a Docker daemon and base image access.
Depends-on: e711cf3454
Refs: crates/lanspread-peer-cli/README.md
348 lines
9.8 KiB
Rust
348 lines
9.8 KiB
Rust
//! Shared parser, fixture, and JSONL helpers for the scripted peer harness.
|
|
|
|
#![allow(clippy::missing_errors_doc)]
|
|
|
|
use std::{
|
|
net::SocketAddr,
|
|
path::{Path, PathBuf},
|
|
time::Duration,
|
|
};
|
|
|
|
use eyre::{Context, OptionExt};
|
|
use lanspread_peer::{UnpackFuture, Unpacker};
|
|
use serde::Serialize;
|
|
use serde_json::{Value, json};
|
|
|
|
pub const DEFAULT_FIXTURE_VERSION: &str = "20250101";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CommandEnvelope {
|
|
pub request_id: Option<Value>,
|
|
pub command: CliCommand,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum CliCommand {
|
|
Status,
|
|
ListPeers,
|
|
ListGames,
|
|
SetGameDir {
|
|
path: PathBuf,
|
|
},
|
|
Download {
|
|
game_id: String,
|
|
install_after_download: bool,
|
|
},
|
|
Install {
|
|
game_id: String,
|
|
},
|
|
Uninstall {
|
|
game_id: String,
|
|
},
|
|
WaitPeers {
|
|
count: usize,
|
|
timeout: Duration,
|
|
},
|
|
Connect {
|
|
addr: SocketAddr,
|
|
},
|
|
Shutdown,
|
|
}
|
|
|
|
impl CliCommand {
|
|
#[must_use]
|
|
pub fn name(&self) -> &'static str {
|
|
match self {
|
|
Self::Status => "status",
|
|
Self::ListPeers => "list-peers",
|
|
Self::ListGames => "list-games",
|
|
Self::SetGameDir { .. } => "set-game-dir",
|
|
Self::Download { .. } => "download",
|
|
Self::Install { .. } => "install",
|
|
Self::Uninstall { .. } => "uninstall",
|
|
Self::WaitPeers { .. } => "wait-peers",
|
|
Self::Connect { .. } => "connect",
|
|
Self::Shutdown => "shutdown",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn parse_command_line(line: &str) -> eyre::Result<CommandEnvelope> {
|
|
let value = serde_json::from_str::<Value>(line).wrap_err("command must be a JSON object")?;
|
|
parse_command_value(&value)
|
|
}
|
|
|
|
pub fn parse_command_value(value: &Value) -> eyre::Result<CommandEnvelope> {
|
|
let object = value
|
|
.as_object()
|
|
.ok_or_eyre("command must be a JSON object")?;
|
|
let request_id = object.get("id").cloned();
|
|
let command_name = object
|
|
.get("cmd")
|
|
.or_else(|| object.get("command"))
|
|
.and_then(Value::as_str)
|
|
.ok_or_eyre("command object must include a string cmd")?;
|
|
let command_name = command_name.replace('_', "-");
|
|
|
|
let command = match command_name.as_str() {
|
|
"status" => CliCommand::Status,
|
|
"list-peers" => CliCommand::ListPeers,
|
|
"list-games" => CliCommand::ListGames,
|
|
"set-game-dir" => CliCommand::SetGameDir {
|
|
path: PathBuf::from(required_str(object, "path")?),
|
|
},
|
|
"download" => CliCommand::Download {
|
|
game_id: game_id(object)?,
|
|
install_after_download: install_after_download(object)?,
|
|
},
|
|
"install" => CliCommand::Install {
|
|
game_id: game_id(object)?,
|
|
},
|
|
"uninstall" => CliCommand::Uninstall {
|
|
game_id: game_id(object)?,
|
|
},
|
|
"wait-peers" => CliCommand::WaitPeers {
|
|
count: required_u64(object, "count")?
|
|
.try_into()
|
|
.wrap_err("count does not fit in usize")?,
|
|
timeout: Duration::from_millis(required_u64(object, "timeout_ms")?),
|
|
},
|
|
"connect" | "direct-connect" => CliCommand::Connect {
|
|
addr: required_str(object, "addr")?
|
|
.parse()
|
|
.wrap_err("addr must be a socket address like 127.0.0.1:12345")?,
|
|
},
|
|
"shutdown" => CliCommand::Shutdown,
|
|
other => eyre::bail!("unknown command: {other}"),
|
|
};
|
|
|
|
Ok(CommandEnvelope {
|
|
request_id,
|
|
command,
|
|
})
|
|
}
|
|
|
|
fn required_str(
|
|
object: &serde_json::Map<String, Value>,
|
|
field: &'static str,
|
|
) -> eyre::Result<String> {
|
|
object
|
|
.get(field)
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.ok_or_else(|| eyre::eyre!("missing string field {field}"))
|
|
}
|
|
|
|
fn required_u64(object: &serde_json::Map<String, Value>, field: &'static str) -> eyre::Result<u64> {
|
|
object
|
|
.get(field)
|
|
.and_then(Value::as_u64)
|
|
.ok_or_else(|| eyre::eyre!("missing integer field {field}"))
|
|
}
|
|
|
|
fn game_id(object: &serde_json::Map<String, Value>) -> eyre::Result<String> {
|
|
object
|
|
.get("game_id")
|
|
.or_else(|| object.get("game"))
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.ok_or_eyre("game commands must include string game_id")
|
|
}
|
|
|
|
fn install_after_download(object: &serde_json::Map<String, Value>) -> eyre::Result<bool> {
|
|
if let Some(value) = object.get("install") {
|
|
return value
|
|
.as_bool()
|
|
.ok_or_eyre("install must be boolean when provided");
|
|
}
|
|
|
|
if let Some(value) = object.get("no_install") {
|
|
return value
|
|
.as_bool()
|
|
.map(|no_install| !no_install)
|
|
.ok_or_eyre("no_install must be boolean when provided");
|
|
}
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct FixtureSeed {
|
|
pub game_id: String,
|
|
pub game_root: PathBuf,
|
|
pub archive: PathBuf,
|
|
}
|
|
|
|
pub fn seed_fixture_game(game_dir: &Path, fixture_name: &str) -> eyre::Result<FixtureSeed> {
|
|
let game_id = fixture_name.to_string();
|
|
let game_root = game_dir.join(&game_id);
|
|
std::fs::create_dir_all(&game_root)?;
|
|
|
|
let version_path = game_root.join("version.ini");
|
|
std::fs::write(version_path, DEFAULT_FIXTURE_VERSION.as_bytes())?;
|
|
|
|
let archive = game_root.join(format!("{game_id}.eti"));
|
|
std::fs::write(
|
|
&archive,
|
|
format!("fixture archive for {game_id}\n").as_bytes(),
|
|
)?;
|
|
|
|
Ok(FixtureSeed {
|
|
game_id,
|
|
game_root,
|
|
archive,
|
|
})
|
|
}
|
|
|
|
pub struct FixtureUnpacker;
|
|
|
|
impl Unpacker for FixtureUnpacker {
|
|
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
|
Box::pin(async move {
|
|
tokio::fs::create_dir_all(dest).await?;
|
|
let payload = tokio::fs::read(archive).await?;
|
|
tokio::fs::write(dest.join("fixture-payload.txt"), payload).await?;
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct ExternalUnrarUnpacker {
|
|
program: PathBuf,
|
|
}
|
|
|
|
impl ExternalUnrarUnpacker {
|
|
#[must_use]
|
|
pub fn new(program: PathBuf) -> Self {
|
|
Self { program }
|
|
}
|
|
}
|
|
|
|
impl Unpacker for ExternalUnrarUnpacker {
|
|
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> {
|
|
Box::pin(async move {
|
|
tokio::fs::create_dir_all(dest).await?;
|
|
let status = tokio::process::Command::new(&self.program)
|
|
.arg("x")
|
|
.arg("-o+")
|
|
.arg(archive)
|
|
.arg(dest)
|
|
.status()
|
|
.await?;
|
|
if !status.success() {
|
|
eyre::bail!(
|
|
"unrar failed for {} with status {status}",
|
|
archive.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn result_line(id: &Option<Value>, command: &str, data: Value) -> eyre::Result<String> {
|
|
output_line(json!({
|
|
"type": "result",
|
|
"id": id,
|
|
"command": command,
|
|
"ok": true,
|
|
"data": data,
|
|
}))
|
|
}
|
|
|
|
pub fn error_line(id: &Option<Value>, command: Option<&str>, error: &str) -> eyre::Result<String> {
|
|
output_line(json!({
|
|
"type": "error",
|
|
"id": id,
|
|
"command": command,
|
|
"error": error,
|
|
}))
|
|
}
|
|
|
|
pub fn event_line(event: &str, data: Value) -> eyre::Result<String> {
|
|
output_line(json!({
|
|
"type": "event",
|
|
"event": event,
|
|
"data": data,
|
|
}))
|
|
}
|
|
|
|
fn output_line(value: Value) -> eyre::Result<String> {
|
|
serde_json::to_string(&value).wrap_err("failed to serialize JSONL output")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use super::*;
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new(name: &str) -> Self {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"{name}-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("system clock should be after unix epoch")
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&path).expect("temp dir should be created");
|
|
Self(path)
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_download_command_with_correlation_id() {
|
|
let parsed = parse_command_line(
|
|
r#"{"id":"req-1","cmd":"download","game_id":"fixture-one","install":false}"#,
|
|
)
|
|
.expect("command should parse");
|
|
|
|
assert_eq!(parsed.request_id, Some(json!("req-1")));
|
|
assert_eq!(
|
|
parsed.command,
|
|
CliCommand::Download {
|
|
game_id: "fixture-one".to_string(),
|
|
install_after_download: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn formats_result_lines() {
|
|
let line = result_line(&Some(json!(7)), "status", json!({"peer_count": 0}))
|
|
.expect("line should serialize");
|
|
let parsed = serde_json::from_str::<Value>(&line).expect("line should be json");
|
|
assert_eq!(parsed["type"], "result");
|
|
assert_eq!(parsed["id"], 7);
|
|
assert_eq!(parsed["data"]["peer_count"], 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fixture_unpacker_creates_install_payload() {
|
|
let temp = TempDir::new("lanspread-peer-cli-fixture");
|
|
let seed = seed_fixture_game(temp.path(), "fixture-one").expect("fixture should seed");
|
|
let dest = temp.path().join("staging");
|
|
Arc::new(FixtureUnpacker)
|
|
.unpack(&seed.archive, &dest)
|
|
.await
|
|
.expect("fixture archive should unpack");
|
|
|
|
let payload = std::fs::read_to_string(dest.join("fixture-payload.txt"))
|
|
.expect("payload should be written");
|
|
assert!(payload.contains("fixture-one"));
|
|
}
|
|
}
|