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
7//!   `mem_cache::install_read_mem` helper the local path uses.
8//!   No authentication required — registry downloads are public.
9//!
10//! Both shapes:
11//!
12//! 1. Copy (or re-validate) the archive into the global mem cache
13//!    (`<data_dir>/memstead/mems/<name>-<key>.mem`) via the engine helper.
14//! 2. Add a `readMems` entry to the target mem's `config.json` if
15//!    the name isn't already declared. Local installs write
16//!    `source: { type: "local" }`; registry installs write
17//!    `source: { type: "url", url: "<registry>/api/mem/..." }`.
18
19use std::path::{Path, PathBuf};
20
21use clap::Parser;
22use serde_json::json;
23
24use memstead_git_branch::mem_cache::{self, TargetMem};
25use memstead_git_branch::mem_repo_config;
26
27use crate::CliError;
28use crate::output::{ExitKind, print_json, print_markdown};
29use crate::registry::{self, DownloadError};
30use crate::setup::CliContext;
31use crate::setup::cli_ctx;
32
33/// Install a sealed mem archive into the global mem cache and register it
34/// in the current project's `readMems`. Archives with non-slug-form
35/// body wiki-links refuse with `INVALID_WIKI_LINK_TARGET` — convert via
36/// search-and-replace before installing.
37#[derive(Parser, Debug)]
38pub struct Args {
39    /// Either a path to a `.mem` file, or
40    /// `<scope>/<name>` for registry installs (no `@` prefix).
41    #[arg(value_name = "PATH or SCOPE/NAME")]
42    pub source: String,
43
44    /// Which writable mem to register this read-mem into (by
45    /// name). Defaults to the first writable mem when omitted.
46    ///
47    /// This flag selects the *host* mem — the writable workspace
48    /// mem that will list the archive in its read-mems set. It does
49    /// NOT rename the archive's internal mem; the archive's internal
50    /// name is the canonical identity used by all cross-mem
51    /// references and shadow checks.
52    #[arg(long = "mem", value_name = "NAME")]
53    pub mem_name: Option<String>,
54
55    /// Registry URL for `<scope>/<name>` installs. Ignored for local paths.
56    /// Overrides `MEMSTEAD_REGISTRY`; defaults to https://memstead.io.
57    #[arg(long, value_name = "URL")]
58    pub registry: Option<String>,
59}
60
61pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
62    let engine = crate::setup::pro_engine(ctx)?;
63
64    let mem_name = resolve_mem_name(&engine, args.mem_name.clone())?;
65    // Resolve target shape: mem-repo-backed mems have `dir: None`
66    // under the dir-less create flow; the registration lands in
67    // `mem-repo-git:__MEMSTEAD:mems/<mem_name>/config.json`. Disk
68    // mems still get the `<mem_dir>/.memstead/config.json` rewrite.
69    let mem_disk_dir = engine
70        .mem_router()
71        .dir_for_mem(&mem_name)
72        .map(|p| p.to_path_buf());
73    let workspace_root = engine
74        .workspace_root()
75        .map(|p| p.to_path_buf())
76        .unwrap_or_default();
77    // Snapshot the workspace's writable-mount roster
78    // so `install_read_mem` can refuse a shadowing archive name
79    // before the cache copy + config registration lands.
80    let writable: Vec<String> = engine
81        .mem_router()
82        .writable_mems()
83        .iter()
84        .map(|n| n.to_string())
85        .collect();
86
87    // The legacy `@scope/name` syntax is rejected, not silently treated as a
88    // local path.
89    if args.source.starts_with('@') {
90        anyhow::bail!(
91            "the `@scope/name` syntax is no longer supported — use \
92             `github:<handle>/<name>`, `<domain>/<name>`, or a bare `<handle>/<name>`"
93        );
94    }
95    // Registry install path: "<scope>/<name>".
96    if let Some((scope, name)) = registry::parse_ref(&args.source) {
97        return install_from_registry(
98            ctx,
99            &mem_name,
100            mem_disk_dir.as_deref(),
101            &workspace_root,
102            &scope,
103            &name,
104            args.registry.as_deref(),
105            &writable,
106        );
107    }
108
109    // Local path install.
110    install_from_local(
111        ctx,
112        &mem_name,
113        mem_disk_dir.as_deref(),
114        &workspace_root,
115        &PathBuf::from(&args.source),
116        &writable,
117    )
118}
119
120fn install_from_local(
121    ctx: &CliContext,
122    mem_name: &str,
123    mem_disk_dir: Option<&Path>,
124    workspace_root: &Path,
125    archive: &Path,
126    writable: &[String],
127) -> anyhow::Result<()> {
128    let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
129    let target = build_target(mem_name, mem_disk_dir, workspace_root);
130    let commit_ctx = cli_ctx();
131    let message = format!("memstead: install (read-mem registration into {mem_name})");
132    let outcome =
133        mem_cache::install_read_mem(archive, target, &commit_ctx, &message, &writable_refs)
134            .map_err(install_err_to_cli)?;
135    emit_outcome(ctx, mem_name, outcome, None)
136}
137
138// Flat forwarding of the install subcommand's CLI flags to the one
139// registry download path; a params struct would just re-declare them.
140#[allow(clippy::too_many_arguments)]
141fn install_from_registry(
142    ctx: &CliContext,
143    mem_name: &str,
144    mem_disk_dir: Option<&Path>,
145    workspace_root: &Path,
146    scope: &str,
147    name: &str,
148    registry_arg: Option<&str>,
149    writable: &[String],
150) -> anyhow::Result<()> {
151    let base = registry::registry_base(registry_arg);
152    let client = registry::build_http()?;
153
154    // Stream the archive into a tempfile; `install_read_mem` reads
155    // from a path, so a tempfile is the cheapest bridge.
156    let tmp = tempfile::NamedTempFile::new().map_err(|e| {
157        CliError::new(
158            ExitKind::Generic,
159            crate::INTERNAL_CODE,
160            format!("tempfile: {e}"),
161        )
162    })?;
163    registry::download_mem(&client, &base, scope, name, tmp.path()).map_err(|e| {
164        let msg = match &e {
165            DownloadError::NotFound => {
166                format!("{scope}/{name} not found on {base}")
167            }
168            DownloadError::Gone => {
169                format!("{scope}/{name} has been taken down")
170            }
171            _ => format!("download failed: {e}"),
172        };
173        let code: &'static str = match &e {
174            DownloadError::NotFound => "REGISTRY_NOT_FOUND",
175            DownloadError::Gone => "GONE",
176            _ => "REGISTRY_ERROR",
177        };
178        CliError::new(
179            match e {
180                DownloadError::NotFound => ExitKind::NotFound,
181                _ => ExitKind::Generic,
182            },
183            code,
184            msg,
185        )
186    })?;
187
188    // The archive now lives at tmp.path(); hand it to the same helper
189    // the local path uses. `install_read_mem` re-validates — the
190    // consumer side is symmetric with the registry's server-side
191    // validator by construction.
192    let writable_refs: Vec<&str> = writable.iter().map(String::as_str).collect();
193    let target = build_target(mem_name, mem_disk_dir, workspace_root);
194    let commit_ctx = cli_ctx();
195    let message = format!("memstead: install (read-mem registration into {mem_name})");
196    let outcome =
197        mem_cache::install_read_mem(tmp.path(), target, &commit_ctx, &message, &writable_refs)
198            .map_err(install_err_to_cli)?;
199
200    let source_url = format!(
201        "{base}/api/mem/{scope}/{name}.mem",
202        base = base,
203        scope = scope,
204        name = name
205    );
206    update_source_to_url(
207        mem_name,
208        mem_disk_dir,
209        workspace_root,
210        &outcome.mem_name,
211        &source_url,
212    )?;
213
214    emit_outcome(ctx, mem_name, outcome, Some(source_url))
215}
216
217/// Resolve the install target: prefer the disk dir when present
218/// (legacy disk-shaped mem), otherwise fall back to the mem-repo
219/// shape rooted at the workspace.
220fn build_target<'a>(
221    mem_name: &'a str,
222    mem_disk_dir: Option<&'a Path>,
223    workspace_root: &'a Path,
224) -> TargetMem<'a> {
225    match mem_disk_dir {
226        Some(p) => TargetMem::Disk(p),
227        None => TargetMem::MemRepo {
228            workspace_root,
229            mem_name,
230        },
231    }
232}
233
234/// Rewrite the fresh `readMems` entry so its `source` becomes
235/// `type: "url"` pointing back at the registry — `install_read_mem`
236/// always writes `type: "local"`, which is right for files dropped by
237/// hand but wrong for registry installs where the CLI can re-fetch.
238///
239/// Idempotent and safe: if the entry already has a `url` source, we
240/// leave it alone (user edits win). Branches on disk vs. mem-repo
241/// shape — disk mems rewrite `<mem_dir>/.memstead/config.json`,
242/// mem-repo mems commit the updated blob to
243/// `mem-repo-git:__MEMSTEAD:mems/<host_mem>/config.json`.
244fn update_source_to_url(
245    host_mem_name: &str,
246    mem_disk_dir: Option<&Path>,
247    workspace_root: &Path,
248    read_mem_name: &str,
249    source_url: &str,
250) -> anyhow::Result<()> {
251    use serde_json::{Map, Value, json};
252
253    // Build the URL-source entry, preserving the content-addressed
254    // `cacheKey` that `install_read_mem` wrote — the loader resolves the
255    // cache file by `<name>-<cacheKey>.mem`, so dropping it here would
256    // strand the just-installed archive.
257    let url_entry = |existing: Option<&Value>| -> Value {
258        let mut obj = Map::new();
259        obj.insert("source".into(), json!({ "type": "url", "url": source_url }));
260        if let Some(key) = existing
261            .and_then(|e| e.get("cacheKey"))
262            .and_then(|k| k.as_str())
263        {
264            obj.insert("cacheKey".into(), json!(key));
265        }
266        Value::Object(obj)
267    };
268
269    match mem_disk_dir {
270        Some(mem_dir) => {
271            let (mut config, config_path) =
272                memstead_schema::config::load_config(mem_dir).map_err(|e| {
273                    CliError::new(
274                        ExitKind::Generic,
275                        "WORKSPACE_CONFIG_READ_FAILED",
276                        format!("reading config: {e}"),
277                    )
278                })?;
279
280            let root = config.as_object_mut().ok_or_else(|| {
281                CliError::new(
282                    ExitKind::Generic,
283                    "WORKSPACE_CONFIG_INVALID",
284                    "config root must be a JSON object",
285                )
286            })?;
287            let read_mems = root
288                .entry("readMems")
289                .or_insert_with(|| Value::Object(Map::new()))
290                .as_object_mut()
291                .ok_or_else(|| {
292                    CliError::new(
293                        ExitKind::Generic,
294                        "WORKSPACE_CONFIG_INVALID",
295                        "readMems must be a JSON object",
296                    )
297                })?;
298
299            let existing_is_url_same = read_mems
300                .get(read_mem_name)
301                .and_then(|v| v.get("source"))
302                .and_then(|s| s.get("type"))
303                .and_then(|t| t.as_str())
304                .is_some_and(|t| t == "url");
305            if existing_is_url_same {
306                return Ok(());
307            }
308
309            let entry = url_entry(read_mems.get(read_mem_name));
310            read_mems.insert(read_mem_name.to_string(), entry);
311
312            let body = serde_json::to_string_pretty(&config).map_err(|e| {
313                CliError::new(
314                    ExitKind::Generic,
315                    crate::INTERNAL_CODE,
316                    format!("serializing config: {e}"),
317                )
318            })?;
319            std::fs::write(&config_path, body + "\n").map_err(|e| {
320                CliError::new(
321                    ExitKind::Generic,
322                    crate::INTERNAL_CODE,
323                    format!("writing config: {e}"),
324                )
325            })?;
326            Ok(())
327        }
328        None => {
329            // Mem-repo shape: read configs/<host_mem>.json, mutate, commit on main.
330            let config =
331                mem_repo_config::read_config(workspace_root, host_mem_name).map_err(|e| {
332                    CliError::new(
333                        ExitKind::Generic,
334                        "WORKSPACE_CONFIG_READ_FAILED",
335                        format!("reading configs/{host_mem_name}.json from mem-repo-git:main: {e}"),
336                    )
337                })?;
338            let mut value = serde_json::to_value(&config).map_err(|e| {
339                CliError::new(
340                    ExitKind::Generic,
341                    crate::INTERNAL_CODE,
342                    format!("re-serialize MemConfig: {e}"),
343                )
344            })?;
345            let root = value.as_object_mut().ok_or_else(|| {
346                CliError::new(
347                    ExitKind::Generic,
348                    "WORKSPACE_CONFIG_INVALID",
349                    "config root must be a JSON object",
350                )
351            })?;
352            let read_mems = root
353                .entry("readMems")
354                .or_insert_with(|| Value::Object(Map::new()))
355                .as_object_mut()
356                .ok_or_else(|| {
357                    CliError::new(
358                        ExitKind::Generic,
359                        "WORKSPACE_CONFIG_INVALID",
360                        "readMems must be a JSON object",
361                    )
362                })?;
363
364            let existing_is_url_same = read_mems
365                .get(read_mem_name)
366                .and_then(|v| v.get("source"))
367                .and_then(|s| s.get("type"))
368                .and_then(|t| t.as_str())
369                .is_some_and(|t| t == "url");
370            if existing_is_url_same {
371                return Ok(());
372            }
373
374            let entry = url_entry(read_mems.get(read_mem_name));
375            read_mems.insert(read_mem_name.to_string(), entry);
376
377            let updated_bytes = serde_json::to_vec_pretty(&value).map_err(|e| {
378                CliError::new(
379                    ExitKind::Generic,
380                    crate::INTERNAL_CODE,
381                    format!("serializing updated config: {e}"),
382                )
383            })?;
384            let commit_ctx = cli_ctx();
385            let message = format!(
386                "memstead: install (rewrite source URL for {read_mem_name} in {host_mem_name})"
387            );
388            mem_repo_config::commit_config(
389                workspace_root,
390                host_mem_name,
391                &updated_bytes,
392                &commit_ctx,
393                &message,
394            )
395            .map_err(|e| {
396                CliError::new(
397                    ExitKind::Generic,
398                    "WORKSPACE_CONFIG_WRITE_FAILED",
399                    format!("commit configs/{host_mem_name}.json: {e}"),
400                )
401            })?;
402            Ok(())
403        }
404    }
405}
406
407fn emit_outcome(
408    ctx: &CliContext,
409    target_mem: &str,
410    outcome: mem_cache::InstallOutcome,
411    source_url: Option<String>,
412) -> anyhow::Result<()> {
413    if ctx.json {
414        print_json(&json!({
415            "mem_name": outcome.mem_name,
416            "copied_to_cache": outcome.copied_to_cache,
417            "registered_in_config": outcome.registered_in_config,
418            "target_mem": target_mem,
419            "source_url": source_url,
420            // `{ code, message, details }` envelopes — same shape every
421            // warning-carrying surface uses.
422            "warnings": outcome.warnings,
423        }))?;
424    } else {
425        let cache_status = if outcome.copied_to_cache {
426            "copied into cache"
427        } else {
428            "already in cache (unchanged)"
429        };
430        // Drop the on-disk `.memstead/config.json` path
431        // from the success message. The path string does not exist for
432        // mem-repo workspaces (configs live in `__MEMSTEAD` blobs in the
433        // workspace registry ref); the message read as if the operator
434        // could grep that path, which they cannot. Name the workspace
435        // role instead.
436        let config_status = if outcome.registered_in_config {
437            format!("registered as a read-mem on `{target_mem}`'s workspace config")
438        } else {
439            format!("already registered as a read-mem on `{target_mem}`'s workspace config")
440        };
441        let mut body = format!(
442            "# Installed `{}`\n\n- Archive: {}\n- Config: {}",
443            outcome.mem_name, cache_status, config_status,
444        );
445        if let Some(url) = source_url {
446            body.push_str(&format!("\n- Source: {url}"));
447        }
448        if !outcome.warnings.is_empty() {
449            body.push_str("\n\n## Warnings\n");
450            for w in &outcome.warnings {
451                body.push_str(&format!("\n- **{}**: {}", w.code(), w.message()));
452            }
453        }
454        print_markdown(&body);
455    }
456    Ok(())
457}
458
459/// Map `InstallError` into the CLI error envelope. The
460/// `ShadowsWritable` variant gets a typed
461/// `READ_MEM_SHADOWS_WRITABLE` wire code with structured
462/// `details.archive_name` + `details.shadows_writable` so callers
463/// branch on the code rather than parsing the message. Other
464/// variants stay on the generic exit code with the underlying error
465/// message — they already carry the right shape for the CLI.
466fn install_err_to_cli(e: memstead_git_branch::mem_cache::InstallError) -> anyhow::Error {
467    use memstead_git_branch::mem_cache::InstallError;
468    if let InstallError::ShadowsWritable {
469        archive_name,
470        shadows_writable,
471    } = &e
472    {
473        return CliError::new(
474            ExitKind::Validation,
475            "READ_MEM_SHADOWS_WRITABLE",
476            e.to_string(),
477        )
478        .with_details(json!({
479            "archive_name": archive_name,
480            "shadows_writable": shadows_writable,
481        }))
482        .into();
483    }
484    // There is no `CACHE_NAME_COLLISION` mapping: the cache is
485    // content-addressed (`<name>-<content_key>.mem`), so distinct bytes
486    // under the same mem name don't collide and the engine cannot produce
487    // `InstallError::CacheNameCollision`.
488    // Install-archive validation failures route through the typed
489    // ARCHIVE_VALIDATION_FAILED code (F10 of the 2026-05-18 CLI probe).
490    // Other InstallError variants (write failures, etc.) flow through the
491    // same envelope but the wire-shape captures the refusal source via the
492    // message text.
493    CliError::new(
494        ExitKind::Generic,
495        crate::ARCHIVE_VALIDATION_FAILED_CODE,
496        e.to_string(),
497    )
498    .into()
499}
500
501fn resolve_mem_name(
502    engine: &memstead_base::Engine,
503    explicit: Option<String>,
504) -> anyhow::Result<String> {
505    let writable: Vec<String> = engine
506        .mem_configs_named()
507        .filter(|(name, _)| engine.mem_router().is_writable(name))
508        .map(|(name, _)| name.to_string())
509        .collect();
510
511    if let Some(name) = explicit {
512        // Precondition check at the entry point. Otherwise an
513        // unknown mem name flows through to archive validation,
514        // which surfaces a misleading `ARCHIVE_VALIDATION_FAILED`
515        // envelope carrying a `__MEMSTEAD:mems/...` internal path
516        // (the path is engine-private; the failure root cause is
517        // the missing host mem). The typed refusal here pins the
518        // actual precondition the caller violated and short-
519        // circuits the leak path.
520        if !writable.iter().any(|v| v == &name) {
521            return Err(CliError::new(
522                ExitKind::Validation,
523                "HOST_MEM_NOT_REGISTERED",
524                format!(
525                    "host mem `{name}` is not a registered writable mem — \
526                     run `memstead mem init {name}` first OR pass `--mem <existing>`",
527                ),
528            )
529            .with_details(json!({
530                "requested": name,
531                "known_mems": writable,
532            }))
533            .into());
534        }
535        return Ok(name);
536    }
537
538    match writable.len() {
539        0 => Err(CliError::new(
540            ExitKind::Generic,
541            "NO_WRITABLE_MEM",
542            "no writable mem loaded — nothing to install into",
543        )
544        .into()),
545        1 => Ok(writable.into_iter().next().unwrap()),
546        _ => Err(CliError::new(
547            ExitKind::Validation,
548            "AMBIGUOUS_MEM",
549            format!(
550                "multiple writable mems loaded ({}); pass --mem <name> \
551                 to pick the install target",
552                writable.join(", ")
553            ),
554        )
555        .with_details(json!({ "mems": writable }))
556        .into()),
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use crate::registry::parse_ref;
563
564    #[test]
565    fn parse_ref_accepts_three_scope_forms() {
566        assert_eq!(
567            parse_ref("memstead/knowledge"),
568            Some(("memstead".into(), "knowledge".into()))
569        );
570        assert_eq!(
571            parse_ref("github:alice/foo"),
572            Some(("github:alice".into(), "foo".into()))
573        );
574        assert_eq!(
575            parse_ref("acme.com:payments/foo"),
576            Some(("acme.com:payments".into(), "foo".into()))
577        );
578    }
579
580    #[test]
581    fn parse_ref_rejects_local_paths() {
582        assert!(parse_ref("/tmp/foo.mem").is_none());
583        assert!(parse_ref("./foo.mem").is_none());
584        assert!(parse_ref("foo.mem").is_none());
585    }
586
587    #[test]
588    fn parse_ref_rejects_legacy_at_and_malformed() {
589        // The legacy `@scope/name` syntax is not a valid registry ref.
590        assert!(parse_ref("@memstead/knowledge").is_none());
591        assert!(parse_ref("memstead").is_none()); // no name
592        assert!(parse_ref("/knowledge").is_none()); // empty scope
593        assert!(parse_ref("memstead/").is_none()); // empty name
594        assert!(parse_ref("memstead/knowledge.mem").is_none()); // extension
595        assert!(parse_ref("memstead/subdir/knowledge").is_none()); // path-shaped name
596    }
597}