cli: configure target defaults
This commit is contained in:
parent
8ec940f624
commit
297c56c72d
|
|
@ -306,7 +306,7 @@ impl WorkspaceBackendConfigFile {
|
||||||
match fs::read_to_string(&path) {
|
match fs::read_to_string(&path) {
|
||||||
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
|
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
|
||||||
"workspace backend local config `{}` does not exist; run `yoi workspace init --workspace {}` first",
|
"workspace backend local config `{}` does not exist; run `yoi-workspace-server init --workspace {}` first",
|
||||||
path.display(),
|
path.display(),
|
||||||
workspace_root.display()
|
workspace_root.display()
|
||||||
))),
|
))),
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ impl WorkspaceIdentity {
|
||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
Ok(raw) => Self::parse_str(&raw, &path),
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
Err(Error::WorkspaceIdentity(format!(
|
Err(Error::WorkspaceIdentity(format!(
|
||||||
"workspace is not initialized at {}; run `yoi workspace init --workspace {}` first",
|
"workspace is not initialized at {}; run `yoi-workspace-server init --workspace {}` first",
|
||||||
workspace_root.as_ref().display(),
|
workspace_root.as_ref().display(),
|
||||||
workspace_root.as_ref().display()
|
workspace_root.as_ref().display()
|
||||||
)))
|
)))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use client::{BackendTarget, LocalTarget, Target, TargetKind};
|
use client::{BackendTarget, LocalTarget, Target, TargetKind};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::{ParseError, resolve_backend_url};
|
use super::{ParseError, read_client_default_connection, resolve_backend_url};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum CliConnectionRequirement {
|
pub(crate) enum CliConnectionRequirement {
|
||||||
|
|
@ -26,7 +26,6 @@ pub(crate) enum CliCommand {
|
||||||
Mcp,
|
Mcp,
|
||||||
MemoryLint,
|
MemoryLint,
|
||||||
Session,
|
Session,
|
||||||
WorkspaceServer,
|
|
||||||
Login,
|
Login,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +46,6 @@ impl CliCommand {
|
||||||
CliCommand::Mcp => "yoi mcp",
|
CliCommand::Mcp => "yoi mcp",
|
||||||
CliCommand::MemoryLint => "yoi memory lint",
|
CliCommand::MemoryLint => "yoi memory lint",
|
||||||
CliCommand::Session => "yoi session",
|
CliCommand::Session => "yoi session",
|
||||||
CliCommand::WorkspaceServer => "yoi workspace/server",
|
|
||||||
CliCommand::Login => "yoi login",
|
CliCommand::Login => "yoi login",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,15 +66,13 @@ impl CliCommand {
|
||||||
| CliCommand::Plugin
|
| CliCommand::Plugin
|
||||||
| CliCommand::Mcp
|
| CliCommand::Mcp
|
||||||
| CliCommand::MemoryLint
|
| CliCommand::MemoryLint
|
||||||
| CliCommand::Session
|
| CliCommand::Session => CliConnectionRequirement::LocalOnly,
|
||||||
| CliCommand::WorkspaceServer => CliConnectionRequirement::LocalOnly,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) enum ClientDefaultConnection {
|
pub(crate) enum ClientDefaultConnection {
|
||||||
Local,
|
Local,
|
||||||
Backend,
|
Backend,
|
||||||
|
|
@ -90,7 +86,10 @@ impl Default for ClientDefaultConnection {
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) enum CliConnectionInput<'a> {
|
pub(crate) enum CliConnectionInput<'a> {
|
||||||
LocalDefault,
|
DefaultTarget {
|
||||||
|
workspace_id: Option<&'a str>,
|
||||||
|
},
|
||||||
|
LocalTarget,
|
||||||
BackendTarget {
|
BackendTarget {
|
||||||
explicit_backend_url: Option<String>,
|
explicit_backend_url: Option<String>,
|
||||||
workspace_id: Option<&'a str>,
|
workspace_id: Option<&'a str>,
|
||||||
|
|
@ -115,21 +114,29 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
||||||
input: CliConnectionInput<'_>,
|
input: CliConnectionInput<'_>,
|
||||||
) -> Result<Box<dyn Target>, ParseError> {
|
) -> Result<Box<dyn Target>, ParseError> {
|
||||||
match (command.connection_requirement(), input) {
|
match (command.connection_requirement(), input) {
|
||||||
(CliConnectionRequirement::LocalOnly, CliConnectionInput::LocalDefault) => {
|
(
|
||||||
Ok(Box::new(LocalTarget::new()))
|
CliConnectionRequirement::LocalOnly,
|
||||||
}
|
CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget,
|
||||||
|
) => Ok(Box::new(LocalTarget::new())),
|
||||||
(CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => {
|
(CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => {
|
||||||
Err(ParseError(format!(
|
Err(ParseError(format!(
|
||||||
"{} uses a local connection target and cannot accept Backend target options",
|
"{} uses a local connection target and cannot accept Backend target options",
|
||||||
command.display_name()
|
command.display_name()
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
(CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalDefault) => {
|
(CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalTarget) => {
|
||||||
Err(ParseError(format!(
|
Err(ParseError(format!(
|
||||||
"{} requires a Backend connection target",
|
"{} requires a Backend connection target",
|
||||||
command.display_name()
|
command.display_name()
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
CliConnectionRequirement::BackendOnly,
|
||||||
|
CliConnectionInput::DefaultTarget { workspace_id },
|
||||||
|
) => Ok(Box::new(BackendTarget::new(
|
||||||
|
resolve_backend_url(None, workspace_id)?,
|
||||||
|
workspace_id.map(str::to_string),
|
||||||
|
))),
|
||||||
(
|
(
|
||||||
CliConnectionRequirement::BackendOnly | CliConnectionRequirement::ConnectionAware,
|
CliConnectionRequirement::BackendOnly | CliConnectionRequirement::ConnectionAware,
|
||||||
CliConnectionInput::BackendTarget {
|
CliConnectionInput::BackendTarget {
|
||||||
|
|
@ -140,9 +147,19 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
||||||
resolve_backend_url(explicit_backend_url, workspace_id)?,
|
resolve_backend_url(explicit_backend_url, workspace_id)?,
|
||||||
workspace_id.map(str::to_string),
|
workspace_id.map(str::to_string),
|
||||||
))),
|
))),
|
||||||
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalDefault) => {
|
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => {
|
||||||
Ok(Box::new(LocalTarget::new()))
|
Ok(Box::new(LocalTarget::new()))
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
CliConnectionRequirement::ConnectionAware,
|
||||||
|
CliConnectionInput::DefaultTarget { workspace_id },
|
||||||
|
) => match read_client_default_connection()? {
|
||||||
|
ClientDefaultConnection::Local => Ok(Box::new(LocalTarget::new())),
|
||||||
|
ClientDefaultConnection::Backend => Ok(Box::new(BackendTarget::new(
|
||||||
|
resolve_backend_url(None, workspace_id)?,
|
||||||
|
workspace_id.map(str::to_string),
|
||||||
|
))),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +168,7 @@ pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
|
||||||
resolver: &R,
|
resolver: &R,
|
||||||
command: CliCommand,
|
command: CliCommand,
|
||||||
) -> Result<Box<dyn Target>, ParseError> {
|
) -> Result<Box<dyn Target>, ParseError> {
|
||||||
let target = resolver.resolve_connection(command, CliConnectionInput::LocalDefault)?;
|
let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?;
|
||||||
match target.kind() {
|
match target.kind() {
|
||||||
TargetKind::Local => Ok(target),
|
TargetKind::Local => Ok(target),
|
||||||
TargetKind::Backend => Err(ParseError(format!(
|
TargetKind::Backend => Err(ParseError(format!(
|
||||||
|
|
@ -196,7 +213,7 @@ pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver +
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if explicit_local {
|
if explicit_local {
|
||||||
return resolver.resolve_connection(command, CliConnectionInput::LocalDefault);
|
return resolver.resolve_connection(command, CliConnectionInput::LocalTarget);
|
||||||
}
|
}
|
||||||
if explicit_backend_url.is_some() {
|
if explicit_backend_url.is_some() {
|
||||||
return resolver.resolve_connection(
|
return resolver.resolve_connection(
|
||||||
|
|
@ -207,7 +224,7 @@ pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver +
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
resolver.resolve_connection(command, CliConnectionInput::LocalDefault)
|
resolver.resolve_connection(command, CliConnectionInput::DefaultTarget { workspace_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn backend_target_option_error_for_local_command(
|
pub(crate) fn backend_target_option_error_for_local_command(
|
||||||
|
|
@ -266,7 +283,6 @@ mod tests {
|
||||||
CliCommand::Mcp,
|
CliCommand::Mcp,
|
||||||
CliCommand::MemoryLint,
|
CliCommand::MemoryLint,
|
||||||
CliCommand::Session,
|
CliCommand::Session,
|
||||||
CliCommand::WorkspaceServer,
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
command.connection_requirement(),
|
command.connection_requirement(),
|
||||||
|
|
@ -297,10 +313,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cli_connection_resolver_rejects_backend_only_local_default() {
|
fn cli_connection_resolver_rejects_backend_only_local_target() {
|
||||||
let resolver = ClientConfigCliConnectionResolver;
|
let resolver = ClientConfigCliConnectionResolver;
|
||||||
let err = resolver
|
let err = resolver
|
||||||
.resolve_connection(CliCommand::Login, CliConnectionInput::LocalDefault)
|
.resolve_connection(CliCommand::Login, CliConnectionInput::LocalTarget)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,10 @@ mod ticket_cli;
|
||||||
mod worker_cleanup_cli;
|
mod worker_cleanup_cli;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::ExitCode;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use cli_connection::{
|
use cli_connection::{
|
||||||
|
|
@ -39,11 +38,6 @@ enum Mode {
|
||||||
Session(session_cli::SessionCli),
|
Session(session_cli::SessionCli),
|
||||||
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
|
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
|
||||||
Ticket(ticket_cli::TicketCli),
|
Ticket(ticket_cli::TicketCli),
|
||||||
WorkspaceHelp,
|
|
||||||
WorkspaceServer {
|
|
||||||
subcommand: String,
|
|
||||||
args: Vec<String>,
|
|
||||||
},
|
|
||||||
Login {
|
Login {
|
||||||
backend_url: String,
|
backend_url: String,
|
||||||
no_wait: bool,
|
no_wait: bool,
|
||||||
|
|
@ -93,11 +87,6 @@ async fn main() -> ExitCode {
|
||||||
print_memory_lint_help();
|
print_memory_lint_help();
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
Mode::WorkspaceHelp => {
|
|
||||||
print_workspace_help();
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Mode::WorkspaceServer { subcommand, args } => run_workspace_server(&subcommand, args),
|
|
||||||
Mode::Login {
|
Mode::Login {
|
||||||
backend_url,
|
backend_url,
|
||||||
no_wait,
|
no_wait,
|
||||||
|
|
@ -332,7 +321,7 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||||
&target_selection,
|
&target_selection,
|
||||||
&workspace_root,
|
&workspace_root,
|
||||||
)?;
|
)?;
|
||||||
let mode = if target_selection.explicit_backend() {
|
let mode = if target.kind() == client::TargetKind::Backend {
|
||||||
LaunchMode::Workers {
|
LaunchMode::Workers {
|
||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
include_stopped: false,
|
include_stopped: false,
|
||||||
|
|
@ -390,16 +379,6 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||||
let plugin_cli = parse_plugin_args(&args[1..])?;
|
let plugin_cli = parse_plugin_args(&args[1..])?;
|
||||||
return Ok(Mode::Plugin(plugin_cli));
|
return Ok(Mode::Plugin(plugin_cli));
|
||||||
}
|
}
|
||||||
"workspace" => {
|
|
||||||
let _target =
|
|
||||||
resolve_local_cli_connection(connection_resolver, CliCommand::WorkspaceServer)?;
|
|
||||||
return parse_workspace_args(&args[1..]);
|
|
||||||
}
|
|
||||||
"server" => {
|
|
||||||
let _target =
|
|
||||||
resolve_local_cli_connection(connection_resolver, CliCommand::WorkspaceServer)?;
|
|
||||||
return parse_workspace_args(&args[1..]);
|
|
||||||
}
|
|
||||||
"login" => {
|
"login" => {
|
||||||
return parse_login_args(&args[1..], connection_resolver);
|
return parse_login_args(&args[1..], connection_resolver);
|
||||||
}
|
}
|
||||||
|
|
@ -937,28 +916,65 @@ struct WorkspaceIdentityFile {
|
||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default)]
|
||||||
struct ClientConfigFile {
|
struct ClientConfigFile {
|
||||||
default_backend: Option<String>,
|
default_backend: Option<String>,
|
||||||
// Parsed as part of the target model; applying configured connection defaults is left to
|
|
||||||
// the later user-facing connection-mode work so current `yoi` default launch stays local.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[serde(default)]
|
|
||||||
default_connection: ClientDefaultConnection,
|
default_connection: ClientDefaultConnection,
|
||||||
#[serde(default)]
|
|
||||||
backends: BTreeMap<String, ClientBackendConfig>,
|
backends: BTreeMap<String, ClientBackendConfig>,
|
||||||
#[serde(default)]
|
|
||||||
workspaces: BTreeMap<String, ClientWorkspaceConfig>,
|
workspaces: BTreeMap<String, ClientWorkspaceConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct ClientBackendConfig {
|
struct ClientConfigOverlay {
|
||||||
url: String,
|
default_backend: Option<String>,
|
||||||
|
default_connection: Option<ClientDefaultConnection>,
|
||||||
|
#[serde(default)]
|
||||||
|
backends: BTreeMap<String, ClientBackendConfigOverlay>,
|
||||||
|
#[serde(default)]
|
||||||
|
workspaces: BTreeMap<String, ClientWorkspaceConfigOverlay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Default)]
|
||||||
|
struct ClientBackendConfig {
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ClientBackendConfigOverlay {
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
struct ClientWorkspaceConfig {
|
struct ClientWorkspaceConfig {
|
||||||
backend: String,
|
backend: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct ClientWorkspaceConfigOverlay {
|
||||||
|
backend: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientConfigFile {
|
||||||
|
fn apply_overlay(&mut self, overlay: ClientConfigOverlay) {
|
||||||
|
if let Some(default_backend) = overlay.default_backend {
|
||||||
|
self.default_backend = Some(default_backend);
|
||||||
|
}
|
||||||
|
if let Some(default_connection) = overlay.default_connection {
|
||||||
|
self.default_connection = default_connection;
|
||||||
|
}
|
||||||
|
for (name, backend) in overlay.backends {
|
||||||
|
let entry = self.backends.entry(name).or_default();
|
||||||
|
if let Some(url) = backend.url {
|
||||||
|
entry.url = Some(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (id, workspace) in overlay.workspaces {
|
||||||
|
let entry = self.workspaces.entry(id).or_default();
|
||||||
|
if let Some(backend) = workspace.backend {
|
||||||
|
entry.backend = Some(backend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result<Option<String>, ParseError> {
|
fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result<Option<String>, ParseError> {
|
||||||
|
|
@ -1004,7 +1020,7 @@ fn resolve_backend_url(
|
||||||
config
|
config
|
||||||
.workspaces
|
.workspaces
|
||||||
.get(id)
|
.get(id)
|
||||||
.map(|workspace| workspace.backend.as_str())
|
.and_then(|workspace| workspace.backend.as_deref())
|
||||||
})
|
})
|
||||||
.or(config.default_backend.as_deref())
|
.or(config.default_backend.as_deref())
|
||||||
.ok_or_else(|| ParseError(client_config_missing_message(workspace_id)))?;
|
.ok_or_else(|| ParseError(client_config_missing_message(workspace_id)))?;
|
||||||
|
|
@ -1013,7 +1029,11 @@ fn resolve_backend_url(
|
||||||
"client config references backend `{backend_name}`, but [backends.{backend_name}] is not defined"
|
"client config references backend `{backend_name}`, but [backends.{backend_name}] is not defined"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let url = backend.url.trim();
|
let Some(url) = backend.url.as_deref().map(str::trim) else {
|
||||||
|
return Err(ParseError(format!(
|
||||||
|
"client config backend `{backend_name}` must contain a url"
|
||||||
|
)));
|
||||||
|
};
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
return Err(ParseError(format!(
|
return Err(ParseError(format!(
|
||||||
"client config backend `{backend_name}` must contain a non-empty url"
|
"client config backend `{backend_name}` must contain a non-empty url"
|
||||||
|
|
@ -1022,30 +1042,67 @@ fn resolve_backend_url(
|
||||||
Ok(url.to_string())
|
Ok(url.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_client_default_connection() -> Result<ClientDefaultConnection, ParseError> {
|
||||||
|
Ok(read_client_config()?
|
||||||
|
.map(|config| config.default_connection)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> {
|
fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> {
|
||||||
let Some(path) = client_config_path() else {
|
let mut config = ClientConfigFile::default();
|
||||||
return Ok(None);
|
let mut found = false;
|
||||||
};
|
|
||||||
|
if let Some(path) = client_global_config_path() {
|
||||||
|
if let Some(overlay) = read_client_config_overlay(&path)? {
|
||||||
|
config.apply_overlay(overlay);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cwd_path = client_cwd_config_path()?;
|
||||||
|
if let Some(overlay) = read_client_config_overlay(&cwd_path)? {
|
||||||
|
config.apply_overlay(overlay);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(found.then_some(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_client_config_overlay(path: &Path) -> Result<Option<ClientConfigOverlay>, ParseError> {
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let contents = fs::read_to_string(&path)
|
let contents = fs::read_to_string(path)
|
||||||
.map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?;
|
.map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?;
|
||||||
toml::from_str::<ClientConfigFile>(&contents)
|
toml::from_str::<ClientConfigOverlay>(&contents)
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))
|
.map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_config_path() -> Option<PathBuf> {
|
fn client_global_config_path() -> Option<PathBuf> {
|
||||||
yoi_config_dir().map(|dir| dir.join("client.toml"))
|
manifest::paths::data_dir().map(|dir| dir.join("client").join("config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_cwd_config_path() -> Result<PathBuf, ParseError> {
|
||||||
|
Ok(current_dir()?.join(".yoi").join("client.config.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_config_location_message() -> String {
|
||||||
|
match client_global_config_path() {
|
||||||
|
Some(path) => format!("{} or <cwd>/.yoi/client.config.toml", path.display()),
|
||||||
|
None => "<data_dir>/client/config.toml or <cwd>/.yoi/client.config.toml".to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_config_missing_message(workspace_id: Option<&str>) -> String {
|
fn client_config_missing_message(workspace_id: Option<&str>) -> String {
|
||||||
|
let locations = client_config_location_message();
|
||||||
match workspace_id {
|
match workspace_id {
|
||||||
Some(workspace_id) => format!(
|
Some(workspace_id) => format!(
|
||||||
"Backend URL is required. Pass --backend <URL> or configure $XDG_CONFIG_HOME/yoi/client.toml with [workspaces.{workspace_id}] backend = <name> and [backends.<name>].url"
|
"Backend URL is required. Pass --backend <URL> or configure {locations} with [workspaces.{workspace_id}] backend = <name> and [backends.<name>].url"
|
||||||
|
),
|
||||||
|
None => format!(
|
||||||
|
"Backend URL is required. Pass --backend <URL> or configure default_backend in {locations}"
|
||||||
),
|
),
|
||||||
None => "Backend URL is required. Pass --backend <URL> or configure default_backend in $XDG_CONFIG_HOME/yoi/client.toml".to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1189,94 +1246,6 @@ fn yoi_config_dir() -> Option<PathBuf> {
|
||||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
|
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
|
||||||
return Err(ParseError(
|
|
||||||
"yoi workspace requires `init`, `config`, `identity`, `trust-runtime`, or `serve` (try `yoi workspace --help`)"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
match subcommand.as_str() {
|
|
||||||
"init" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
return Ok(Mode::WorkspaceHelp);
|
|
||||||
}
|
|
||||||
Ok(Mode::WorkspaceServer {
|
|
||||||
subcommand: "init".to_string(),
|
|
||||||
args: rest.to_vec(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"config" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
return Ok(Mode::WorkspaceHelp);
|
|
||||||
}
|
|
||||||
Ok(Mode::WorkspaceServer {
|
|
||||||
subcommand: "config".to_string(),
|
|
||||||
args: rest.to_vec(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"identity" | "trust-runtime" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
return Ok(Mode::WorkspaceHelp);
|
|
||||||
}
|
|
||||||
Ok(Mode::WorkspaceServer {
|
|
||||||
subcommand: subcommand.clone(),
|
|
||||||
args: rest.to_vec(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"serve" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
return Ok(Mode::WorkspaceHelp);
|
|
||||||
}
|
|
||||||
Ok(Mode::WorkspaceServer {
|
|
||||||
subcommand: "serve".to_string(),
|
|
||||||
args: rest.to_vec(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"--help" | "-h" => Ok(Mode::WorkspaceHelp),
|
|
||||||
other => Err(ParseError(format!(
|
|
||||||
"unknown yoi workspace subcommand `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, or `serve`"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_workspace_server(subcommand: &str, args: Vec<String>) -> 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(subcommand);
|
|
||||||
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<OsString, ParseError> {
|
|
||||||
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<plugin_cli::PluginCliCommand, ParseError> {
|
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
|
|
@ -1622,20 +1591,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi\n\nUsage:\n yoi [TARGET_OPTIONS] [OPTIONS]\n yoi [TARGET_OPTIONS] resume [--workspace <PATH>] [--all] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
|
"yoi\n\nUsage:\n yoi [TARGET_OPTIONS] [OPTIONS]\n yoi [TARGET_OPTIONS] resume [--workspace <PATH>] [--all] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||||
yoi workspace config <COMMAND> [OPTIONS]
|
|
||||||
yoi workspace identity <COMMAND> [OPTIONS]
|
|
||||||
yoi workspace trust-runtime <COMMAND> [OPTIONS]
|
|
||||||
yoi workspace serve [OPTIONS]
|
|
||||||
yoi server identity <COMMAND> [OPTIONS]
|
|
||||||
yoi server trust-runtime <COMMAND> [OPTIONS]
|
|
||||||
yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_workspace_help() {
|
|
||||||
println!(
|
|
||||||
"yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config <COMMAND> [OPTIONS]\n yoi workspace identity <COMMAND> [OPTIONS]\n yoi workspace trust-runtime <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi server identity <COMMAND> [OPTIONS]\n yoi server trust-runtime <COMMAND> [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` reads Workspace records from the Yoi server DB.\n\nSubcommands:\n init Initialize .yoi/workspace.toml, .yoi/workspace-backend.local.toml, and a server DB Workspace record\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n identity Initialize/show the Server signing identity\n trust-runtime Register/list/revoke trusted remote Runtime records\n serve Serve the Workspace recorded in the server DB\n\nOptions forwarded to init/config diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --listen <ADDR> Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1668,7 +1624,9 @@ mod tests {
|
||||||
input: CliConnectionInput<'_>,
|
input: CliConnectionInput<'_>,
|
||||||
) -> Result<Box<dyn Target>, ParseError> {
|
) -> Result<Box<dyn Target>, ParseError> {
|
||||||
match input {
|
match input {
|
||||||
CliConnectionInput::LocalDefault => Ok(Box::new(LocalTarget::new())),
|
CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget => {
|
||||||
|
Ok(Box::new(LocalTarget::new()))
|
||||||
|
}
|
||||||
CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new(
|
CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new(
|
||||||
BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)),
|
BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)),
|
||||||
)),
|
)),
|
||||||
|
|
@ -1706,6 +1664,74 @@ mod tests {
|
||||||
assert_eq!(config.default_connection, ClientDefaultConnection::Local);
|
assert_eq!(config.default_connection, ClientDefaultConnection::Local);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_config_overlay_merges_property_wise() {
|
||||||
|
let mut config = ClientConfigFile::default();
|
||||||
|
config.apply_overlay(
|
||||||
|
toml::from_str(
|
||||||
|
r#"
|
||||||
|
default_backend = "global"
|
||||||
|
default_connection = "backend"
|
||||||
|
|
||||||
|
[backends.global]
|
||||||
|
url = "http://global.example"
|
||||||
|
|
||||||
|
[backends.shared]
|
||||||
|
url = "http://shared-global.example"
|
||||||
|
|
||||||
|
[workspaces.workspace-a]
|
||||||
|
backend = "global"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
config.apply_overlay(
|
||||||
|
toml::from_str(
|
||||||
|
r#"
|
||||||
|
default_backend = "shared"
|
||||||
|
|
||||||
|
[backends.shared]
|
||||||
|
url = "http://shared-cwd.example"
|
||||||
|
|
||||||
|
[workspaces.workspace-b]
|
||||||
|
backend = "shared"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(config.default_connection, ClientDefaultConnection::Backend);
|
||||||
|
assert_eq!(config.default_backend.as_deref(), Some("shared"));
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.backends
|
||||||
|
.get("global")
|
||||||
|
.and_then(|backend| backend.url.as_deref()),
|
||||||
|
Some("http://global.example")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.backends
|
||||||
|
.get("shared")
|
||||||
|
.and_then(|backend| backend.url.as_deref()),
|
||||||
|
Some("http://shared-cwd.example")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.workspaces
|
||||||
|
.get("workspace-a")
|
||||||
|
.and_then(|workspace| workspace.backend.as_deref()),
|
||||||
|
Some("global")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config
|
||||||
|
.workspaces
|
||||||
|
.get("workspace-b")
|
||||||
|
.and_then(|workspace| workspace.backend.as_deref()),
|
||||||
|
Some("shared")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_local_only_commands_reject_backend_target_options() {
|
fn parse_local_only_commands_reject_backend_target_options() {
|
||||||
let err = parse_args_from(["panel", "--runtime-id", "runtime-a"]).unwrap_err();
|
let err = parse_args_from(["panel", "--runtime-id", "runtime-a"]).unwrap_err();
|
||||||
|
|
@ -2009,55 +2035,15 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_workspace_serve_passthrough() {
|
fn parse_workspace_command_is_removed_from_yoi_surface() {
|
||||||
match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() {
|
let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err();
|
||||||
Mode::WorkspaceServer { subcommand, args } => {
|
assert_eq!(err.to_string(), "unknown command `workspace`");
|
||||||
assert_eq!(subcommand, "serve");
|
|
||||||
assert_eq!(args, vec!["--listen", "127.0.0.1:0"]);
|
|
||||||
}
|
|
||||||
other => panic!("unexpected mode: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_workspace_serve_leaves_legacy_flags_to_server() {
|
fn parse_server_command_is_removed_from_yoi_surface() {
|
||||||
match parse_args_from(["workspace", "serve", "--workspace", "/tmp/ws"]).unwrap() {
|
let err = parse_args_from(["server", "identity", "show"]).unwrap_err();
|
||||||
Mode::WorkspaceServer { subcommand, args } => {
|
assert_eq!(err.to_string(), "unknown command `server`");
|
||||||
assert_eq!(subcommand, "serve");
|
|
||||||
assert_eq!(args, vec!["--workspace", "/tmp/ws"]);
|
|
||||||
}
|
|
||||||
other => panic!("unexpected mode: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_workspace_init_passthrough() {
|
|
||||||
match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() {
|
|
||||||
Mode::WorkspaceServer { subcommand, args } => {
|
|
||||||
assert_eq!(subcommand, "init");
|
|
||||||
assert_eq!(args, vec!["--workspace", "/tmp/ws"]);
|
|
||||||
}
|
|
||||||
other => panic!("unexpected mode: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_workspace_config_passthrough() {
|
|
||||||
match parse_args_from(["workspace", "config", "diff", "--workspace", "/tmp/ws"]).unwrap() {
|
|
||||||
Mode::WorkspaceServer { subcommand, args } => {
|
|
||||||
assert_eq!(subcommand, "config");
|
|
||||||
assert_eq!(args, vec!["diff", "--workspace", "/tmp/ws"]);
|
|
||||||
}
|
|
||||||
other => panic!("unexpected mode: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_workspace_help() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse_args_from(["workspace", "--help"]).unwrap(),
|
|
||||||
Mode::WorkspaceHelp
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
# Workspace Backend local config template.
|
# Workspace Backend local config template.
|
||||||
#
|
#
|
||||||
# `yoi workspace init` copies this packaged template to
|
# `yoi-workspace-server init` copies this packaged template to
|
||||||
# `.yoi/workspace-backend.local.toml` without overwriting an existing file.
|
# `.yoi/workspace-backend.local.toml` without overwriting an existing file.
|
||||||
# The `.local` file is intentionally git-ignored.
|
# The `.local` file is intentionally git-ignored.
|
||||||
#
|
#
|
||||||
# Print the latest packaged template with:
|
# Print the latest packaged template with:
|
||||||
# yoi workspace config default
|
# yoi-workspace-server config default
|
||||||
#
|
#
|
||||||
# Compare the local config with the latest packaged template with:
|
# Compare the local config with the latest packaged template with:
|
||||||
# yoi workspace config diff
|
# yoi-workspace-server config diff
|
||||||
#
|
#
|
||||||
# Omit a key to use the built-in fallback. TOML has no `null`, so optional
|
# Omit a key to use the built-in fallback. TOML has no `null`, so optional
|
||||||
# settings are represented by leaving the key commented out.
|
# settings are represented by leaving the key commented out.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user