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