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        // Typed, not INTERNAL: a full or unwritable temp directory is
76        // an environment condition the user can act on, and no leaf of
77        // the install flow may collapse into the generic sentinel.
78        let tmp = tempfile::NamedTempFile::new().map_err(|e| {
79            CliError::new(
80                ExitKind::Generic,
81                "INTERNAL_IO_ERROR",
82                format!(
83                    "could not create a temporary file to download into ({e}) — check that the \
84                     system temp directory is writable and has free space"
85                ),
86            )
87        })?;
88        registry::download_mem(&client, &base, &scope, &name, tmp.path()).map_err(|e| {
89            let msg = match &e {
90                DownloadError::NotFound => {
91                    format!("{scope}/{name} not found on {base}")
92                }
93                DownloadError::Gone => {
94                    format!("{scope}/{name} has been taken down")
95                }
96                _ => format!("download failed: {e}"),
97            };
98            let code: &'static str = match &e {
99                DownloadError::NotFound => "REGISTRY_NOT_FOUND",
100                DownloadError::Gone => "GONE",
101                _ => "REGISTRY_ERROR",
102            };
103            CliError::new(
104                match e {
105                    DownloadError::NotFound => ExitKind::NotFound,
106                    _ => ExitKind::Generic,
107                },
108                code,
109                msg,
110            )
111        })?;
112
113        let source_url = format!(
114            "{base}/api/mem/{scope}/{name}.mem",
115            base = base,
116            scope = scope,
117            name = name
118        );
119        return install_archive(ctx, &mut engine, tmp.path(), Some(source_url));
120    }
121
122    // Local path install.
123    let path = PathBuf::from(&args.source);
124    install_archive(ctx, &mut engine, &path, None)
125}
126
127/// The shared back half of both install shapes: cache the archive,
128/// then register (or refresh) the workspace-level read-only mount.
129fn install_archive(
130    ctx: &CliContext,
131    engine: &mut memstead_base::Engine,
132    archive: &Path,
133    source_url: Option<String>,
134) -> anyhow::Result<()> {
135    // The shadow gate runs against the writable roster — an archive
136    // whose internal name collides with a writable mem refuses before
137    // any side effect.
138    let writable: Vec<String> = engine
139        .mem_router()
140        .writable_mems()
141        .iter()
142        .map(|n| n.to_string())
143        .collect();
144    let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
145
146    let outcome =
147        mem_cache::install_to_cache(archive, &writable_refs).map_err(install_err_to_cli)?;
148
149    let mount_state = mem_cache::register_cached_archive(engine, &outcome, "memstead install")
150        .map_err(engine_err_to_cli)?;
151    if mount_state != mem_cache::MountRegistration::AlreadyRegistered {
152        engine.persist_state().map_err(engine_err_to_cli)?;
153    }
154
155    emit_outcome(ctx, outcome, mount_state, source_url)
156}
157
158fn emit_outcome(
159    ctx: &CliContext,
160    outcome: CacheInstallOutcome,
161    mount_state: MountRegistration,
162    source_url: Option<String>,
163) -> anyhow::Result<()> {
164    let mount_status_wire = match mount_state {
165        MountRegistration::Registered => "registered",
166        MountRegistration::AlreadyRegistered => "already_registered",
167        MountRegistration::Refreshed => "refreshed",
168    };
169    if ctx.json {
170        print_json(&json!({
171            "mem_name": outcome.mem_name,
172            "copied_to_cache": outcome.copied_to_cache,
173            "mount": mount_status_wire,
174            "cache_path": outcome.cache_path.to_string_lossy(),
175            "source_url": source_url,
176            // `{ code, message, details }` envelopes — same shape every
177            // warning-carrying surface uses.
178            "warnings": outcome.warnings,
179        }))?;
180    } else {
181        let cache_status = if outcome.copied_to_cache {
182            "copied into cache"
183        } else {
184            "already in cache (unchanged)"
185        };
186        let mount_status = match mount_state {
187            MountRegistration::Registered => {
188                "registered as a workspace-level read-only mount".to_string()
189            }
190            MountRegistration::AlreadyRegistered => {
191                "already registered as a read-mem mount (unchanged)".to_string()
192            }
193            MountRegistration::Refreshed => {
194                "read-mem mount refreshed to the new archive content".to_string()
195            }
196        };
197        let mut body = format!(
198            "# Installed `{}`\n\n- Archive: {}\n- Mount: {}",
199            outcome.mem_name, cache_status, mount_status,
200        );
201        if let Some(url) = source_url {
202            body.push_str(&format!("\n- Source: {url}"));
203        }
204        if !outcome.warnings.is_empty() {
205            body.push_str("\n\n## Warnings\n");
206            for w in &outcome.warnings {
207                body.push_str(&format!("\n- **{}**: {}", w.code(), w.message()));
208            }
209        }
210        print_markdown(&body);
211    }
212    Ok(())
213}
214
215/// Map `InstallError` into the CLI error envelope. The
216/// `ShadowsWritable` variant gets a typed
217/// `READ_MEM_SHADOWS_WRITABLE` wire code with structured
218/// `details.archive_name` + `details.shadows_writable` so callers
219/// branch on the code rather than parsing the message. Other
220/// variants stay on the generic exit code with the underlying error
221/// message — they already carry the right shape for the CLI.
222fn install_err_to_cli(e: memstead_git_branch::mem_cache::InstallError) -> anyhow::Error {
223    use memstead_base::validator::ValidationError;
224    use memstead_git_branch::mem_cache::InstallError;
225    // An archive whose own embedded schema will not load is its own
226    // refusal class, not a generic validation failure and emphatically
227    // not `SCHEMA_NOT_FOUND`: the package is inside the archive the
228    // user just handed us, so no amount of `memstead schema install`
229    // helps. Same code the engine raises when the staging half catches
230    // it, so one class reads as one code whichever gate fires.
231    if let InstallError::Validation(
232        ValidationError::EmbeddedSchemaInvalid { .. }
233        | ValidationError::EmbeddedSchemaMismatch { .. },
234    ) = &e
235    {
236        return CliError::new(
237            ExitKind::Validation,
238            "EMBEDDED_SCHEMA_INVALID",
239            e.to_string(),
240        )
241        .into();
242    }
243    if let InstallError::ShadowsWritable {
244        archive_name,
245        shadows_writable,
246    } = &e
247    {
248        return CliError::new(
249            ExitKind::Validation,
250            "READ_MEM_SHADOWS_WRITABLE",
251            e.to_string(),
252        )
253        .with_details(json!({
254            "archive_name": archive_name,
255            "shadows_writable": shadows_writable,
256        }))
257        .into();
258    }
259    // There is no `CACHE_NAME_COLLISION` mapping: the cache is
260    // content-addressed (`<name>-<content_key>.mem`), so distinct bytes
261    // under the same mem name don't collide and the engine cannot produce
262    // `InstallError::CacheNameCollision`.
263    // Install-archive validation failures route through the typed
264    // ARCHIVE_VALIDATION_FAILED code (F10 of the 2026-05-18 CLI probe).
265    // Other InstallError variants (write failures, etc.) flow through the
266    // same envelope but the wire-shape captures the refusal source via the
267    // message text.
268    CliError::new(
269        ExitKind::Generic,
270        crate::ARCHIVE_VALIDATION_FAILED_CODE,
271        e.to_string(),
272    )
273    .into()
274}
275
276/// Map engine-side registration errors into the typed CLI envelope.
277fn engine_err_to_cli(e: memstead_base::EngineError) -> anyhow::Error {
278    CliError::from_engine_op(e).into()
279}
280
281#[cfg(test)]
282mod tests {
283    use crate::registry::parse_ref;
284
285    #[test]
286    fn parse_ref_accepts_three_scope_forms() {
287        assert_eq!(
288            parse_ref("memstead/knowledge"),
289            Some(("memstead".into(), "knowledge".into()))
290        );
291        assert_eq!(
292            parse_ref("github:alice/foo"),
293            Some(("github:alice".into(), "foo".into()))
294        );
295        assert_eq!(
296            parse_ref("acme.com:payments/foo"),
297            Some(("acme.com:payments".into(), "foo".into()))
298        );
299    }
300
301    #[test]
302    fn parse_ref_rejects_local_paths() {
303        assert!(parse_ref("/tmp/foo.mem").is_none());
304        assert!(parse_ref("./foo.mem").is_none());
305        assert!(parse_ref("foo.mem").is_none());
306    }
307
308    #[test]
309    fn parse_ref_rejects_legacy_at_and_malformed() {
310        // The legacy `@scope/name` syntax is not a valid registry ref.
311        assert!(parse_ref("@memstead/knowledge").is_none());
312        assert!(parse_ref("memstead").is_none()); // no name
313        assert!(parse_ref("/knowledge").is_none()); // empty scope
314        assert!(parse_ref("memstead/").is_none()); // empty name
315        assert!(parse_ref("memstead/knowledge.mem").is_none()); // extension
316        assert!(parse_ref("memstead/subdir/knowledge").is_none()); // path-shaped name
317    }
318}