Skip to content
Merged
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
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ clickhousectl local server dotenv --local --user default --database mydb # Incl

Stopping a server preserves its data and identity metadata, so it remains visible in `server list` with a `stopped` status. Version and ports are shown only while running because they are resolved again on each start. Starting the same name resumes the existing data directory.

Project-local server commands select `.clickhouse` under the exact current working directory. They do not search parent directories, so running `list`, `stop`, or `remove` from a child directory selects a different project scope. Change to the local project root where the server was started first; this is where `.clickhouse` typically lives. There is intentionally no project-path override for project-local commands; `server stop --global --project <project-root>` is only for an explicitly confirmed server found with `server list --global`.

Without a name, `server stop` selects an existing `default`, then a sole known ClickHouse server. It succeeds without changing anything when none exist, and requires a name or `server stop-all` when multiple non-default servers exist. Bare `server remove` is deliberately stricter: it removes an existing `default` only and otherwise requires an explicit name, even when there is just one custom server.

**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.
Expand Down Expand Up @@ -968,13 +970,44 @@ Local runtime failures also use structured output when `local --json` is set or
{
"error": {
"code": "server_not_found",
"message": "Server 'default' not found",
"command": "clickhousectl local server list"
"message": "Server 'default' was not found in the current project",
"project_scope": {
"kind": "exact_current_project",
"path": "/path/to/project",
"parent_projects_searched": false
},
"server": {
"name": "default"
},
"guidance": [
{
"action": "return_to_project_root",
"message": "Change to the local project root where the server was started",
"command": "cd <project-root>"
},
{
"action": "list_project_servers",
"message": "List servers after returning to that exact project",
"command": "clickhousectl local server list"
},
{
"action": "list_global_servers",
"message": "Locate running ClickHouse servers across projects",
"command": "clickhousectl local server list --global"
},
{
"action": "stop_global_project_server",
"message": "After confirming the project, stop the server with explicit global project selection",
"command": "clickhousectl local server stop <name> --global --project <project-root>"
}
]
}
}
```

`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.
`error.code` and `error.message` are always present. General errors can include an optional top-level `error.command` safe recovery command. Project-local `server stop` and `server remove` not-found errors instead include `project_scope`, `server`, and ordered `guidance`; their top-level `command` field is absent. 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.

When `.clickhouse` is absent from the current directory, bare `server stop` includes the same `project_scope` and `guidance` in its successful no-op output, while bare `server remove` includes them in its `server_selection_required` error. This distinguishes a missing project root from an initialized project that has no matching ClickHouse server.

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.

Expand Down
84 changes: 84 additions & 0 deletions crates/clickhousectl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,84 @@ impl std::error::Error for ManagedClientError {
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectServerCommand {
Stop,
Remove,
}

impl fmt::Display for ProjectServerCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Stop => "stop",
Self::Remove => "remove",
})
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectServerNotFound {
pub command: ProjectServerCommand,
pub project_dir: PathBuf,
pub server_name: String,
}

impl fmt::Display for ProjectServerNotFound {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"Server '{}' was not found in project '{}'.",
self.server_name,
self.project_dir.display()
)?;
writeln!(
f,
"Project-local server {} uses the exact current working directory; parent `.clickhouse` directories are not searched.",
self.command
)?;
write!(
f,
"Return to the local project root where the server was started and run `clickhousectl local server list`; use `clickhousectl local server list --global` to locate running servers in other projects"
)?;
if self.command == ProjectServerCommand::Stop {
write!(
f,
"; after confirming the project, use `clickhousectl local server stop <name> --global --project <project-root>`"
)?;
}
write!(f, ".")
}
}

impl std::error::Error for ProjectServerNotFound {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectServerStateMissing {
pub command: ProjectServerCommand,
pub project_dir: PathBuf,
}

impl fmt::Display for ProjectServerStateMissing {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"No `.clickhouse` project state was found in '{}'.",
self.project_dir.display()
)?;
writeln!(
f,
"Project-local server {} uses the exact current working directory; parent `.clickhouse` directories are not searched.",
self.command
)?;
write!(
f,
"The `.clickhouse` directory typically lives in the local project root where the server was started. Return there and run `clickhousectl local server list`; use `clickhousectl local server list --global` to locate running servers in other projects."
)
}
}

impl std::error::Error for ProjectServerStateMissing {}

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

#[error("{0}")]
ProjectServerNotFound(ProjectServerNotFound),

#[error("{0}")]
ProjectServerStateMissing(ProjectServerStateMissing),

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

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

/// The physical directory whose project-local state is selected by this
/// invocation. Local commands intentionally do not search parent directories.
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
8 changes: 8 additions & 0 deletions crates/clickhousectl/src/local/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,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-local commands use .clickhouse under the exact current working directory and do
not search parent directories. Change to the project root before running them.
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 @@ -402,6 +404,8 @@ CONTEXT FOR AGENTS:
#[command(after_help = "\
CONTEXT FOR AGENTS:
Shows all named ClickHouse server instances and their status.
The default list uses only .clickhouse under the exact current working directory; parent
directories are not searched. Use --global to list running ClickHouse servers across projects.
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 @@ -420,6 +424,8 @@ 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>`.
Project-local lookup uses the exact current working directory and never searches parent
.clickhouse directories. Change to the project root, or inspect --global before global action.
Related: `clickhousectl local server list` to see servers.")]
Stop {
/// Name of the server to stop (auto-selects default or a sole ClickHouse server when omitted)
Expand Down Expand Up @@ -466,6 +472,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-local lookup uses the exact current working directory and never searches parent
.clickhouse directories. Change to the project root before removing server data.
Related: `clickhousectl local server stop [name]` to stop first, `clickhousectl local server list` to see servers.")]
Remove {
/// Name of the server to remove (only an existing "default" is selected when omitted)
Expand Down
45 changes: 41 additions & 4 deletions crates/clickhousectl/src/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ pub mod symlink;
use cli::{ClientVersionArg, InstallVersionArg, LocalCommands, ServerCommands, ServerVersionArg};

use crate::error::{
Error, ManagedClientError, ManagedClientErrorKind, ManagedClientSelection, Result,
Error, ManagedClientError, ManagedClientErrorKind, ManagedClientSelection,
ProjectServerCommand, ProjectServerNotFound, ProjectServerStateMissing, Result,
};
use crate::{init, paths, version_manager};
use std::io::Write;
Expand Down Expand Up @@ -291,7 +292,7 @@ fn run_client(
};
// Resolve symlinks so diagnostics identify the one physical directory
// whose project-local state was inspected.
let project_dir = std::env::current_dir()?.canonicalize()?;
let project_dir = init::canonical_project_dir()?;
let managed_error = |kind, binary_version| {
Error::ManagedClient(ManagedClientError {
kind,
Expand Down Expand Up @@ -892,6 +893,8 @@ fn stop_server(
if let Some(name) = explicit_name {
server::validate_server_name(name)?;
}
let project_dir = init::canonical_project_dir()?;
let project_state_missing = !init::local_dir().is_dir();

// Recover orphaned servers so we can stop processes
// that lost their metadata files.
Expand All @@ -916,6 +919,13 @@ fn stop_server(
stopped: false,
selection: output::ServerSelection::Implicit,
reason: "no_clickhouse_servers",
project_scope: project_state_missing
.then(|| output::exact_current_project_scope(&project_dir)),
guidance: if project_state_missing {
output::project_scope_guidance(Some(ProjectServerCommand::Stop))
} else {
Vec::new()
},
};
output::print_output(&out, json);
return Ok(());
Expand Down Expand Up @@ -961,7 +971,11 @@ fn stop_server(
Ok(())
}
// No such server in this project — surface the typo.
StopOutcome::NotFound => Err(Error::ServerNotFound(name)),
StopOutcome::NotFound => Err(Error::ProjectServerNotFound(ProjectServerNotFound {
command: ProjectServerCommand::Stop,
project_dir,
server_name: name,
})),
}
}

Expand All @@ -973,6 +987,8 @@ fn remove_server(name_input: ServerNameInput, json: bool) -> Result<()> {
if let Some(name) = explicit_name {
server::validate_server_name(name)?;
}
let project_dir = init::canonical_project_dir()?;
let project_state_missing = !init::local_dir().is_dir();

// Recover orphaned servers so we correctly detect a running
// process even when its metadata file is missing.
Expand All @@ -987,6 +1003,14 @@ fn remove_server(name_input: ServerNameInput, json: bool) -> Result<()> {
if names.iter().any(|name| name == "default") {
("default".to_string(), output::ServerSelection::Implicit)
} else {
if project_state_missing && names.is_empty() {
return Err(Error::ProjectServerStateMissing(
ProjectServerStateMissing {
command: ProjectServerCommand::Remove,
project_dir,
},
));
}
Comment thread
cursor[bot] marked this conversation as resolved.
return Err(Error::ServerRemoveSelectionRequired {
available: names.len(),
});
Expand All @@ -999,7 +1023,11 @@ fn remove_server(name_input: ServerNameInput, json: bool) -> Result<()> {
}
let data_dir = server::server_data_dir(&name);
if !data_dir.exists() {
return Err(Error::ServerNotFound(name));
return Err(Error::ProjectServerNotFound(ProjectServerNotFound {
command: ProjectServerCommand::Remove,
project_dir,
server_name: name,
}));
}
// Remove the whole server directory (parent of data/)
let server_dir = data_dir.parent().unwrap();
Expand Down Expand Up @@ -1034,6 +1062,7 @@ fn classify_stop(running: bool, exists_on_disk: bool) -> StopOutcome {
}

fn list_servers_local(json: bool) -> Result<()> {
let project_dir = init::canonical_project_dir()?;
let entries = server::list_all_servers()?;
let running_count = entries.iter().filter(|e| e.running).count();
let total = entries.len();
Expand Down Expand Up @@ -1111,6 +1140,12 @@ fn list_servers_local(json: bool) -> Result<()> {
.collect(),
total_servers: total,
total_running_servers: running_count,
project_scope: Some(output::exact_current_project_scope(&project_dir)),
guidance: if total == 0 {
output::project_scope_guidance(None)
} else {
Vec::new()
},
};
output::print_output(&out, json);
Ok(())
Expand All @@ -1137,6 +1172,8 @@ fn list_servers_global(json: bool) -> Result<()> {
.collect(),
total_servers: total,
total_running_servers: total,
project_scope: None,
guidance: Vec::new(),
};
output::print_output(&out, json);
Ok(())
Expand Down
Loading