Skip to main content

oino/cli/
packages.rs

1//! Package manager subcommands (modes.md §1.2, runtime §15): `oino install <source>
2//! [--project] [--yes]`, `remove|uninstall <id> [--project]`, `update <id>… | --extensions`,
3//! `list`, `config`.
4//!
5//! Sources: a local package directory, a `.tar.zst` / `.tar.gz` / `.tgz` archive file, an
6//! `https://` URL of such an archive, or `git+https://…[#ref]` (cloned with the system
7//! `git`). Archives are unpacked with the system `tar`. A package is a directory with
8//! `extension.json` (at the source root or in its only subdirectory); it is validated
9//! (manifest, `apiVersion`, `checksums`) before it is copied to `<agent>/extensions/<id>/`
10//! (or `.oino/extensions/<id>/` with `--project`) next to a `.source.json` record that
11//! `update` re-installs from. `list` also shows the distribution share-dir packages
12//! (`OINO_DIST_EXTENSIONS`), which the installers own and `remove` refuses to touch.
13
14use std::io::{BufRead, IsTerminal, Write};
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20/// The record `update` re-installs from.
21pub const SOURCE_FILE: &str = ".source.json";
22
23/// Failure of a package command (printed as `Error: …`).
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum PackageError {
27    /// Bad command line.
28    #[error("{0}")]
29    Usage(String),
30    /// The source could not be fetched or unpacked.
31    #[error("{0}")]
32    Fetch(String),
33    /// The package is invalid.
34    #[error("invalid package: {0}")]
35    Invalid(String),
36    /// The user declined, or `--yes` is missing without a terminal.
37    #[error("{0}")]
38    Declined(String),
39    /// File system failure.
40    #[error("{context}: {source}")]
41    Io {
42        /// What was being done.
43        context: String,
44        /// Cause.
45        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/// `.source.json`.
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct SourceRecord {
58    /// Source as given (absolute for local paths).
59    pub source: String,
60    /// Installed version.
61    pub version: String,
62    /// Install time (RFC 3339, UTC).
63    pub installed_at: String,
64}
65
66/// Where a command operates.
67#[derive(Clone, Debug)]
68pub struct PackageContext {
69    /// Agent dir (`~/.oino`).
70    pub agent_dir: PathBuf,
71    /// Working directory (project scope, relative sources).
72    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/// Parsed flags of one subcommand.
86#[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
110/// A fetched source: the package directory (and a temp dir to drop afterwards).
111struct 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
165/// The directory holding `extension.json`: `dir` itself or its only subdirectory.
166fn 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
189/// Normalises a source (absolute local paths) for `.source.json`.
190fn 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
262/// Non-default permission entries of a manifest, one `key: value` line each.
263fn 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    // Civil-from-days (Howard Hinnant), UTC.
309    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
335/// Installs `source`; returns the installed id and version.
336///
337/// # Errors
338///
339/// Fetch, validation, confirmation and copy failures.
340pub 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
402/// Removes an installed package.
403///
404/// # Errors
405///
406/// Unknown, distribution-owned ids, I/O failures.
407pub 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/// One distribution, installed or project package for `list`.
423#[derive(Clone, Debug, PartialEq, Eq)]
424pub struct Listed {
425    /// Id.
426    pub id: String,
427    /// Version.
428    pub version: String,
429    /// `dist`, `global` or `project`.
430    pub scope: &'static str,
431    /// Recorded source.
432    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/// Distribution (share dir), installed packages (global, then project).
440#[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
464/// Re-installs `ids` (every package with a source record when empty) from their records.
465///
466/// # Errors
467///
468/// The first failure; earlier packages stay updated.
469pub 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
501/// Runs `oino <command> <args…>` for the package commands; returns the exit code.
502pub 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}