1use anyhow::Context as _;
2use gobby_core::provisioning::{
3 DEFAULT_EMBEDDING_VECTOR_DIM, DEFAULT_LM_STUDIO_API_BASE, DEFAULT_OLLAMA_API_BASE,
4 DEFAULT_OLLAMA_MODEL, DockerProvisioningReport, DockerServiceOptions, EmbeddingBootstrap,
5 StandaloneConfig, compose_file_path, gcore_config_path, provision_docker_services,
6};
7use postgres::{Client, NoTls};
8use std::net::{TcpStream, ToSocketAddrs};
9use std::time::Duration;
10
11use crate::config::{self, QdrantConfig};
12use crate::db;
13use crate::graph::code_graph;
14use crate::output::{self, Format};
15use crate::setup::{
16 self, StandaloneEmbeddingStatus, StandaloneServicesStatus, StandaloneSetupRequest,
17};
18use crate::vector::code_symbols;
19
20pub fn run(request: StandaloneSetupRequest, format: Format, quiet: bool) -> anyhow::Result<()> {
21 setup::validate_standalone_request(&request)?;
22
23 let home = db::gobby_home()?;
24 let mut service_options = DockerServiceOptions::new(home.clone());
25 apply_service_overrides(&request, &mut service_options);
26
27 let embedding = resolve_embedding_bootstrap(&request)?;
28 let (database_url, service_report) = resolve_or_provision_database(&request, &service_options)?;
29 let mut client = connect_postgres_with_retry(&database_url, service_report.is_some())?;
30 if request.overwrite_code_index {
31 clear_overwrite_projections(&home, &request, &service_options, service_report.as_ref())?;
32 }
33 let mut status = setup::run_standalone_setup(&request, &mut client)?;
34 if !status.failed.is_empty() {
35 match format {
36 Format::Json => {
37 output::print_json(&status)?;
38 }
39 Format::Text => {
40 for (object, message) in &status.failed {
41 eprintln!("Failed to create {object}: {message}");
42 }
43 }
44 }
45 anyhow::bail!("standalone gcode setup failed");
46 }
47
48 let config_file = write_gcore_config(
49 &home,
50 &request,
51 &service_options,
52 &database_url,
53 service_report.as_ref(),
54 embedding.as_ref(),
55 )?;
56 status.config_file = Some(config_file.display().to_string());
57 status.services = Some(match service_report {
58 Some(report) => StandaloneServicesStatus {
59 provisioned: true,
60 compose_file: Some(report.compose_file.display().to_string()),
61 health_checks: report.health_checks,
62 },
63 None => StandaloneServicesStatus {
64 provisioned: false,
65 compose_file: service_configured_compose_file(&home),
66 health_checks: Vec::new(),
67 },
68 });
69 status.embedding = embedding.map(|embedding| StandaloneEmbeddingStatus {
70 provider: embedding.provider,
71 api_base: embedding.api_base,
72 model: embedding.model,
73 vector_dim: embedding.vector_dim,
74 api_key_env: embedding.api_key_env,
75 });
76
77 match format {
78 Format::Json => output::print_json(&status),
79 Format::Text => {
80 if !quiet {
81 output::print_text(&format!(
82 "Standalone gcode setup complete in schema {}",
83 status.schema
84 ))?;
85 }
86 Ok(())
87 }
88 }
89}
90
91struct OverwriteProjectionConfigs {
92 falkordb: Option<config::FalkorConfig>,
93 qdrant: Option<QdrantConfig>,
94}
95
96fn clear_overwrite_projections(
97 home: &std::path::Path,
98 request: &StandaloneSetupRequest,
99 service_options: &DockerServiceOptions,
100 service_report: Option<&DockerProvisioningReport>,
101) -> anyhow::Result<()> {
102 let configs = overwrite_projection_configs(home, request, service_options, service_report)?;
103 if let Some(falkordb) = configs.falkordb {
104 code_graph::clear_all_code_index(&falkordb)
105 .context("failed to clear FalkorDB code-index projection during overwrite setup")?;
106 }
107 if let Some(qdrant) = configs.qdrant {
108 code_symbols::delete_code_symbol_collections_with_prefix(&qdrant)
109 .context("failed to delete Qdrant code-symbol collections during overwrite setup")?;
110 }
111 Ok(())
112}
113
114fn overwrite_projection_configs(
115 home: &std::path::Path,
116 request: &StandaloneSetupRequest,
117 service_options: &DockerServiceOptions,
118 service_report: Option<&DockerProvisioningReport>,
119) -> anyhow::Result<OverwriteProjectionConfigs> {
120 let mut standalone = StandaloneConfig::read_at(&gcore_config_path(home))?
121 .unwrap_or_else(StandaloneConfig::empty);
122
123 if service_report.is_some() {
124 standalone.set("databases.falkordb.host", &service_options.falkordb_host);
125 standalone.set(
126 "databases.falkordb.port",
127 service_options.falkordb_port.to_string(),
128 );
129 standalone.set(
130 "databases.falkordb.password",
131 &service_options.falkordb_password,
132 );
133 standalone.set("databases.qdrant.url", service_options.qdrant_url());
134 }
135
136 if let Some(host) = request.falkordb_host.as_deref() {
137 standalone.set("databases.falkordb.host", host);
138 }
139 if let Some(port) = request.falkordb_port {
140 standalone.set("databases.falkordb.port", port.to_string());
141 }
142 if let Some(password) = request.falkordb_password.as_deref() {
143 standalone.set("databases.falkordb.password", password);
144 }
145 if let Some(qdrant_url) = request.qdrant_url.as_deref() {
146 standalone.set("databases.qdrant.url", qdrant_url);
147 }
148
149 let falkordb = gobby_core::config::resolve_falkordb_config(&mut standalone).map(|connection| {
150 config::FalkorConfig {
151 host: connection.host,
152 port: connection.port,
153 password: connection.password,
154 graph_name: config::FALKORDB_GRAPH_NAME.to_string(),
155 }
156 });
157 let qdrant = gobby_core::config::resolve_qdrant_config(&mut standalone);
158
159 Ok(OverwriteProjectionConfigs { falkordb, qdrant })
160}
161
162fn resolve_or_provision_database(
163 request: &StandaloneSetupRequest,
164 service_options: &DockerServiceOptions,
165) -> anyhow::Result<(String, Option<DockerProvisioningReport>)> {
166 if let Some(database_url) = request.database_url.as_deref() {
167 return Ok((database_url.to_string(), None));
168 }
169
170 if request.no_services {
171 return db::resolve_database_url().map(|url| (url, None));
172 }
173
174 match db::resolve_database_url() {
175 Ok(database_url) => Ok((database_url, None)),
176 Err(_) => {
177 let report = provision_docker_services(service_options)
178 .context("failed to provision standalone Docker services")?;
179 Ok((service_options.database_url(), Some(report)))
180 }
181 }
182}
183
184fn apply_service_overrides(
185 request: &StandaloneSetupRequest,
186 service_options: &mut DockerServiceOptions,
187) {
188 if let Some(host) = request.falkordb_host.as_deref() {
189 service_options.falkordb_host = host.to_string();
190 }
191 if let Some(port) = request.falkordb_port {
192 service_options.falkordb_port = port;
193 }
194 if let Some(password) = request.falkordb_password.as_deref() {
195 service_options.falkordb_password = password.to_string();
196 }
197}
198
199fn connect_postgres_with_retry(database_url: &str, retry: bool) -> anyhow::Result<Client> {
200 let attempts = if retry { 30 } else { 1 };
201 let mut last_error = None;
202 for attempt in 0..attempts {
203 match Client::connect(database_url, NoTls) {
204 Ok(client) => return Ok(client),
205 Err(err) => last_error = Some(err),
206 }
207 if attempt + 1 < attempts {
208 std::thread::sleep(Duration::from_secs(2));
209 }
210 }
211 match last_error {
212 Some(err) => Err(anyhow::Error::new(err)
213 .context("failed to connect to the standalone PostgreSQL database")),
214 None => anyhow::bail!("failed to connect to the standalone PostgreSQL database"),
215 }
216}
217
218fn write_gcore_config(
219 home: &std::path::Path,
220 request: &StandaloneSetupRequest,
221 service_options: &DockerServiceOptions,
222 database_url: &str,
223 service_report: Option<&DockerProvisioningReport>,
224 embedding: Option<&EmbeddingBootstrap>,
225) -> anyhow::Result<std::path::PathBuf> {
226 let path = gcore_config_path(home);
227 let mut config = StandaloneConfig::read_at(&path)?.unwrap_or_else(StandaloneConfig::empty);
228
229 config.set("databases.postgres.dsn", database_url);
230
231 if let Some(report) = service_report {
232 config.set("databases.falkordb.host", &service_options.falkordb_host);
233 config.set(
234 "databases.falkordb.port",
235 service_options.falkordb_port.to_string(),
236 );
237 config.set(
238 "databases.falkordb.password",
239 &service_options.falkordb_password,
240 );
241 config.remove("databases.falkordb.requirepass");
242 config.set("databases.qdrant.url", service_options.qdrant_url());
243 config.set(
244 "services.compose_file",
245 report.compose_file.display().to_string(),
246 );
247 } else {
248 if let Some(host) = request.falkordb_host.as_deref() {
249 config.set("databases.falkordb.host", host);
250 }
251 if let Some(port) = request.falkordb_port {
252 config.set("databases.falkordb.port", port.to_string());
253 }
254 if let Some(password) = request.falkordb_password.as_deref() {
255 config.set("databases.falkordb.password", password);
256 config.remove("databases.falkordb.requirepass");
257 }
258 if let Some(qdrant_url) = request.qdrant_url.as_deref() {
259 config.set("databases.qdrant.url", qdrant_url);
260 }
261 }
262
263 if let Some(embedding) = embedding {
264 config.set("embeddings.provider", &embedding.provider);
265 config.set("embeddings.api_base", &embedding.api_base);
266 config.set("embeddings.model", &embedding.model);
267 config.set("embeddings.vector_dim", embedding.vector_dim.to_string());
268 match embedding.api_key_env.as_deref() {
269 Some(api_key_env) => config.set("embeddings.api_key_env", api_key_env),
270 None => config.remove("embeddings.api_key_env"),
271 }
272 }
273
274 config.write_at(&path)?;
275 Ok(path)
276}
277
278fn service_configured_compose_file(home: &std::path::Path) -> Option<String> {
279 let compose = compose_file_path(home);
280 compose.exists().then(|| compose.display().to_string())
281}
282
283fn resolve_embedding_bootstrap(
284 request: &StandaloneSetupRequest,
285) -> anyhow::Result<Option<EmbeddingBootstrap>> {
286 let provider = request
287 .embedding_provider
288 .as_deref()
289 .map(|provider| provider.trim().to_ascii_lowercase());
290
291 let mut embedding = match provider.as_deref() {
292 Some("none") => return Ok(None),
293 Some("lm-studio") | Some("lmstudio") => EmbeddingBootstrap::lm_studio(),
294 Some("ollama") => EmbeddingBootstrap::ollama(),
295 Some("openai-compatible") | Some("openai") | Some("remote") => {
296 explicit_embedding_bootstrap(request)?
297 }
298 Some(other) => anyhow::bail!(
299 "unsupported embedding provider `{other}`; expected lm-studio, ollama, openai-compatible, or none"
300 ),
301 None if request.embedding_api_base.is_some() || request.embedding_model.is_some() => {
302 explicit_embedding_bootstrap(request)?
303 }
304 None if endpoint_reachable(DEFAULT_LM_STUDIO_API_BASE) => EmbeddingBootstrap::lm_studio(),
305 None if endpoint_reachable(DEFAULT_OLLAMA_API_BASE) => EmbeddingBootstrap::ollama(),
306 None => EmbeddingBootstrap::lm_studio(),
307 };
308
309 if let Some(api_base) = request.embedding_api_base.as_deref() {
310 embedding.api_base = api_base.to_string();
311 }
312 if let Some(model) = request.embedding_model.as_deref() {
313 embedding.model = model.to_string();
314 }
315 if let Some(vector_dim) = request.embedding_vector_dim {
316 if vector_dim == 0 {
317 anyhow::bail!("--embedding-vector-dim must be positive");
318 }
319 embedding.vector_dim = vector_dim;
320 }
321 if let Some(api_key_env) = request.embedding_api_key_env.as_deref() {
322 embedding.api_key_env = Some(api_key_env.to_string());
323 }
324
325 Ok(Some(embedding))
326}
327
328fn explicit_embedding_bootstrap(
329 request: &StandaloneSetupRequest,
330) -> anyhow::Result<EmbeddingBootstrap> {
331 let Some(api_base) = request.embedding_api_base.as_deref() else {
332 anyhow::bail!("--embedding-api-base is required for openai-compatible embeddings");
333 };
334 Ok(EmbeddingBootstrap {
335 provider: "openai-compatible".to_string(),
336 api_base: api_base.to_string(),
337 model: request
338 .embedding_model
339 .clone()
340 .unwrap_or_else(|| DEFAULT_OLLAMA_MODEL.to_string()),
341 vector_dim: request
342 .embedding_vector_dim
343 .unwrap_or(DEFAULT_EMBEDDING_VECTOR_DIM),
344 api_key_env: request.embedding_api_key_env.clone(),
345 })
346}
347
348fn endpoint_reachable(api_base: &str) -> bool {
349 let Ok(url) = reqwest::Url::parse(api_base) else {
350 return false;
351 };
352 let Some(host) = url.host_str() else {
353 return false;
354 };
355 let Some(port) = url.port_or_known_default() else {
356 return false;
357 };
358 let Ok(addrs) = (host, port).to_socket_addrs() else {
359 return false;
360 };
361 addrs
362 .into_iter()
363 .any(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(150)).is_ok())
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 #[serial_test::serial]
372 fn standalone_command_installs_public_code_index_subset() {
373 let Ok(database_url) = std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL") else {
374 return;
375 };
376 let home = tempfile::tempdir().expect("temp home");
377 unsafe { std::env::set_var("GOBBY_HOME", home.path()) };
378 let request = StandaloneSetupRequest::new(true, Some(database_url.clone()), None);
379
380 run(request, Format::Json, true).expect("standalone setup runs");
381
382 let mut client =
383 postgres::Client::connect(&database_url, postgres::NoTls).expect("connect test db");
384 let exists: bool = client
385 .query_one("SELECT to_regclass('public.code_symbols') IS NOT NULL", &[])
386 .expect("check code_symbols")
387 .get(0);
388 assert!(exists);
389
390 let forbidden_exists: bool = client
391 .query_one("SELECT to_regclass('public.config_store') IS NOT NULL", &[])
392 .expect("check config_store")
393 .get(0);
394 assert!(!forbidden_exists);
395 assert!(home.path().join("gcore.yaml").exists());
396
397 client
398 .batch_execute(
399 "DROP INDEX IF EXISTS public.code_symbols_search_bm25;
400 DROP INDEX IF EXISTS public.code_content_search_bm25;
401 DROP TABLE IF EXISTS public.code_calls;
402 DROP TABLE IF EXISTS public.code_imports;
403 DROP TABLE IF EXISTS public.code_content_chunks;
404 DROP TABLE IF EXISTS public.code_symbols;
405 DROP TABLE IF EXISTS public.code_indexed_files;
406 DROP TABLE IF EXISTS public.code_indexed_projects;",
407 )
408 .expect("drop code-index test objects");
409 unsafe { std::env::remove_var("GOBBY_HOME") };
410 }
411}