1use std::io::{BufRead, IsTerminal, Write};
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20pub const SOURCE_FILE: &str = ".source.json";
22
23#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum PackageError {
27 #[error("{0}")]
29 Usage(String),
30 #[error("{0}")]
32 Fetch(String),
33 #[error("invalid package: {0}")]
35 Invalid(String),
36 #[error("{0}")]
38 Declined(String),
39 #[error("{context}: {source}")]
41 Io {
42 context: String,
44 source: std::io::Error,
46 },
47}
48
49fn io(context: impl Into<String>) -> impl FnOnce(std::io::Error) -> PackageError {
50 let context = context.into();
51 move |source| PackageError::Io { context, source }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct SourceRecord {
58 pub source: String,
60 pub version: String,
62 pub installed_at: String,
64}
65
66#[derive(Clone, Debug)]
68pub struct PackageContext {
69 pub agent_dir: PathBuf,
71 pub cwd: PathBuf,
73}
74
75impl PackageContext {
76 fn root(&self, project: bool) -> PathBuf {
77 if project {
78 self.cwd.join(".oino").join("extensions")
79 } else {
80 self.agent_dir.join("extensions")
81 }
82 }
83}
84
85#[derive(Debug, Default)]
87struct Flags {
88 project: bool,
89 yes: bool,
90 extensions: bool,
91 positional: Vec<String>,
92}
93
94fn parse(args: &[String]) -> Result<Flags, PackageError> {
95 let mut flags = Flags::default();
96 for arg in args {
97 match arg.as_str() {
98 "--project" | "-l" | "--local" => flags.project = true,
99 "--yes" | "-y" => flags.yes = true,
100 "--extensions" => flags.extensions = true,
101 other if other.starts_with('-') => {
102 return Err(PackageError::Usage(format!("Unknown option: {other}")));
103 }
104 other => flags.positional.push(other.to_owned()),
105 }
106 }
107 Ok(flags)
108}
109
110struct Fetched {
112 root: PathBuf,
113 _temp: Option<tempfile::TempDir>,
114}
115
116fn temp_dir(context: &PackageContext) -> Result<tempfile::TempDir, PackageError> {
117 let base = context.agent_dir.join("cache").join("packages");
118 std::fs::create_dir_all(&base).map_err(io(format!("creating {}", base.display())))?;
119 tempfile::tempdir_in(&base).map_err(io("creating a temporary directory"))
120}
121
122fn run(program: &str, args: &[&str]) -> Result<(), PackageError> {
123 let output = std::process::Command::new(program)
124 .args(args)
125 .output()
126 .map_err(|e| PackageError::Fetch(format!("running {program}: {e}")))?;
127 if output.status.success() {
128 Ok(())
129 } else {
130 Err(PackageError::Fetch(format!(
131 "{program} failed: {}",
132 String::from_utf8_lossy(&output.stderr).trim()
133 )))
134 }
135}
136
137fn is_archive(name: &str) -> bool {
138 [".tar.zst", ".tar.gz", ".tgz", ".tar"]
139 .iter()
140 .any(|ext| name.ends_with(ext))
141}
142
143fn unpack(archive: &Path, into: &Path) -> Result<(), PackageError> {
144 let archive = archive.to_string_lossy();
145 let into = into.to_string_lossy();
146 if archive.ends_with(".tar.zst") {
147 run("tar", &["--zstd", "-xf", &archive, "-C", &into])
148 } else {
149 run("tar", &["-xf", &archive, "-C", &into])
150 }
151}
152
153async fn download(url: &str, to: &Path) -> Result<(), PackageError> {
154 let response = reqwest::get(url)
155 .await
156 .and_then(reqwest::Response::error_for_status)
157 .map_err(|e| PackageError::Fetch(format!("downloading {url}: {e}")))?;
158 let bytes = response
159 .bytes()
160 .await
161 .map_err(|e| PackageError::Fetch(format!("downloading {url}: {e}")))?;
162 std::fs::write(to, &bytes).map_err(io(format!("writing {}", to.display())))
163}
164
165fn package_root(dir: &Path) -> Result<PathBuf, PackageError> {
167 if dir.join("extension.json").is_file() {
168 return Ok(dir.to_path_buf());
169 }
170 let children: Vec<PathBuf> = std::fs::read_dir(dir)
171 .map_err(io(format!("reading {}", dir.display())))?
172 .filter_map(Result::ok)
173 .map(|e| e.path())
174 .filter(|p| p.is_dir() && p.join("extension.json").is_file())
175 .collect();
176 match children.as_slice() {
177 [only] => Ok(only.clone()),
178 [] => Err(PackageError::Invalid(format!(
179 "no extension.json in {}",
180 dir.display()
181 ))),
182 _ => Err(PackageError::Invalid(format!(
183 "{} holds several packages; install one directory",
184 dir.display()
185 ))),
186 }
187}
188
189fn canonical_source(source: &str, cwd: &Path) -> String {
191 if source.starts_with("https://") || source.starts_with("git+") {
192 return source.to_owned();
193 }
194 let path = Path::new(source);
195 let path = if path.is_absolute() {
196 path.to_path_buf()
197 } else {
198 cwd.join(path)
199 };
200 path.display().to_string()
201}
202
203async fn fetch(source: &str, context: &PackageContext) -> Result<Fetched, PackageError> {
204 if let Some(git) = source.strip_prefix("git+") {
205 let (url, reference) = git
206 .split_once('#')
207 .map_or((git, None), |(u, r)| (u, Some(r)));
208 let temp = temp_dir(context)?;
209 let target = temp.path().join("repo");
210 let target_text = target.to_string_lossy().into_owned();
211 let mut args = vec!["clone", "--depth", "1"];
212 if let Some(reference) = reference {
213 args.extend(["--branch", reference]);
214 }
215 args.extend([url, target_text.as_str()]);
216 run("git", &args)?;
217 return Ok(Fetched {
218 root: package_root(&target)?,
219 _temp: Some(temp),
220 });
221 }
222 if source.starts_with("https://") || source.starts_with("http://") {
223 if !is_archive(source.split(['?', '#']).next().unwrap_or(source)) {
224 return Err(PackageError::Usage(format!(
225 "URL sources must point to a .tar.zst, .tar.gz or .tgz archive: {source}"
226 )));
227 }
228 let temp = temp_dir(context)?;
229 let name = source
230 .split(['?', '#'])
231 .next()
232 .and_then(|s| s.rsplit('/').next())
233 .unwrap_or("package.tar.gz");
234 let archive = temp.path().join(name);
235 download(source, &archive).await?;
236 let unpacked = temp.path().join("unpacked");
237 std::fs::create_dir_all(&unpacked).map_err(io("creating the unpack directory"))?;
238 unpack(&archive, &unpacked)?;
239 return Ok(Fetched {
240 root: package_root(&unpacked)?,
241 _temp: Some(temp),
242 });
243 }
244 let path = PathBuf::from(canonical_source(source, &context.cwd));
245 if path.is_dir() {
246 return Ok(Fetched {
247 root: package_root(&path)?,
248 _temp: None,
249 });
250 }
251 if path.is_file() && is_archive(&path.to_string_lossy()) {
252 let temp = temp_dir(context)?;
253 unpack(&path, temp.path())?;
254 return Ok(Fetched {
255 root: package_root(temp.path())?,
256 _temp: Some(temp),
257 });
258 }
259 Err(PackageError::Fetch(format!("Source not found: {source}")))
260}
261
262fn permission_lines(manifest: &oino_extension_api::manifest::Manifest) -> Vec<String> {
264 let Ok(Value::Object(permissions)) = serde_json::to_value(&manifest.permissions) else {
265 return Vec::new();
266 };
267 permissions
268 .into_iter()
269 .filter(|(_, v)| match v {
270 Value::Bool(b) => *b,
271 Value::Null => false,
272 Value::Array(a) => !a.is_empty(),
273 Value::Object(o) => o.values().any(|v| {
274 !matches!(v, Value::Bool(false) | Value::Null) && v != &json!([]) && v != &json!({})
275 }),
276 Value::String(s) => !s.is_empty() && s != "none",
277 Value::Number(_) => true,
278 })
279 .map(|(k, v)| format!(" {k}: {v}"))
280 .collect()
281}
282
283fn copy_dir(from: &Path, to: &Path) -> Result<(), PackageError> {
284 std::fs::create_dir_all(to).map_err(io(format!("creating {}", to.display())))?;
285 for entry in std::fs::read_dir(from).map_err(io(format!("reading {}", from.display())))? {
286 let entry = entry.map_err(io(format!("reading {}", from.display())))?;
287 let name = entry.file_name();
288 if name == ".git" {
289 continue;
290 }
291 let target = to.join(&name);
292 if entry.file_type().is_ok_and(|t| t.is_dir()) {
293 copy_dir(&entry.path(), &target)?;
294 } else {
295 std::fs::copy(entry.path(), &target)
296 .map_err(io(format!("copying {}", entry.path().display())))?;
297 }
298 }
299 Ok(())
300}
301
302fn now_rfc3339() -> String {
303 let secs = std::time::SystemTime::now()
304 .duration_since(std::time::UNIX_EPOCH)
305 .map_or(0, |d| d.as_secs());
306 let days = secs / 86_400;
307 let rem = secs % 86_400;
308 let z = i64::try_from(days).unwrap_or(0) + 719_468;
310 let era = z.div_euclid(146_097);
311 let doe = z.rem_euclid(146_097);
312 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
313 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
314 let mp = (5 * doy + 2) / 153;
315 let day = doy - (153 * mp + 2) / 5 + 1;
316 let month = if mp < 10 { mp + 3 } else { mp - 9 };
317 let year = yoe + era * 400 + i64::from(month <= 2);
318 format!(
319 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
320 rem / 3600,
321 (rem % 3600) / 60,
322 rem % 60
323 )
324}
325
326fn confirm(question: &str) -> bool {
327 let mut stderr = std::io::stderr().lock();
328 let _ = write!(stderr, "{question} [y/N] ");
329 let _ = stderr.flush();
330 let mut line = String::new();
331 std::io::stdin().lock().read_line(&mut line).is_ok()
332 && matches!(line.trim().to_lowercase().as_str(), "y" | "yes")
333}
334
335pub async fn install(
341 source: &str,
342 project: bool,
343 yes: bool,
344 context: &PackageContext,
345 out: &mut dyn Write,
346) -> Result<(String, String), PackageError> {
347 let fetched = fetch(source, context).await?;
348 let package = oino_wasm_host::packages::load_dir(&fetched.root, false)
349 .map_err(|e| PackageError::Invalid(e.to_string()))?;
350 let manifest = &package.manifest;
351 let id = manifest.id.to_string();
352 let lines = permission_lines(manifest);
353 let _ = writeln!(out, "{} {} ({id})", manifest.name, manifest.version);
354 if lines.is_empty() {
355 let _ = writeln!(out, "Permissions: none");
356 } else {
357 let _ = writeln!(out, "Permissions:");
358 for line in &lines {
359 let _ = writeln!(out, "{line}");
360 }
361 }
362 if !yes {
363 if !std::io::stdin().is_terminal() {
364 return Err(PackageError::Declined(
365 "Pass --yes to install without a terminal".to_owned(),
366 ));
367 }
368 if !confirm("Install?") {
369 return Err(PackageError::Declined("Installation cancelled".to_owned()));
370 }
371 }
372 let root = context.root(project);
373 std::fs::create_dir_all(&root).map_err(io(format!("creating {}", root.display())))?;
374 let target = root.join(&id);
375 let staging = root.join(format!(".{id}.installing"));
376 let old = root.join(format!(".{id}.old"));
377 let _ = std::fs::remove_dir_all(&staging);
378 let _ = std::fs::remove_dir_all(&old);
379 copy_dir(&fetched.root, &staging)?;
380 let record = SourceRecord {
381 source: canonical_source(source, &context.cwd),
382 version: manifest.version.clone(),
383 installed_at: now_rfc3339(),
384 };
385 let text = serde_json::to_string_pretty(&record).unwrap_or_default();
386 std::fs::write(staging.join(SOURCE_FILE), format!("{text}\n"))
387 .map_err(io("writing the source record"))?;
388 if target.exists() {
389 std::fs::rename(&target, &old).map_err(io(format!("replacing {}", target.display())))?;
390 }
391 std::fs::rename(&staging, &target).map_err(io(format!("installing {}", target.display())))?;
392 let _ = std::fs::remove_dir_all(&old);
393 let _ = writeln!(
394 out,
395 "Installed {id} {} into {}",
396 manifest.version,
397 target.display()
398 );
399 Ok((id, manifest.version.clone()))
400}
401
402pub fn remove(id: &str, project: bool, context: &PackageContext) -> Result<PathBuf, PackageError> {
408 let target = context.root(project).join(id);
409 if !target.join("extension.json").is_file() {
410 let share = oino_core::extensions::sources::dist_share_dir();
411 if share.is_some_and(|dir| dir.join(id).join("extension.json").is_file()) {
412 return Err(PackageError::Usage(format!(
413 "{id} is a distribution package and cannot be removed; disable it with \"disabledExtensions\" in settings.json"
414 )));
415 }
416 return Err(PackageError::Usage(format!("Not installed: {id}")));
417 }
418 std::fs::remove_dir_all(&target).map_err(io(format!("removing {}", target.display())))?;
419 Ok(target)
420}
421
422#[derive(Clone, Debug, PartialEq, Eq)]
424pub struct Listed {
425 pub id: String,
427 pub version: String,
429 pub scope: &'static str,
431 pub source: Option<String>,
433}
434
435fn read_record(dir: &Path) -> Option<SourceRecord> {
436 serde_json::from_str(&std::fs::read_to_string(dir.join(SOURCE_FILE)).ok()?).ok()
437}
438
439#[must_use]
441pub fn list(context: &PackageContext) -> Vec<Listed> {
442 let mut out = Vec::new();
443 if let Some(share) = oino_core::extensions::sources::dist_share_dir() {
444 let (packages, _) = oino_wasm_host::packages::discover(std::slice::from_ref(&share), true);
445 out.extend(packages.into_iter().map(|p| Listed {
446 id: p.manifest.id.to_string(),
447 version: p.manifest.version.clone(),
448 scope: "dist",
449 source: None,
450 }));
451 }
452 for (project, scope) in [(false, "global"), (true, "project")] {
453 let (packages, _) = oino_wasm_host::packages::discover(&[context.root(project)], false);
454 out.extend(packages.into_iter().map(|p| Listed {
455 id: p.manifest.id.to_string(),
456 version: p.manifest.version.clone(),
457 scope,
458 source: p.root.as_deref().and_then(read_record).map(|r| r.source),
459 }));
460 }
461 out
462}
463
464pub async fn update(
470 ids: &[String],
471 project: bool,
472 context: &PackageContext,
473 out: &mut dyn Write,
474) -> Result<usize, PackageError> {
475 let root = context.root(project);
476 let (packages, _) = oino_wasm_host::packages::discover(std::slice::from_ref(&root), false);
477 let mut updated = 0;
478 for package in packages {
479 let id = package.manifest.id.to_string();
480 if !ids.is_empty() && !ids.contains(&id) {
481 continue;
482 }
483 let Some(record) = package.root.as_deref().and_then(read_record) else {
484 let _ = writeln!(out, "Skipping {id}: no {SOURCE_FILE} (installed by hand)");
485 continue;
486 };
487 let (_, version) = install(&record.source, project, true, context, out).await?;
488 if version != record.version {
489 let _ = writeln!(out, "Updated {id}: {} → {version}", record.version);
490 }
491 updated += 1;
492 }
493 for id in ids {
494 if !root.join(id).join("extension.json").is_file() {
495 return Err(PackageError::Usage(format!("Not installed: {id}")));
496 }
497 }
498 Ok(updated)
499}
500
501pub async fn run_command(command: &str, args: &[String], context: &PackageContext) -> i32 {
503 let mut stdout = std::io::stdout().lock();
504 if args.iter().any(|a| a == "--help" || a == "-h") {
505 let usage = match command {
506 "install" => {
507 "oino install <source> [--project|-l] [--yes|-y]\n <source>: package dir, .tar.zst/.tar.gz archive, https:// archive URL, git+https://…[#ref]"
508 }
509 "remove" | "uninstall" => "oino remove <id> [--project|-l]",
510 "update" => {
511 "oino update [--check] update Oino itself\noino update <id>… | --extensions re-install packages from .source.json"
512 }
513 "list" => "oino list",
514 _ => "oino config",
515 };
516 let _ = writeln!(stdout, "Usage: {usage}");
517 return 0;
518 }
519 let result = match command {
520 "install" => match parse(args) {
521 Ok(flags) => match flags.positional.as_slice() {
522 [source] => install(source, flags.project, flags.yes, context, &mut stdout)
523 .await
524 .map(drop),
525 _ => Err(PackageError::Usage(
526 "Usage: oino install <source> [--project] [--yes]".to_owned(),
527 )),
528 },
529 Err(error) => Err(error),
530 },
531 "remove" | "uninstall" => match parse(args) {
532 Ok(flags) => match flags.positional.as_slice() {
533 [id] => remove(id, flags.project, context).map(|path| {
534 let _ = writeln!(stdout, "Removed {id} ({})", path.display());
535 }),
536 _ => Err(PackageError::Usage(format!(
537 "Usage: oino {command} <id> [--project]"
538 ))),
539 },
540 Err(error) => Err(error),
541 },
542 "update" => match parse(args) {
543 Ok(flags) => update(&flags.positional, flags.project, context, &mut stdout)
544 .await
545 .map(|n| {
546 let _ = writeln!(stdout, "{n} package(s) updated");
547 }),
548 Err(error) => Err(error),
549 },
550 "list" => {
551 for item in list(context) {
552 let source = item.source.map(|s| format!(" {s}")).unwrap_or_default();
553 let _ = writeln!(
554 stdout,
555 "{:<32} {:<14} {:<8}{source}",
556 item.id, item.version, item.scope
557 );
558 }
559 Ok(())
560 }
561 "config" => {
562 let _ = writeln!(
563 stdout,
564 "{}",
565 context.agent_dir.join("settings.json").display()
566 );
567 let project = context.cwd.join(".oino").join("settings.json");
568 if project.is_file() {
569 let _ = writeln!(stdout, "{}", project.display());
570 }
571 Ok(())
572 }
573 other => Err(PackageError::Usage(format!("Unknown command: {other}"))),
574 };
575 match result {
576 Ok(()) => 0,
577 Err(error) => {
578 crate::eprintln_line(&format!("Error: {error}"));
579 1
580 }
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn dates_and_sources() {
590 let date = now_rfc3339();
591 assert_eq!(date.len(), 20, "{date}");
592 assert!(date.starts_with("20"));
593 assert_eq!(
594 canonical_source("pkg", Path::new("/w")),
595 Path::new("/w/pkg").display().to_string()
596 );
597 assert_eq!(
598 canonical_source("git+https://x/y#v1", Path::new("/w")),
599 "git+https://x/y#v1"
600 );
601 assert!(is_archive("a.tar.zst") && is_archive("a.tgz") && !is_archive("a.zip"));
602 }
603
604 #[test]
605 fn flags_parse() {
606 let flags = parse(&["x".into(), "-y".into(), "--project".into()]).unwrap();
607 assert!(flags.yes && flags.project);
608 assert_eq!(flags.positional, ["x"]);
609 assert!(parse(&["--bogus".into()]).is_err());
610 }
611}