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