diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index e7a6d8d8..444f36b0 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -15,7 +15,7 @@ serde_json.workspace = true serde_yaml.workspace = true thiserror.workspace = true ticket.workspace = true -tokio = { workspace = true, features = ["fs", "net", "rt", "sync"] } +tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tracing.workspace = true [dev-dependencies] diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs new file mode 100644 index 00000000..21dea0e5 --- /dev/null +++ b/crates/workspace-server/src/main.rs @@ -0,0 +1,185 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::Arc; + +use tokio::net::TcpListener; +use yoi_workspace_server::{ServerConfig, SqliteWorkspaceStore, serve}; + +#[derive(Debug)] +struct ServeOptions { + workspace: PathBuf, + db: Option, + frontend: Option, + listen: SocketAddr, +} + +#[derive(Debug)] +struct CliError(String); + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for CliError {} + +#[tokio::main] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("yoi-workspace-server: {error}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), Box> { + let args = std::env::args().skip(1).collect::>(); + let Some((command, rest)) = args.split_first() else { + print_help(); + return Ok(()); + }; + + match command.as_str() { + "serve" => { + if rest.iter().any(|arg| arg == "--help" || arg == "-h") { + print_serve_help(); + return Ok(()); + } + let options = parse_serve_options(rest)?; + run_serve(options).await?; + Ok(()) + } + "--help" | "-h" => { + print_help(); + Ok(()) + } + other => Err(Box::new(CliError(format!( + "unknown command `{other}`; expected `serve`" + )))), + } +} + +async fn run_serve(options: ServeOptions) -> Result<(), Box> { + let db = options + .db + .unwrap_or_else(|| options.workspace.join(".yoi/workspace.db")); + if let Some(parent) = db.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let store = Arc::new(SqliteWorkspaceStore::open(&db)?); + let mut config = ServerConfig::local_dev(&options.workspace); + config.static_assets_dir = options.frontend; + let listener = TcpListener::bind(options.listen).await?; + eprintln!( + "yoi-workspace-server: serving workspace `{}` on http://{}", + options.workspace.display(), + listener.local_addr()? + ); + serve(config, store, listener).await?; + Ok(()) +} + +fn parse_serve_options(args: &[String]) -> Result { + let mut workspace = std::env::current_dir() + .map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?; + let mut db = None; + let mut frontend = None; + let mut listen = "127.0.0.1:8787".parse::().unwrap(); + + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + match arg.as_str() { + "--workspace" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError("--workspace requires a value".to_string()))?; + workspace = PathBuf::from(value); + } + "--db" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError("--db requires a value".to_string()))?; + db = Some(PathBuf::from(value)); + } + "--frontend" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError("--frontend requires a value".to_string()))?; + frontend = Some(PathBuf::from(value)); + } + "--listen" => { + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError("--listen requires a value".to_string()))?; + listen = parse_listen(value)?; + } + _ if arg.starts_with("--workspace=") => { + workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); + } + _ if arg.starts_with("--db=") => { + db = Some(PathBuf::from(value_after_equals(arg, "--db")?)); + } + _ if arg.starts_with("--frontend=") => { + frontend = Some(PathBuf::from(value_after_equals(arg, "--frontend")?)); + } + _ if arg.starts_with("--listen=") => { + listen = parse_listen(value_after_equals(arg, "--listen")?)?; + } + _ if arg.starts_with('-') => { + return Err(CliError(format!("unknown serve option `{arg}`"))); + } + _ => { + return Err(CliError(format!( + "unexpected positional argument `{arg}`; use --workspace " + ))); + } + } + index += 1; + } + + Ok(ServeOptions { + workspace, + db, + frontend, + listen, + }) +} + +fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> { + let value = arg + .strip_prefix(flag) + .and_then(|rest| rest.strip_prefix('=')) + .unwrap_or_default(); + if value.is_empty() { + return Err(CliError(format!("{flag} requires a value"))); + } + Ok(value) +} + +fn parse_listen(value: &str) -> Result { + value + .parse() + .map_err(|_| CliError(format!("invalid --listen address `{value}`"))) +} + +fn print_help() { + println!( + "yoi-workspace-server\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + ); +} + +fn print_serve_help() { + println!( + "yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n --workspace Workspace root containing .yoi project records (defaults to cwd)\n --db SQLite database path (defaults to /.yoi/workspace.db)\n --frontend Static SPA build directory to serve\n --listen Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help" + ); +} diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 40f27524..048628b4 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -5,9 +5,10 @@ mod plugin_cli; mod session_cli; mod ticket_cli; +use std::ffi::OsString; use std::fmt; use std::path::PathBuf; -use std::process::ExitCode; +use std::process::{Command, ExitCode}; use client::PodRuntimeCommand; use memory_lint::{LintCliOptions, LintStatus}; @@ -25,6 +26,8 @@ enum Mode { Objective(objective_cli::ObjectiveCli), Session(session_cli::SessionCli), Ticket(ticket_cli::TicketCli), + WorkspaceHelp, + WorkspaceServe(Vec), PodRuntime(Vec), Keys, SetupModel, @@ -69,6 +72,11 @@ async fn main() -> ExitCode { print_memory_lint_help(); ExitCode::SUCCESS } + Mode::WorkspaceHelp => { + print_workspace_help(); + ExitCode::SUCCESS + } + Mode::WorkspaceServe(args) => run_workspace_server(args), Mode::MemoryLint(options) => match memory_lint::run(&options) { Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Failed) => ExitCode::FAILURE, @@ -200,6 +208,9 @@ fn parse_args_slice(args: &[String]) -> Result { let plugin_cli = parse_plugin_args(&args[1..])?; return Ok(Mode::Plugin(plugin_cli)); } + "workspace" => { + return parse_workspace_args(&args[1..]); + } "mcp" => { let mcp_cli = parse_mcp_args(&args[1..])?; return Ok(Mode::Mcp(mcp_cli)); @@ -472,6 +483,63 @@ fn current_dir() -> Result { .map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))) } +fn parse_workspace_args(args: &[String]) -> Result { + let Some((subcommand, rest)) = args.split_first() else { + return Err(ParseError( + "yoi workspace requires `serve` (try `yoi workspace --help`)".to_string(), + )); + }; + match subcommand.as_str() { + "serve" => { + if rest.iter().any(|arg| arg == "--help" || arg == "-h") { + return Ok(Mode::WorkspaceHelp); + } + Ok(Mode::WorkspaceServe(rest.to_vec())) + } + "--help" | "-h" => Ok(Mode::WorkspaceHelp), + other => Err(ParseError(format!( + "unknown yoi workspace subcommand `{other}`" + ))), + } +} + +fn run_workspace_server(args: Vec) -> ExitCode { + let command = match resolve_workspace_server_command() { + Ok(command) => command, + Err(error) => { + eprintln!("yoi workspace: {error}"); + return ExitCode::FAILURE; + } + }; + + let mut child = Command::new(&command); + child.arg("serve"); + child.args(args); + match child.status() { + Ok(status) if status.success() => ExitCode::SUCCESS, + Ok(status) => ExitCode::from(status.code().unwrap_or(1).min(255) as u8), + Err(error) => { + eprintln!( + "yoi workspace: failed to launch `{}`: {error}", + command.to_string_lossy() + ); + ExitCode::FAILURE + } + } +} + +fn resolve_workspace_server_command() -> Result { + if let Some(value) = std::env::var_os("YOI_WORKSPACE_SERVER_COMMAND") { + if !value.is_empty() { + return Ok(value); + } + } + let current = std::env::current_exe() + .map_err(|error| ParseError(format!("failed to resolve current executable: {error}")))?; + let sibling = current.with_file_name("yoi-workspace-server"); + Ok(sibling.into_os_string()) +} + fn parse_plugin_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( @@ -810,7 +878,13 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + ); +} + +fn print_workspace_help() { + println!( + "yoi workspace\n\nUsage:\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nOptions forwarded to yoi-workspace-server serve:\n --workspace Workspace root containing .yoi project records (defaults to cwd)\n --db SQLite database path (defaults to /.yoi/workspace.db)\n --frontend Static SPA build directory to serve\n --listen Listen address (defaults to 127.0.0.1:8787)\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n" ); } @@ -931,6 +1005,22 @@ mod tests { } } + #[test] + fn parse_workspace_serve_passthrough() { + match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() { + Mode::WorkspaceServe(args) => assert_eq!(args, vec!["--listen", "127.0.0.1:0"]), + other => panic!("unexpected mode: {other:?}"), + } + } + + #[test] + fn parse_workspace_help() { + assert!(matches!( + parse_args_from(["workspace", "--help"]).unwrap(), + Mode::WorkspaceHelp + )); + } + #[test] fn parse_keys_subcommand() { match parse_args_from(["keys"]).unwrap() { diff --git a/package.nix b/package.nix index 7ac7830b..bb4d8b16 100644 --- a/package.nix +++ b/package.nix @@ -91,17 +91,39 @@ rustPlatform.buildRustPackage rec { "yoi" ]; + postBuild = '' + cargo build --offline --profile release -p yoi-workspace-server --bin yoi-workspace-server + ''; + # The package check is a credential-free install smoke check below. Running the # workspace test suite is intentionally left to cargo-based CI because this # derivation is scoped to packaging the user-facing binaries. doCheck = false; + installPhase = '' + runHook preInstall + + yoi_bin=$(find . -type f -name yoi | head -n 1) + workspace_server_bin=$(find . -type f -name yoi-workspace-server | head -n 1) + if [ -z "$yoi_bin" ] || [ -z "$workspace_server_bin" ]; then + echo "built binaries not found" >&2 + find . -maxdepth 6 -type f \( -name yoi -o -name yoi-workspace-server \) -print >&2 + exit 1 + fi + install -Dm755 "$yoi_bin" "$out/bin/yoi" + install -Dm755 "$workspace_server_bin" "$out/bin/yoi-workspace-server" + + runHook postInstall + ''; + doInstallCheck = true; installCheckPhase = '' runHook preInstallCheck "$out/bin/yoi" pod --help >/dev/null test -x "$out/bin/yoi" + test -x "$out/bin/yoi-workspace-server" + "$out/bin/yoi-workspace-server" --help >/dev/null test ! -e "$out/bin/yoi-pod" test ! -e "$out/share/yoi/resources" if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then