algocline_app/service/
pkg.rs1use std::path::Path;
2
3use super::path::{copy_dir, ContainedPath};
4use super::resolve::{
5 install_scenarios_from_dir, is_system_package, packages_dir, scenarios_dir, DirEntryFailures,
6 AUTO_INSTALL_SOURCES,
7};
8use super::AppService;
9
10impl AppService {
11 pub async fn pkg_list(&self) -> Result<String, String> {
13 let pkg_dir = packages_dir()?;
14 if !pkg_dir.is_dir() {
15 return Ok(serde_json::json!({ "packages": [] }).to_string());
16 }
17
18 let mut packages = Vec::new();
19 let entries =
20 std::fs::read_dir(&pkg_dir).map_err(|e| format!("Failed to read packages dir: {e}"))?;
21
22 for entry in entries.flatten() {
23 let path = entry.path();
24 if !path.is_dir() {
25 continue;
26 }
27 let init_lua = path.join("init.lua");
28 if !init_lua.exists() {
29 continue;
30 }
31 let name = entry.file_name().to_string_lossy().to_string();
32 if is_system_package(&name) {
34 continue;
35 }
36 let code = format!(
37 r#"local pkg = require("{name}")
38return pkg.meta or {{ name = "{name}" }}"#
39 );
40 match self.executor.eval_simple(code).await {
41 Ok(meta) => packages.push(meta),
42 Err(_) => {
43 packages
44 .push(serde_json::json!({ "name": name, "error": "failed to load meta" }));
45 }
46 }
47 }
48
49 Ok(serde_json::json!({ "packages": packages }).to_string())
50 }
51
52 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
54 let pkg_dir = packages_dir()?;
55 let _ = std::fs::create_dir_all(&pkg_dir);
56
57 let local_path = Path::new(&url);
59 if local_path.is_absolute() && local_path.is_dir() {
60 return self.install_from_local_path(local_path, &pkg_dir, name);
61 }
62
63 let git_url = if url.starts_with("http://")
65 || url.starts_with("https://")
66 || url.starts_with("file://")
67 || url.starts_with("git@")
68 {
69 url.clone()
70 } else {
71 format!("https://{url}")
72 };
73
74 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
76
77 let output = tokio::process::Command::new("git")
78 .args([
79 "clone",
80 "--depth",
81 "1",
82 &git_url,
83 &staging.path().to_string_lossy(),
84 ])
85 .output()
86 .await
87 .map_err(|e| format!("Failed to run git: {e}"))?;
88
89 if !output.status.success() {
90 let stderr = String::from_utf8_lossy(&output.stderr);
91 return Err(format!("git clone failed: {stderr}"));
92 }
93
94 let _ = std::fs::remove_dir_all(staging.path().join(".git"));
96
97 if staging.path().join("init.lua").exists() {
99 let name = name.unwrap_or_else(|| {
101 url.trim_end_matches('/')
102 .rsplit('/')
103 .next()
104 .unwrap_or("unknown")
105 .trim_end_matches(".git")
106 .to_string()
107 });
108
109 let dest = ContainedPath::child(&pkg_dir, &name)?;
110 if dest.as_ref().exists() {
111 return Err(format!(
112 "Package '{name}' already exists at {}. Remove it first.",
113 dest.as_ref().display()
114 ));
115 }
116
117 copy_dir(staging.path(), dest.as_ref())
118 .map_err(|e| format!("Failed to copy package: {e}"))?;
119
120 Ok(serde_json::json!({
121 "installed": [name],
122 "mode": "single",
123 })
124 .to_string())
125 } else {
126 if name.is_some() {
128 return Err(
130 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
131 This repository is a collection (subdirs with init.lua)."
132 .to_string(),
133 );
134 }
135
136 let mut installed = Vec::new();
137 let mut skipped = Vec::new();
138
139 let entries = std::fs::read_dir(staging.path())
140 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
141
142 for entry in entries {
143 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
144 let path = entry.path();
145 if !path.is_dir() {
146 continue;
147 }
148 if !path.join("init.lua").exists() {
149 continue;
150 }
151 let pkg_name = entry.file_name().to_string_lossy().to_string();
152 let dest = pkg_dir.join(&pkg_name);
153 if dest.exists() {
154 skipped.push(pkg_name);
155 continue;
156 }
157 copy_dir(&path, &dest)
158 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
159 installed.push(pkg_name);
160 }
161
162 let scenarios_subdir = staging.path().join("scenarios");
166 let mut scenarios_installed: Vec<String> = Vec::new();
167 let mut scenarios_failures: DirEntryFailures = Vec::new();
168 if scenarios_subdir.is_dir() {
169 if let Ok(sc_dir) = scenarios_dir() {
170 std::fs::create_dir_all(&sc_dir)
171 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
172 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
173 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
174 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
175 scenarios_installed = arr
176 .iter()
177 .filter_map(|v| v.as_str().map(String::from))
178 .collect();
179 }
180 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
181 scenarios_failures = arr
182 .iter()
183 .filter_map(|v| v.as_str().map(String::from))
184 .collect();
185 }
186 }
187 }
188 }
189 }
190
191 if installed.is_empty() && skipped.is_empty() {
192 return Err(
193 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
194 .to_string(),
195 );
196 }
197
198 Ok(serde_json::json!({
199 "installed": installed,
200 "skipped": skipped,
201 "scenarios_installed": scenarios_installed,
202 "scenarios_failures": scenarios_failures,
203 "mode": "collection",
204 })
205 .to_string())
206 }
207 }
208
209 fn install_from_local_path(
211 &self,
212 source: &Path,
213 pkg_dir: &Path,
214 name: Option<String>,
215 ) -> Result<String, String> {
216 if source.join("init.lua").exists() {
217 let name = name.unwrap_or_else(|| {
219 source
220 .file_name()
221 .map(|n| n.to_string_lossy().to_string())
222 .unwrap_or_else(|| "unknown".to_string())
223 });
224
225 let dest = ContainedPath::child(pkg_dir, &name)?;
226 if dest.as_ref().exists() {
227 let _ = std::fs::remove_dir_all(&dest);
229 }
230
231 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
232 let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
234
235 Ok(serde_json::json!({
236 "installed": [name],
237 "mode": "local_single",
238 })
239 .to_string())
240 } else {
241 if name.is_some() {
243 return Err(
244 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
245 .to_string(),
246 );
247 }
248
249 let mut installed = Vec::new();
250 let mut updated = Vec::new();
251
252 let entries =
253 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
254
255 for entry in entries {
256 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
257 let path = entry.path();
258 if !path.is_dir() || !path.join("init.lua").exists() {
259 continue;
260 }
261 let pkg_name = entry.file_name().to_string_lossy().to_string();
262 let dest = pkg_dir.join(&pkg_name);
263 let existed = dest.exists();
264 if existed {
265 let _ = std::fs::remove_dir_all(&dest);
266 }
267 copy_dir(&path, &dest)
268 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
269 let _ = std::fs::remove_dir_all(dest.join(".git"));
270 if existed {
271 updated.push(pkg_name);
272 } else {
273 installed.push(pkg_name);
274 }
275 }
276
277 if installed.is_empty() && updated.is_empty() {
278 return Err(
279 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
280 .to_string(),
281 );
282 }
283
284 Ok(serde_json::json!({
285 "installed": installed,
286 "updated": updated,
287 "mode": "local_collection",
288 })
289 .to_string())
290 }
291 }
292
293 pub async fn pkg_remove(&self, name: &str) -> Result<String, String> {
295 let pkg_dir = packages_dir()?;
296 let dest = ContainedPath::child(&pkg_dir, name)?;
297
298 if !dest.as_ref().exists() {
299 return Err(format!("Package '{name}' not found"));
300 }
301
302 std::fs::remove_dir_all(&dest).map_err(|e| format!("Failed to remove '{name}': {e}"))?;
303
304 Ok(serde_json::json!({ "removed": name }).to_string())
305 }
306
307 pub(super) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
309 let mut errors: Vec<String> = Vec::new();
310 for url in AUTO_INSTALL_SOURCES {
311 tracing::info!("auto-installing from {url}");
312 if let Err(e) = self.pkg_install(url.to_string(), None).await {
313 tracing::warn!("failed to auto-install from {url}: {e}");
314 errors.push(format!("{url}: {e}"));
315 }
316 }
317 if errors.len() == AUTO_INSTALL_SOURCES.len() {
319 return Err(format!(
320 "Failed to auto-install bundled packages: {}",
321 errors.join("; ")
322 ));
323 }
324 Ok(())
325 }
326}