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}