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}