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