Skip to content
Closed
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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ When a project-scoped `server stop` omits the name, it selects an existing `defa

An omitted `server remove` is deliberately more conservative: it removes only an existing `default` server. It never infers a custom name, even when there is only one. If `default` does not exist, the command reports whether custom ClickHouse servers are available and directs you to `server list` before you pass a name explicitly.

Project-scoped `server list`, `stop`, and `remove` use `.clickhouse` under the canonical current directory only. They do not search parent directories, including when the current directory is reached through a symlink or has its own nested `.clickhouse`. Lookup and state errors print that canonical project directory, direct you to change to the intended project directory for stopped servers, and suggest `clickhousectl local server list --global` for finding running servers across projects.

**Server naming:** Without a name, the first server is called "default". If "default" is already running, a random name is generated (e.g. "bold-crane"). Pass a name positionally for stable identities you can start/stop repeatedly.

**Ports:** Defaults are HTTP 8123 and TCP 9000. If these are already in use, free ports are automatically assigned and shown in the output. Use `--http-port` and `--tcp-port` to set explicit ports.
Expand Down Expand Up @@ -924,13 +926,14 @@ When a dispatched `local` command fails in explicit `--json` or detected-agent m
{
"error": {
"code": "server_not_found",
"message": "Server 'missing' not found",
"command": "clickhousectl local server list"
"message": "Server 'missing' not found\nProject directory used for lookup: \"/work/app\"\nOnly this exact directory's `.clickhouse` is searched; parent `.clickhouse` directories are not searched.\nRun `clickhousectl local server list --global` to find running servers. For stopped servers, change to the intended project directory and run `clickhousectl local server list`.",
"command": "clickhousectl local server list --global",
"project": "/work/app"
}
}
```

`code` is a stable, bounded value. `command` is fixed CLI guidance and never contains user input. Opaque download, filesystem, startup, and fallback diagnostics are not copied into the JSON message.
`code` is a stable, bounded value. `command` is fixed CLI guidance and never contains user input. Project-scoped server lookup and state errors also include the canonical `project` directory; that path is emitted only in command output and is never added to telemetry. Opaque download, filesystem, startup, and fallback diagnostics are not copied into the JSON message.

| Local runtime code | Meaning |
| ---------------------- | -------------------------------------------- |
Expand Down
105 changes: 103 additions & 2 deletions crates/clickhousectl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,16 +202,26 @@ pub enum Error {
DefaultServerNotFoundForRemove,

#[error(
"No removable 'default' ClickHouse server exists. Run `clickhousectl local server list`, then pass a custom server name explicitly with `clickhousectl local server remove <name>`."
"No removable 'default' ClickHouse server exists. Run `clickhousectl local server list`, then retry with an explicit custom server name."
)]
ServerNameRequiredForRemove,

#[error("Server '{0}' is already running")]
ServerAlreadyRunning(String),

#[error("Server '{0}' is running; stop it first with `clickhousectl local server stop {0}`")]
#[error(
"Server '{0}' is running and cannot be removed. Run `clickhousectl local server list`, then stop it by name before retrying."
)]
ServerRunningCannotRemove(String),

#[error("{message}")]
ProjectServerScope {
message: String,
project: String,
#[source]
source: Box<Error>,
},

#[error("{0}")]
Cloud(String),

Expand Down Expand Up @@ -275,6 +285,55 @@ impl Error {
_ => 1,
}
}

pub fn with_project_server_scope(self, project: String) -> Self {
if !matches!(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
&self,
Error::ServerNotFound(_)
| Error::ServerNotRunning(_)
| Error::ServerMetadataRead { .. }
| Error::ServerMetadataPermission { .. }
| Error::ServerMetadataParse { .. }
| Error::ServerMetadataWrite { .. }
| Error::ServerNameRequiredForStop
| Error::DefaultServerNotFoundForRemove
| Error::ServerNameRequiredForRemove
| Error::ServerRunningCannotRemove(_)
) {
return self;
}

self.into_project_server_scope(project)
}

pub fn with_project_server_list_scope(self, project: String) -> Self {
if matches!(&self, Error::Io(_)) {
return self.into_project_server_scope(project);
}

self.with_project_server_scope(project)
}

fn into_project_server_scope(self, project: String) -> Self {
Comment thread
cursor[bot] marked this conversation as resolved.
let message = Self::project_server_scope_message(&self.to_string(), &project);
Error::ProjectServerScope {
message,
project,
source: Box::new(self),
}
}

pub(crate) fn project_server_scope_message(message: &str, project: &str) -> String {
format!(
"{}\nProject directory used for lookup: {project:?}\n\
Only this exact directory's `.clickhouse` is searched; parent `.clickhouse` \
directories are not searched.\n\
Run `clickhousectl local server list --global` to find running servers. For stopped \
servers, change to the intended project directory and run \
`clickhousectl local server list`.",
message
)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -332,6 +391,48 @@ mod tests {
assert_eq!(Error::AuthRequired("nope".into()).exit_code(), 4);
}

#[test]
fn project_server_scope_wraps_only_lookup_and_state_errors() {
let project = "/tmp/project".to_string();
let error =
Error::ServerNotFound("default".into()).with_project_server_scope(project.clone());

assert!(matches!(
error,
Error::ProjectServerScope {
ref project,
ref source,
..
} if project == "/tmp/project" && matches!(source.as_ref(), Error::ServerNotFound(_))
));
assert_eq!(
Error::InvalidServerName("../private".into())
.with_project_server_scope(project)
.to_string(),
"Invalid server name '../private': must not contain path separators or '..'"
);
}

#[test]
fn server_list_scopes_io_without_widening_other_project_commands() {
let project = "/tmp/project".to_string();
let unscoped = Error::Io(std::io::Error::other("read failed"))
.with_project_server_scope(project.clone());
assert!(matches!(unscoped, Error::Io(_)));

let scoped =
Error::Io(std::io::Error::other("read failed")).with_project_server_list_scope(project);
assert!(matches!(
scoped,
Error::ProjectServerScope {
ref project,
ref source,
..
} if project == "/tmp/project"
&& matches!(source.as_ref(), Error::Io(source) if source.to_string() == "read failed")
));
}

#[test]
fn typed_local_boundaries_preserve_human_error_text() {
assert_eq!(
Expand Down
4 changes: 4 additions & 0 deletions crates/clickhousectl/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ pub fn local_dir() -> PathBuf {
.join(".clickhouse")
}

pub fn canonical_project_dir() -> Result<PathBuf> {
Ok(std::env::current_dir()?.canonicalize()?)
}

pub fn project_dir() -> PathBuf {
std::env::current_dir()
.expect("failed to get current directory")
Expand Down
10 changes: 10 additions & 0 deletions crates/clickhousectl/src/local/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ CONTEXT FOR AGENTS:
Manage named local server instances. Project-scoped `server list` and `server stop-all`
include both ClickHouse processes and Docker-backed Postgres containers; other commands
here manage ClickHouse.
Project-scoped lookup uses only the canonical current directory; parent `.clickhouse`
directories are not searched.
Each server has its own data directory.
Data is stored in .clickhouse/servers/<name>/data/ and persists between restarts.
Typical: `clickhousectl local server start` (starts \"default\"), `clickhousectl local server start test`.
Expand Down Expand Up @@ -301,6 +303,8 @@ CONTEXT FOR AGENTS:
#[command(after_help = "\
CONTEXT FOR AGENTS:
Shows all named ClickHouse server instances and their status.
The default view reads only the canonical current directory's `.clickhouse`; it does not
search parent project directories. Use `server list --global` to find running servers elsewhere.
Processes that exited unexpectedly are retained and shown as stopped.
Running ClickHouse entries also show their PID, version, and ports.
Related: `clickhousectl local server start` to start a server, `clickhousectl local server stop [name]` to stop one.")]
Expand All @@ -319,6 +323,10 @@ CONTEXT FOR AGENTS:
Use `clickhousectl local server list` to find names.
The server's data and metadata are preserved so it remains visible in `server list`.
Restart with `clickhousectl local server start <name>`.
Idempotent: a server that exists but is already stopped exits 0 (no error).
An unknown server name still errors so typos are caught.
Project lookup uses only the canonical current directory; parent `.clickhouse` directories
are not searched. Use `server list --global` to find running servers in other projects.
Related: `clickhousectl local server list` to see servers.")]
Stop {
/// Server name; omitted selection prefers "default", then a sole known ClickHouse server
Expand Down Expand Up @@ -365,6 +373,8 @@ CONTEXT FOR AGENTS:
This is irreversible — all data for this server instance will be lost.
Without a name, removes \"default\" only when it exists. It never guesses a custom server;
use `server list`, then pass a custom name positionally.
Project lookup uses only the canonical current directory; parent `.clickhouse` directories
are not searched. Change to the intended project directory before retrying.
Related: `clickhousectl local server stop [name]` to stop first, `clickhousectl local server list` to see servers.")]
Remove {
/// Server name; when omitted, only an existing "default" is removed
Expand Down
14 changes: 13 additions & 1 deletion crates/clickhousectl/src/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,9 @@ async fn run_server_commands(command: ServerCommands, json: bool) -> Result<()>
if global {
list_servers_global(json)
} else {
let project = canonical_project_dir()?;
list_servers_local(json)
.map_err(|error| error.with_project_server_list_scope(project))
}
}
ServerCommands::Stop {
Expand All @@ -752,7 +754,9 @@ async fn run_server_commands(command: ServerCommands, json: bool) -> Result<()>
let name = name.unwrap_or_else(|| "default".to_string());
stop_server_global(&name, project.as_deref(), json)
} else {
let project = canonical_project_dir()?;
stop_server_local(name, json)
.map_err(|error| error.with_project_server_scope(project))
}
}
ServerCommands::StopAll { global } => {
Expand All @@ -769,10 +773,18 @@ async fn run_server_commands(command: ServerCommands, json: bool) -> Result<()>
password,
database,
} => dotenv_server(name.as_deref(), local, user, password, database, json),
ServerCommands::Remove { name, name_flag } => remove_server_local(name.or(name_flag), json),
ServerCommands::Remove { name, name_flag } => {
let project = canonical_project_dir()?;
remove_server_local(name.or(name_flag), json)
.map_err(|error| error.with_project_server_scope(project))
}
}
}

fn canonical_project_dir() -> Result<String> {
Ok(init::canonical_project_dir()?.display().to_string())
}

fn stop_server_local(name: Option<String>, json: bool) -> Result<()> {
let name = match name {
Some(name) => name,
Expand Down
63 changes: 63 additions & 0 deletions crates/clickhousectl/src/local/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,24 @@ pub struct LocalErrorBody {
pub code: LocalErrorCode,
pub message: String,
pub command: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
}

impl LocalErrorOutput {
pub fn from_error(error: &Error) -> Self {
if let Error::ProjectServerScope {
project, source, ..
} = error
{
let mut output = Self::from_error(source);
output.error.message =
Error::project_server_scope_message(&output.error.message, project);
output.error.command = "clickhousectl local server list --global";
output.error.project = Some(project.clone());
return output;
}

let error = match error {
Error::PostgresStartupRollback { primary, .. }
if matches!(
Expand Down Expand Up @@ -179,6 +193,7 @@ impl LocalErrorOutput {
code,
message,
command,
project: None,
},
}
}
Expand Down Expand Up @@ -926,6 +941,54 @@ mod tests {
}
}

#[test]
fn project_scoped_errors_preserve_codes_and_expose_the_lookup_directory() {
let output = LocalErrorOutput::from_error(
&Error::ServerRunningCannotRemove("default".into())
.with_project_server_scope("/tmp/project".into()),
);

assert_eq!(output.error.code, LocalErrorCode::ServerRunning);
assert_eq!(output.error.project.as_deref(), Some("/tmp/project"));
assert_eq!(
output.error.command,
"clickhousectl local server list --global"
);
assert!(
output
.error
.message
.contains("parent `.clickhouse` directories are not searched")
);
}

#[test]
fn project_scoped_io_errors_keep_structured_diagnostics_opaque() {
let output = LocalErrorOutput::from_error(
&Error::Io(std::io::Error::other(
"/secret/raw/path: filesystem diagnostics",
))
.with_project_server_list_scope("/tmp/project".into()),
);

assert_eq!(output.error.code, LocalErrorCode::IoError);
assert_eq!(output.error.project.as_deref(), Some("/tmp/project"));
assert!(
output.error.message.starts_with(
"Local filesystem operation failed\nProject directory used for lookup:"
)
);
assert!(
output
.error
.message
.contains("parent `.clickhouse` directories are not searched")
);
assert!(!output.error.message.contains("/secret/raw/path"));
assert!(!output.error.message.contains("filesystem diagnostics"));
assert!(!output.error.message.contains("IO error:"));
}

#[test]
fn engine_specific_port_errors_share_exact_structured_output() {
for (error, message) in [
Expand Down
19 changes: 17 additions & 2 deletions crates/clickhousectl/tests/local_server_metadata_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ fn valid_metadata(project: &Path) -> Vec<u8> {
.unwrap()
}

fn assert_json_error(output: &Output, code: &str, message_fragment: &str) {
fn assert_json_error(output: &Output, project: &Path, code: &str, message_fragment: &str) {
assert_eq!(
output.status.code(),
Some(1),
Expand All @@ -68,14 +68,26 @@ fn assert_json_error(output: &Output, code: &str, message_fragment: &str) {
assert!(output.stdout.is_empty());
let body: Value = serde_json::from_slice(&output.stderr).expect("parse structured error");
assert_eq!(body["error"]["code"], code);
assert_eq!(body["error"]["command"], "clickhousectl local server list");
let project = project.canonicalize().expect("canonical project");
assert_eq!(
body["error"]["command"],
"clickhousectl local server list --global"
);
assert_eq!(body["error"]["project"], project.display().to_string());
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains(message_fragment),
"{body}"
);
assert!(
body["error"]["message"]
.as_str()
.unwrap()
.contains("parent `.clickhouse` directories are not searched"),
"{body}"
);
}

fn assert_lock_error(output: &Output, operation: &str, path: &Path) {
Expand Down Expand Up @@ -157,6 +169,7 @@ fn selected_partial_json_and_invalid_utf8_are_parse_errors() {
let json = run(project.path(), home.path(), true);
assert_json_error(
&json,
project.path(),
"server_metadata_invalid",
"Metadata for server 'default'",
);
Expand All @@ -182,6 +195,7 @@ fn selected_metadata_read_failure_is_not_reported_as_stopped() {
let json = run(project.path(), home.path(), true);
assert_json_error(
&json,
project.path(),
"server_metadata_read",
"Could not read metadata for server 'default'",
);
Expand Down Expand Up @@ -209,6 +223,7 @@ fn selected_metadata_permission_failure_has_its_own_action() {

assert_json_error(
&json,
project.path(),
"server_metadata_permission",
"Permission denied accessing metadata for server 'default'",
);
Expand Down
Loading
Loading