From b88612197153610f681a57ca48301bfcb1ad85c8 Mon Sep 17 00:00:00 2001 From: xav-db Date: Tue, 27 Jan 2026 08:11:20 +0000 Subject: [PATCH 1/7] implemented helix sync --- helix-cli/src/commands/integrations/helix.rs | 16 +- helix-cli/src/commands/mod.rs | 2 +- helix-cli/src/commands/pull.rs | 73 ------- helix-cli/src/commands/sync.rs | 217 +++++++++++++++++++ helix-cli/src/config.rs | 51 +++++ helix-cli/src/main.rs | 2 +- helix-cli/src/prompts.rs | 17 ++ 7 files changed, 302 insertions(+), 76 deletions(-) delete mode 100644 helix-cli/src/commands/pull.rs create mode 100644 helix-cli/src/commands/sync.rs diff --git a/helix-cli/src/commands/integrations/helix.rs b/helix-cli/src/commands/integrations/helix.rs index 50a2342d..068c249b 100644 --- a/helix-cli/src/commands/integrations/helix.rs +++ b/helix-cli/src/commands/integrations/helix.rs @@ -184,13 +184,27 @@ impl<'a> HelixManager<'a> { let dev_profile = build_mode == BuildMode::Dev; + // Read helix.toml if it exists + let helix_toml_content = if helix_toml_path.exists() { + match std::fs::read_to_string(&helix_toml_path) { + Ok(content) => Some(content), + Err(e) => { + output::warning(&format!("Failed to read helix.toml: {}", e)); + None + } + } + } else { + None + }; + // Prepare deployment payload let payload = json!({ "schema": schema_content, "queries": queries_map, "env_vars": cluster_info.env_vars, "instance_name": cluster_name, - "dev_profile": dev_profile + "dev_profile": dev_profile, + "helix_toml": helix_toml_content }); // Initiate deployment with SSE streaming diff --git a/helix-cli/src/commands/mod.rs b/helix-cli/src/commands/mod.rs index e8fd3c5b..544fd5de 100644 --- a/helix-cli/src/commands/mod.rs +++ b/helix-cli/src/commands/mod.rs @@ -14,7 +14,7 @@ pub mod logs; pub mod metrics; pub mod migrate; pub mod prune; -pub mod pull; +pub mod sync; pub mod push; pub mod start; pub mod status; diff --git a/helix-cli/src/commands/pull.rs b/helix-cli/src/commands/pull.rs deleted file mode 100644 index 026bcf79..00000000 --- a/helix-cli/src/commands/pull.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::config::InstanceInfo; -use crate::output::Operation; -use crate::project::ProjectContext; -use crate::utils::print_warning; -use eyre::Result; - -pub async fn run(instance_name: String) -> Result<()> { - // Load project context - let project = ProjectContext::find_and_load(None)?; - - // Get instance config - let instance_config = project.config.get_instance(&instance_name)?; - - if instance_config.is_local() { - pull_from_local_instance(&project, &instance_name).await - } else { - pull_from_cloud_instance(&project, &instance_name, instance_config).await - } -} - -async fn pull_from_local_instance(project: &ProjectContext, instance_name: &str) -> Result<()> { - let op = Operation::new("Pulling", instance_name); - - // For local instances, we'd need to extract the .hql files from the running container - // or from the compiled workspace - - let workspace = project.instance_workspace(instance_name); - let container_dir = workspace.join("helix-container"); - - if !container_dir.exists() { - op.failure(); - return Err(eyre::eyre!( - "Instance '{instance_name}' has not been built yet. Run 'helix build {instance_name}' first." - )); - } - - // TODO: Implement extraction of .hql files from compiled container - // This would reverse-engineer the queries from the compiled Rust code - // or maintain source files alongside compiled versions - - print_warning("Local instance query extraction not yet implemented"); - println!(" Local instances compile queries into Rust code."); - println!(" Query extraction from compiled code is not currently supported."); - - Ok(()) -} - -async fn pull_from_cloud_instance( - _project: &ProjectContext, - instance_name: &str, - instance_config: InstanceInfo<'_>, -) -> Result<()> { - let op = Operation::new("Pulling", instance_name); - - let cluster_id = instance_config.cluster_id().ok_or_else(|| { - op.failure(); - eyre::eyre!("Cloud instance '{instance_name}' must have a cluster_id") - })?; - - crate::output::Step::verbose_substep(&format!("Downloading from cluster: {cluster_id}")); - - // TODO: Implement cloud query download - // This would: - // 1. Connect to the cloud cluster - // 2. Download the current .hql files - // 3. Update local schema.hx and queries.hx files - - print_warning("Cloud query pull not yet implemented"); - println!(" This will download the latest .hql files from cluster: {cluster_id}"); - println!(" and update your local schema.hx and queries.hx files."); - - Ok(()) -} diff --git a/helix-cli/src/commands/sync.rs b/helix-cli/src/commands/sync.rs new file mode 100644 index 00000000..8b8ae7a6 --- /dev/null +++ b/helix-cli/src/commands/sync.rs @@ -0,0 +1,217 @@ +use crate::commands::auth::require_auth; +use crate::commands::integrations::helix::CLOUD_AUTHORITY; +use crate::config::InstanceInfo; +use crate::output::{Operation, Step}; +use crate::project::ProjectContext; +use crate::utils::print_warning; +use eyre::{Result, eyre}; +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Deserialize)] +struct SyncResponse { + #[allow(dead_code)] + helix_toml: Option, + hx_files: HashMap, + #[allow(dead_code)] + instance_name: String, +} + +pub async fn run(instance_name: String) -> Result<()> { + // Load project context + let project = ProjectContext::find_and_load(None)?; + + // Get instance config + let instance_config = project.config.get_instance(&instance_name)?; + + if instance_config.is_local() { + pull_from_local_instance(&project, &instance_name).await + } else { + pull_from_cloud_instance(&project, &instance_name, instance_config).await + } +} + +async fn pull_from_local_instance(project: &ProjectContext, instance_name: &str) -> Result<()> { + let op = Operation::new("Syncing", instance_name); + + // For local instances, we'd need to extract the .hx files from the running container + // or from the compiled workspace + + let workspace = project.instance_workspace(instance_name); + let container_dir = workspace.join("helix-container"); + + if !container_dir.exists() { + op.failure(); + return Err(eyre!( + "Instance '{instance_name}' has not been built yet. Run 'helix build {instance_name}' first." + )); + } + + // TODO: Implement extraction of .hx files from compiled container + // This would reverse-engineer the queries from the compiled Rust code + // or maintain source files alongside compiled versions + + print_warning("Local instance query extraction not yet implemented"); + println!(" Local instances compile queries into Rust code."); + println!(" Query extraction from compiled code is not currently supported."); + + Ok(()) +} + +async fn pull_from_cloud_instance( + project: &ProjectContext, + instance_name: &str, + instance_config: InstanceInfo<'_>, +) -> Result<()> { + let op = Operation::new("Syncing", instance_name); + + // Verify this is a Helix Cloud instance + let cluster_id = match &instance_config { + InstanceInfo::Helix(config) => &config.cluster_id, + InstanceInfo::FlyIo(_) => { + op.failure(); + return Err(eyre!( + "Sync is only supported for Helix Cloud instances, not Fly.io deployments" + )); + } + InstanceInfo::Ecr(_) => { + op.failure(); + return Err(eyre!( + "Sync is only supported for Helix Cloud instances, not ECR deployments" + )); + } + InstanceInfo::Local(_) => { + op.failure(); + return Err(eyre!("Sync is only supported for cloud instances")); + } + }; + + // Check auth + let credentials = require_auth().await?; + + Step::verbose_substep(&format!("Downloading from cluster: {cluster_id}")); + + // Make API request to sync endpoint + let client = reqwest::Client::new(); + let sync_url = format!("https://{}/api/clusters/{}/sync", *CLOUD_AUTHORITY, cluster_id); + + let mut sync_step = Step::with_messages("Fetching source files", "Source files fetched"); + sync_step.start(); + + let response = match client + .get(&sync_url) + .header("x-api-key", &credentials.helix_admin_key) + .header("x-cluster-id", cluster_id) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + sync_step.fail(); + op.failure(); + return Err(eyre!("Failed to connect to Helix Cloud: {}", e)); + } + }; + + // Handle response status + match response.status() { + reqwest::StatusCode::OK => {} + reqwest::StatusCode::NOT_FOUND => { + sync_step.fail(); + op.failure(); + return Err(eyre!( + "No source files found for cluster '{}'. Make sure you have deployed at least once with `helix push`.", + cluster_id + )); + } + reqwest::StatusCode::UNAUTHORIZED => { + sync_step.fail(); + op.failure(); + return Err(eyre!( + "Authentication failed. Run 'helix auth login' to re-authenticate." + )); + } + reqwest::StatusCode::FORBIDDEN => { + sync_step.fail(); + op.failure(); + return Err(eyre!( + "Access denied to cluster '{}'. Make sure you have permission to access this cluster.", + cluster_id + )); + } + status => { + let error_text = response.text().await.unwrap_or_default(); + sync_step.fail(); + op.failure(); + return Err(eyre!("Sync failed ({}): {}", status, error_text)); + } + } + + // Parse response + let sync_response: SyncResponse = match response.json().await { + Ok(resp) => resp, + Err(e) => { + sync_step.fail(); + op.failure(); + return Err(eyre!("Failed to parse sync response: {}", e)); + } + }; + + sync_step.done(); + + // Get the queries directory from project config + let queries_dir = project.root.join(&project.config.project.queries); + + // Create queries directory if it doesn't exist + if !queries_dir.exists() { + std::fs::create_dir_all(&queries_dir)?; + } + + // Write .hx files + let mut write_step = Step::with_messages("Writing source files", "Source files written"); + write_step.start(); + + let mut files_written = 0; + for (filename, content) in &sync_response.hx_files { + let file_path = queries_dir.join(filename); + + // Create parent directories if needed + if let Some(parent) = file_path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + + std::fs::write(&file_path, content) + .map_err(|e| eyre!("Failed to write {}: {}", filename, e))?; + + files_written += 1; + Step::verbose_substep(&format!(" Wrote {}", filename)); + } + + write_step.done_with_info(&format!("{} files", files_written)); + + op.success(); + + // Print summary + println!(); + crate::output::info(&format!( + "Synced {} files from cluster '{}'", + files_written, cluster_id + )); + crate::output::info(&format!( + "Files saved to: {}", + queries_dir.display() + )); + + // List files that were synced + if !sync_response.hx_files.is_empty() { + println!(); + println!("Files synced:"); + for filename in sync_response.hx_files.keys() { + println!(" - {}", filename); + } + } + + Ok(()) +} diff --git a/helix-cli/src/config.rs b/helix-cli/src/config.rs index 2477ffef..e50ae1a7 100644 --- a/helix-cli/src/config.rs +++ b/helix-cli/src/config.rs @@ -7,6 +7,57 @@ use std::path::{Path, PathBuf}; use crate::commands::integrations::ecr::EcrConfig; use crate::commands::integrations::fly::FlyInstanceConfig; +/// Global workspace configuration stored in ~/.helix/config +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[allow(dead_code)] +pub struct WorkspaceConfig { + pub workspace_id: Option, +} + +#[allow(dead_code)] +impl WorkspaceConfig { + /// Get the path to the global config file + pub fn config_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| eyre!("Cannot find home directory"))?; + Ok(home.join(".helix").join("config")) + } + + /// Load the workspace config from ~/.helix/config + pub fn load() -> Result { + let path = Self::config_path()?; + if !path.exists() { + return Ok(Self::default()); + } + + let content = fs::read_to_string(&path) + .map_err(|e| eyre!("Failed to read config file: {}", e))?; + + toml::from_str(&content).map_err(|e| eyre!("Failed to parse config file: {}", e)) + } + + /// Save the workspace config to ~/.helix/config + pub fn save(&self) -> Result<()> { + let path = Self::config_path()?; + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let content = + toml::to_string_pretty(self).map_err(|e| eyre!("Failed to serialize config: {}", e))?; + + fs::write(&path, content).map_err(|e| eyre!("Failed to write config file: {}", e))?; + + Ok(()) + } + + /// Check if workspace_id is set + pub fn has_workspace_id(&self) -> bool { + self.workspace_id.as_ref().is_some_and(|id| !id.is_empty()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HelixConfig { pub project: ProjectConfig, diff --git a/helix-cli/src/main.rs b/helix-cli/src/main.rs index d3fd7796..e6d56416 100644 --- a/helix-cli/src/main.rs +++ b/helix-cli/src/main.rs @@ -271,7 +271,7 @@ async fn main() -> Result<()> { Commands::Push { instance, dev } => { commands::push::run(instance, dev, &metrics_sender).await } - Commands::Pull { instance } => commands::pull::run(instance).await, + Commands::Pull { instance } => commands::sync::run(instance).await, Commands::Start { instance } => commands::start::run(instance).await, Commands::Stop { instance } => commands::stop::run(instance).await, Commands::Status => commands::status::run().await, diff --git a/helix-cli/src/prompts.rs b/helix-cli/src/prompts.rs index 3b391b3b..94110e91 100644 --- a/helix-cli/src/prompts.rs +++ b/helix-cli/src/prompts.rs @@ -332,3 +332,20 @@ pub fn input_feedback_message() -> Result { Ok(message) } + +/// Prompt user to enter their workspace ID +#[allow(dead_code)] +pub fn input_workspace_id() -> Result { + let workspace_id: String = cliclack::input("Enter your workspace ID") + .placeholder("ws_abc123...") + .validate(|input: &String| { + if input.trim().is_empty() { + Err("Workspace ID cannot be empty") + } else { + Ok(()) + } + }) + .interact()?; + + Ok(workspace_id.trim().to_string()) +} From 8c98b6f2cbda34320669ad412e29b00c338367ed Mon Sep 17 00:00:00 2001 From: xav-db Date: Tue, 27 Jan 2026 08:39:34 +0000 Subject: [PATCH 2/7] adding prompt for user for diffed changes --- helix-cli/src/commands/integrations/helix.rs | 27 +++-- helix-cli/src/commands/sync.rs | 101 ++++++++++++++++++- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/helix-cli/src/commands/integrations/helix.rs b/helix-cli/src/commands/integrations/helix.rs index 068c249b..369d19c1 100644 --- a/helix-cli/src/commands/integrations/helix.rs +++ b/helix-cli/src/commands/integrations/helix.rs @@ -184,17 +184,30 @@ impl<'a> HelixManager<'a> { let dev_profile = build_mode == BuildMode::Dev; - // Read helix.toml if it exists - let helix_toml_content = if helix_toml_path.exists() { - match std::fs::read_to_string(&helix_toml_path) { - Ok(content) => Some(content), + // Build a pruned HelixConfig containing only [project] and the deployed [cloud.] + let helix_toml_content = { + use crate::config::HelixConfig; + let pruned = HelixConfig { + project: self.project.config.project.clone(), + local: HashMap::new(), + cloud: { + let mut m = HashMap::new(); + m.insert( + cluster_name.clone(), + crate::config::CloudConfig::from( + self.project.config.get_instance(&cluster_name)?, + ), + ); + m + }, + }; + match toml::to_string_pretty(&pruned) { + Ok(s) => Some(s), Err(e) => { - output::warning(&format!("Failed to read helix.toml: {}", e)); + output::warning(&format!("Failed to serialize pruned helix.toml: {}", e)); None } } - } else { - None }; // Prepare deployment payload diff --git a/helix-cli/src/commands/sync.rs b/helix-cli/src/commands/sync.rs index 8b8ae7a6..0422dcdd 100644 --- a/helix-cli/src/commands/sync.rs +++ b/helix-cli/src/commands/sync.rs @@ -10,7 +10,6 @@ use std::collections::HashMap; #[derive(Deserialize)] struct SyncResponse { - #[allow(dead_code)] helix_toml: Option, hx_files: HashMap, #[allow(dead_code)] @@ -167,6 +166,72 @@ async fn pull_from_cloud_instance( std::fs::create_dir_all(&queries_dir)?; } + // Collect files that differ from local + let mut differing_files: Vec = Vec::new(); + for (filename, content) in &sync_response.hx_files { + let file_path = queries_dir.join(filename); + if file_path.exists() { + if let Ok(local_content) = std::fs::read_to_string(&file_path) { + if local_content != *content { + differing_files.push(filename.clone()); + } + } + } + } + + // Check if helix.toml would change + let helix_toml_path = project.root.join("helix.toml"); + let helix_toml_differs = if let Some(ref remote_toml) = sync_response.helix_toml { + if helix_toml_path.exists() { + // Parse remote and see if merge would change anything + if let (Ok(remote_config), Ok(local_content)) = ( + toml::from_str::(remote_toml), + std::fs::read_to_string(&helix_toml_path), + ) { + if let Ok(local_config) = toml::from_str::(&local_content) { + // Check if project section or the cloud instance entry differs + let project_differs = toml::to_string(&remote_config.project).ok() + != toml::to_string(&local_config.project).ok(); + let cloud_differs = remote_config.cloud.iter().any(|(k, v)| { + local_config.cloud.get(k).map(|lv| { + toml::to_string(lv).ok() != toml::to_string(v).ok() + }).unwrap_or(true) + }); + project_differs || cloud_differs + } else { + true + } + } else { + false + } + } else { + true // new file + } + } else { + false + }; + + if helix_toml_differs { + differing_files.push("helix.toml".to_string()); + } + + // Prompt for confirmation if files differ + if !differing_files.is_empty() { + println!(); + println!("The following files differ from remote:"); + for f in &differing_files { + println!(" - {}", f); + } + println!(); + if !crate::prompts::confirm(&format!( + "Overwrite {} files that differ from remote?", + differing_files.len() + ))? { + op.failure(); + return Err(eyre!("Sync aborted by user")); + } + } + // Write .hx files let mut write_step = Step::with_messages("Writing source files", "Source files written"); write_step.start(); @@ -189,6 +254,40 @@ async fn pull_from_cloud_instance( Step::verbose_substep(&format!(" Wrote {}", filename)); } + // Merge helix.toml if remote provided one + if let Some(ref remote_toml) = sync_response.helix_toml { + if let Ok(remote_config) = toml::from_str::(remote_toml) { + let mut merged = if helix_toml_path.exists() { + let local_content = std::fs::read_to_string(&helix_toml_path) + .map_err(|e| eyre!("Failed to read helix.toml: {}", e))?; + toml::from_str::(&local_content) + .map_err(|e| eyre!("Failed to parse local helix.toml: {}", e))? + } else { + crate::config::HelixConfig { + project: remote_config.project.clone(), + local: HashMap::new(), + cloud: HashMap::new(), + } + }; + + // Update project section + merged.project = remote_config.project; + + // Merge cloud instance entries (insert/update only those from remote) + for (name, cloud_config) in remote_config.cloud { + merged.cloud.insert(name, cloud_config); + } + + let merged_toml = toml::to_string_pretty(&merged) + .map_err(|e| eyre!("Failed to serialize merged helix.toml: {}", e))?; + std::fs::write(&helix_toml_path, &merged_toml) + .map_err(|e| eyre!("Failed to write helix.toml: {}", e))?; + + files_written += 1; + Step::verbose_substep(" Wrote helix.toml (merged)"); + } + } + write_step.done_with_info(&format!("{} files", files_written)); op.success(); From 84799d747af2a682e033df120e7bce8488d1cf6a Mon Sep 17 00:00:00 2001 From: xav-db Date: Tue, 27 Jan 2026 08:48:47 +0000 Subject: [PATCH 3/7] changed pull command to sync --- helix-cli/src/main.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/helix-cli/src/main.rs b/helix-cli/src/main.rs index e6d56416..8f3e8664 100644 --- a/helix-cli/src/main.rs +++ b/helix-cli/src/main.rs @@ -105,9 +105,9 @@ enum Commands { dev: bool, }, - /// Pull .hql files from instance back to local project - Pull { - /// Instance name to pull from + /// Sync .hx source files and config from a deployed Helix Cloud instance + Sync { + /// Instance name to sync from instance: String, }, @@ -271,7 +271,7 @@ async fn main() -> Result<()> { Commands::Push { instance, dev } => { commands::push::run(instance, dev, &metrics_sender).await } - Commands::Pull { instance } => commands::sync::run(instance).await, + Commands::Sync { instance } => commands::sync::run(instance).await, Commands::Start { instance } => commands::start::run(instance).await, Commands::Stop { instance } => commands::stop::run(instance).await, Commands::Status => commands::status::run().await, From 74340533e07892f0bf2b4b5c0c69e009ade0d68c Mon Sep 17 00:00:00 2001 From: xav-db Date: Tue, 27 Jan 2026 09:13:26 +0000 Subject: [PATCH 4/7] making it so it saves workspace, syncs all instances then asks which cluster to pull queries from. --- helix-cli/src/commands/sync.rs | 256 ++++++++++++++++++++++++++++++++- helix-cli/src/config.rs | 3 - helix-cli/src/main.rs | 4 +- helix-cli/src/prompts.rs | 33 ++++- 4 files changed, 286 insertions(+), 10 deletions(-) diff --git a/helix-cli/src/commands/sync.rs b/helix-cli/src/commands/sync.rs index 0422dcdd..2b7905a8 100644 --- a/helix-cli/src/commands/sync.rs +++ b/helix-cli/src/commands/sync.rs @@ -1,8 +1,9 @@ use crate::commands::auth::require_auth; use crate::commands::integrations::helix::CLOUD_AUTHORITY; -use crate::config::InstanceInfo; +use crate::config::{InstanceInfo, WorkspaceConfig}; use crate::output::{Operation, Step}; use crate::project::ProjectContext; +use crate::prompts; use crate::utils::print_warning; use eyre::{Result, eyre}; use serde::Deserialize; @@ -16,9 +17,56 @@ struct SyncResponse { instance_name: String, } -pub async fn run(instance_name: String) -> Result<()> { - // Load project context - let project = ProjectContext::find_and_load(None)?; +#[derive(Deserialize)] +pub struct CliWorkspace { + pub id: String, + pub name: String, + #[allow(dead_code)] + pub url_slug: String, +} + +#[derive(Deserialize)] +pub struct CliCluster { + pub cluster_id: String, + pub cluster_name: String, + pub project_name: String, +} + +pub async fn run(instance_name: Option) -> Result<()> { + // Try to load project context + let project = ProjectContext::find_and_load(None).ok(); + + // Resolve instance name + let instance_name = match instance_name { + Some(name) => name, + None if prompts::is_interactive() => { + if let Some(ref project) = project { + let instances = project.config.list_instances_with_types(); + if !instances.is_empty() { + prompts::intro( + "helix sync", + Some("Sync .hx source files and config from a deployed instance."), + )?; + prompts::select_instance(&instances)? + } else { + // No instances in helix.toml, fall through to workspace flow + return run_workspace_sync_flow().await; + } + } else { + // No helix.toml found, use workspace flow + return run_workspace_sync_flow().await; + } + } + None => { + return Err(eyre!( + "No instance specified. Run 'helix sync ' or run interactively in a project directory." + )); + } + }; + + let project = project.ok_or_else(|| { + eyre!("No helix.toml found. Run 'helix init' to create a project first.") + })?; // Get instance config let instance_config = project.config.get_instance(&instance_name)?; @@ -30,6 +78,205 @@ pub async fn run(instance_name: String) -> Result<()> { } } +/// Interactive flow when no project/instance is available: prompt workspace → cluster selection. +async fn run_workspace_sync_flow() -> Result<()> { + prompts::intro( + "helix sync", + Some("No helix.toml found. Select a workspace and cluster to sync from."), + )?; + + let credentials = require_auth().await?; + let client = reqwest::Client::new(); + let base_url = format!("https://{}", *CLOUD_AUTHORITY); + + // Load or prompt for workspace + let mut workspace_config = WorkspaceConfig::load()?; + + let workspace_id = if workspace_config.has_workspace_id() { + workspace_config.workspace_id.clone().unwrap() + } else { + // Fetch workspaces + let workspaces: Vec = client + .get(format!("{}/api/cli/workspaces", base_url)) + .header("x-api-key", &credentials.helix_admin_key) + .send() + .await + .map_err(|e| eyre!("Failed to fetch workspaces: {}", e))? + .error_for_status() + .map_err(|e| eyre!("Failed to fetch workspaces: {}", e))? + .json() + .await + .map_err(|e| eyre!("Failed to parse workspaces response: {}", e))?; + + if workspaces.is_empty() { + return Err(eyre!( + "No workspaces found. Create a workspace at https://app.helixdb.cloud first." + )); + } + + let selected = prompts::select_workspace(&workspaces)?; + + // Save workspace selection + workspace_config.workspace_id = Some(selected.clone()); + workspace_config.save()?; + + selected + }; + + // Fetch clusters for workspace + let clusters: Vec = client + .get(format!( + "{}/api/cli/workspaces/{}/clusters", + base_url, workspace_id + )) + .header("x-api-key", &credentials.helix_admin_key) + .send() + .await + .map_err(|e| eyre!("Failed to fetch clusters: {}", e))? + .error_for_status() + .map_err(|e| eyre!("Failed to fetch clusters: {}", e))? + .json() + .await + .map_err(|e| eyre!("Failed to parse clusters response: {}", e))?; + + if clusters.is_empty() { + return Err(eyre!( + "No clusters found in this workspace. Deploy a cluster first with 'helix push'." + )); + } + + let cluster_id = prompts::select_cluster(&clusters)?; + + // Sync from the selected cluster (no project context needed) + sync_from_cluster_id(&credentials.helix_admin_key, &cluster_id).await +} + +/// Sync directly from a cluster ID without a project context. +async fn sync_from_cluster_id(api_key: &str, cluster_id: &str) -> Result<()> { + let op = Operation::new("Syncing", cluster_id); + + let client = reqwest::Client::new(); + let sync_url = format!("https://{}/api/clusters/{}/sync", *CLOUD_AUTHORITY, cluster_id); + + let mut sync_step = Step::with_messages("Fetching source files", "Source files fetched"); + sync_step.start(); + + let response = match client + .get(&sync_url) + .header("x-api-key", api_key) + .header("x-cluster-id", cluster_id) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + sync_step.fail(); + op.failure(); + return Err(eyre!("Failed to connect to Helix Cloud: {}", e)); + } + }; + + match response.status() { + reqwest::StatusCode::OK => {} + reqwest::StatusCode::NOT_FOUND => { + sync_step.fail(); + op.failure(); + return Err(eyre!( + "No source files found for cluster '{}'. Make sure you have deployed at least once with `helix push`.", + cluster_id + )); + } + reqwest::StatusCode::UNAUTHORIZED => { + sync_step.fail(); + op.failure(); + return Err(eyre!( + "Authentication failed. Run 'helix auth login' to re-authenticate." + )); + } + reqwest::StatusCode::FORBIDDEN => { + sync_step.fail(); + op.failure(); + return Err(eyre!( + "Access denied to cluster '{}'. Make sure you have permission to access this cluster.", + cluster_id + )); + } + status => { + let error_text = response.text().await.unwrap_or_default(); + sync_step.fail(); + op.failure(); + return Err(eyre!("Sync failed ({}): {}", status, error_text)); + } + } + + let sync_response: SyncResponse = match response.json().await { + Ok(resp) => resp, + Err(e) => { + sync_step.fail(); + op.failure(); + return Err(eyre!("Failed to parse sync response: {}", e)); + } + }; + + sync_step.done(); + + // Write files to current directory + let cwd = std::env::current_dir()?; + let queries_dir = if let Some(ref remote_toml) = sync_response.helix_toml { + if let Ok(remote_config) = toml::from_str::(remote_toml) { + cwd.join(&remote_config.project.queries) + } else { + cwd.join("db") + } + } else { + cwd.join("db") + }; + + if !queries_dir.exists() { + std::fs::create_dir_all(&queries_dir)?; + } + + let mut write_step = Step::with_messages("Writing source files", "Source files written"); + write_step.start(); + + let mut files_written = 0; + for (filename, content) in &sync_response.hx_files { + let file_path = queries_dir.join(filename); + if let Some(parent) = file_path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + std::fs::write(&file_path, content) + .map_err(|e| eyre!("Failed to write {}: {}", filename, e))?; + files_written += 1; + Step::verbose_substep(&format!(" Wrote {}", filename)); + } + + if let Some(ref remote_toml) = sync_response.helix_toml { + let helix_toml_path = cwd.join("helix.toml"); + std::fs::write(&helix_toml_path, remote_toml) + .map_err(|e| eyre!("Failed to write helix.toml: {}", e))?; + files_written += 1; + Step::verbose_substep(" Wrote helix.toml"); + } + + write_step.done_with_info(&format!("{} files", files_written)); + op.success(); + + println!(); + crate::output::info(&format!( + "Synced {} files from cluster '{}'", + files_written, cluster_id + )); + crate::output::info(&format!( + "Files saved to: {}", + queries_dir.display() + )); + + Ok(()) +} + async fn pull_from_local_instance(project: &ProjectContext, instance_name: &str) -> Result<()> { let op = Operation::new("Syncing", instance_name); @@ -314,3 +561,4 @@ async fn pull_from_cloud_instance( Ok(()) } + diff --git a/helix-cli/src/config.rs b/helix-cli/src/config.rs index e50ae1a7..bc3e2464 100644 --- a/helix-cli/src/config.rs +++ b/helix-cli/src/config.rs @@ -9,12 +9,10 @@ use crate::commands::integrations::fly::FlyInstanceConfig; /// Global workspace configuration stored in ~/.helix/config #[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[allow(dead_code)] pub struct WorkspaceConfig { pub workspace_id: Option, } -#[allow(dead_code)] impl WorkspaceConfig { /// Get the path to the global config file pub fn config_path() -> Result { @@ -527,7 +525,6 @@ impl HelixConfig { /// List all instances with their type labels for display /// Returns tuples of (name, type_hint) e.g. ("dev", "local"), ("prod", "Helix Cloud") - #[allow(dead_code)] pub fn list_instances_with_types(&self) -> Vec<(&String, &'static str)> { let mut instances = Vec::new(); diff --git a/helix-cli/src/main.rs b/helix-cli/src/main.rs index 8f3e8664..b3c7f5ec 100644 --- a/helix-cli/src/main.rs +++ b/helix-cli/src/main.rs @@ -107,8 +107,8 @@ enum Commands { /// Sync .hx source files and config from a deployed Helix Cloud instance Sync { - /// Instance name to sync from - instance: String, + /// Instance name to sync from (interactive selection if not provided) + instance: Option, }, /// Start an instance (doesn't rebuild) diff --git a/helix-cli/src/prompts.rs b/helix-cli/src/prompts.rs index 94110e91..97f9c0e1 100644 --- a/helix-cli/src/prompts.rs +++ b/helix-cli/src/prompts.rs @@ -333,8 +333,39 @@ pub fn input_feedback_message() -> Result { Ok(message) } +/// Prompt user to select a workspace from a list +pub fn select_workspace(workspaces: &[crate::commands::sync::CliWorkspace]) -> Result { + if workspaces.len() == 1 { + return Ok(workspaces[0].id.clone()); + } + + let mut select = cliclack::select("Select a workspace"); + for ws in workspaces { + select = select.item(ws.id.clone(), ws.name.as_str(), ""); + } + let selected = select.interact()?; + Ok(selected) +} + +/// Prompt user to select a cluster from a list +pub fn select_cluster(clusters: &[crate::commands::sync::CliCluster]) -> Result { + if clusters.len() == 1 { + return Ok(clusters[0].cluster_id.clone()); + } + + let mut select = cliclack::select("Select a cluster"); + for c in clusters { + select = select.item( + c.cluster_id.clone(), + c.cluster_name.as_str(), + c.project_name.as_str(), + ); + } + let selected = select.interact()?; + Ok(selected) +} + /// Prompt user to enter their workspace ID -#[allow(dead_code)] pub fn input_workspace_id() -> Result { let workspace_id: String = cliclack::input("Enter your workspace ID") .placeholder("ws_abc123...") From 1db88505dd390aa203e121c41577e64e9d1de33b Mon Sep 17 00:00:00 2001 From: xav-db Date: Tue, 27 Jan 2026 09:43:33 +0000 Subject: [PATCH 5/7] clippy fix --- helix-cli/src/commands/sync.rs | 81 +++++++++++++++++----------------- 1 file changed, 40 insertions(+), 41 deletions(-) diff --git a/helix-cli/src/commands/sync.rs b/helix-cli/src/commands/sync.rs index 2b7905a8..4e1bf4e8 100644 --- a/helix-cli/src/commands/sync.rs +++ b/helix-cli/src/commands/sync.rs @@ -242,10 +242,10 @@ async fn sync_from_cluster_id(api_key: &str, cluster_id: &str) -> Result<()> { let mut files_written = 0; for (filename, content) in &sync_response.hx_files { let file_path = queries_dir.join(filename); - if let Some(parent) = file_path.parent() { - if !parent.exists() { - std::fs::create_dir_all(parent)?; - } + if let Some(parent) = file_path.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent)?; } std::fs::write(&file_path, content) .map_err(|e| eyre!("Failed to write {}: {}", filename, e))?; @@ -417,12 +417,11 @@ async fn pull_from_cloud_instance( let mut differing_files: Vec = Vec::new(); for (filename, content) in &sync_response.hx_files { let file_path = queries_dir.join(filename); - if file_path.exists() { - if let Ok(local_content) = std::fs::read_to_string(&file_path) { - if local_content != *content { - differing_files.push(filename.clone()); - } - } + if file_path.exists() + && let Ok(local_content) = std::fs::read_to_string(&file_path) + && local_content != *content + { + differing_files.push(filename.clone()); } } @@ -488,10 +487,10 @@ async fn pull_from_cloud_instance( let file_path = queries_dir.join(filename); // Create parent directories if needed - if let Some(parent) = file_path.parent() { - if !parent.exists() { - std::fs::create_dir_all(parent)?; - } + if let Some(parent) = file_path.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent)?; } std::fs::write(&file_path, content) @@ -502,37 +501,37 @@ async fn pull_from_cloud_instance( } // Merge helix.toml if remote provided one - if let Some(ref remote_toml) = sync_response.helix_toml { - if let Ok(remote_config) = toml::from_str::(remote_toml) { - let mut merged = if helix_toml_path.exists() { - let local_content = std::fs::read_to_string(&helix_toml_path) - .map_err(|e| eyre!("Failed to read helix.toml: {}", e))?; - toml::from_str::(&local_content) - .map_err(|e| eyre!("Failed to parse local helix.toml: {}", e))? - } else { - crate::config::HelixConfig { - project: remote_config.project.clone(), - local: HashMap::new(), - cloud: HashMap::new(), - } - }; - - // Update project section - merged.project = remote_config.project; - - // Merge cloud instance entries (insert/update only those from remote) - for (name, cloud_config) in remote_config.cloud { - merged.cloud.insert(name, cloud_config); + if let Some(ref remote_toml) = sync_response.helix_toml + && let Ok(remote_config) = toml::from_str::(remote_toml) + { + let mut merged = if helix_toml_path.exists() { + let local_content = std::fs::read_to_string(&helix_toml_path) + .map_err(|e| eyre!("Failed to read helix.toml: {}", e))?; + toml::from_str::(&local_content) + .map_err(|e| eyre!("Failed to parse local helix.toml: {}", e))? + } else { + crate::config::HelixConfig { + project: remote_config.project.clone(), + local: HashMap::new(), + cloud: HashMap::new(), } + }; - let merged_toml = toml::to_string_pretty(&merged) - .map_err(|e| eyre!("Failed to serialize merged helix.toml: {}", e))?; - std::fs::write(&helix_toml_path, &merged_toml) - .map_err(|e| eyre!("Failed to write helix.toml: {}", e))?; + // Update project section + merged.project = remote_config.project; - files_written += 1; - Step::verbose_substep(" Wrote helix.toml (merged)"); + // Merge cloud instance entries (insert/update only those from remote) + for (name, cloud_config) in remote_config.cloud { + merged.cloud.insert(name, cloud_config); } + + let merged_toml = toml::to_string_pretty(&merged) + .map_err(|e| eyre!("Failed to serialize merged helix.toml: {}", e))?; + std::fs::write(&helix_toml_path, &merged_toml) + .map_err(|e| eyre!("Failed to write helix.toml: {}", e))?; + + files_written += 1; + Step::verbose_substep(" Wrote helix.toml (merged)"); } write_step.done_with_info(&format!("{} files", files_written)); From 6382b135418e5e283df61651e88591f7556c1309 Mon Sep 17 00:00:00 2001 From: xav-db Date: Tue, 27 Jan 2026 14:55:44 +0000 Subject: [PATCH 6/7] Remove dead code by deleting the unused `instance_name` field from the `SyncResponse` struct in `sync.rs`. --- helix-cli/src/commands/sync.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/helix-cli/src/commands/sync.rs b/helix-cli/src/commands/sync.rs index 4e1bf4e8..c432b8ab 100644 --- a/helix-cli/src/commands/sync.rs +++ b/helix-cli/src/commands/sync.rs @@ -13,8 +13,6 @@ use std::collections::HashMap; struct SyncResponse { helix_toml: Option, hx_files: HashMap, - #[allow(dead_code)] - instance_name: String, } #[derive(Deserialize)] From 2bfc0316ad2f3461c123014da97d25680ae78370 Mon Sep 17 00:00:00 2001 From: xav-db Date: Tue, 27 Jan 2026 15:24:08 +0000 Subject: [PATCH 7/7] Remove unused `input_workspace_id` function from prompts.rs to clean up dead code. --- helix-cli/src/prompts.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/helix-cli/src/prompts.rs b/helix-cli/src/prompts.rs index 97f9c0e1..b071bdd8 100644 --- a/helix-cli/src/prompts.rs +++ b/helix-cli/src/prompts.rs @@ -365,18 +365,3 @@ pub fn select_cluster(clusters: &[crate::commands::sync::CliCluster]) -> Result< Ok(selected) } -/// Prompt user to enter their workspace ID -pub fn input_workspace_id() -> Result { - let workspace_id: String = cliclack::input("Enter your workspace ID") - .placeholder("ws_abc123...") - .validate(|input: &String| { - if input.trim().is_empty() { - Err("Workspace ID cannot be empty") - } else { - Ok(()) - } - }) - .interact()?; - - Ok(workspace_id.trim().to_string()) -}