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}