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, CliEngine};
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    /// Override the workspace root for the no-arg / `--mem` shapes.
49    /// Ignored when an archive PATH is provided. Defaults to walking up
50    /// from cwd.
51    #[arg(long, value_name = "PATH")]
52    pub workspace: Option<PathBuf>,
53
54    /// Export-and-publish a named mem from the current workspace in
55    /// one step — the path for mem-repo (multi-mem, git-branch)
56    /// workspaces, which have no folder to wrap up. Ignored when an
57    /// archive PATH is provided. A single-mem folder workspace can
58    /// omit this and just run `memstead publish`.
59    #[arg(long, value_name = "NAME")]
60    pub mem: Option<String>,
61
62    /// Override the auto-derived scope — admin-only, reserved scopes
63    /// only (currently just `memstead`). Without this flag the registry
64    /// stores the mem under your GitHub username.
65    #[arg(long, value_name = "NAME")]
66    pub scope: Option<String>,
67
68    /// Explicit token override. Takes precedence over `MEMSTEAD_TOKEN`
69    /// and stored credentials.
70    #[arg(long, value_name = "TOKEN")]
71    pub token: Option<String>,
72
73    /// Registry URL (overrides `MEMSTEAD_REGISTRY`; defaults to https://memstead.io).
74    #[arg(long, value_name = "URL")]
75    pub registry: Option<String>,
76
77    /// Set the mem's version to this semver and publish in one step,
78    /// persisting the bump to the mem config (like `npm version` +
79    /// `npm publish`). Requires `--mem <name>`; not valid with a
80    /// pre-built archive PATH, whose version is already baked in. Omit
81    /// to publish whatever version the mem config currently carries.
82    #[arg(long, value_name = "SEMVER")]
83    pub version: Option<String>,
84
85    /// Assemble and resolve everything, print exactly what would be
86    /// published (mem, version, scope, archive size), but POST
87    /// nothing and mutate nothing — including no version bump. The safe
88    /// way to confirm a publish before it goes out.
89    #[arg(long)]
90    pub dry_run: bool,
91}
92
93pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
94    let base = registry::registry_base(args.registry.as_deref());
95    let host = registry::registry_host(&base);
96    let client = registry::build_http()?;
97
98    // 0. Validate `--version` up front: it persists a bump through the
99    //    workspace engine, so it needs `--mem <name>` and is
100    //    meaningless against pre-built archive bytes whose version is
101    //    already sealed.
102    let target_version = match args.version.as_deref() {
103        Some(v) => {
104            if args.archive.is_some() {
105                return Err(CliError::new(
106                    ExitKind::Validation,
107                    "INVALID_INPUT",
108                    "--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",
109                )
110                .into());
111            }
112            if args.mem.is_none() {
113                return Err(CliError::new(
114                    ExitKind::Validation,
115                    "INVALID_INPUT",
116                    "--version requires --mem <name> so the bump knows which mem to re-version",
117                )
118                .into());
119            }
120            Some(semver::Version::parse(v).map_err(|e| {
121                CliError::new(
122                    ExitKind::Validation,
123                    "INVALID_VERSION",
124                    format!("--version {v:?} is not a valid semver: {e}"),
125                )
126            })?)
127        }
128        None => None,
129    };
130
131    // 1. Resolve archive bytes by input shape (priority order):
132    //    archive PATH > `--mem NAME` (engine export-to-bytes, any
133    //    backend) > bare (folder assembly). The two assembling shapes
134    //    stage their bytes through a tempfile so the existing
135    //    `registry::publish` POST path stays file-based; the tempfile
136    //    guard is held until the end of `run` so the path stays valid
137    //    for the POST call. `resolved_version` is the version that will
138    //    actually publish — surfaced in the dry-run preview.
139    let mut resolved_version: Option<String> = None;
140    let (archive_path, _tempfile_guard): (PathBuf, Option<NamedTempFile>) =
141        if let Some(p) = args.archive {
142            (p, None)
143        } else if let Some(mem_name) = args.mem.as_deref() {
144            let workspace_root = resolve_workspace_root(args.workspace.as_deref())?;
145            let mut engine = match ctx.cli_engine_at(&workspace_root)? {
146                #[cfg(feature = "mem-repo")]
147                CliEngine::MemRepo(e) => e,
148                CliEngine::Filesystem(e) => e,
149            };
150            // Persist the version bump before exporting — but never
151            // under --dry-run, which must leave the workspace untouched.
152            if let Some(ver) = target_version.clone()
153                && !args.dry_run
154            {
155                engine
156                    .set_mem_version(mem_name, ver, Some("version bump for registry publish"))
157                    .map_err(CliError::from_engine_op)?;
158            }
159            resolved_version = target_version.as_ref().map(|v| v.to_string()).or_else(|| {
160                engine
161                    .mem_config_for(mem_name)
162                    .and_then(|c| c.version.clone())
163                    .map(|v| v.to_string())
164            });
165            let bytes = engine
166                .export_mem_to_bytes(mem_name)
167                .map_err(CliError::from_engine_op)?;
168            stage_bytes_to_tempfile(&bytes)?
169        } else {
170            let workspace_root = resolve_workspace_root(args.workspace.as_deref())?;
171            let bytes = assemble_archive(&workspace_root).map_err(|e| {
172                CliError::new(
173                    ExitKind::Validation,
174                    "ARCHIVE_ASSEMBLY_FAILED",
175                    format!("assemble archive: {e}"),
176                )
177            })?;
178            stage_bytes_to_tempfile(&bytes)?
179        };
180
181    // 2. Dry run: report the resolved publish and stop — no auth, no
182    //    POST, no mutation (any --version bump was skipped above).
183    if args.dry_run {
184        return emit_dry_run(
185            ctx,
186            &base,
187            &archive_path,
188            args.mem.as_deref(),
189            resolved_version.as_deref(),
190            args.scope.as_deref(),
191        );
192    }
193
194    // 3. Authorise + POST. A `<domain>:<handle>` scope is a domain-authority
195    //    publish: it signs the upload with the domain's locally-stored key and
196    //    needs no GitHub account. Any other scope uses the GitHub token path
197    //    (with interactive device-flow fallback on a TTY).
198    if let Some(domain) = domain_scope(args.scope.as_deref()) {
199        let scope = args.scope.as_deref().expect("domain_scope implies a scope");
200        let sig = build_domain_signature(&archive_path, scope, &domain)?;
201        return match registry::publish(&client, &base, &archive_path, None, Some(scope), Some(&sig))
202        {
203            Ok(resp) => emit_success(ctx, &base, &resp),
204            Err(e) => Err(map_publish_error(e).into()),
205        };
206    }
207
208    let token = match resolve_token(&host, args.token.as_deref())? {
209        Some(r) => r.token,
210        None => {
211            if !std::io::stdin().is_terminal() {
212                return Err(CliError::new(
213                    ExitKind::Generic,
214                    "NOT_AUTHENTICATED",
215                    "not logged in and stdin is not a TTY — set MEMSTEAD_TOKEN \
216                     or run `memstead login` first",
217                )
218                .into());
219            }
220            login_inline(&client, &host)?
221        }
222    };
223
224    match registry::publish(
225        &client,
226        &base,
227        &archive_path,
228        Some(&token),
229        args.scope.as_deref(),
230        None,
231    ) {
232        Ok(resp) => emit_success(ctx, &base, &resp),
233        Err(e) => Err(map_publish_error(e).into()),
234    }
235}
236
237/// A `<domain>:<handle>` scope override → the domain. A domain scope's prefix
238/// contains a `.` (e.g. `acme.com:payments`); `github:<h>` and bare handles do
239/// not, so they fall through to the GitHub path.
240fn domain_scope(scope: Option<&str>) -> Option<String> {
241    let (prefix, handle) = scope?.split_once(':')?;
242    if prefix.contains('.') && !handle.is_empty() {
243        Some(prefix.to_ascii_lowercase())
244    } else {
245        None
246    }
247}
248
249/// Build the per-publish domain signature: canonicalize the archive (the
250/// signature covers the canonical content hash the registry will also compute),
251/// then sign `(hash, scope, name, version, now)` with the domain's stored key.
252#[cfg(feature = "mem-repo")]
253fn build_domain_signature(
254    archive_path: &Path,
255    scope: &str,
256    domain: &str,
257) -> anyhow::Result<registry::DomainSignature> {
258    use memstead_base::domain_authority_wire::signing_payload;
259    use memstead_git_branch::validator::validate_and_normalize_archive;
260    use sha2::{Digest, Sha256};
261
262    use crate::auth::domain_key;
263
264    let bytes = std::fs::read(archive_path).map_err(|e| {
265        CliError::new(
266            ExitKind::Generic,
267            "ARCHIVE_READ_FAILED",
268            format!("read archive: {e}"),
269        )
270    })?;
271    let validated = validate_and_normalize_archive(&bytes).map_err(|e| {
272        CliError::new(
273            ExitKind::Validation,
274            "ARCHIVE_INVALID",
275            format!("archive failed local validation before signing: {e}"),
276        )
277    })?;
278    let content_sha256 = {
279        let mut h = Sha256::new();
280        h.update(&validated.canonical_bytes);
281        h.finalize()
282            .iter()
283            .map(|b| format!("{b:02x}"))
284            .collect::<String>()
285    };
286    let name = validated.config.name.clone();
287    let version = validated.config.version.to_string();
288
289    let signing = domain_key::load(domain)
290        .map_err(|e| CliError::new(ExitKind::NotFound, "DOMAIN_KEY_NOT_FOUND", e.to_string()))?;
291    let timestamp = std::time::SystemTime::now()
292        .duration_since(std::time::UNIX_EPOCH)
293        .map(|d| d.as_secs() as i64)
294        .unwrap_or(0);
295    let payload = signing_payload(&content_sha256, scope, &name, &version, timestamp);
296    Ok(registry::DomainSignature {
297        key: domain_key::public_key_string(&signing),
298        signature: domain_key::sign(&signing, &payload),
299        timestamp,
300    })
301}
302
303/// Lean build: canonicalizing an archive needs the git-branch validator, which
304/// is only compiled into the full `memstead` binary. Domain publishing is
305/// therefore unavailable here.
306#[cfg(not(feature = "mem-repo"))]
307fn build_domain_signature(
308    _archive_path: &Path,
309    _scope: &str,
310    _domain: &str,
311) -> anyhow::Result<registry::DomainSignature> {
312    Err(CliError::new(
313        ExitKind::Generic,
314        "DOMAIN_PUBLISH_UNAVAILABLE",
315        "domain publishing requires the full `memstead` build (the lean build cannot \
316         canonicalize archives for signing)",
317    )
318    .into())
319}
320
321/// Render the `--dry-run` preview: what the real publish would send,
322/// with nothing posted and nothing mutated. `scope` is the admin
323/// override when present; otherwise the registry derives it from the
324/// caller's GitHub login, which the client cannot know offline.
325fn emit_dry_run(
326    ctx: &CliContext,
327    base: &str,
328    archive_path: &Path,
329    mem: Option<&str>,
330    version: Option<&str>,
331    scope: Option<&str>,
332) -> anyhow::Result<()> {
333    let size = std::fs::metadata(archive_path)
334        .map(|m| m.len())
335        .unwrap_or(0);
336    let mem_label = mem.unwrap_or("(workspace mem)");
337    let version_label = version.unwrap_or("(from mem config / archive)");
338    if ctx.json {
339        print_json(&json!({
340            "dry_run": true,
341            "mem": mem,
342            "version": version,
343            "scope": scope,
344            "archive_bytes": size,
345            "registry": base,
346            "published": false,
347        }))?;
348    } else {
349        let scope_label = match scope {
350            Some(s) => format!("`{s}` (override)"),
351            None => "derived from your GitHub login".to_string(),
352        };
353        print_markdown(&format!(
354            "# Dry run — would publish\n\n\
355             - Mem: `{mem_label}`\n\
356             - Version: `{version_label}`\n\
357             - Scope: {scope_label}\n\
358             - Archive: {size} bytes\n\
359             - Registry: {base}\n\n\
360             Nothing was published and nothing was changed.",
361        ));
362    }
363    Ok(())
364}
365
366/// Walk upward from cwd looking for the first ancestor that carries
367/// `.memstead/workspace.toml` — the post-rebuild workspace marker.
368/// Mirrors `memstead link`'s resolver and the MCP binary's walker; keep
369/// them in sync.
370fn find_filesystem_workspace_root() -> anyhow::Result<PathBuf> {
371    let cwd = std::env::current_dir().map_err(|e| {
372        CliError::new(
373            ExitKind::Generic,
374            crate::INTERNAL_CODE,
375            format!("read cwd: {e}"),
376        )
377    })?;
378    let mut current: &Path = &cwd;
379    loop {
380        if memstead_base::is_workspace_root(current) {
381            return Ok(current.to_path_buf());
382        }
383        match current.parent() {
384            Some(p) => current = p,
385            None => {
386                return Err(CliError::new(
387                    ExitKind::NotFound,
388                    "WORKSPACE_NOT_INITIALISED",
389                    format!(
390                        "no workspace found from {} or any ancestor (missing \
391                         .memstead/workspace.toml) — run `memstead init` first, pass \
392                         --workspace <path>, or supply an archive path",
393                        cwd.display()
394                    ),
395                )
396                .into());
397            }
398        }
399    }
400}
401
402/// Resolve the workspace root for the assembling shapes: honour an
403/// explicit `--workspace` override (validated against the marker) or
404/// walk up from cwd. Shared by the `--mem` and bare-folder paths.
405fn resolve_workspace_root(workspace: Option<&Path>) -> anyhow::Result<PathBuf> {
406    match workspace {
407        Some(p) => {
408            let canon = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
409            if !memstead_base::is_workspace_root(&canon) {
410                return Err(CliError::new(
411                    ExitKind::NotFound,
412                    "WORKSPACE_NOT_INITIALISED",
413                    format!(
414                        "no workspace at {} (missing .memstead/workspace.toml)",
415                        canon.display()
416                    ),
417                )
418                .into());
419            }
420            Ok(canon)
421        }
422        None => find_filesystem_workspace_root(),
423    }
424}
425
426/// Write assembled archive bytes to a tempfile so the file-based POST
427/// path can read them back. Returns the path plus the `NamedTempFile`
428/// guard the caller must hold until the POST completes.
429fn stage_bytes_to_tempfile(bytes: &[u8]) -> anyhow::Result<(PathBuf, Option<NamedTempFile>)> {
430    let tempfile = NamedTempFile::new().map_err(|e| {
431        CliError::new(
432            ExitKind::Generic,
433            crate::INTERNAL_CODE,
434            format!("tempfile: {e}"),
435        )
436    })?;
437    std::fs::write(tempfile.path(), bytes).map_err(|e| {
438        CliError::new(
439            ExitKind::Generic,
440            crate::INTERNAL_CODE,
441            format!("write tempfile {}: {e}", tempfile.path().display()),
442        )
443    })?;
444    let path = tempfile.path().to_path_buf();
445    Ok((path, Some(tempfile)))
446}
447
448fn login_inline(client: &reqwest::blocking::Client, host: &str) -> anyhow::Result<String> {
449    println!("Not logged in — starting GitHub Device Flow…");
450    let outcome = device_flow::run(
451        client,
452        device_flow::MEMSTEAD_GITHUB_CLIENT_ID,
453        device_flow::MEMSTEAD_GITHUB_SCOPE,
454        |url| {
455            let _ = device_flow::open_browser(url);
456        },
457    )
458    .map_err(|e| {
459        CliError::new(
460            ExitKind::Generic,
461            "LOGIN_FAILED",
462            format!("login failed: {e}"),
463        )
464    })?;
465
466    // Best-effort username lookup for the credentials entry.
467    let user_login = fetch_login(client, &outcome.access_token).unwrap_or_default();
468
469    let entry = credentials::Entry::new(
470        outcome.access_token.clone(),
471        user_login,
472        outcome.scopes.clone(),
473    );
474    credentials::save_for(host, entry)?;
475
476    Ok(outcome.access_token)
477}
478
479fn fetch_login(client: &reqwest::blocking::Client, token: &str) -> anyhow::Result<String> {
480    let base = std::env::var("MEMSTEAD_GITHUB_API_BASE")
481        .unwrap_or_else(|_| "https://api.github.com".to_string());
482    let url = format!("{}/user", base.trim_end_matches('/'));
483    let resp = client
484        .get(url)
485        .bearer_auth(token)
486        .header("accept", "application/vnd.github+json")
487        .send()?;
488    if !resp.status().is_success() {
489        anyhow::bail!("GitHub /user returned {}", resp.status());
490    }
491    #[derive(serde::Deserialize)]
492    struct User {
493        login: String,
494    }
495    let user: User = resp.json()?;
496    Ok(user.login)
497}
498
499fn emit_success(
500    ctx: &CliContext,
501    base: &str,
502    resp: &registry::PublishResponse,
503) -> anyhow::Result<()> {
504    let full_url = format!("{}{}", base, resp.url);
505    // Honest signal: the registry promotes the highest published version
506    // to `current`, so publishing an older version succeeds but does not
507    // become what users get by default. Surface that rather than letting
508    // the bare "Published vX" imply X is now live.
509    let demoted = resp.current.as_deref().filter(|cur| *cur != resp.version);
510    if ctx.json {
511        print_json(&json!({
512            "ok": true,
513            "scope": resp.scope,
514            "name": resp.name,
515            "version": resp.version,
516            "current": resp.current,
517            "url": full_url,
518        }))?;
519    } else {
520        let mut block = format!(
521            "# Published {}/{} v{}\n\n- URL: {}",
522            resp.scope, resp.name, resp.version, full_url,
523        );
524        if let Some(cur) = demoted {
525            block.push_str(&format!(
526                "\n\n> Note: `current` stays at v{cur} — you published an older version, \
527                 so it is retained and resolvable but is not the default users get.",
528            ));
529        }
530        print_markdown(&block);
531    }
532    Ok(())
533}
534
535fn map_publish_error(err: PublishError) -> CliError {
536    match err {
537        PublishError::Io(e) => CliError::new(
538            ExitKind::Generic,
539            "ARCHIVE_READ_FAILED",
540            format!("cannot read archive: {e}"),
541        ),
542        PublishError::Network(e) => CliError::new(
543            ExitKind::Generic,
544            "NETWORK_ERROR",
545            format!("network error: {e}"),
546        ),
547        PublishError::Malformed(e) => CliError::new(
548            ExitKind::Generic,
549            "REGISTRY_MALFORMED_RESPONSE",
550            format!("registry sent an unparseable success response: {e}"),
551        ),
552        PublishError::Raw { status, text } => CliError::new(
553            ExitKind::Generic,
554            "REGISTRY_ERROR",
555            format!("registry returned {status}: {text}"),
556        ),
557        PublishError::Api { status, envelope } => map_api_error(status, envelope),
558    }
559}
560
561fn map_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
562    let kind = match status.as_u16() {
563        400 => ExitKind::Validation,
564        401 | 403 => ExitKind::Generic,
565        404 => ExitKind::NotFound,
566        410 => ExitKind::Generic,
567        413 | 429 => ExitKind::Generic,
568        _ => ExitKind::Generic,
569    };
570    let code: &'static str = match status.as_u16() {
571        400 => "REGISTRY_VALIDATION_FAILED",
572        401 => "NOT_AUTHENTICATED",
573        403 => "FORBIDDEN",
574        404 => "REGISTRY_NOT_FOUND",
575        410 => "GONE",
576        413 => "ARCHIVE_TOO_LARGE",
577        429 => "RATE_LIMITED",
578        _ => "REGISTRY_ERROR",
579    };
580
581    let mut msg = match status.as_u16() {
582        400 => {
583            let variant = envelope
584                .variant
585                .clone()
586                .unwrap_or_else(|| "ValidationFailed".to_string());
587            let detail = envelope
588                .detail
589                .clone()
590                .unwrap_or_else(|| "validation failed (no detail)".to_string());
591            if let Some(path) = envelope.path.as_deref() {
592                format!("{variant} at {path}: {detail}")
593            } else {
594                format!("{variant}: {detail}")
595            }
596        }
597        401 => {
598            "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
599        }
600        403 => envelope
601            .detail
602            .clone()
603            .map(|d| format!("forbidden: {d}"))
604            .unwrap_or_else(|| "forbidden".to_string()),
605        404 => "registry returned 404 — is the URL correct?".to_string(),
606        410 => envelope
607            .detail
608            .clone()
609            .map(|d| format!("gone: {d}"))
610            .unwrap_or_else(|| "content is gone (taken down or deny-listed)".to_string()),
611        413 => "archive exceeds the 2 MB publisher cap".to_string(),
612        429 => {
613            let retry = envelope.retry_after_seconds.unwrap_or(0);
614            if retry > 0 {
615                format!("rate-limited — retry after {retry}s")
616            } else {
617                "rate-limited".to_string()
618            }
619        }
620        _ => envelope
621            .detail
622            .clone()
623            .unwrap_or_else(|| format!("registry returned {status}")),
624    };
625
626    // Preserve the error discriminator so programmatic callers can
627    // still see the wire `error` string.
628    if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
629        msg = format!("{msg} [{}]", envelope.error);
630    }
631
632    CliError::new(kind, code, msg)
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use memstead_base::filesystem::config::{WorkspaceConfig, write_workspace_config};
639    use memstead_schema::SchemaRef;
640    use tempfile::TempDir;
641
642    fn write_publishable_workspace(tmp: &TempDir, name: &str) {
643        // Lay down the post-rebuild marker so the publish command's
644        // walk-up resolves.
645        let memstead_dir = tmp.path().join(".memstead");
646        std::fs::create_dir_all(&memstead_dir).unwrap();
647        std::fs::write(
648            memstead_dir.join("workspace.toml"),
649            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
650        )
651        .unwrap();
652        // Round-trip via serde so the test does not need a direct
653        // `semver` dependency. The workspace-config writer accepts
654        // an optional `version` field; we slot it in by serialising
655        // a JSON value that matches the on-disk schema.
656        let pin: SchemaRef = "default@1.0.0".parse().unwrap();
657        let cfg = WorkspaceConfig::new(name, pin);
658        write_workspace_config(tmp.path(), &cfg).unwrap();
659        // Patch in the version field by re-reading + re-writing the
660        // raw JSON. Avoids the test having to depend on `semver`
661        // directly; the `to_published()` path needs `version` set.
662        let cfg_path = tmp.path().join(".memstead").join("config.json");
663        let raw = std::fs::read_to_string(&cfg_path).unwrap();
664        let mut value: serde_json::Value = serde_json::from_str(&raw).unwrap();
665        value["version"] = serde_json::json!("0.1.0");
666        std::fs::write(&cfg_path, serde_json::to_string_pretty(&value).unwrap()).unwrap();
667    }
668
669    /// Spin up an axum fixture that accepts `POST /api/publish` and
670    /// echoes a success body. The body is captured so the test can
671    /// assert it is a non-empty zip-shaped buffer (zip magic
672    /// `PK\x03\x04`).
673    async fn spawn_fixture_publish_registry() -> (
674        String,
675        std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
676        tokio::task::JoinHandle<()>,
677    ) {
678        use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
679        use std::sync::{Arc, Mutex};
680
681        let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
682        let captured_clone = captured.clone();
683        let app: Router = Router::new()
684            .route(
685                "/api/publish",
686                post(
687                    move |State(buf): State<Arc<Mutex<Vec<u8>>>>, body: axum::body::Bytes| async move {
688                        *buf.lock().unwrap() = body.to_vec();
689                        (
690                            StatusCode::OK,
691                            Json(serde_json::json!({
692                                "ok": true,
693                                "scope": "fixture",
694                                "name": "demo",
695                                "version": "0.1.0",
696                                "url": "/v/fixture/demo",
697                            })),
698                        )
699                    },
700                ),
701            )
702            .with_state(captured_clone);
703        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
704        let addr = listener.local_addr().unwrap();
705        let handle = tokio::spawn(async move {
706            axum::serve(listener, app).await.unwrap();
707        });
708        (format!("http://{addr}"), captured, handle)
709    }
710
711    #[tokio::test(flavor = "multi_thread")]
712    async fn publish_assembles_from_workspace_when_no_archive_arg() {
713        let tmp = TempDir::new().unwrap();
714        write_publishable_workspace(&tmp, "demo");
715
716        let (base, captured, handle) = spawn_fixture_publish_registry().await;
717
718        let workspace = tmp.path().to_path_buf();
719        let base_clone = base.clone();
720        let captured_clone = captured.clone();
721        let result = tokio::task::spawn_blocking(move || {
722            let ctx = CliContext {
723                json: false,
724                quiet: false,
725            };
726            run(
727                &ctx,
728                Args {
729                    archive: None,
730                    workspace: Some(workspace),
731                    mem: None,
732                    scope: None,
733                    version: None,
734                    dry_run: false,
735                    token: Some("fixture-token".to_string()),
736                    registry: Some(base_clone),
737                },
738            )?;
739            let body = captured_clone.lock().unwrap().clone();
740            Ok::<Vec<u8>, anyhow::Error>(body)
741        })
742        .await
743        .unwrap();
744        handle.abort();
745        let body = result.unwrap();
746
747        // Body must be a non-empty zip buffer.
748        assert!(body.len() > 4);
749        assert_eq!(&body[0..4], b"PK\x03\x04");
750    }
751
752    #[test]
753    fn publish_rejects_when_workspace_override_lacks_config() {
754        let tmp = TempDir::new().unwrap();
755        // No `.memstead/workspace.toml` under tmp.
756        let ctx = CliContext {
757            json: false,
758            quiet: false,
759        };
760        let err = run(
761            &ctx,
762            Args {
763                archive: None,
764                workspace: Some(tmp.path().to_path_buf()),
765                mem: None,
766                scope: None,
767                version: None,
768                dry_run: false,
769                token: Some("fixture-token".to_string()),
770                registry: Some("http://127.0.0.1:1".to_string()),
771            },
772        )
773        .unwrap_err();
774        let msg = err.to_string();
775        assert!(
776            msg.contains("no workspace") || msg.contains("missing .memstead/workspace.toml"),
777            "expected workspace-not-found error, got: {msg}"
778        );
779    }
780
781    #[test]
782    fn publish_mem_flag_routes_through_engine_and_maps_unknown_mem() {
783        // `--mem NAME` must take the engine export-to-bytes branch
784        // (not the bare folder assembly): it opens the workspace engine
785        // and asks it to export the named mem. A name the workspace
786        // does not carry surfaces the engine's typed `UNKNOWN_MEM`
787        // through `from_engine_op` rather than a folder-assembly error —
788        // proof the new dispatch reaches the engine path. The happy
789        // path (a real mem → zip bytes) reuses the same
790        // `export_mem_to_bytes` primitive that `memstead export
791        // --format mem` exercises under test.
792        let tmp = TempDir::new().unwrap();
793        write_publishable_workspace(&tmp, "demo");
794        let ctx = CliContext {
795            json: false,
796            quiet: false,
797        };
798        let err = run(
799            &ctx,
800            Args {
801                archive: None,
802                workspace: Some(tmp.path().to_path_buf()),
803                mem: Some("nonexistent".to_string()),
804                scope: None,
805                version: None,
806                dry_run: false,
807                token: Some("fixture-token".to_string()),
808                registry: Some("http://127.0.0.1:1".to_string()),
809            },
810        )
811        .unwrap_err();
812        let msg = err.to_string();
813        assert!(
814            msg.contains("unknown mem") || msg.contains("nonexistent"),
815            "expected an engine UNKNOWN_MEM error from the --mem path, got: {msg}"
816        );
817    }
818
819    #[test]
820    fn publish_version_without_mem_is_rejected_before_any_io() {
821        // `--version` persists a bump through the workspace engine, so
822        // it is meaningless without `--mem` — and must refuse up front
823        // (no workspace touched, no network) with an actionable message.
824        let ctx = CliContext {
825            json: false,
826            quiet: false,
827        };
828        let err = run(
829            &ctx,
830            Args {
831                archive: None,
832                workspace: None,
833                mem: None,
834                scope: None,
835                version: Some("0.2.0".to_string()),
836                dry_run: false,
837                token: None,
838                registry: Some("http://127.0.0.1:1".to_string()),
839            },
840        )
841        .unwrap_err();
842        let msg = err.to_string();
843        assert!(
844            msg.contains("--version requires --mem"),
845            "expected a --version-requires-mem refusal, got: {msg}"
846        );
847    }
848
849    #[tokio::test(flavor = "multi_thread")]
850    async fn dry_run_posts_nothing() {
851        // `--dry-run` resolves the archive but must not hit the
852        // registry: the fixture captures the request body, and it stays
853        // empty because no POST is made.
854        let tmp = TempDir::new().unwrap();
855        write_publishable_workspace(&tmp, "demo");
856
857        let (base, captured, handle) = spawn_fixture_publish_registry().await;
858
859        let workspace = tmp.path().to_path_buf();
860        let base_clone = base.clone();
861        let result = tokio::task::spawn_blocking(move || {
862            let ctx = CliContext {
863                json: false,
864                quiet: false,
865            };
866            run(
867                &ctx,
868                Args {
869                    archive: None,
870                    workspace: Some(workspace),
871                    mem: None,
872                    scope: None,
873                    version: None,
874                    dry_run: true,
875                    token: None,
876                    registry: Some(base_clone),
877                },
878            )?;
879            Ok::<(), anyhow::Error>(())
880        })
881        .await
882        .unwrap();
883        handle.abort();
884        result.unwrap();
885
886        assert!(
887            captured.lock().unwrap().is_empty(),
888            "dry-run must not POST anything to the registry"
889        );
890    }
891}