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        remove_legacy_embedding_keys(&mut config);
268        config.set(embedding_keys::AI_PROVIDER, &embedding.provider);
269        config.set(embedding_keys::AI_API_BASE, &embedding.api_base);
270        config.set(embedding_keys::AI_MODEL, &embedding.model);
271        config.set(embedding_keys::AI_DIM, embedding.vector_dim.to_string());
272        match embedding.query_prefix.as_deref() {
273            Some(query_prefix) => config.set(embedding_keys::AI_QUERY_PREFIX, query_prefix),
274            None => config.remove(embedding_keys::AI_QUERY_PREFIX),
275        }
276        match embedding.api_key.as_deref() {
277            Some(api_key) => config.set(embedding_keys::AI_API_KEY, api_key),
278            None => config.remove(embedding_keys::AI_API_KEY),
279        }
280    } else {
281        remove_embedding_keys(&mut config);
282    }
283
284    config.write_at(&path)?;
285    Ok(path)
286}
287
288fn remove_embedding_keys(config: &mut StandaloneConfig) {
289    remove_legacy_embedding_keys(config);
290    for key in [
291        embedding_keys::AI_PROVIDER,
292        embedding_keys::AI_API_BASE,
293        embedding_keys::AI_MODEL,
294        embedding_keys::AI_DIM,
295        embedding_keys::AI_QUERY_PREFIX,
296        embedding_keys::AI_API_KEY,
297    ] {
298        config.remove(key);
299    }
300}
301
302fn remove_legacy_embedding_keys(config: &mut StandaloneConfig) {
303    for key in embedding_keys::legacy_keys() {
304        config.remove(&key);
305    }
306}
307
308fn service_configured_compose_file(home: &std::path::Path) -> Option<String> {
309    let compose = compose_file_path(home);
310    compose.exists().then(|| compose.display().to_string())
311}
312
313fn resolve_embedding_bootstrap(
314    request: &StandaloneSetupRequest,
315) -> anyhow::Result<Option<EmbeddingBootstrap>> {
316    let provider = request
317        .embedding_provider
318        .as_deref()
319        .map(|provider| provider.trim().to_ascii_lowercase());
320
321    let mut embedding = match provider.as_deref() {
322        Some("none") => return Ok(None),
323        Some("lm-studio") | Some("lmstudio") => EmbeddingBootstrap::lm_studio(),
324        Some("ollama") => EmbeddingBootstrap::ollama(),
325        Some("openai-compatible") | Some("openai") | Some("remote") => {
326            explicit_embedding_bootstrap(request)?
327        }
328        Some(other) => anyhow::bail!(
329            "unsupported embedding provider `{other}`; expected lm-studio, ollama, openai-compatible, or none"
330        ),
331        None if request.embedding_api_base.is_some()
332            || request.embedding_model.is_some()
333            || request.embedding_query_prefix.is_some()
334            || request.embedding_api_key.is_some() =>
335        {
336            explicit_embedding_bootstrap(request)?
337        }
338        None if endpoint_reachable(DEFAULT_LM_STUDIO_API_BASE) => EmbeddingBootstrap::lm_studio(),
339        None if endpoint_reachable(DEFAULT_OLLAMA_API_BASE) => EmbeddingBootstrap::ollama(),
340        None => EmbeddingBootstrap::lm_studio(),
341    };
342
343    if let Some(api_base) = request.embedding_api_base.as_deref() {
344        embedding.api_base = api_base.to_string();
345    }
346    if let Some(model) = request.embedding_model.as_deref() {
347        embedding.model = model.to_string();
348    }
349    if let Some(query_prefix) = request.embedding_query_prefix.as_deref() {
350        embedding.query_prefix = Some(query_prefix.to_string());
351    }
352    if let Some(vector_dim) = request.embedding_vector_dim {
353        if vector_dim == 0 {
354            anyhow::bail!("--embedding-vector-dim must be positive");
355        }
356        embedding.vector_dim = vector_dim;
357    }
358    if let Some(api_key) = request.embedding_api_key.as_deref() {
359        embedding.api_key = Some(api_key.to_string());
360    }
361
362    Ok(Some(embedding))
363}
364
365fn explicit_embedding_bootstrap(
366    request: &StandaloneSetupRequest,
367) -> anyhow::Result<EmbeddingBootstrap> {
368    let Some(api_base) = request.embedding_api_base.as_deref() else {
369        anyhow::bail!("--embedding-api-base is required for openai-compatible embeddings");
370    };
371    Ok(EmbeddingBootstrap {
372        provider: "openai-compatible".to_string(),
373        api_base: api_base.to_string(),
374        model: request
375            .embedding_model
376            .clone()
377            .unwrap_or_else(|| DEFAULT_OLLAMA_MODEL.to_string()),
378        vector_dim: request
379            .embedding_vector_dim
380            .unwrap_or(DEFAULT_EMBEDDING_VECTOR_DIM),
381        query_prefix: request.embedding_query_prefix.clone(),
382        api_key: request.embedding_api_key.clone_inner(),
383    })
384}
385
386fn endpoint_reachable(api_base: &str) -> bool {
387    let Ok(url) = reqwest::Url::parse(api_base) else {
388        return false;
389    };
390    let Some(host) = url.host_str() else {
391        return false;
392    };
393    let Some(port) = url.port_or_known_default() else {
394        return false;
395    };
396    let Ok(addrs) = (host, port).to_socket_addrs() else {
397        return false;
398    };
399    addrs
400        .into_iter()
401        .any(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_ok())
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use serde_json::Value;
408
409    #[test]
410    fn write_gcore_config_writes_ai_embeddings_and_redacts_api_key() {
411        let home = tempfile::tempdir().expect("temp home");
412        let path = gcore_config_path(home.path());
413        let legacy_keys = embedding_keys::legacy_keys();
414        let mut existing = StandaloneConfig::empty();
415        existing.set(legacy_keys[1].clone(), "http://legacy.local/v1");
416        existing
417            .write_at(&path)
418            .expect("write existing standalone config");
419
420        let request = StandaloneSetupRequest::new(
421            true,
422            Some("postgresql://localhost/gobby".to_string()),
423            None,
424        );
425        let service_options = DockerServiceOptions::new(home.path().to_path_buf());
426        let embedding = EmbeddingBootstrap {
427            provider: "openai-compatible".to_string(),
428            api_base: "http://localhost:1234/v1".to_string(),
429            model: "embed-small".to_string(),
430            vector_dim: 1024,
431            query_prefix: Some("query: ".to_string()),
432            api_key: Some("local-api-key".to_string()),
433        };
434
435        let path = write_gcore_config(
436            home.path(),
437            &request,
438            &service_options,
439            "postgresql://localhost/gobby",
440            None,
441            Some(&embedding),
442        )
443        .expect("write gcore config");
444        let config = StandaloneConfig::read_at(&path)
445            .expect("read gcore config")
446            .expect("config present");
447
448        assert_eq!(
449            config.get(embedding_keys::AI_API_BASE),
450            Some("http://localhost:1234/v1")
451        );
452        assert_eq!(config.get(embedding_keys::AI_MODEL), Some("embed-small"));
453        assert_eq!(config.get(embedding_keys::AI_DIM), Some("1024"));
454        assert_eq!(config.get(embedding_keys::AI_QUERY_PREFIX), Some("query: "));
455        assert_eq!(
456            config.get(embedding_keys::AI_API_KEY),
457            Some("local-api-key")
458        );
459        for key in legacy_keys {
460            assert_eq!(config.get(&key), None, "legacy key survived: {key}");
461        }
462
463        let status = StandaloneEmbeddingStatus {
464            provider: embedding.provider,
465            api_base: embedding.api_base,
466            model: embedding.model,
467            query_prefix: embedding.query_prefix,
468            vector_dim: embedding.vector_dim,
469            api_key_present: embedding.api_key.is_some(),
470            api_key_fingerprint: embedding.api_key.as_deref().map(api_key_fingerprint),
471        };
472        let output = serde_json::to_value(status).expect("serialize status");
473        assert_eq!(output["api_key_present"], Value::Bool(true));
474        assert_eq!(
475            output["api_key_fingerprint"],
476            Value::String(api_key_fingerprint("local-api-key"))
477        );
478        assert!(
479            !output.to_string().contains("local-api-key"),
480            "setup status leaked plaintext API key"
481        );
482    }
483
484    #[test]
485    fn write_gcore_config_clears_embedding_keys_when_disabled() {
486        let home = tempfile::tempdir().expect("temp home");
487        let path = gcore_config_path(home.path());
488        let legacy_keys = embedding_keys::legacy_keys();
489        let mut existing = StandaloneConfig::empty();
490        existing.set(embedding_keys::AI_PROVIDER, "lm-studio");
491        existing.set(embedding_keys::AI_API_BASE, "http://localhost:1234/v1");
492        existing.set(embedding_keys::AI_MODEL, "embed-small");
493        existing.set(embedding_keys::AI_DIM, "1024");
494        existing.set(embedding_keys::AI_QUERY_PREFIX, "query: ");
495        existing.set(embedding_keys::AI_API_KEY, "local-api-key");
496        existing.set(legacy_keys[0].clone(), "legacy-provider");
497        existing
498            .write_at(&path)
499            .expect("write existing standalone config");
500
501        let request = StandaloneSetupRequest::new(
502            true,
503            Some("postgresql://localhost/gobby".to_string()),
504            None,
505        );
506        let service_options = DockerServiceOptions::new(home.path().to_path_buf());
507
508        let path = write_gcore_config(
509            home.path(),
510            &request,
511            &service_options,
512            "postgresql://localhost/gobby",
513            None,
514            None,
515        )
516        .expect("write gcore config");
517        let config = StandaloneConfig::read_at(&path)
518            .expect("read gcore config")
519            .expect("config present");
520
521        for key in [
522            embedding_keys::AI_PROVIDER,
523            embedding_keys::AI_API_BASE,
524            embedding_keys::AI_MODEL,
525            embedding_keys::AI_DIM,
526            embedding_keys::AI_QUERY_PREFIX,
527            embedding_keys::AI_API_KEY,
528        ] {
529            assert_eq!(config.get(key), None, "embedding key survived: {key}");
530        }
531        for key in legacy_keys {
532            assert_eq!(config.get(&key), None, "legacy key survived: {key}");
533        }
534        assert_eq!(
535            config.get("databases.postgres.dsn"),
536            Some("postgresql://localhost/gobby")
537        );
538    }
539
540    #[test]
541    #[serial_test::serial]
542    fn standalone_command_installs_public_code_index_subset() {
543        let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
544            return;
545        };
546        let home = tempfile::tempdir().expect("temp home");
547        unsafe { std::env::set_var("GOBBY_HOME", home.path()) };
548        let request = StandaloneSetupRequest::new(true, Some(database_url.clone()), None);
549
550        run(request, Format::Json, true).expect("standalone setup runs");
551
552        let mut client =
553            gobby_core::postgres::connect_readwrite(&database_url).expect("connect test db");
554        let exists: bool = client
555            .query_one("SELECT to_regclass('public.code_symbols') IS NOT NULL", &[])
556            .expect("check code_symbols")
557            .get(0);
558        assert!(exists);
559
560        let forbidden_exists: bool = client
561            .query_one("SELECT to_regclass('public.config_store') IS NOT NULL", &[])
562            .expect("check config_store")
563            .get(0);
564        assert!(!forbidden_exists);
565        assert!(home.path().join("gcore.yaml").exists());
566
567        client
568            .batch_execute(
569                "DROP INDEX IF EXISTS public.code_symbols_search_bm25;
570                 DROP INDEX IF EXISTS public.code_content_search_bm25;
571                 DROP TABLE IF EXISTS public.code_calls;
572                 DROP TABLE IF EXISTS public.code_imports;
573                 DROP TABLE IF EXISTS public.code_content_chunks;
574                 DROP TABLE IF EXISTS public.code_symbols;
575                 DROP TABLE IF EXISTS public.code_indexed_files;
576                 DROP TABLE IF EXISTS public.code_indexed_projects;",
577            )
578            .expect("drop code-index test objects");
579        unsafe { std::env::remove_var("GOBBY_HOME") };
580    }
581}