Skip to main content

memstead_cli/commands/
publish.rs

1//! `memstead publish [<file.mem>]` — upload a mem to the registry.
2//!
3//! Three input shapes, resolved in priority order:
4//!
5//! - **`memstead publish <file.mem>`** — archive-already-built. Publish
6//!   pre-existing bytes (e.g. produced by `memstead export --format mem`).
7//! - **`memstead publish --mem <name>`** — export-and-publish in one
8//!   step. Opens the current workspace's engine (any backend, including
9//!   git-branch mem-repo), assembles the named mem's `.mem` archive
10//!   in-process via [`memstead_base::Engine::export_mem_to_bytes`],
11//!   stages it through a tempfile, and posts. This is the one-step path
12//!   for mem-repo workspaces, where there is no folder to wrap up.
13//! - **`memstead publish`** (no archive arg, no `--mem`) —
14//!   filesystem-mem assembly. Walks up from cwd to the workspace
15//!   marker, builds the archive in-memory via
16//!   [`memstead_base::filesystem::publish::assemble_archive`], and posts.
17//!   Equivalent to "wrap up what's in the current folder and ship it".
18//!
19//! Token resolution (first hit wins): `--token` → `MEMSTEAD_TOKEN` env →
20//! `~/.config/memstead/credentials` → GitHub Device Flow on first use
21//! (only if stdin is a TTY; CI sees "missing MEMSTEAD_TOKEN" instead).
22//!
23//! On success prints `<scope>/<name> vX.Y.Z` + the full mem URL so the
24//! user has a clickable link.
25
26use std::io::IsTerminal;
27use std::path::{Path, PathBuf};
28
29use clap::Parser;
30use memstead_base::filesystem::publish::assemble_archive;
31use serde_json::json;
32use tempfile::NamedTempFile;
33
34use crate::CliError;
35use crate::auth::{credentials, device_flow, resolve_token};
36use crate::output::{ExitKind, print_json, print_markdown};
37use crate::registry::{self, ApiErrorBody, PublishError};
38use crate::setup::CliContext;
39
40#[derive(Parser, Debug)]
41pub struct Args {
42    /// Path to a `.mem` archive on disk. Omit to assemble the
43    /// archive from the surrounding filesystem-mem workspace
44    /// (walks up from cwd to find the workspace root).
45    #[arg(value_name = "PATH")]
46    pub archive: Option<PathBuf>,
47
48    /// Export-and-publish a named mem from the current workspace in
49    /// one step — the path for mem-repo (multi-mem, git-branch)
50    /// workspaces, which have no folder to wrap up. Ignored when an
51    /// archive PATH is provided. A single-mem folder workspace can
52    /// omit this and just run `memstead publish`.
53    #[arg(long, value_name = "NAME")]
54    pub mem: Option<String>,
55
56    /// Override the auto-derived scope — admin-only, reserved scopes
57    /// only (currently just `memstead`). Without this flag the registry
58    /// stores the mem under your GitHub username.
59    #[arg(long, value_name = "NAME")]
60    pub scope: Option<String>,
61
62    /// Explicit token override. Takes precedence over `MEMSTEAD_TOKEN`
63    /// and stored credentials.
64    #[arg(long, value_name = "TOKEN")]
65    pub token: Option<String>,
66
67    /// Registry URL (overrides `MEMSTEAD_REGISTRY`; defaults to https://memstead.io).
68    #[arg(long, value_name = "URL")]
69    pub registry: Option<String>,
70
71    /// Set the mem's version to this semver and publish in one step,
72    /// persisting the bump to the mem config (like `npm version` +
73    /// `npm publish`). Requires `--mem <name>`; not valid with a
74    /// pre-built archive PATH, whose version is already baked in. Omit
75    /// to publish whatever version the mem config currently carries.
76    #[arg(long, value_name = "SEMVER")]
77    pub version: Option<String>,
78
79    /// Blank every artifact reference in the packaged anchors sidecar —
80    /// the `artifact` field and each `derived_from` entry — so the
81    /// published mem discloses no source identity while the trust
82    /// metadata (provenance class, at_version, grain, hash, source name)
83    /// stays readable. Redact, not strip: consumers still see how
84    /// strongly each entity claims fidelity to a source, without
85    /// learning which source. The workspace's own sidecar is never
86    /// touched. Residual disclosure remains by design — grain reveals
87    /// the medium shape, at_version may carry a commit SHA, source is
88    /// your chosen name, and hash permits confirming guessed content.
89    /// Not valid with a pre-built archive PATH, whose anchors are
90    /// already baked in — use --mem or the bare folder shape instead.
91    #[arg(long)]
92    pub redact_anchors: bool,
93
94    /// Assemble and resolve everything, print exactly what would be
95    /// published (mem, version, scope, archive size), but POST
96    /// nothing and mutate nothing — including no version bump. The safe
97    /// way to confirm a publish before it goes out.
98    #[arg(long)]
99    pub dry_run: bool,
100}
101
102pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
103    run_with_root(ctx, args, None)
104}
105
106/// Inner seam: `root_override` replaces the cwd walk for the
107/// assembling shapes — used by tests (the CLI-level workspace override
108/// is the ROOT command's global `--workspace` / `MEMSTEAD_WORKSPACE`,
109/// applied before dispatch; no subcommand-level flag exists).
110fn run_with_root(
111    ctx: &CliContext,
112    args: Args,
113    root_override: Option<PathBuf>,
114) -> anyhow::Result<()> {
115    let base = registry::registry_base(args.registry.as_deref());
116    let host = registry::registry_host(&base);
117    let client = registry::build_http()?;
118
119    // 0. Validate `--version` up front: it persists a bump through the
120    //    workspace engine, so it needs `--mem <name>` and is
121    //    meaningless against pre-built archive bytes whose version is
122    //    already sealed.
123    let target_version = match args.version.as_deref() {
124        Some(v) => {
125            if args.archive.is_some() {
126                return Err(CliError::new(
127                    ExitKind::Validation,
128                    "INVALID_INPUT",
129                    "--version cannot be combined with a pre-built archive PATH (its version is already baked in) — drop the PATH and use --mem, or re-export at the new version",
130                )
131                .into());
132            }
133            if args.mem.is_none() {
134                return Err(CliError::new(
135                    ExitKind::Validation,
136                    "INVALID_INPUT",
137                    "--version requires --mem <name> so the bump knows which mem to re-version",
138                )
139                .into());
140            }
141            Some(semver::Version::parse(v).map_err(|e| {
142                CliError::new(
143                    ExitKind::Validation,
144                    "INVALID_VERSION",
145                    format!("--version {v:?} is not a valid semver: {e}"),
146                )
147            })?)
148        }
149        None => None,
150    };
151
152    // 0b. `--redact-anchors` transforms the sidecar where the archive is
153    //     assembled — pre-built bytes are refused up front (before any
154    //     auth or network step), same precedent as `--version` above.
155    if args.redact_anchors && args.archive.is_some() {
156        return Err(CliError::new(
157            ExitKind::Validation,
158            "INVALID_INPUT",
159            "--redact-anchors cannot be combined with a pre-built archive PATH (its \
160             anchors are already baked in) — drop the PATH and use --mem <name>, or run \
161             the bare `memstead publish` from the mem's folder, both of which assemble \
162             the archive and can redact it",
163        )
164        .into());
165    }
166
167    // 1. Resolve archive bytes by input shape (priority order):
168    //    archive PATH > `--mem NAME` (engine export-to-bytes, any
169    //    backend) > bare (folder assembly). The two assembling shapes
170    //    stage their bytes through a tempfile so the existing
171    //    `registry::publish` POST path stays file-based; the tempfile
172    //    guard is held until the end of `run` so the path stays valid
173    //    for the POST call. `resolved_version` is the version that will
174    //    actually publish — surfaced in the dry-run preview.
175    let mut resolved_version: Option<String> = None;
176    let (archive_path, _tempfile_guard): (PathBuf, Option<NamedTempFile>) =
177        if let Some(p) = args.archive {
178            (p, None)
179        } else if let Some(mem_name) = args.mem.as_deref() {
180            let workspace_root = resolve_workspace_root(root_override.as_deref())?;
181            let mut engine = ctx.cli_engine_at(&workspace_root)?.into_base();
182            // Persist the version bump before exporting — but never
183            // under --dry-run, which must leave the workspace untouched.
184            if let Some(ver) = target_version.clone()
185                && !args.dry_run
186            {
187                let bumped = engine
188                    .set_mem_version(mem_name, ver, Some("version bump for registry publish"))
189                    .map_err(CliError::from_engine_op)?;
190                // Publish is about to ship this mem outward, so a config that
191                // moved under us is exactly what the operator needs to see
192                // before the archive leaves (04/03, criterion 3).
193                for w in &bumped.warnings {
194                    crate::output::print_markdown(&format!("> {w}"));
195                }
196            }
197            resolved_version = target_version.as_ref().map(|v| v.to_string()).or_else(|| {
198                engine
199                    .mem_config_for(mem_name)
200                    .and_then(|c| c.version.clone())
201                    .map(|v| v.to_string())
202            });
203            let bytes = engine
204                .export_mem_to_bytes(mem_name)
205                .map_err(CliError::from_engine_op)?;
206            stage_bytes_to_tempfile(&redact_if_requested(&args, bytes)?)?
207        } else {
208            let workspace_root = resolve_workspace_root(root_override.as_deref())?;
209            // Engine-first: the engine exports whatever layout it
210            // booted — the mount roster locates the mem folder and its
211            // config, so the bare shape works from the workspace root
212            // of a current-layout (`workspace.toml` + mounts) workspace
213            // exactly like `--mem` does (sealed-gate finding F6's seam;
214            // the legacy assembly resolved the config against the
215            // workspace root, which only coincides with the mem folder
216            // in the legacy single-mem layout). The legacy folder
217            // assembly stays as the fallback for exactly that layout,
218            // where no engine boots.
219            // Exactly one writable mem routes through the engine;
220            // multiple demand `--mem`; zero (an engine that boots but
221            // mounts nothing writable — some legacy-shaped test trees)
222            // falls through to the folder assembly like a failed boot.
223            let engine_mem: Option<(memstead_base::Engine, String)> =
224                match ctx.cli_engine_at(&workspace_root) {
225                    Ok(cli_engine) => {
226                        let engine = cli_engine.into_base();
227                        let writable: Vec<String> = engine
228                            // Every mount (04/05, criterion 8). Counting only
229                            // config-readable mems could report exactly one
230                            // writable mem while a second, broken one exists,
231                            // and silently publish the wrong one.
232                            .mounts_with_optional_config()
233                            .filter(|(name, _)| engine.mem_router().is_writable(name))
234                            .map(|(name, _)| name.to_string())
235                            .collect();
236                        match writable.len() {
237                            1 => Some((engine, writable.into_iter().next().unwrap())),
238                            0 => None,
239                            _ => {
240                                return Err(CliError::new(
241                                    ExitKind::Validation,
242                                    "AMBIGUOUS_MEM",
243                                    format!(
244                                        "multiple writable mems loaded ({}); pass --mem <name>",
245                                        writable.join(", ")
246                                    ),
247                                )
248                                .into());
249                            }
250                        }
251                    }
252                    Err(_) => None,
253                };
254            let bytes = match engine_mem {
255                Some((engine, mem_name)) => {
256                    resolved_version = engine
257                        .mem_config_for(&mem_name)
258                        .and_then(|c| c.version.clone())
259                        .map(|v| v.to_string());
260                    engine
261                        .export_mem_to_bytes(&mem_name)
262                        .map_err(CliError::from_engine_op)?
263                }
264                None => assemble_archive(&workspace_root).map_err(|e| {
265                    CliError::new(
266                        ExitKind::Validation,
267                        "ARCHIVE_ASSEMBLY_FAILED",
268                        format!("assemble archive: {e}"),
269                    )
270                })?,
271            };
272            stage_bytes_to_tempfile(&redact_if_requested(&args, bytes)?)?
273        };
274
275    // 2. Dry run: report the resolved publish and stop — no auth, no
276    //    POST, no mutation (any --version bump was skipped above).
277    if args.dry_run {
278        return emit_dry_run(
279            ctx,
280            &base,
281            &archive_path,
282            args.mem.as_deref(),
283            resolved_version.as_deref(),
284            args.scope.as_deref(),
285        );
286    }
287
288    // 3. Authorise + POST. A `<domain>:<handle>` scope is a domain-authority
289    //    publish: it signs the upload with the domain's locally-stored key and
290    //    needs no GitHub account. Any other scope uses the GitHub token path
291    //    (with interactive device-flow fallback on a TTY).
292    if let Some(domain) = domain_scope(args.scope.as_deref()) {
293        let scope = args.scope.as_deref().expect("domain_scope implies a scope");
294        let sig = build_domain_signature(&archive_path, scope, &domain)?;
295        return match registry::publish(&client, &base, &archive_path, None, Some(scope), Some(&sig))
296        {
297            Ok(resp) => emit_success(ctx, &base, &resp),
298            Err(e) => Err(map_publish_error(e).into()),
299        };
300    }
301
302    let token = match resolve_token(&host, args.token.as_deref())? {
303        Some(r) => r.token,
304        None => {
305            if !std::io::stdin().is_terminal() {
306                return Err(CliError::new(
307                    ExitKind::Generic,
308                    "NOT_AUTHENTICATED",
309                    "not logged in and stdin is not a TTY — set MEMSTEAD_TOKEN \
310                     or run `memstead login` first",
311                )
312                .into());
313            }
314            login_inline(&client, &host)?
315        }
316    };
317
318    match registry::publish(
319        &client,
320        &base,
321        &archive_path,
322        Some(&token),
323        args.scope.as_deref(),
324        None,
325    ) {
326        Ok(resp) => emit_success(ctx, &base, &resp),
327        Err(e) => Err(map_publish_error(e).into()),
328    }
329}
330
331/// Apply `--redact-anchors` to assembled archive bytes — publish-time
332/// only, on the staged copy; the workspace's own sidecar is untouched.
333/// An archive with no anchors member passes through byte-identical.
334fn redact_if_requested(args: &Args, bytes: Vec<u8>) -> Result<Vec<u8>, CliError> {
335    if !args.redact_anchors {
336        return Ok(bytes);
337    }
338    memstead_base::filesystem::publish::redact_archive_anchors(&bytes).map_err(|e| {
339        CliError::new(
340            ExitKind::Validation,
341            "ARCHIVE_ASSEMBLY_FAILED",
342            format!("redact anchors: {e}"),
343        )
344    })
345}
346
347/// A `<domain>:<handle>` scope override → the domain. A domain scope's prefix
348/// contains a `.` (e.g. `acme.com:payments`); `github:<h>` and bare handles do
349/// not, so they fall through to the GitHub path.
350fn domain_scope(scope: Option<&str>) -> Option<String> {
351    let (prefix, handle) = scope?.split_once(':')?;
352    if prefix.contains('.') && !handle.is_empty() {
353        Some(prefix.to_ascii_lowercase())
354    } else {
355        None
356    }
357}
358
359/// Build the per-publish domain signature: canonicalize the archive (the
360/// signature covers the canonical content hash the registry will also compute),
361/// then sign `(hash, scope, name, version, now)` with the domain's stored key.
362#[cfg(feature = "mem-repo")]
363fn build_domain_signature(
364    archive_path: &Path,
365    scope: &str,
366    domain: &str,
367) -> anyhow::Result<registry::DomainSignature> {
368    use memstead_base::domain_authority_wire::signing_payload;
369    use memstead_git_branch::validator::validate_and_normalize_archive;
370    use sha2::{Digest, Sha256};
371
372    use crate::auth::domain_key;
373
374    let bytes = std::fs::read(archive_path).map_err(|e| {
375        CliError::new(
376            ExitKind::Generic,
377            "ARCHIVE_READ_FAILED",
378            format!("read archive: {e}"),
379        )
380    })?;
381    let validated = validate_and_normalize_archive(&bytes).map_err(|e| {
382        CliError::new(
383            ExitKind::Validation,
384            "ARCHIVE_INVALID",
385            format!("archive failed local validation before signing: {e}"),
386        )
387    })?;
388    let content_sha256 = {
389        let mut h = Sha256::new();
390        h.update(&validated.canonical_bytes);
391        h.finalize()
392            .iter()
393            .map(|b| format!("{b:02x}"))
394            .collect::<String>()
395    };
396    let name = validated.config.name.clone();
397    let version = validated.config.version.to_string();
398
399    let signing = domain_key::load(domain)
400        .map_err(|e| CliError::new(ExitKind::NotFound, "DOMAIN_KEY_NOT_FOUND", e.to_string()))?;
401    let timestamp = std::time::SystemTime::now()
402        .duration_since(std::time::UNIX_EPOCH)
403        .map(|d| d.as_secs() as i64)
404        .unwrap_or(0);
405    let payload = signing_payload(&content_sha256, scope, &name, &version, timestamp);
406    Ok(registry::DomainSignature {
407        key: domain_key::public_key_string(&signing),
408        signature: domain_key::sign(&signing, &payload),
409        timestamp,
410    })
411}
412
413/// Lean build: canonicalizing an archive needs the git-branch validator, which
414/// is only compiled into the full `memstead` binary. Domain publishing is
415/// therefore unavailable here.
416#[cfg(not(feature = "mem-repo"))]
417fn build_domain_signature(
418    _archive_path: &Path,
419    _scope: &str,
420    _domain: &str,
421) -> anyhow::Result<registry::DomainSignature> {
422    Err(CliError::new(
423        ExitKind::Generic,
424        "DOMAIN_PUBLISH_UNAVAILABLE",
425        "domain publishing requires the full `memstead` build (the lean build cannot \
426         canonicalize archives for signing)",
427    )
428    .into())
429}
430
431/// Render the `--dry-run` preview: what the real publish would send,
432/// with nothing posted and nothing mutated. `scope` is the admin
433/// override when present; otherwise the registry derives it from the
434/// caller's GitHub login, which the client cannot know offline.
435fn emit_dry_run(
436    ctx: &CliContext,
437    base: &str,
438    archive_path: &Path,
439    mem: Option<&str>,
440    version: Option<&str>,
441    scope: Option<&str>,
442) -> anyhow::Result<()> {
443    let size = std::fs::metadata(archive_path)
444        .map(|m| m.len())
445        .unwrap_or(0);
446    let mem_label = mem.unwrap_or("(workspace mem)");
447    let version_label = version.unwrap_or("(from mem config / archive)");
448    if ctx.json {
449        print_json(&json!({
450            "dry_run": true,
451            "mem": mem,
452            "version": version,
453            "scope": scope,
454            "archive_bytes": size,
455            "registry": base,
456            "published": false,
457        }))?;
458    } else {
459        let scope_label = match scope {
460            Some(s) => format!("`{s}` (override)"),
461            None => "derived from your GitHub login".to_string(),
462        };
463        print_markdown(&format!(
464            "# Dry run — would publish\n\n\
465             - Mem: `{mem_label}`\n\
466             - Version: `{version_label}`\n\
467             - Scope: {scope_label}\n\
468             - Archive: {size} bytes\n\
469             - Registry: {base}\n\n\
470             Nothing was published and nothing was changed.",
471        ));
472    }
473    Ok(())
474}
475
476/// Walk upward from cwd looking for the first ancestor that carries
477/// `.memstead/workspace.toml` — the post-rebuild workspace marker.
478/// Mirrors the MCP binary's walker; keep the two in sync.
479fn find_filesystem_workspace_root() -> anyhow::Result<PathBuf> {
480    let cwd = std::env::current_dir().map_err(|e| {
481        CliError::new(
482            ExitKind::Generic,
483            crate::INTERNAL_CODE,
484            format!("read cwd: {e}"),
485        )
486    })?;
487    let mut current: &Path = &cwd;
488    loop {
489        if memstead_base::is_workspace_root(current) {
490            return Ok(current.to_path_buf());
491        }
492        match current.parent() {
493            Some(p) => current = p,
494            None => {
495                return Err(CliError::new(
496                    ExitKind::NotFound,
497                    "WORKSPACE_NOT_INITIALISED",
498                    format!(
499                        "no workspace found from {} or any ancestor (missing \
500                         .memstead/workspace.toml) — run `memstead init` first, pass \
501                         --workspace <path>, or supply an archive path",
502                        cwd.display()
503                    ),
504                )
505                .into());
506            }
507        }
508    }
509}
510
511/// Resolve the workspace root for the assembling shapes by walking up
512/// from cwd. Shared by the `--mem` and bare-folder paths.
513fn resolve_workspace_root(root_override: Option<&Path>) -> anyhow::Result<PathBuf> {
514    // Workspace targeting is the root command's job (global
515    // `--workspace` / `MEMSTEAD_WORKSPACE`, validated + applied before
516    // dispatch); `root_override` is the in-process test seam.
517    match root_override {
518        Some(p) => Ok(p.to_path_buf()),
519        None => find_filesystem_workspace_root(),
520    }
521}
522
523/// Write assembled archive bytes to a tempfile so the file-based POST
524/// path can read them back. Returns the path plus the `NamedTempFile`
525/// guard the caller must hold until the POST completes.
526fn stage_bytes_to_tempfile(bytes: &[u8]) -> anyhow::Result<(PathBuf, Option<NamedTempFile>)> {
527    let tempfile = NamedTempFile::new().map_err(|e| {
528        CliError::new(
529            ExitKind::Generic,
530            crate::INTERNAL_CODE,
531            format!("tempfile: {e}"),
532        )
533    })?;
534    std::fs::write(tempfile.path(), bytes).map_err(|e| {
535        CliError::new(
536            ExitKind::Generic,
537            crate::INTERNAL_CODE,
538            format!("write tempfile {}: {e}", tempfile.path().display()),
539        )
540    })?;
541    let path = tempfile.path().to_path_buf();
542    Ok((path, Some(tempfile)))
543}
544
545fn login_inline(client: &reqwest::blocking::Client, host: &str) -> anyhow::Result<String> {
546    println!("Not logged in — starting GitHub Device Flow…");
547    let outcome = device_flow::run(
548        client,
549        device_flow::MEMSTEAD_GITHUB_CLIENT_ID,
550        device_flow::MEMSTEAD_GITHUB_SCOPE,
551        |url| {
552            let _ = device_flow::open_browser(url);
553        },
554    )
555    .map_err(|e| {
556        CliError::new(
557            ExitKind::Generic,
558            "LOGIN_FAILED",
559            format!("login failed: {e}"),
560        )
561    })?;
562
563    // Best-effort username lookup for the credentials entry.
564    let user_login = fetch_login(client, &outcome.access_token).unwrap_or_default();
565
566    let entry = credentials::Entry::new(
567        outcome.access_token.clone(),
568        user_login,
569        outcome.scopes.clone(),
570    );
571    credentials::save_for(host, entry)?;
572
573    Ok(outcome.access_token)
574}
575
576fn fetch_login(client: &reqwest::blocking::Client, token: &str) -> anyhow::Result<String> {
577    let base = std::env::var("MEMSTEAD_GITHUB_API_BASE")
578        .unwrap_or_else(|_| "https://api.github.com".to_string());
579    let url = format!("{}/user", base.trim_end_matches('/'));
580    let resp = client
581        .get(url)
582        .bearer_auth(token)
583        .header("accept", "application/vnd.github+json")
584        .send()?;
585    if !resp.status().is_success() {
586        anyhow::bail!("GitHub /user returned {}", resp.status());
587    }
588    #[derive(serde::Deserialize)]
589    struct User {
590        login: String,
591    }
592    let user: User = resp.json()?;
593    Ok(user.login)
594}
595
596fn emit_success(
597    ctx: &CliContext,
598    base: &str,
599    resp: &registry::PublishResponse,
600) -> anyhow::Result<()> {
601    let full_url = format!("{}{}", base, resp.url);
602    // Honest signal: the registry promotes the highest published version
603    // to `current`, so publishing an older version succeeds but does not
604    // become what users get by default. Surface that rather than letting
605    // the bare "Published vX" imply X is now live.
606    let demoted = resp.current.as_deref().filter(|cur| *cur != resp.version);
607    if ctx.json {
608        print_json(&json!({
609            "ok": true,
610            "scope": resp.scope,
611            "name": resp.name,
612            "version": resp.version,
613            "current": resp.current,
614            "url": full_url,
615        }))?;
616    } else {
617        let mut block = format!(
618            "# Published {}/{} v{}\n\n- URL: {}",
619            resp.scope, resp.name, resp.version, full_url,
620        );
621        if let Some(cur) = demoted {
622            block.push_str(&format!(
623                "\n\n> Note: `current` stays at v{cur} — you published an older version, \
624                 so it is retained and resolvable but is not the default users get.",
625            ));
626        }
627        print_markdown(&block);
628    }
629    Ok(())
630}
631
632fn map_publish_error(err: PublishError) -> CliError {
633    match err {
634        PublishError::Io(e) => CliError::new(
635            ExitKind::Generic,
636            "ARCHIVE_READ_FAILED",
637            format!("cannot read archive: {e}"),
638        ),
639        PublishError::Network(e) => CliError::new(
640            ExitKind::Generic,
641            "NETWORK_ERROR",
642            format!("network error: {e}"),
643        ),
644        PublishError::Malformed(e) => CliError::new(
645            ExitKind::Generic,
646            "REGISTRY_MALFORMED_RESPONSE",
647            format!("registry sent an unparseable success response: {e}"),
648        ),
649        PublishError::Raw { status, text } => CliError::new(
650            ExitKind::Generic,
651            "REGISTRY_ERROR",
652            format!("registry returned {status}: {text}"),
653        ),
654        PublishError::Api { status, envelope } => map_api_error(status, envelope),
655    }
656}
657
658fn map_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
659    let kind = match status.as_u16() {
660        400 => ExitKind::Validation,
661        401 | 403 => ExitKind::Generic,
662        404 => ExitKind::NotFound,
663        410 => ExitKind::Generic,
664        413 | 429 => ExitKind::Generic,
665        _ => ExitKind::Generic,
666    };
667    let code: &'static str = match status.as_u16() {
668        400 => "REGISTRY_VALIDATION_FAILED",
669        401 => "NOT_AUTHENTICATED",
670        403 => "FORBIDDEN",
671        404 => "REGISTRY_NOT_FOUND",
672        410 => "GONE",
673        413 => "ARCHIVE_TOO_LARGE",
674        429 => "RATE_LIMITED",
675        _ => "REGISTRY_ERROR",
676    };
677
678    let mut msg = match status.as_u16() {
679        400 => {
680            let variant = envelope
681                .variant
682                .clone()
683                .unwrap_or_else(|| "ValidationFailed".to_string());
684            let detail = envelope
685                .detail
686                .clone()
687                .unwrap_or_else(|| "validation failed (no detail)".to_string());
688            if let Some(path) = envelope.path.as_deref() {
689                format!("{variant} at {path}: {detail}")
690            } else {
691                format!("{variant}: {detail}")
692            }
693        }
694        401 => {
695            "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
696        }
697        403 => envelope
698            .detail
699            .clone()
700            .map(|d| format!("forbidden: {d}"))
701            .unwrap_or_else(|| "forbidden".to_string()),
702        404 => "registry returned 404 — is the URL correct?".to_string(),
703        410 => envelope
704            .detail
705            .clone()
706            .map(|d| format!("gone: {d}"))
707            .unwrap_or_else(|| "content is gone (taken down or deny-listed)".to_string()),
708        413 => "archive exceeds the 2 MB publisher cap".to_string(),
709        429 => {
710            let retry = envelope.retry_after_seconds.unwrap_or(0);
711            if retry > 0 {
712                format!("rate-limited — retry after {retry}s")
713            } else {
714                "rate-limited".to_string()
715            }
716        }
717        _ => envelope
718            .detail
719            .clone()
720            .unwrap_or_else(|| format!("registry returned {status}")),
721    };
722
723    // Preserve the error discriminator so programmatic callers can
724    // still see the wire `error` string.
725    if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
726        msg = format!("{msg} [{}]", envelope.error);
727    }
728
729    CliError::new(kind, code, msg)
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use memstead_base::filesystem::config::{WorkspaceConfig, write_workspace_config};
736    use memstead_schema::SchemaRef;
737    use tempfile::TempDir;
738
739    fn write_publishable_workspace(tmp: &TempDir, name: &str) {
740        // Lay down the post-rebuild marker so the publish command's
741        // walk-up resolves.
742        let memstead_dir = tmp.path().join(".memstead");
743        std::fs::create_dir_all(&memstead_dir).unwrap();
744        std::fs::write(
745            memstead_dir.join("workspace.toml"),
746            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
747        )
748        .unwrap();
749        // Round-trip via serde so the test does not need a direct
750        // `semver` dependency. The workspace-config writer accepts
751        // an optional `version` field; we slot it in by serialising
752        // a JSON value that matches the on-disk schema.
753        let pin: SchemaRef = "default@1.0.0".parse().unwrap();
754        let cfg = WorkspaceConfig::new(name, pin);
755        write_workspace_config(tmp.path(), &cfg).unwrap();
756        // Patch in the version field by re-reading + re-writing the
757        // raw JSON. Avoids the test having to depend on `semver`
758        // directly; the `to_published()` path needs `version` set.
759        let cfg_path = tmp.path().join(".memstead").join("config.json");
760        let raw = std::fs::read_to_string(&cfg_path).unwrap();
761        let mut value: serde_json::Value = serde_json::from_str(&raw).unwrap();
762        value["version"] = serde_json::json!("0.1.0");
763        std::fs::write(&cfg_path, serde_json::to_string_pretty(&value).unwrap()).unwrap();
764    }
765
766    /// Spin up an axum fixture that accepts `POST /api/publish` and
767    /// echoes a success body. The body is captured so the test can
768    /// assert it is a non-empty zip-shaped buffer (zip magic
769    /// `PK\x03\x04`).
770    async fn spawn_fixture_publish_registry() -> (
771        String,
772        std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
773        tokio::task::JoinHandle<()>,
774    ) {
775        use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
776        use std::sync::{Arc, Mutex};
777
778        let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
779        let captured_clone = captured.clone();
780        let app: Router = Router::new()
781            .route(
782                "/api/publish",
783                post(
784                    move |State(buf): State<Arc<Mutex<Vec<u8>>>>, body: axum::body::Bytes| async move {
785                        *buf.lock().unwrap() = body.to_vec();
786                        (
787                            StatusCode::OK,
788                            Json(serde_json::json!({
789                                "ok": true,
790                                "scope": "fixture",
791                                "name": "demo",
792                                "version": "0.1.0",
793                                "url": "/v/fixture/demo",
794                            })),
795                        )
796                    },
797                ),
798            )
799            .with_state(captured_clone);
800        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
801        let addr = listener.local_addr().unwrap();
802        let handle = tokio::spawn(async move {
803            axum::serve(listener, app).await.unwrap();
804        });
805        (format!("http://{addr}"), captured, handle)
806    }
807
808    #[tokio::test(flavor = "multi_thread")]
809    async fn publish_assembles_from_workspace_when_no_archive_arg() {
810        let tmp = TempDir::new().unwrap();
811        write_publishable_workspace(&tmp, "demo");
812
813        let (base, captured, handle) = spawn_fixture_publish_registry().await;
814
815        let workspace = tmp.path().to_path_buf();
816        let base_clone = base.clone();
817        let captured_clone = captured.clone();
818        let result = tokio::task::spawn_blocking(move || {
819            let ctx = CliContext {
820                json: false,
821                quiet: false,
822                role: Default::default(),
823            };
824            run_with_root(
825                &ctx,
826                Args {
827                    archive: None,
828                    mem: None,
829                    scope: None,
830                    version: None,
831                    dry_run: false,
832                    redact_anchors: false,
833                    token: Some("fixture-token".to_string()),
834                    registry: Some(base_clone),
835                },
836                Some(workspace),
837            )?;
838            let body = captured_clone.lock().unwrap().clone();
839            Ok::<Vec<u8>, anyhow::Error>(body)
840        })
841        .await
842        .unwrap();
843        handle.abort();
844        let body = result.unwrap();
845
846        // Body must be a non-empty zip buffer.
847        assert!(body.len() > 4);
848        assert_eq!(&body[0..4], b"PK\x03\x04");
849    }
850
851    /// The marker validation for workspace overrides lives on the ROOT
852    /// command now (global `--workspace` / `MEMSTEAD_WORKSPACE`,
853    /// refused before dispatch naming the tried path — covered by
854    /// `read_commands::workspace_override_flag_env_precedence_and_refusal`).
855    /// Through the in-process test seam a marker-less root still fails
856    /// loudly downstream rather than publishing garbage.
857    #[test]
858    fn publish_errors_when_root_lacks_workspace_shape() {
859        let tmp = TempDir::new().unwrap();
860        // No `.memstead/workspace.toml` under tmp.
861        let ctx = CliContext {
862            json: false,
863            quiet: false,
864            role: Default::default(),
865        };
866        let err = run_with_root(
867            &ctx,
868            Args {
869                archive: None,
870                mem: None,
871                scope: None,
872                version: None,
873                dry_run: false,
874                redact_anchors: false,
875                token: Some("fixture-token".to_string()),
876                registry: Some("http://127.0.0.1:1".to_string()),
877            },
878            Some(tmp.path().to_path_buf()),
879        )
880        .unwrap_err();
881        let msg = err.to_string();
882        assert!(!msg.is_empty(), "marker-less root must error, got: {msg}");
883    }
884
885    #[test]
886    fn publish_mem_flag_routes_through_engine_and_maps_unknown_mem() {
887        // `--mem NAME` must take the engine export-to-bytes branch
888        // (not the bare folder assembly): it opens the workspace engine
889        // and asks it to export the named mem. A name the workspace
890        // does not carry surfaces the engine's typed `UNKNOWN_MEM`
891        // through `from_engine_op` rather than a folder-assembly error —
892        // proof the new dispatch reaches the engine path. The happy
893        // path (a real mem → zip bytes) reuses the same
894        // `export_mem_to_bytes` primitive that `memstead export
895        // --format mem` exercises under test.
896        let tmp = TempDir::new().unwrap();
897        write_publishable_workspace(&tmp, "demo");
898        let ctx = CliContext {
899            json: false,
900            quiet: false,
901            role: Default::default(),
902        };
903        let err = run_with_root(
904            &ctx,
905            Args {
906                archive: None,
907                mem: Some("nonexistent".to_string()),
908                scope: None,
909                version: None,
910                dry_run: false,
911                redact_anchors: false,
912                token: Some("fixture-token".to_string()),
913                registry: Some("http://127.0.0.1:1".to_string()),
914            },
915            Some(tmp.path().to_path_buf()),
916        )
917        .unwrap_err();
918        let msg = err.to_string();
919        assert!(
920            msg.contains("unknown mem") || msg.contains("nonexistent"),
921            "expected an engine UNKNOWN_MEM error from the --mem path, got: {msg}"
922        );
923    }
924
925    #[test]
926    fn publish_version_without_mem_is_rejected_before_any_io() {
927        // `--version` persists a bump through the workspace engine, so
928        // it is meaningless without `--mem` — and must refuse up front
929        // (no workspace touched, no network) with an actionable message.
930        let ctx = CliContext {
931            json: false,
932            quiet: false,
933            role: Default::default(),
934        };
935        let err = run(
936            &ctx,
937            Args {
938                archive: None,
939                mem: None,
940                scope: None,
941                version: Some("0.2.0".to_string()),
942                dry_run: false,
943                redact_anchors: false,
944                token: None,
945                registry: Some("http://127.0.0.1:1".to_string()),
946            },
947        )
948        .unwrap_err();
949        let msg = err.to_string();
950        assert!(
951            msg.contains("--version requires --mem"),
952            "expected a --version-requires-mem refusal, got: {msg}"
953        );
954    }
955
956    #[tokio::test(flavor = "multi_thread")]
957    async fn dry_run_posts_nothing() {
958        // `--dry-run` resolves the archive but must not hit the
959        // registry: the fixture captures the request body, and it stays
960        // empty because no POST is made.
961        let tmp = TempDir::new().unwrap();
962        write_publishable_workspace(&tmp, "demo");
963
964        let (base, captured, handle) = spawn_fixture_publish_registry().await;
965
966        let workspace = tmp.path().to_path_buf();
967        let base_clone = base.clone();
968        let result = tokio::task::spawn_blocking(move || {
969            let ctx = CliContext {
970                json: false,
971                quiet: false,
972                role: Default::default(),
973            };
974            run_with_root(
975                &ctx,
976                Args {
977                    archive: None,
978                    mem: None,
979                    scope: None,
980                    version: None,
981                    dry_run: true,
982                    redact_anchors: false,
983                    token: None,
984                    registry: Some(base_clone),
985                },
986                Some(workspace),
987            )?;
988            Ok::<(), anyhow::Error>(())
989        })
990        .await
991        .unwrap();
992        handle.abort();
993        result.unwrap();
994
995        assert!(
996            captured.lock().unwrap().is_empty(),
997            "dry-run must not POST anything to the registry"
998        );
999    }
1000
1001    /// `--redact-anchors` with a pre-built archive PATH refuses typed,
1002    /// BEFORE any auth or network step — the registry URL points at a
1003    /// closed port, so reaching the network would surface a different
1004    /// error than the refusal asserted here. The unflagged pre-built
1005    /// shape is untouched by the new gate (it proceeds far enough to
1006    /// hit the file read instead).
1007    #[test]
1008    fn redact_anchors_refuses_prebuilt_archive_path() {
1009        let ctx = CliContext {
1010            json: false,
1011            quiet: false,
1012            role: Default::default(),
1013        };
1014        let err = run_with_root(
1015            &ctx,
1016            Args {
1017                archive: Some(PathBuf::from("/nonexistent/some.mem")),
1018                mem: None,
1019                scope: None,
1020                version: None,
1021                dry_run: false,
1022                redact_anchors: true,
1023                token: Some("fixture-token".to_string()),
1024                registry: Some("http://127.0.0.1:1".to_string()),
1025            },
1026            None,
1027        )
1028        .unwrap_err();
1029        let msg = err.to_string();
1030        assert!(
1031            msg.contains("--redact-anchors") && msg.contains("baked in"),
1032            "typed refusal names the flag and the alternative: {msg}"
1033        );
1034    }
1035
1036    /// A redacted bare-shape publish POSTs a package whose anchors
1037    /// sidecar carries the sentinel and not the source path — and the
1038    /// workspace's own sidecar is untouched afterwards.
1039    #[tokio::test(flavor = "multi_thread")]
1040    async fn redacted_publish_posts_sentinel_sidecar() {
1041        let tmp = TempDir::new().unwrap();
1042        write_publishable_workspace(&tmp, "demo");
1043        std::fs::write(
1044            tmp.path().join("first.md"),
1045            "---\ntype: spec\n---\n# First\n",
1046        )
1047        .unwrap();
1048        let sidecar_path = tmp.path().join(".memstead").join("anchors.json");
1049        let local = br#"{"version":1,"entities":{"demo--first":[{"artifact":"src/private.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
1050        std::fs::write(&sidecar_path, local).unwrap();
1051
1052        let (base, captured, handle) = spawn_fixture_publish_registry().await;
1053        let workspace = tmp.path().to_path_buf();
1054        let base_clone = base.clone();
1055        let captured_clone = captured.clone();
1056        let body = tokio::task::spawn_blocking(move || {
1057            let ctx = CliContext {
1058                json: false,
1059                quiet: false,
1060                role: Default::default(),
1061            };
1062            run_with_root(
1063                &ctx,
1064                Args {
1065                    archive: None,
1066                    mem: None,
1067                    scope: None,
1068                    version: None,
1069                    dry_run: false,
1070                    redact_anchors: true,
1071                    token: Some("fixture-token".to_string()),
1072                    registry: Some(base_clone),
1073                },
1074                Some(workspace),
1075            )?;
1076            Ok::<Vec<u8>, anyhow::Error>(captured_clone.lock().unwrap().clone())
1077        })
1078        .await
1079        .unwrap()
1080        .unwrap();
1081        handle.abort();
1082
1083        let posted = String::from_utf8_lossy(&body).into_owned();
1084        assert!(
1085            posted.contains("[redacted]"),
1086            "posted package carries the sentinel"
1087        );
1088        assert!(
1089            !posted.contains("src/private.rs"),
1090            "posted package must not carry the artifact path"
1091        );
1092        // Publish-time only: the workspace sidecar still names the source.
1093        let after = std::fs::read(&sidecar_path).unwrap();
1094        assert_eq!(after, local, "local sidecar bytes are byte-identical");
1095    }
1096}