Skip to main content

memstead_cli/commands/
install.rs

1//! `memstead install` — two accepted input shapes:
2//!
3//! * `memstead install <path/to/file.mem>` — local-file install.
4//! * `memstead install <scope>/<name>` — registry install.
5//!   Downloads the archive from `<registry>/api/mem/<scope>/<name>.mem`
6//!   into a tempfile, then funnels through the same cache helper the
7//!   local path uses. No authentication required — registry downloads
8//!   are public.
9//!
10//! Both shapes:
11//!
12//! 1. Validate and copy (or re-validate) the archive into the global
13//!    mem cache (`<data_dir>/memstead/mems/<name>-<key>.mem`).
14//! 2. Register the archive as a **workspace-level read-only mount**
15//!    in the engine-managed mount state (`.memstead/state/mounts.json`),
16//!    carrying `capability: read_only` and the content-addressed cache
17//!    path as its `Archive` storage reference. No writable mem's
18//!    config is touched — a read-mem attaches to the workspace, not to
19//!    a host mem. `memstead uninstall <name>` is the symmetric removal.
20
21use std::path::{Path, PathBuf};
22
23use clap::Parser;
24use serde_json::json;
25
26use memstead_git_branch::mem_cache::{self, CacheInstallOutcome, MountRegistration};
27
28use crate::CliError;
29use crate::output::{ExitKind, print_json, print_markdown};
30use crate::registry::{self, DownloadError};
31use crate::setup::CliContext;
32
33/// Install a sealed mem archive: validate + copy into the global mem
34/// cache, then register it as a workspace-level read-only mount. The
35/// archive's internal name is its sole identity — cross-mem references
36/// and shadow checks use it. Archives with non-slug-form body
37/// wiki-links refuse with `INVALID_WIKI_LINK_TARGET` — convert via
38/// search-and-replace before installing.
39#[derive(Parser, Debug)]
40pub struct Args {
41    /// Either a path to a `.mem` file, or
42    /// `<scope>/<name>` for registry installs (no `@` prefix).
43    #[arg(value_name = "PATH or SCOPE/NAME")]
44    pub source: String,
45
46    /// Registry URL for `<scope>/<name>` installs. Ignored for local paths.
47    /// Overrides `MEMSTEAD_REGISTRY`; defaults to https://memstead.io.
48    #[arg(long, value_name = "URL")]
49    pub registry: Option<String>,
50}
51
52pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
53    let mut engine = crate::setup::full_engine(ctx)?;
54
55    // The legacy `@scope/name` syntax is rejected, not silently treated as a
56    // local path. Typed refusal — a user-triggerable input shape must
57    // never surface as INTERNAL.
58    if args.source.starts_with('@') {
59        return Err(CliError::new(
60            ExitKind::Validation,
61            "INVALID_INPUT",
62            "the `@scope/name` syntax is no longer supported — use \
63             `github:<handle>/<name>`, `<domain>/<name>`, or a bare `<handle>/<name>`",
64        )
65        .into());
66    }
67
68    // Registry install path: "<scope>/<name>".
69    if let Some((scope, name)) = registry::parse_ref(&args.source) {
70        let base = registry::registry_base(args.registry.as_deref());
71        let client = registry::build_http()?;
72
73        // Stream the archive into a tempfile; the cache helper reads
74        // from a path, so a tempfile is the cheapest bridge.
75        let tmp = tempfile::NamedTempFile::new().map_err(|e| {
76            CliError::new(
77                ExitKind::Generic,
78                crate::INTERNAL_CODE,
79                format!("tempfile: {e}"),
80            )
81        })?;
82        registry::download_mem(&client, &base, &scope, &name, tmp.path()).map_err(|e| {
83            let msg = match &e {
84                DownloadError::NotFound => {
85                    format!("{scope}/{name} not found on {base}")
86                }
87                DownloadError::Gone => {
88                    format!("{scope}/{name} has been taken down")
89                }
90                _ => format!("download failed: {e}"),
91            };
92            let code: &'static str = match &e {
93                DownloadError::NotFound => "REGISTRY_NOT_FOUND",
94                DownloadError::Gone => "GONE",
95                _ => "REGISTRY_ERROR",
96            };
97            CliError::new(
98                match e {
99                    DownloadError::NotFound => ExitKind::NotFound,
100                    _ => ExitKind::Generic,
101                },
102                code,
103                msg,
104            )
105        })?;
106
107        let source_url = format!(
108            "{base}/api/mem/{scope}/{name}.mem",
109            base = base,
110            scope = scope,
111            name = name
112        );
113        return install_archive(ctx, &mut engine, tmp.path(), Some(source_url));
114    }
115
116    // Local path install.
117    let path = PathBuf::from(&args.source);
118    install_archive(ctx, &mut engine, &path, None)
119}
120
121/// The shared back half of both install shapes: cache the archive,
122/// then register (or refresh) the workspace-level read-only mount.
123fn install_archive(
124    ctx: &CliContext,
125    engine: &mut memstead_base::Engine,
126    archive: &Path,
127    source_url: Option<String>,
128) -> anyhow::Result<()> {
129    // The shadow gate runs against the writable roster — an archive
130    // whose internal name collides with a writable mem refuses before
131    // any side effect.
132    let writable: Vec<String> = engine
133        .mem_router()
134        .writable_mems()
135        .iter()
136        .map(|n| n.to_string())
137        .collect();
138    let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
139
140    let outcome =
141        mem_cache::install_to_cache(archive, &writable_refs).map_err(install_err_to_cli)?;
142
143    let mount_state = mem_cache::register_cached_archive(engine, &outcome, "memstead install")
144        .map_err(engine_err_to_cli)?;
145    if mount_state != mem_cache::MountRegistration::AlreadyRegistered {
146        engine.persist_state().map_err(engine_err_to_cli)?;
147    }
148
149    emit_outcome(ctx, outcome, mount_state, source_url)
150}
151
152fn emit_outcome(
153    ctx: &CliContext,
154    outcome: CacheInstallOutcome,
155    mount_state: MountRegistration,
156    source_url: Option<String>,
157) -> anyhow::Result<()> {
158    let mount_status_wire = match mount_state {
159        MountRegistration::Registered => "registered",
160        MountRegistration::AlreadyRegistered => "already_registered",
161        MountRegistration::Refreshed => "refreshed",
162    };
163    if ctx.json {
164        print_json(&json!({
165            "mem_name": outcome.mem_name,
166            "copied_to_cache": outcome.copied_to_cache,
167            "mount": mount_status_wire,
168            "cache_path": outcome.cache_path.to_string_lossy(),
169            "source_url": source_url,
170            // `{ code, message, details }` envelopes — same shape every
171            // warning-carrying surface uses.
172            "warnings": outcome.warnings,
173        }))?;
174    } else {
175        let cache_status = if outcome.copied_to_cache {
176            "copied into cache"
177        } else {
178            "already in cache (unchanged)"
179        };
180        let mount_status = match mount_state {
181            MountRegistration::Registered => {
182                "registered as a workspace-level read-only mount".to_string()
183            }
184            MountRegistration::AlreadyRegistered => {
185                "already registered as a read-mem mount (unchanged)".to_string()
186            }
187            MountRegistration::Refreshed => {
188                "read-mem mount refreshed to the new archive content".to_string()
189            }
190        };
191        let mut body = format!(
192            "# Installed `{}`\n\n- Archive: {}\n- Mount: {}",
193            outcome.mem_name, cache_status, mount_status,
194        );
195        if let Some(url) = source_url {
196            body.push_str(&format!("\n- Source: {url}"));
197        }
198        if !outcome.warnings.is_empty() {
199            body.push_str("\n\n## Warnings\n");
200            for w in &outcome.warnings {
201                body.push_str(&format!("\n- **{}**: {}", w.code(), w.message()));
202            }
203        }
204        print_markdown(&body);
205    }
206    Ok(())
207}
208
209/// Map `InstallError` into the CLI error envelope. The
210/// `ShadowsWritable` variant gets a typed
211/// `READ_MEM_SHADOWS_WRITABLE` wire code with structured
212/// `details.archive_name` + `details.shadows_writable` so callers
213/// branch on the code rather than parsing the message. Other
214/// variants stay on the generic exit code with the underlying error
215/// message — they already carry the right shape for the CLI.
216fn install_err_to_cli(e: memstead_git_branch::mem_cache::InstallError) -> anyhow::Error {
217    use memstead_git_branch::mem_cache::InstallError;
218    if let InstallError::ShadowsWritable {
219        archive_name,
220        shadows_writable,
221    } = &e
222    {
223        return CliError::new(
224            ExitKind::Validation,
225            "READ_MEM_SHADOWS_WRITABLE",
226            e.to_string(),
227        )
228        .with_details(json!({
229            "archive_name": archive_name,
230            "shadows_writable": shadows_writable,
231        }))
232        .into();
233    }
234    // There is no `CACHE_NAME_COLLISION` mapping: the cache is
235    // content-addressed (`<name>-<content_key>.mem`), so distinct bytes
236    // under the same mem name don't collide and the engine cannot produce
237    // `InstallError::CacheNameCollision`.
238    // Install-archive validation failures route through the typed
239    // ARCHIVE_VALIDATION_FAILED code (F10 of the 2026-05-18 CLI probe).
240    // Other InstallError variants (write failures, etc.) flow through the
241    // same envelope but the wire-shape captures the refusal source via the
242    // message text.
243    CliError::new(
244        ExitKind::Generic,
245        crate::ARCHIVE_VALIDATION_FAILED_CODE,
246        e.to_string(),
247    )
248    .into()
249}
250
251/// Map engine-side registration errors into the typed CLI envelope.
252fn engine_err_to_cli(e: memstead_base::EngineError) -> anyhow::Error {
253    CliError::from_engine_op(e).into()
254}
255
256#[cfg(test)]
257mod tests {
258    use crate::registry::parse_ref;
259
260    #[test]
261    fn parse_ref_accepts_three_scope_forms() {
262        assert_eq!(
263            parse_ref("memstead/knowledge"),
264            Some(("memstead".into(), "knowledge".into()))
265        );
266        assert_eq!(
267            parse_ref("github:alice/foo"),
268            Some(("github:alice".into(), "foo".into()))
269        );
270        assert_eq!(
271            parse_ref("acme.com:payments/foo"),
272            Some(("acme.com:payments".into(), "foo".into()))
273        );
274    }
275
276    #[test]
277    fn parse_ref_rejects_local_paths() {
278        assert!(parse_ref("/tmp/foo.mem").is_none());
279        assert!(parse_ref("./foo.mem").is_none());
280        assert!(parse_ref("foo.mem").is_none());
281    }
282
283    #[test]
284    fn parse_ref_rejects_legacy_at_and_malformed() {
285        // The legacy `@scope/name` syntax is not a valid registry ref.
286        assert!(parse_ref("@memstead/knowledge").is_none());
287        assert!(parse_ref("memstead").is_none()); // no name
288        assert!(parse_ref("/knowledge").is_none()); // empty scope
289        assert!(parse_ref("memstead/").is_none()); // empty name
290        assert!(parse_ref("memstead/knowledge.mem").is_none()); // extension
291        assert!(parse_ref("memstead/subdir/knowledge").is_none()); // path-shaped name
292    }
293}