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