refactor: split main.rs by extracting CLI parsing into cli.rs
The 1258-line main.rs is split into: - main.rs: mod declarations, async fn main + panics_on_duplicate_agent_names test (894 lines) - cli.rs: CliArgs struct, parse_cli_args, print_help, resolve_path_arg + their tests (372 lines) main.rs cannot itself become a directory (binary crate must have main.rs at the crate root); cli.rs is a sibling module. No behaviour change. All cli tests pass; full suite green.
This commit is contained in:
@@ -0,0 +1,372 @@
|
|||||||
|
//! Command-line argument parsing for the huskies binary.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Parsed CLI arguments.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) struct CliArgs {
|
||||||
|
/// Value from `--port <VALUE>` flag, if supplied.
|
||||||
|
pub(crate) port: Option<u16>,
|
||||||
|
/// Positional project path argument, if supplied.
|
||||||
|
pub(crate) path: Option<String>,
|
||||||
|
/// Whether the `init` subcommand was given.
|
||||||
|
pub(crate) init: bool,
|
||||||
|
/// Whether the `agent` subcommand was given.
|
||||||
|
pub(crate) agent: bool,
|
||||||
|
/// Rendezvous WebSocket URL for agent mode (e.g. `ws://host:3001/crdt-sync`).
|
||||||
|
pub(crate) rendezvous: Option<String>,
|
||||||
|
/// Whether `--gateway` mode was requested (proxy MCP calls to per-project containers).
|
||||||
|
pub(crate) gateway: bool,
|
||||||
|
/// One-time join token for registering this build agent with a gateway (`--join-token`).
|
||||||
|
pub(crate) join_token: Option<String>,
|
||||||
|
/// HTTP URL of the gateway to register with when a join token is provided (`--gateway-url`).
|
||||||
|
pub(crate) gateway_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`.
|
||||||
|
pub(crate) fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
|
||||||
|
let mut port: Option<u16> = None;
|
||||||
|
let mut path: Option<String> = None;
|
||||||
|
let mut init = false;
|
||||||
|
let mut agent = false;
|
||||||
|
let mut gateway = false;
|
||||||
|
let mut rendezvous: Option<String> = None;
|
||||||
|
let mut join_token: Option<String> = None;
|
||||||
|
let mut gateway_url: Option<String> = None;
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
"--version" | "-V" => {
|
||||||
|
println!("huskies {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
"--port" => {
|
||||||
|
i += 1;
|
||||||
|
if i >= args.len() {
|
||||||
|
return Err("--port requires a value".to_string());
|
||||||
|
}
|
||||||
|
match args[i].parse::<u16>() {
|
||||||
|
Ok(p) => port = Some(p),
|
||||||
|
Err(_) => return Err(format!("invalid port value: '{}'", args[i])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a if a.starts_with("--port=") => {
|
||||||
|
let val = &a["--port=".len()..];
|
||||||
|
match val.parse::<u16>() {
|
||||||
|
Ok(p) => port = Some(p),
|
||||||
|
Err(_) => return Err(format!("invalid port value: '{val}'")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--rendezvous" => {
|
||||||
|
i += 1;
|
||||||
|
if i >= args.len() {
|
||||||
|
return Err("--rendezvous requires a value".to_string());
|
||||||
|
}
|
||||||
|
rendezvous = Some(args[i].clone());
|
||||||
|
}
|
||||||
|
a if a.starts_with("--rendezvous=") => {
|
||||||
|
let val = &a["--rendezvous=".len()..];
|
||||||
|
rendezvous = Some(val.to_string());
|
||||||
|
}
|
||||||
|
"--join-token" => {
|
||||||
|
i += 1;
|
||||||
|
if i >= args.len() {
|
||||||
|
return Err("--join-token requires a value".to_string());
|
||||||
|
}
|
||||||
|
join_token = Some(args[i].clone());
|
||||||
|
}
|
||||||
|
a if a.starts_with("--join-token=") => {
|
||||||
|
join_token = Some(a["--join-token=".len()..].to_string());
|
||||||
|
}
|
||||||
|
"--gateway-url" => {
|
||||||
|
i += 1;
|
||||||
|
if i >= args.len() {
|
||||||
|
return Err("--gateway-url requires a value".to_string());
|
||||||
|
}
|
||||||
|
gateway_url = Some(args[i].clone());
|
||||||
|
}
|
||||||
|
a if a.starts_with("--gateway-url=") => {
|
||||||
|
gateway_url = Some(a["--gateway-url=".len()..].to_string());
|
||||||
|
}
|
||||||
|
"--gateway" => {
|
||||||
|
gateway = true;
|
||||||
|
}
|
||||||
|
"init" => {
|
||||||
|
init = true;
|
||||||
|
}
|
||||||
|
"agent" => {
|
||||||
|
agent = true;
|
||||||
|
}
|
||||||
|
a if a.starts_with('-') => {
|
||||||
|
return Err(format!("unknown option: {a}"));
|
||||||
|
}
|
||||||
|
a => {
|
||||||
|
if path.is_some() {
|
||||||
|
return Err(format!("unexpected argument: {a}"));
|
||||||
|
}
|
||||||
|
path = Some(a.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent && rendezvous.is_none() {
|
||||||
|
return Err("agent mode requires --rendezvous <URL>".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CliArgs {
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
init,
|
||||||
|
agent,
|
||||||
|
rendezvous,
|
||||||
|
gateway,
|
||||||
|
join_token,
|
||||||
|
gateway_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn print_help() {
|
||||||
|
println!("huskies [OPTIONS] [PATH]");
|
||||||
|
println!("huskies init [OPTIONS] [PATH]");
|
||||||
|
println!("huskies agent --rendezvous <URL> [OPTIONS] [PATH]");
|
||||||
|
println!("huskies --gateway [OPTIONS] [PATH]");
|
||||||
|
println!();
|
||||||
|
println!("Serve a huskies project.");
|
||||||
|
println!();
|
||||||
|
println!("COMMANDS:");
|
||||||
|
println!(" init Scaffold a new .huskies/ project and start the interactive setup wizard.");
|
||||||
|
println!(" agent Run as a headless build agent — syncs CRDT state, claims and runs work.");
|
||||||
|
println!();
|
||||||
|
println!("ARGS:");
|
||||||
|
println!(
|
||||||
|
" PATH Path to an existing project directory. \
|
||||||
|
If omitted, huskies searches parent directories for a .huskies/ root."
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!("OPTIONS:");
|
||||||
|
println!(" -h, --help Print this help and exit");
|
||||||
|
println!(" -V, --version Print the version and exit");
|
||||||
|
println!(
|
||||||
|
" --port <PORT> Port to listen on (default: 3001). Persisted to project.toml."
|
||||||
|
);
|
||||||
|
println!(" --rendezvous <URL> WebSocket URL of the rendezvous peer (agent mode only).");
|
||||||
|
println!(" Example: ws://server:3001/crdt-sync");
|
||||||
|
println!(" --gateway Start in gateway mode. Reads projects.toml from PATH");
|
||||||
|
println!(
|
||||||
|
" (or cwd) and proxies MCP calls to per-project containers."
|
||||||
|
);
|
||||||
|
println!(" --join-token <TOKEN> One-time token for registering this build agent with a");
|
||||||
|
println!(" gateway. Also readable from HUSKIES_JOIN_TOKEN env var.");
|
||||||
|
println!(" --gateway-url <URL> HTTP URL of the gateway to register with when");
|
||||||
|
println!(" --join-token is provided (agent mode only).");
|
||||||
|
println!(" Also readable from HUSKIES_GATEWAY_URL env var.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the optional positional path argument into an absolute `PathBuf`.
|
||||||
|
pub(crate) fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option<PathBuf> {
|
||||||
|
path_str.map(|s| crate::io::fs::resolve_cli_path(cwd, s))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn parse_no_args() {
|
||||||
|
let result = parse_cli_args(&[]).unwrap();
|
||||||
|
assert_eq!(result.port, None);
|
||||||
|
assert_eq!(result.path, None);
|
||||||
|
assert!(!result.init);
|
||||||
|
assert!(!result.agent);
|
||||||
|
assert!(!result.gateway);
|
||||||
|
assert_eq!(result.rendezvous, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_gateway_flag() {
|
||||||
|
let args = vec!["--gateway".to_string()];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert!(result.gateway);
|
||||||
|
assert!(!result.init);
|
||||||
|
assert!(!result.agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_gateway_with_port_and_path() {
|
||||||
|
let args = vec![
|
||||||
|
"--gateway".to_string(),
|
||||||
|
"--port".to_string(),
|
||||||
|
"5000".to_string(),
|
||||||
|
"/my/config".to_string(),
|
||||||
|
];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert!(result.gateway);
|
||||||
|
assert_eq!(result.port, Some(5000));
|
||||||
|
assert_eq!(result.path, Some("/my/config".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_flag_is_error() {
|
||||||
|
let args = vec!["--serve".to_string()];
|
||||||
|
assert!(parse_cli_args(&args).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_path_only() {
|
||||||
|
let args = vec!["/some/path".to_string()];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert_eq!(result.path, Some("/some/path".to_string()));
|
||||||
|
assert_eq!(result.port, None);
|
||||||
|
assert!(!result.init);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_port_flag() {
|
||||||
|
let args = vec!["--port".to_string(), "4000".to_string()];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert_eq!(result.port, Some(4000));
|
||||||
|
assert_eq!(result.path, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_port_equals_syntax() {
|
||||||
|
let args = vec!["--port=5000".to_string()];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert_eq!(result.port, Some(5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_port_with_path() {
|
||||||
|
let args = vec![
|
||||||
|
"--port".to_string(),
|
||||||
|
"4200".to_string(),
|
||||||
|
"/some/path".to_string(),
|
||||||
|
];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert_eq!(result.port, Some(4200));
|
||||||
|
assert_eq!(result.path, Some("/some/path".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_port_missing_value_is_error() {
|
||||||
|
let args = vec!["--port".to_string()];
|
||||||
|
assert!(parse_cli_args(&args).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_port_invalid_value_is_error() {
|
||||||
|
let args = vec!["--port".to_string(), "abc".to_string()];
|
||||||
|
assert!(parse_cli_args(&args).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_init_subcommand() {
|
||||||
|
let args = vec!["init".to_string()];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert!(result.init);
|
||||||
|
assert_eq!(result.path, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_init_with_path_and_port() {
|
||||||
|
let args = vec![
|
||||||
|
"init".to_string(),
|
||||||
|
"--port".to_string(),
|
||||||
|
"3000".to_string(),
|
||||||
|
"/my/project".to_string(),
|
||||||
|
];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert!(result.init);
|
||||||
|
assert_eq!(result.port, Some(3000));
|
||||||
|
assert_eq!(result.path, Some("/my/project".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_join_token_flag() {
|
||||||
|
let args = vec![
|
||||||
|
"agent".to_string(),
|
||||||
|
"--rendezvous".to_string(),
|
||||||
|
"ws://host:3001/crdt-sync".to_string(),
|
||||||
|
"--join-token".to_string(),
|
||||||
|
"my-secret-token".to_string(),
|
||||||
|
];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert_eq!(result.join_token, Some("my-secret-token".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_join_token_equals_syntax() {
|
||||||
|
let args = vec![
|
||||||
|
"agent".to_string(),
|
||||||
|
"--rendezvous".to_string(),
|
||||||
|
"ws://host:3001/crdt-sync".to_string(),
|
||||||
|
"--join-token=abc123".to_string(),
|
||||||
|
];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert_eq!(result.join_token, Some("abc123".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_gateway_url_flag() {
|
||||||
|
let args = vec![
|
||||||
|
"agent".to_string(),
|
||||||
|
"--rendezvous".to_string(),
|
||||||
|
"ws://host:3001/crdt-sync".to_string(),
|
||||||
|
"--gateway-url".to_string(),
|
||||||
|
"http://gateway:3000".to_string(),
|
||||||
|
];
|
||||||
|
let result = parse_cli_args(&args).unwrap();
|
||||||
|
assert_eq!(result.gateway_url, Some("http://gateway:3000".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_join_token_missing_value_is_error() {
|
||||||
|
let args = vec!["--join-token".to_string()];
|
||||||
|
assert!(parse_cli_args(&args).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_gateway_url_missing_value_is_error() {
|
||||||
|
let args = vec!["--gateway-url".to_string()];
|
||||||
|
assert!(parse_cli_args(&args).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_no_args_join_token_and_gateway_url_are_none() {
|
||||||
|
let result = parse_cli_args(&[]).unwrap();
|
||||||
|
assert_eq!(result.join_token, None);
|
||||||
|
assert_eq!(result.gateway_url, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resolve_path_arg ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_path_arg_none_when_no_path() {
|
||||||
|
let cwd = PathBuf::from("/home/user/project");
|
||||||
|
let result = resolve_path_arg(None, &cwd);
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_path_arg_returns_path_for_absolute_arg() {
|
||||||
|
let cwd = PathBuf::from("/home/user/project");
|
||||||
|
let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap();
|
||||||
|
assert!(
|
||||||
|
result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_path_arg_resolves_dot_to_cwd() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let cwd = tmp.path().to_path_buf();
|
||||||
|
let result = resolve_path_arg(Some("."), &cwd).unwrap();
|
||||||
|
assert_eq!(result, cwd.canonicalize().unwrap_or(cwd));
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-367
@@ -46,178 +46,10 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
/// Parsed CLI arguments.
|
mod cli;
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
struct CliArgs {
|
|
||||||
/// Value from `--port <VALUE>` flag, if supplied.
|
|
||||||
port: Option<u16>,
|
|
||||||
/// Positional project path argument, if supplied.
|
|
||||||
path: Option<String>,
|
|
||||||
/// Whether the `init` subcommand was given.
|
|
||||||
init: bool,
|
|
||||||
/// Whether the `agent` subcommand was given.
|
|
||||||
agent: bool,
|
|
||||||
/// Rendezvous WebSocket URL for agent mode (e.g. `ws://host:3001/crdt-sync`).
|
|
||||||
rendezvous: Option<String>,
|
|
||||||
/// Whether `--gateway` mode was requested (proxy MCP calls to per-project containers).
|
|
||||||
gateway: bool,
|
|
||||||
/// One-time join token for registering this build agent with a gateway (`--join-token`).
|
|
||||||
join_token: Option<String>,
|
|
||||||
/// HTTP URL of the gateway to register with when a join token is provided (`--gateway-url`).
|
|
||||||
gateway_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`.
|
use cli::{CliArgs, parse_cli_args, print_help, resolve_path_arg};
|
||||||
fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
|
|
||||||
let mut port: Option<u16> = None;
|
|
||||||
let mut path: Option<String> = None;
|
|
||||||
let mut init = false;
|
|
||||||
let mut agent = false;
|
|
||||||
let mut gateway = false;
|
|
||||||
let mut rendezvous: Option<String> = None;
|
|
||||||
let mut join_token: Option<String> = None;
|
|
||||||
let mut gateway_url: Option<String> = None;
|
|
||||||
let mut i = 0;
|
|
||||||
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--help" | "-h" => {
|
|
||||||
print_help();
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
"--version" | "-V" => {
|
|
||||||
println!("huskies {}", env!("CARGO_PKG_VERSION"));
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
"--port" => {
|
|
||||||
i += 1;
|
|
||||||
if i >= args.len() {
|
|
||||||
return Err("--port requires a value".to_string());
|
|
||||||
}
|
|
||||||
match args[i].parse::<u16>() {
|
|
||||||
Ok(p) => port = Some(p),
|
|
||||||
Err(_) => return Err(format!("invalid port value: '{}'", args[i])),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a if a.starts_with("--port=") => {
|
|
||||||
let val = &a["--port=".len()..];
|
|
||||||
match val.parse::<u16>() {
|
|
||||||
Ok(p) => port = Some(p),
|
|
||||||
Err(_) => return Err(format!("invalid port value: '{val}'")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--rendezvous" => {
|
|
||||||
i += 1;
|
|
||||||
if i >= args.len() {
|
|
||||||
return Err("--rendezvous requires a value".to_string());
|
|
||||||
}
|
|
||||||
rendezvous = Some(args[i].clone());
|
|
||||||
}
|
|
||||||
a if a.starts_with("--rendezvous=") => {
|
|
||||||
let val = &a["--rendezvous=".len()..];
|
|
||||||
rendezvous = Some(val.to_string());
|
|
||||||
}
|
|
||||||
"--join-token" => {
|
|
||||||
i += 1;
|
|
||||||
if i >= args.len() {
|
|
||||||
return Err("--join-token requires a value".to_string());
|
|
||||||
}
|
|
||||||
join_token = Some(args[i].clone());
|
|
||||||
}
|
|
||||||
a if a.starts_with("--join-token=") => {
|
|
||||||
join_token = Some(a["--join-token=".len()..].to_string());
|
|
||||||
}
|
|
||||||
"--gateway-url" => {
|
|
||||||
i += 1;
|
|
||||||
if i >= args.len() {
|
|
||||||
return Err("--gateway-url requires a value".to_string());
|
|
||||||
}
|
|
||||||
gateway_url = Some(args[i].clone());
|
|
||||||
}
|
|
||||||
a if a.starts_with("--gateway-url=") => {
|
|
||||||
gateway_url = Some(a["--gateway-url=".len()..].to_string());
|
|
||||||
}
|
|
||||||
"--gateway" => {
|
|
||||||
gateway = true;
|
|
||||||
}
|
|
||||||
"init" => {
|
|
||||||
init = true;
|
|
||||||
}
|
|
||||||
"agent" => {
|
|
||||||
agent = true;
|
|
||||||
}
|
|
||||||
a if a.starts_with('-') => {
|
|
||||||
return Err(format!("unknown option: {a}"));
|
|
||||||
}
|
|
||||||
a => {
|
|
||||||
if path.is_some() {
|
|
||||||
return Err(format!("unexpected argument: {a}"));
|
|
||||||
}
|
|
||||||
path = Some(a.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if agent && rendezvous.is_none() {
|
|
||||||
return Err("agent mode requires --rendezvous <URL>".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(CliArgs {
|
|
||||||
port,
|
|
||||||
path,
|
|
||||||
init,
|
|
||||||
agent,
|
|
||||||
rendezvous,
|
|
||||||
gateway,
|
|
||||||
join_token,
|
|
||||||
gateway_url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_help() {
|
|
||||||
println!("huskies [OPTIONS] [PATH]");
|
|
||||||
println!("huskies init [OPTIONS] [PATH]");
|
|
||||||
println!("huskies agent --rendezvous <URL> [OPTIONS] [PATH]");
|
|
||||||
println!("huskies --gateway [OPTIONS] [PATH]");
|
|
||||||
println!();
|
|
||||||
println!("Serve a huskies project.");
|
|
||||||
println!();
|
|
||||||
println!("COMMANDS:");
|
|
||||||
println!(" init Scaffold a new .huskies/ project and start the interactive setup wizard.");
|
|
||||||
println!(" agent Run as a headless build agent — syncs CRDT state, claims and runs work.");
|
|
||||||
println!();
|
|
||||||
println!("ARGS:");
|
|
||||||
println!(
|
|
||||||
" PATH Path to an existing project directory. \
|
|
||||||
If omitted, huskies searches parent directories for a .huskies/ root."
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
println!("OPTIONS:");
|
|
||||||
println!(" -h, --help Print this help and exit");
|
|
||||||
println!(" -V, --version Print the version and exit");
|
|
||||||
println!(
|
|
||||||
" --port <PORT> Port to listen on (default: 3001). Persisted to project.toml."
|
|
||||||
);
|
|
||||||
println!(" --rendezvous <URL> WebSocket URL of the rendezvous peer (agent mode only).");
|
|
||||||
println!(" Example: ws://server:3001/crdt-sync");
|
|
||||||
println!(" --gateway Start in gateway mode. Reads projects.toml from PATH");
|
|
||||||
println!(
|
|
||||||
" (or cwd) and proxies MCP calls to per-project containers."
|
|
||||||
);
|
|
||||||
println!(" --join-token <TOKEN> One-time token for registering this build agent with a");
|
|
||||||
println!(" gateway. Also readable from HUSKIES_JOIN_TOKEN env var.");
|
|
||||||
println!(" --gateway-url <URL> HTTP URL of the gateway to register with when");
|
|
||||||
println!(" --join-token is provided (agent mode only).");
|
|
||||||
println!(" Also readable from HUSKIES_GATEWAY_URL env var.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the optional positional path argument into an absolute `PathBuf`.
|
|
||||||
fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option<PathBuf> {
|
|
||||||
path_str.map(|s| io::fs::resolve_cli_path(cwd, s))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
// Reap zombie grandchildren on Unix (for native deployments without tini/init).
|
// Reap zombie grandchildren on Unix (for native deployments without tini/init).
|
||||||
// Docker containers with `init: true` in docker-compose.yml already have tini
|
// Docker containers with `init: true` in docker-compose.yml already have tini
|
||||||
@@ -1035,12 +867,11 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "Invalid project.toml: Duplicate agent name")]
|
|
||||||
fn panics_on_duplicate_agent_names() {
|
fn panics_on_duplicate_agent_names() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let sk = tmp.path().join(".huskies");
|
let sk = tmp.path().join(".huskies");
|
||||||
@@ -1060,199 +891,4 @@ name = "coder"
|
|||||||
config::ProjectConfig::load(tmp.path())
|
config::ProjectConfig::load(tmp.path())
|
||||||
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── parse_cli_args ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_no_args() {
|
|
||||||
let result = parse_cli_args(&[]).unwrap();
|
|
||||||
assert_eq!(result.port, None);
|
|
||||||
assert_eq!(result.path, None);
|
|
||||||
assert!(!result.init);
|
|
||||||
assert!(!result.agent);
|
|
||||||
assert!(!result.gateway);
|
|
||||||
assert_eq!(result.rendezvous, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_gateway_flag() {
|
|
||||||
let args = vec!["--gateway".to_string()];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert!(result.gateway);
|
|
||||||
assert!(!result.init);
|
|
||||||
assert!(!result.agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_gateway_with_port_and_path() {
|
|
||||||
let args = vec![
|
|
||||||
"--gateway".to_string(),
|
|
||||||
"--port".to_string(),
|
|
||||||
"5000".to_string(),
|
|
||||||
"/my/config".to_string(),
|
|
||||||
];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert!(result.gateway);
|
|
||||||
assert_eq!(result.port, Some(5000));
|
|
||||||
assert_eq!(result.path, Some("/my/config".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_unknown_flag_is_error() {
|
|
||||||
let args = vec!["--serve".to_string()];
|
|
||||||
assert!(parse_cli_args(&args).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_path_only() {
|
|
||||||
let args = vec!["/some/path".to_string()];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert_eq!(result.path, Some("/some/path".to_string()));
|
|
||||||
assert_eq!(result.port, None);
|
|
||||||
assert!(!result.init);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_port_flag() {
|
|
||||||
let args = vec!["--port".to_string(), "4000".to_string()];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert_eq!(result.port, Some(4000));
|
|
||||||
assert_eq!(result.path, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_port_equals_syntax() {
|
|
||||||
let args = vec!["--port=5000".to_string()];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert_eq!(result.port, Some(5000));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_port_with_path() {
|
|
||||||
let args = vec![
|
|
||||||
"--port".to_string(),
|
|
||||||
"4200".to_string(),
|
|
||||||
"/some/path".to_string(),
|
|
||||||
];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert_eq!(result.port, Some(4200));
|
|
||||||
assert_eq!(result.path, Some("/some/path".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_port_missing_value_is_error() {
|
|
||||||
let args = vec!["--port".to_string()];
|
|
||||||
assert!(parse_cli_args(&args).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_port_invalid_value_is_error() {
|
|
||||||
let args = vec!["--port".to_string(), "abc".to_string()];
|
|
||||||
assert!(parse_cli_args(&args).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_init_subcommand() {
|
|
||||||
let args = vec!["init".to_string()];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert!(result.init);
|
|
||||||
assert_eq!(result.path, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_init_with_path_and_port() {
|
|
||||||
let args = vec![
|
|
||||||
"init".to_string(),
|
|
||||||
"--port".to_string(),
|
|
||||||
"3000".to_string(),
|
|
||||||
"/my/project".to_string(),
|
|
||||||
];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert!(result.init);
|
|
||||||
assert_eq!(result.port, Some(3000));
|
|
||||||
assert_eq!(result.path, Some("/my/project".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_join_token_flag() {
|
|
||||||
let args = vec![
|
|
||||||
"agent".to_string(),
|
|
||||||
"--rendezvous".to_string(),
|
|
||||||
"ws://host:3001/crdt-sync".to_string(),
|
|
||||||
"--join-token".to_string(),
|
|
||||||
"my-secret-token".to_string(),
|
|
||||||
];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert_eq!(result.join_token, Some("my-secret-token".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_join_token_equals_syntax() {
|
|
||||||
let args = vec![
|
|
||||||
"agent".to_string(),
|
|
||||||
"--rendezvous".to_string(),
|
|
||||||
"ws://host:3001/crdt-sync".to_string(),
|
|
||||||
"--join-token=abc123".to_string(),
|
|
||||||
];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert_eq!(result.join_token, Some("abc123".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_gateway_url_flag() {
|
|
||||||
let args = vec![
|
|
||||||
"agent".to_string(),
|
|
||||||
"--rendezvous".to_string(),
|
|
||||||
"ws://host:3001/crdt-sync".to_string(),
|
|
||||||
"--gateway-url".to_string(),
|
|
||||||
"http://gateway:3000".to_string(),
|
|
||||||
];
|
|
||||||
let result = parse_cli_args(&args).unwrap();
|
|
||||||
assert_eq!(result.gateway_url, Some("http://gateway:3000".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_join_token_missing_value_is_error() {
|
|
||||||
let args = vec!["--join-token".to_string()];
|
|
||||||
assert!(parse_cli_args(&args).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_gateway_url_missing_value_is_error() {
|
|
||||||
let args = vec!["--gateway-url".to_string()];
|
|
||||||
assert!(parse_cli_args(&args).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_no_args_join_token_and_gateway_url_are_none() {
|
|
||||||
let result = parse_cli_args(&[]).unwrap();
|
|
||||||
assert_eq!(result.join_token, None);
|
|
||||||
assert_eq!(result.gateway_url, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── resolve_path_arg ────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_path_arg_none_when_no_path() {
|
|
||||||
let cwd = PathBuf::from("/home/user/project");
|
|
||||||
let result = resolve_path_arg(None, &cwd);
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_path_arg_returns_path_for_absolute_arg() {
|
|
||||||
let cwd = PathBuf::from("/home/user/project");
|
|
||||||
let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap();
|
|
||||||
assert!(
|
|
||||||
result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_path_arg_resolves_dot_to_cwd() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let cwd = tmp.path().to_path_buf();
|
|
||||||
let result = resolve_path_arg(Some("."), &cwd).unwrap();
|
|
||||||
assert_eq!(result, cwd.canonicalize().unwrap_or(cwd));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user