Skip to main content

gobby_code/commands/
setup.rs

1use anyhow::Context as _;
2use gobby_core::config::embedding_keys;
3use gobby_core::provisioning::{
4    DEFAULT_EMBEDDING_VECTOR_DIM, DEFAULT_LM_STUDIO_API_BASE, DEFAULT_OLLAMA_API_BASE,
5    DEFAULT_OLLAMA_MODEL, DockerProvisioningReport, DockerServiceOptions, EmbeddingBootstrap,
6    EnsureHubOptions, StandaloneConfig, compose_file_path, ensure_hub, gcore_config_path,
7};
8use postgres::Client;
9use std::net::{TcpStream, ToSocketAddrs};
10use std::time::Duration;
11
12use crate::config::{self, QdrantConfig};
13use crate::db;
14use crate::graph::code_graph;
15use crate::output::{self, Format};
16use crate::setup::{
17    self, StandaloneEmbeddingStatus, StandaloneServicesStatus, StandaloneSetupRequest,
18};
19use crate::utils::api_key_fingerprint;
20use crate::vector::code_symbols;
21
22pub fn run(request: StandaloneSetupRequest, format: Format, quiet: bool) -> anyhow::Result<()> {
23    setup::validate_standalone_request(&request)?;
24
25    let home = db::gobby_home()?;
26    let mut service_options = DockerServiceOptions::new(home.clone());
27    apply_service_overrides(&request, &mut service_options);
28
29    let embedding = resolve_embedding_bootstrap(&request)?;
30    let (database_url, service_report) =
31        resolve_or_provision_database(&home, &request, &service_options)?;
32    let mut client = connect_postgres_with_retry(&database_url, service_report.is_some())?;
33    if request.overwrite_code_index {
34        clear_overwrite_projections(&home, &request, &service_options, service_report.as_ref())?;
35    }
36    let mut status = setup::run_standalone_setup(&request, &mut client)?;
37    if !status.failed.is_empty() {
38        match format {
39            Format::Json => {
40                output::print_json(&status)?;
41            }
42            Format::Text => {
43                for failure in &status.failed {
44                    eprintln!("Failed to create {}: {}", failure.name, failure.reason);
45                }
46            }
47        }
48        anyhow::bail!("standalone gcode setup failed");
49    }
50
51    let config_file = write_gcore_config(
52        &home,
53        &request,
54        &service_options,
55        &database_url,
56        service_report.as_ref(),
57        embedding.as_ref(),
58    )?;
59    status.config_file = Some(config_file.display().to_string());
60    status.services = Some(match service_report {
61        Some(report) => StandaloneServicesStatus {
62            provisioned: true,
63            compose_file: Some(report.compose_file.display().to_string()),
64            health_checks: report.health_checks,
65        },
66        None => StandaloneServicesStatus {
67            provisioned: false,
68            compose_file: service_configured_compose_file(&home),
69            health_checks: Vec::new(),
70        },
71    });
72    status.embedding = embedding.map(|embedding| StandaloneEmbeddingStatus {
73        provider: embedding.provider,
74        api_base: embedding.api_base,
75        model: embedding.model,
76        query_prefix: embedding.query_prefix,
77        vector_dim: embedding.vector_dim,
78        api_key_present: embedding.api_key.is_some(),
79        api_key_fingerprint: embedding.api_key.as_deref().map(api_key_fingerprint),
80    });
81
82    match format {
83        Format::Json => output::print_json(&status),
84        Format::Text => {
85            if !quiet {
86                output::print_text(&format!(
87                    "Standalone gcode setup complete in schema {}",
88                    status.schema
89                ))?;
90            }
91            Ok(())
92        }
93    }
94}
95
96struct OverwriteProjectionConfigs {
97    falkordb: Option<config::FalkorConfig>,
98    qdrant: Option<QdrantConfig>,
99}
100
101fn clear_overwrite_projections(
102    home: &std::path::Path,
103    request: &StandaloneSetupRequest,
104    service_options: &DockerServiceOptions,
105    service_report: Option<&DockerProvisioningReport>,
106) -> anyhow::Result<()> {
107    let configs = overwrite_projection_configs(home, request, service_options, service_report)?;
108    if let Some(falkordb) = configs.falkordb {
109        code_graph::clear_all_code_index(&falkordb)
110            .context("failed to clear FalkorDB code-index projection during overwrite setup")?;
111    }
112    if let Some(qdrant) = configs.qdrant {
113        code_symbols::delete_code_symbol_collections_with_prefix(&qdrant)
114            .context("failed to delete Qdrant code-symbol collections during overwrite setup")?;
115    }
116    Ok(())
117}
118
119fn overwrite_projection_configs(
120    home: &std::path::Path,
121    request: &StandaloneSetupRequest,
122    service_options: &DockerServiceOptions,
123    service_report: Option<&DockerProvisioningReport>,
124) -> anyhow::Result<OverwriteProjectionConfigs> {
125    let mut standalone = StandaloneConfig::read_at(&gcore_config_path(home))?
126        .unwrap_or_else(StandaloneConfig::empty);
127
128    if service_report.is_some() {
129        standalone.set("databases.falkordb.host", &service_options.falkordb_host);
130        standalone.set(
131            "databases.falkordb.port",
132            service_options.falkordb_port.to_string(),
133        );
134        standalone.set(
135            "databases.falkordb.password",
136            &service_options.falkordb_password,
137        );
138        standalone.set("databases.qdrant.url", service_options.qdrant_url());
139    }
140
141    if let Some(host) = request.falkordb_host.as_deref() {
142        standalone.set("databases.falkordb.host", host);
143    }
144    if let Some(port) = request.falkordb_port {
145        standalone.set("databases.falkordb.port", port.to_string());
146    }
147    if let Some(password) = request.falkordb_password.as_deref() {
148        standalone.set("databases.falkordb.password", password);
149    }
150    if let Some(qdrant_url) = request.qdrant_url.as_deref() {
151        standalone.set("databases.qdrant.url", qdrant_url);
152    }
153
154    let falkordb = gobby_core::config::resolve_falkordb_config(&mut standalone).map(|connection| {
155        config::FalkorConfig {
156            host: connection.host,
157            port: connection.port,
158            password: connection.password,
159            graph_name: config::FALKORDB_GRAPH_NAME.to_string(),
160        }
161    });
162    let qdrant = gobby_core::config::resolve_qdrant_config(&mut standalone);
163
164    Ok(OverwriteProjectionConfigs { falkordb, qdrant })
165}
166
167fn resolve_or_provision_database(
168    home: &std::path::Path,
169    request: &StandaloneSetupRequest,
170    service_options: &DockerServiceOptions,
171) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
172    if let Some(database_url) = request.database_url.as_deref() {
173        return Ok((database_url.to_string(), None));
174    }
175
176    if request.no_services {
177        return db::resolve_database_url().map(|url| (url, None));
178    }
179
180    let mut options = EnsureHubOptions::new(home.to_path_buf());
181    options.service_options = service_options.clone();
182    if let Ok(database_url) = db::resolve_database_url() {
183        options.candidate_database_urls.push(database_url);
184    }
185    ensure_hub(&options)
186}
187
188fn apply_service_overrides(
189    request: &StandaloneSetupRequest,
190    service_options: &mut DockerServiceOptions,
191) {
192    if let Some(host) = request.falkordb_host.as_deref() {
193        service_options.falkordb_host = host.to_string();
194    }
195    if let Some(port) = request.falkordb_port {
196        service_options.falkordb_port = port;
197    }
198    if let Some(password) = request.falkordb_password.as_deref() {
199        service_options.falkordb_password = password.to_string();
200    }
201}
202
203fn connect_postgres_with_retry(database_url: &str, retry: bool) -> anyhow::Result<Client> {
204    let attempts = if retry { 30 } else { 1 };
205    let mut last_error = None;
206    for attempt in 0..attempts {
207        match gobby_core::postgres::connect_readwrite(database_url) {
208            Ok(client) => return Ok(client),
209            Err(err) => last_error = Some(err),
210        }
211        if attempt + 1 < attempts {
212            std::thread::sleep(Duration::from_secs(2));
213        }
214    }
215    match last_error {
216        Some(err) => Err(err.context("failed to connect to the standalone PostgreSQL database")),
217        None => anyhow::bail!("failed to connect to the standalone PostgreSQL database"),
218    }
219}
220
221fn write_gcore_config(
222    home: &std::path::Path,
223    request: &StandaloneSetupRequest,
224    service_options: &DockerServiceOptions,
225    database_url: &str,
226    service_report: Option<&DockerProvisioningReport>,
227    embedding: Option<&EmbeddingBootstrap>,
228) -> anyhow::Result<std::path::PathBuf> {
229    let path = gcore_config_path(home);
230    let mut config = StandaloneConfig::read_at(&path)?.unwrap_or_else(StandaloneConfig::empty);
231
232    config.set("databases.postgres.dsn", database_url);
233
234    if let Some(report) = service_report {
235        config.set("databases.falkordb.host", &service_options.falkordb_host);
236        config.set(
237            "databases.falkordb.port",
238            service_options.falkordb_port.to_string(),
239        );
240        config.set(
241            "databases.falkordb.password",
242            &service_options.falkordb_password,
243        );
244        config.remove("databases.falkordb.requirepass");
245        config.set("databases.qdrant.url", service_options.qdrant_url());
246        config.set(
247            "services.compose_file",
248            report.compose_file.display().to_string(),
249        );
250    } else {
251        if let Some(host) = request.falkordb_host.as_deref() {
252            config.set("databases.falkordb.host", host);
253        }
254        if let Some(port) = request.falkordb_port {
255            config.set("databases.falkordb.port", port.to_string());
256        }
257        if let Some(password) = request.falkordb_password.as_deref() {
258            config.set("databases.falkordb.password", password);
259            config.remove("databases.falkordb.requirepass");
260        }
261        if let Some(qdrant_url) = request.qdrant_url.as_deref() {
262            config.set("databases.qdrant.url", qdrant_url);
263        }
264    }
265
266    if let Some(embedding) = embedding {
267        config.set(embedding_keys::AI_PROVIDER, &embedding.provider);
268        config.set(embedding_keys::AI_API_BASE, &embedding.api_base);
269        config.set(embedding_keys::AI_MODEL, &embedding.model);
270        config.set(embedding_keys::AI_DIM, embedding.vector_dim.to_string());
271        match embedding.query_prefix.as_deref() {
272            Some(query_prefix) => config.set(embedding_keys::AI_QUERY_PREFIX, query_prefix),
273            None => config.remove(embedding_keys::AI_QUERY_PREFIX),
274        }
275        match embedding.api_key.as_deref() {
276            Some(api_key) => config.set(embedding_keys::AI_API_KEY, api_key),
277            None => config.remove(embedding_keys::AI_API_KEY),
278        }
279    } else {
280        remove_embedding_keys(&mut config);
281    }
282
283    config.write_at(&path)?;
284    Ok(path)
285}
286
287fn remove_embedding_keys(config: &mut StandaloneConfig) {
288    for key in [
289        embedding_keys::AI_PROVIDER,
290        embedding_keys::AI_API_BASE,
291        embedding_keys::AI_MODEL,
292        embedding_keys::AI_DIM,
293        embedding_keys::AI_QUERY_PREFIX,
294        embedding_keys::AI_API_KEY,
295    ] {
296        config.remove(key);
297    }
298}
299
300fn service_configured_compose_file(home: &std::path::Path) -> Option<String> {
301    let compose = compose_file_path(home);
302    compose.exists().then(|| compose.display().to_string())
303}
304
305fn resolve_embedding_bootstrap(
306    request: &StandaloneSetupRequest,
307) -> anyhow::Result<Option<EmbeddingBootstrap>> {
308    let provider = request
309        .embedding_provider
310        .as_deref()
311        .map(|provider| provider.trim().to_ascii_lowercase());
312
313    let mut embedding = match provider.as_deref() {
314        Some("none") => return Ok(None),
315        Some("lm-studio") | Some("lmstudio") => EmbeddingBootstrap::lm_studio(),
316        Some("ollama") => EmbeddingBootstrap::ollama(),
317        Some("openai-compatible") | Some("openai") | Some("remote") => {
318            explicit_embedding_bootstrap(request)?
319        }
320        Some(other) => anyhow::bail!(
321            "unsupported embedding provider `{other}`; expected lm-studio, ollama, openai-compatible, or none"
322        ),
323        None if request.embedding_api_base.is_some()
324            || request.embedding_model.is_some()
325            || request.embedding_query_prefix.is_some()
326            || request.embedding_api_key.is_some() =>
327        {
328            explicit_embedding_bootstrap(request)?
329        }
330        None if endpoint_reachable(DEFAULT_LM_STUDIO_API_BASE) => EmbeddingBootstrap::lm_studio(),
331        None if endpoint_reachable(DEFAULT_OLLAMA_API_BASE) => EmbeddingBootstrap::ollama(),
332        None => EmbeddingBootstrap::lm_studio(),
333    };
334
335    if let Some(api_base) = request.embedding_api_base.as_deref() {
336        embedding.api_base = api_base.to_string();
337    }
338    if let Some(model) = request.embedding_model.as_deref() {
339        embedding.model = model.to_string();
340    }
341    if let Some(query_prefix) = request.embedding_query_prefix.as_deref() {
342        embedding.query_prefix = Some(query_prefix.to_string());
343    }
344    if let Some(vector_dim) = request.embedding_vector_dim {
345        if vector_dim == 0 {
346            anyhow::bail!("--embedding-vector-dim must be positive");
347        }
348        embedding.vector_dim = vector_dim;
349    }
350    if let Some(api_key) = request.embedding_api_key.as_deref() {
351        embedding.api_key = Some(api_key.to_string());
352    }
353
354    Ok(Some(embedding))
355}
356
357fn explicit_embedding_bootstrap(
358    request: &StandaloneSetupRequest,
359) -> anyhow::Result<EmbeddingBootstrap> {
360    let Some(api_base) = request.embedding_api_base.as_deref() else {
361        anyhow::bail!("--embedding-api-base is required for openai-compatible embeddings");
362    };
363    Ok(EmbeddingBootstrap {
364        provider: "openai-compatible".to_string(),
365        api_base: api_base.to_string(),
366        model: request
367            .embedding_model
368            .clone()
369            .unwrap_or_else(|| DEFAULT_OLLAMA_MODEL.to_string()),
370        vector_dim: request
371            .embedding_vector_dim
372            .unwrap_or(DEFAULT_EMBEDDING_VECTOR_DIM),
373        query_prefix: request.embedding_query_prefix.clone(),
374        api_key: request.embedding_api_key.clone_inner(),
375    })
376}
377
378fn endpoint_reachable(api_base: &str) -> bool {
379    let Ok(url) = reqwest::Url::parse(api_base) else {
380        return false;
381    };
382    let Some(host) = url.host_str() else {
383        return false;
384    };
385    let Some(port) = url.port_or_known_default() else {
386        return false;
387    };
388    let Ok(addrs) = (host, port).to_socket_addrs() else {
389        return false;
390    };
391    addrs
392        .into_iter()
393        .any(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_ok())
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use serde_json::Value;
400
401    #[test]
402    fn write_gcore_config_writes_ai_embeddings_and_redacts_api_key() {
403        let home = tempfile::tempdir().expect("temp home");
404
405        let request = StandaloneSetupRequest::new(
406            true,
407            Some("postgresql://localhost/gobby".to_string()),
408            None,
409        );
410        let service_options = DockerServiceOptions::new(home.path().to_path_buf());
411        let embedding = EmbeddingBootstrap {
412            provider: "openai-compatible".to_string(),
413            api_base: "http://localhost:1234/v1".to_string(),
414            model: "embed-small".to_string(),
415            vector_dim: 1024,
416            query_prefix: Some("query: ".to_string()),
417            api_key: Some("local-api-key".to_string()),
418        };
419
420        let path = write_gcore_config(
421            home.path(),
422            &request,
423            &service_options,
424            "postgresql://localhost/gobby",
425            None,
426            Some(&embedding),
427        )
428        .expect("write gcore config");
429        let config = StandaloneConfig::read_at(&path)
430            .expect("read gcore config")
431            .expect("config present");
432
433        assert_eq!(
434            config.get(embedding_keys::AI_API_BASE),
435            Some("http://localhost:1234/v1")
436        );
437        assert_eq!(config.get(embedding_keys::AI_MODEL), Some("embed-small"));
438        assert_eq!(config.get(embedding_keys::AI_DIM), Some("1024"));
439        assert_eq!(config.get(embedding_keys::AI_QUERY_PREFIX), Some("query: "));
440        assert_eq!(
441            config.get(embedding_keys::AI_API_KEY),
442            Some("local-api-key")
443        );
444
445        let status = StandaloneEmbeddingStatus {
446            provider: embedding.provider,
447            api_base: embedding.api_base,
448            model: embedding.model,
449            query_prefix: embedding.query_prefix,
450            vector_dim: embedding.vector_dim,
451            api_key_present: embedding.api_key.is_some(),
452            api_key_fingerprint: embedding.api_key.as_deref().map(api_key_fingerprint),
453        };
454        let output = serde_json::to_value(status).expect("serialize status");
455        assert_eq!(output["api_key_present"], Value::Bool(true));
456        assert_eq!(
457            output["api_key_fingerprint"],
458            Value::String(api_key_fingerprint("local-api-key"))
459        );
460        assert!(
461            !output.to_string().contains("local-api-key"),
462            "setup status leaked plaintext API key"
463        );
464    }
465
466    #[test]
467    fn write_gcore_config_clears_embedding_keys_when_disabled() {
468        let home = tempfile::tempdir().expect("temp home");
469        let path = gcore_config_path(home.path());
470        let mut existing = StandaloneConfig::empty();
471        existing.set(embedding_keys::AI_PROVIDER, "lm-studio");
472        existing.set(embedding_keys::AI_API_BASE, "http://localhost:1234/v1");
473        existing.set(embedding_keys::AI_MODEL, "embed-small");
474        existing.set(embedding_keys::AI_DIM, "1024");
475        existing.set(embedding_keys::AI_QUERY_PREFIX, "query: ");
476        existing.set(embedding_keys::AI_API_KEY, "local-api-key");
477        existing
478            .write_at(&path)
479            .expect("write existing standalone config");
480
481        let request = StandaloneSetupRequest::new(
482            true,
483            Some("postgresql://localhost/gobby".to_string()),
484            None,
485        );
486        let service_options = DockerServiceOptions::new(home.path().to_path_buf());
487
488        let path = write_gcore_config(
489            home.path(),
490            &request,
491            &service_options,
492            "postgresql://localhost/gobby",
493            None,
494            None,
495        )
496        .expect("write gcore config");
497        let config = StandaloneConfig::read_at(&path)
498            .expect("read gcore config")
499            .expect("config present");
500
501        for key in [
502            embedding_keys::AI_PROVIDER,
503            embedding_keys::AI_API_BASE,
504            embedding_keys::AI_MODEL,
505            embedding_keys::AI_DIM,
506            embedding_keys::AI_QUERY_PREFIX,
507            embedding_keys::AI_API_KEY,
508        ] {
509            assert_eq!(config.get(key), None, "embedding key survived: {key}");
510        }
511        assert_eq!(
512            config.get("databases.postgres.dsn"),
513            Some("postgresql://localhost/gobby")
514        );
515    }
516
517    mod serial_db {
518        use super::*;
519
520        #[test]
521        #[serial_test::serial(serial_db)]
522        fn standalone_command_installs_public_code_index_subset() {
523            let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
524                return;
525            };
526            let home = tempfile::tempdir().expect("temp home");
527            unsafe { std::env::set_var("GOBBY_HOME", home.path()) };
528            let request = StandaloneSetupRequest::new(true, Some(database_url.clone()), None);
529
530            run(request, Format::Json, true).expect("standalone setup runs");
531
532            let mut client =
533                gobby_core::postgres::connect_readwrite(&database_url).expect("connect test db");
534            let exists: bool = client
535                .query_one("SELECT to_regclass('public.code_symbols') IS NOT NULL", &[])
536                .expect("check code_symbols")
537                .get(0);
538            assert!(exists);
539
540            let forbidden_exists: bool = client
541                .query_one("SELECT to_regclass('public.config_store') IS NOT NULL", &[])
542                .expect("check config_store")
543                .get(0);
544            assert!(!forbidden_exists);
545            assert!(home.path().join("gcore.yaml").exists());
546
547            client
548                .batch_execute(
549                    "DROP INDEX IF EXISTS public.code_symbols_search_bm25;
550                     DROP INDEX IF EXISTS public.code_content_search_bm25;
551                     DROP TABLE IF EXISTS public.code_calls;
552                     DROP TABLE IF EXISTS public.code_imports;
553                     DROP TABLE IF EXISTS public.code_content_chunks;
554                     DROP TABLE IF EXISTS public.code_symbols;
555                     DROP TABLE IF EXISTS public.code_indexed_files;
556                     DROP TABLE IF EXISTS public.code_indexed_projects;",
557                )
558                .expect("drop code-index test objects");
559            unsafe { std::env::remove_var("GOBBY_HOME") };
560        }
561    }
562}