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