diff --git a/helix-cli/src/commands/integrations/helix.rs b/helix-cli/src/commands/integrations/helix.rs index 50a2342d..369d19c1 100644 --- a/helix-cli/src/commands/integrations/helix.rs +++ b/helix-cli/src/commands/integrations/helix.rs @@ -184,13 +184,40 @@ impl<'a> HelixManager<'a> { let dev_profile = build_mode == BuildMode::Dev; + // 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 serialize pruned helix.toml: {}", e)); + 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..c432b8ab --- /dev/null +++ b/helix-cli/src/commands/sync.rs @@ -0,0 +1,561 @@ +use crate::commands::auth::require_auth; +use crate::commands::integrations::helix::CLOUD_AUTHORITY; +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; +use std::collections::HashMap; + +#[derive(Deserialize)] +struct SyncResponse { + helix_toml: Option, + hx_files: HashMap, +} + +#[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)?; + + if instance_config.is_local() { + pull_from_local_instance(&project, &instance_name).await + } else { + pull_from_cloud_instance(&project, &instance_name, instance_config).await + } +} + +/// 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() + && !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); + + // 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)?; + } + + // 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() + && let Ok(local_content) = std::fs::read_to_string(&file_path) + && 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(); + + 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() + && !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)); + } + + // Merge helix.toml if remote provided one + 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(), + } + }; + + // 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(); + + // 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..bc3e2464 100644 --- a/helix-cli/src/config.rs +++ b/helix-cli/src/config.rs @@ -7,6 +7,55 @@ 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)] +pub struct WorkspaceConfig { + pub workspace_id: Option, +} + +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, @@ -476,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 d3fd7796..b3c7f5ec 100644 --- a/helix-cli/src/main.rs +++ b/helix-cli/src/main.rs @@ -105,10 +105,10 @@ enum Commands { dev: bool, }, - /// Pull .hql files from instance back to local project - Pull { - /// Instance name to pull from - instance: String, + /// Sync .hx source files and config from a deployed Helix Cloud instance + Sync { + /// Instance name to sync from (interactive selection if not provided) + instance: Option, }, /// Start an instance (doesn't rebuild) @@ -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::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, diff --git a/helix-cli/src/prompts.rs b/helix-cli/src/prompts.rs index 3b391b3b..b071bdd8 100644 --- a/helix-cli/src/prompts.rs +++ b/helix-cli/src/prompts.rs @@ -332,3 +332,36 @@ 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) +} +