Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ clickhousectl local client --host remote-host --version 26.8.1.1760 # Use an in

`--name` selects the connection and local client binary from managed server metadata, so named mode does not need a global default. It cannot be combined with direct `--host` or `--port` selectors, and named mode does not accept `--version`.

Without `--host` or `--port`, managed client lookup uses `.clickhouse/servers` from the canonical current directory only. It does not search parent directories. If lookup fails, return to the project root that owns the server, inspect that project's servers with `local server list`, or use direct mode.

In direct mode, `--host` and `--port` select the server connection while `--version` independently selects an already installed local client binary. Numeric selectors such as `26`, `26.8`, and `26.8.1.1760` select the newest installed match. This does not install a binary or change `~/.clickhouse/default`.

Without `--version`, direct mode uses the valid default. If no default exists, zero installed versions is an error, one installed version is used without creating a default, and multiple installed versions require either `--version` or `local use`. A default that names a missing binary is an error; repair it with `local use`, or bypass it for one direct connection with `--version`.
Expand Down Expand Up @@ -972,13 +974,19 @@ Local runtime failures also use structured output when `local --json` is set or
}
```

`error.code` and `error.message` are always present. `error.command` is an optional safe recovery command. Messages are built from allowlisted fields and never serialize raw I/O errors, paths, credentials, SQL, container logs, Docker diagnostics, or arbitrary fallback details. Human local errors retain the concise `Error: ...` format. Clap usage errors, Cloud errors, and child-process output are not wrapped in this local schema.
`error.code` and `error.message` are always present. `error.command` is an optional safe recovery command. Messages are built from allowlisted fields and never serialize raw I/O errors, credentials, SQL, container logs, Docker diagnostics, or arbitrary fallback details. Human local errors retain the concise `Error: ...` format. Clap usage errors, Cloud errors, and child-process output are not wrapped in this local schema.

Managed `local client` failures deliberately use dedicated `managed_client_*` codes rather than the general server codes. Their error object includes `project_scope.path` (the canonical directory inspected), `server.selection` and `server.name`, an optional `server.binary_version`, and ordered `guidance` entries with allowlisted messages and optional commands. No raw lock, metadata, or I/O error is included in JSON. The nested shape distinguishes this exact-project lookup contract from failures in other local commands without changing those commands' stable envelopes.

The schema and meanings of existing codes are stable. New optional fields or codes may be added compatibly; unclassified local failures use the bounded `local_error` fallback.

| Code | Meaning |
| ---- | ------- |
| `server_not_found` | The selected local server does not exist |
| `managed_client_server_not_found` | Managed client lookup did not find the selected server in the current project |
| `managed_client_server_not_running` | The managed client server exists in the current project but is stopped |
| `managed_client_binary_not_found` | The client binary selected by managed server metadata is not installed |
| `managed_client_project_state_unavailable` | Managed client lookup could not read or lock current-project server state |
| `server_selection_required` | A server name is required because omission is ambiguous or unsafe |
| `server_not_running` | The selected local server exists but is stopped |
| `server_running` | The operation requires a stopped server |
Expand Down
103 changes: 103 additions & 0 deletions crates/clickhousectl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,106 @@ impl fmt::Display for StartupKind {
}
}

#[derive(Debug)]
pub enum ManagedClientErrorKind {
ServerNotFound,
ServerNotRunning,
BinaryNotFound,
ProjectStateUnavailable(Box<Error>),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManagedClientSelection {
Default,
Named,
}

impl ManagedClientSelection {
fn start_command(self) -> &'static str {
match self {
Self::Default => "clickhousectl local server start",
Self::Named => "clickhousectl local server start <name>",
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

#[derive(Debug)]
pub struct ManagedClientError {
pub kind: ManagedClientErrorKind,
pub project_dir: PathBuf,
pub selection: ManagedClientSelection,
pub server_name: String,
pub binary_version: Option<String>,
}

impl fmt::Display for ManagedClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
ManagedClientErrorKind::ServerNotFound => {
writeln!(
f,
"Managed client mode: server '{}' was not found in current project '{}'; parent projects are not searched.",
self.server_name,
self.project_dir.display()
)?;
write!(
f,
"Run `clickhousectl local server list`; return to the project root if needed; start it with `{}`, or use direct mode with `clickhousectl local client --host <host> --port <port>`.",
self.selection.start_command()
)
}
ManagedClientErrorKind::ServerNotRunning => {
writeln!(
f,
"Managed client mode: server '{}' is not running in current project '{}'.",
self.server_name,
self.project_dir.display()
)?;
write!(
f,
"Run `clickhousectl local server list`, then `{}`; or use direct mode with `clickhousectl local client --host <host> --port <port>`.",
self.selection.start_command()
)
}
ManagedClientErrorKind::BinaryNotFound => {
let version = self.binary_version.as_deref().unwrap_or("unknown");
writeln!(
f,
"Managed client mode: server '{}' in current project '{}' selected ClickHouse version '{}', but its client binary is missing.",
self.server_name,
self.project_dir.display(),
version
)?;
write!(
f,
"Run `clickhousectl local server list` and install the selected version with `clickhousectl local install <version>`, or use direct mode with `clickhousectl local client --host <host> --port <port>`."
)
}
ManagedClientErrorKind::ProjectStateUnavailable(source) => {
writeln!(
f,
"Managed client mode: server '{}' could not be resolved because state in current project '{}' is unavailable: {source}",
self.server_name,
self.project_dir.display()
)?;
write!(
f,
"Repair the project state error above, then run `clickhousectl local server list`; or use direct mode with `clickhousectl local client --host <host> --port <port>`."
)
}
}
}
}

impl std::error::Error for ManagedClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.kind {
ManagedClientErrorKind::ProjectStateUnavailable(source) => Some(source.as_ref()),
_ => None,
}
}
}

#[derive(Error, Debug)]
#[allow(dead_code)]
pub enum Error {
Expand Down Expand Up @@ -310,6 +410,9 @@ pub enum Error {
#[error("Server '{0}' not found")]
ServerNotFound(String),

#[error("{0}")]
ManagedClient(ManagedClientError),

#[error(
"No server name was provided and multiple non-default ClickHouse servers exist (available: {available}). Pass a name with `clickhousectl local server stop <name>`, or stop every server with `clickhousectl local server stop-all`."
)]
Expand Down
5 changes: 4 additions & 1 deletion crates/clickhousectl/src/local/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ CONTEXT FOR AGENTS:
CONTEXT FOR AGENTS:
Two connection modes:
1. Named server: `clickhousectl local client --name dev` — looks up port and version from a
locally managed server started via `clickhousectl local server start`. Defaults to \"default\".
locally managed server started via `clickhousectl local server start`. Lookup uses the exact
current project directory and does not search parents. Defaults to \"default\".
2. Explicit host/port: `clickhousectl local client --host myhost --port 9000` — connects to any
ClickHouse server directly, bypassing local server lookup. Host-only uses port 9000; port-only
connects to localhost. Direct selectors cannot be combined with --name.
Expand Down Expand Up @@ -1082,6 +1083,8 @@ mod tests {
for text in [
"Installed local client version for direct host/port mode",
"Does not change the default",
"Lookup uses the exact",
"current project directory and does not search parents",
] {
assert!(help.contains(text), "missing {text:?} in:\n{help}");
}
Expand Down
67 changes: 51 additions & 16 deletions crates/clickhousectl/src/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ pub mod symlink;

use cli::{ClientVersionArg, InstallVersionArg, LocalCommands, ServerCommands, ServerVersionArg};

use crate::error::{Error, Result};
use crate::error::{
Error, ManagedClientError, ManagedClientErrorKind, ManagedClientSelection, Result,
};
use crate::{init, paths, version_manager};
use std::io::Write;
use std::os::unix::process::CommandExt;
Expand Down Expand Up @@ -271,32 +273,65 @@ fn run_client(
) -> Result<()> {
// If --host or --port is set, connect directly (bypass local server lookup).
// Otherwise, look up the named server for port and version.
let (resolved_host, tcp_port, version) = if host.is_some() || port.is_some() {
let (resolved_host, tcp_port, version, binary) = if host.is_some() || port.is_some() {
let h = host.unwrap_or_else(|| "localhost".to_string());
let p = port.unwrap_or(9000);
let v = resolve_direct_client_version(version_spec)?;
(h, p, v)
let binary = paths::binary_path(&v)?;
if !binary.exists() {
return Err(Error::VersionNotFound(v));
}
(h, p, v, binary)
} else {
let server_name = name.as_deref().unwrap_or("default");
let metadata_lock = server::lock_metadata()?;
server::recover_current_project_servers_locked(&metadata_lock)?;
let entry = server::server_entry_locked(server_name, &metadata_lock)?
.ok_or_else(|| Error::ServerNotFound(server_name.to_string()))?;
let selection = if name.is_some() {
ManagedClientSelection::Named
} else {
ManagedClientSelection::Default
};
Comment thread
cursor[bot] marked this conversation as resolved.
// Resolve symlinks so diagnostics identify the one physical directory
// whose project-local state was inspected.
let project_dir = std::env::current_dir()?.canonicalize()?;
let managed_error = |kind, binary_version| {
Error::ManagedClient(ManagedClientError {
kind,
project_dir: project_dir.clone(),
selection,
server_name: server_name.to_string(),
binary_version,
})
};
let project_state_error = |source| {
managed_error(
ManagedClientErrorKind::ProjectStateUnavailable(Box::new(source)),
None,
)
};
let metadata_lock = server::lock_metadata().map_err(&project_state_error)?;
server::recover_current_project_servers_locked(&metadata_lock)
.map_err(&project_state_error)?;
let entry = server::server_entry_locked(server_name, &metadata_lock)
.map_err(&project_state_error)?
.ok_or_else(|| managed_error(ManagedClientErrorKind::ServerNotFound, None))?;
Comment thread
cursor[bot] marked this conversation as resolved.
if !entry.running {
return Err(Error::ServerNotRunning(server_name.to_string()));
return Err(managed_error(
ManagedClientErrorKind::ServerNotRunning,
None,
));
}
let info = entry
.info
.ok_or_else(|| Error::ServerNotRunning(server_name.to_string()))?;
("localhost".to_string(), info.tcp_port, info.version)
.ok_or_else(|| managed_error(ManagedClientErrorKind::ServerNotRunning, None))?;
let binary = paths::binary_path(&info.version)?;
if !binary.exists() {
return Err(managed_error(
ManagedClientErrorKind::BinaryNotFound,
Some(info.version),
));
}
("localhost".to_string(), info.tcp_port, info.version, binary)
};

let binary = paths::binary_path(&version)?;

if !binary.exists() {
return Err(Error::VersionNotFound(version));
}

ensure_repeated_query_supported(&version, query.len())?;

let mut cmd = Command::new(&binary);
Expand Down
Loading