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