1use 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 #[arg(value_name = "PATH")]
46 pub archive: Option<PathBuf>,
47
48 #[arg(long, value_name = "NAME")]
54 pub mem: Option<String>,
55
56 #[arg(long, value_name = "NAME")]
60 pub scope: Option<String>,
61
62 #[arg(long, value_name = "TOKEN")]
65 pub token: Option<String>,
66
67 #[arg(long, value_name = "URL")]
69 pub registry: Option<String>,
70
71 #[arg(long, value_name = "SEMVER")]
77 pub version: Option<String>,
78
79 #[arg(long)]
92 pub redact_anchors: bool,
93
94 #[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
106fn 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 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 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 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 if let Some(ver) = target_version.clone()
185 && !args.dry_run
186 {
187 let bumped = engine
188 .set_mem_version(mem_name, ver, Some("version bump for registry publish"))
189 .map_err(CliError::from_engine_op)?;
190 for w in &bumped.warnings {
194 crate::output::print_markdown(&format!("> {w}"));
195 }
196 }
197 resolved_version = target_version.as_ref().map(|v| v.to_string()).or_else(|| {
198 engine
199 .mem_config_for(mem_name)
200 .and_then(|c| c.version.clone())
201 .map(|v| v.to_string())
202 });
203 let bytes = engine
204 .export_mem_to_bytes(mem_name)
205 .map_err(CliError::from_engine_op)?;
206 stage_bytes_to_tempfile(&redact_if_requested(&args, bytes)?)?
207 } else {
208 let workspace_root = resolve_workspace_root(root_override.as_deref())?;
209 let engine_mem: Option<(memstead_base::Engine, String)> =
224 match ctx.cli_engine_at(&workspace_root) {
225 Ok(cli_engine) => {
226 let engine = cli_engine.into_base();
227 let writable: Vec<String> = engine
228 .mounts_with_optional_config()
233 .filter(|(name, _)| engine.mem_router().is_writable(name))
234 .map(|(name, _)| name.to_string())
235 .collect();
236 match writable.len() {
237 1 => Some((engine, writable.into_iter().next().unwrap())),
238 0 => None,
239 _ => {
240 return Err(CliError::new(
241 ExitKind::Validation,
242 "AMBIGUOUS_MEM",
243 format!(
244 "multiple writable mems loaded ({}); pass --mem <name>",
245 writable.join(", ")
246 ),
247 )
248 .into());
249 }
250 }
251 }
252 Err(_) => None,
253 };
254 let bytes = match engine_mem {
255 Some((engine, mem_name)) => {
256 resolved_version = engine
257 .mem_config_for(&mem_name)
258 .and_then(|c| c.version.clone())
259 .map(|v| v.to_string());
260 engine
261 .export_mem_to_bytes(&mem_name)
262 .map_err(CliError::from_engine_op)?
263 }
264 None => assemble_archive(&workspace_root).map_err(|e| {
265 CliError::new(
266 ExitKind::Validation,
267 "ARCHIVE_ASSEMBLY_FAILED",
268 format!("assemble archive: {e}"),
269 )
270 })?,
271 };
272 stage_bytes_to_tempfile(&redact_if_requested(&args, bytes)?)?
273 };
274
275 if args.dry_run {
278 return emit_dry_run(
279 ctx,
280 &base,
281 &archive_path,
282 args.mem.as_deref(),
283 resolved_version.as_deref(),
284 args.scope.as_deref(),
285 );
286 }
287
288 if let Some(domain) = domain_scope(args.scope.as_deref()) {
293 let scope = args.scope.as_deref().expect("domain_scope implies a scope");
294 let sig = build_domain_signature(&archive_path, scope, &domain)?;
295 return match registry::publish(&client, &base, &archive_path, None, Some(scope), Some(&sig))
296 {
297 Ok(resp) => emit_success(ctx, &base, &resp),
298 Err(e) => Err(map_publish_error(e).into()),
299 };
300 }
301
302 let token = match resolve_token(&host, args.token.as_deref())? {
303 Some(r) => r.token,
304 None => {
305 if !std::io::stdin().is_terminal() {
306 return Err(CliError::new(
307 ExitKind::Generic,
308 "NOT_AUTHENTICATED",
309 "not logged in and stdin is not a TTY — set MEMSTEAD_TOKEN \
310 or run `memstead login` first",
311 )
312 .into());
313 }
314 login_inline(&client, &host)?
315 }
316 };
317
318 match registry::publish(
319 &client,
320 &base,
321 &archive_path,
322 Some(&token),
323 args.scope.as_deref(),
324 None,
325 ) {
326 Ok(resp) => emit_success(ctx, &base, &resp),
327 Err(e) => Err(map_publish_error(e).into()),
328 }
329}
330
331fn redact_if_requested(args: &Args, bytes: Vec<u8>) -> Result<Vec<u8>, CliError> {
335 if !args.redact_anchors {
336 return Ok(bytes);
337 }
338 memstead_base::filesystem::publish::redact_archive_anchors(&bytes).map_err(|e| {
339 CliError::new(
340 ExitKind::Validation,
341 "ARCHIVE_ASSEMBLY_FAILED",
342 format!("redact anchors: {e}"),
343 )
344 })
345}
346
347fn domain_scope(scope: Option<&str>) -> Option<String> {
351 let (prefix, handle) = scope?.split_once(':')?;
352 if prefix.contains('.') && !handle.is_empty() {
353 Some(prefix.to_ascii_lowercase())
354 } else {
355 None
356 }
357}
358
359#[cfg(feature = "mem-repo")]
363fn build_domain_signature(
364 archive_path: &Path,
365 scope: &str,
366 domain: &str,
367) -> anyhow::Result<registry::DomainSignature> {
368 use memstead_base::domain_authority_wire::signing_payload;
369 use memstead_git_branch::validator::validate_and_normalize_archive;
370 use sha2::{Digest, Sha256};
371
372 use crate::auth::domain_key;
373
374 let bytes = std::fs::read(archive_path).map_err(|e| {
375 CliError::new(
376 ExitKind::Generic,
377 "ARCHIVE_READ_FAILED",
378 format!("read archive: {e}"),
379 )
380 })?;
381 let validated = validate_and_normalize_archive(&bytes).map_err(|e| {
382 CliError::new(
383 ExitKind::Validation,
384 "ARCHIVE_INVALID",
385 format!("archive failed local validation before signing: {e}"),
386 )
387 })?;
388 let content_sha256 = {
389 let mut h = Sha256::new();
390 h.update(&validated.canonical_bytes);
391 h.finalize()
392 .iter()
393 .map(|b| format!("{b:02x}"))
394 .collect::<String>()
395 };
396 let name = validated.config.name.clone();
397 let version = validated.config.version.to_string();
398
399 let signing = domain_key::load(domain)
400 .map_err(|e| CliError::new(ExitKind::NotFound, "DOMAIN_KEY_NOT_FOUND", e.to_string()))?;
401 let timestamp = std::time::SystemTime::now()
402 .duration_since(std::time::UNIX_EPOCH)
403 .map(|d| d.as_secs() as i64)
404 .unwrap_or(0);
405 let payload = signing_payload(&content_sha256, scope, &name, &version, timestamp);
406 Ok(registry::DomainSignature {
407 key: domain_key::public_key_string(&signing),
408 signature: domain_key::sign(&signing, &payload),
409 timestamp,
410 })
411}
412
413#[cfg(not(feature = "mem-repo"))]
417fn build_domain_signature(
418 _archive_path: &Path,
419 _scope: &str,
420 _domain: &str,
421) -> anyhow::Result<registry::DomainSignature> {
422 Err(CliError::new(
423 ExitKind::Generic,
424 "DOMAIN_PUBLISH_UNAVAILABLE",
425 "domain publishing requires the full `memstead` build (the lean build cannot \
426 canonicalize archives for signing)",
427 )
428 .into())
429}
430
431fn emit_dry_run(
436 ctx: &CliContext,
437 base: &str,
438 archive_path: &Path,
439 mem: Option<&str>,
440 version: Option<&str>,
441 scope: Option<&str>,
442) -> anyhow::Result<()> {
443 let size = std::fs::metadata(archive_path)
444 .map(|m| m.len())
445 .unwrap_or(0);
446 let mem_label = mem.unwrap_or("(workspace mem)");
447 let version_label = version.unwrap_or("(from mem config / archive)");
448 if ctx.json {
449 print_json(&json!({
450 "dry_run": true,
451 "mem": mem,
452 "version": version,
453 "scope": scope,
454 "archive_bytes": size,
455 "registry": base,
456 "published": false,
457 }))?;
458 } else {
459 let scope_label = match scope {
460 Some(s) => format!("`{s}` (override)"),
461 None => "derived from your GitHub login".to_string(),
462 };
463 print_markdown(&format!(
464 "# Dry run — would publish\n\n\
465 - Mem: `{mem_label}`\n\
466 - Version: `{version_label}`\n\
467 - Scope: {scope_label}\n\
468 - Archive: {size} bytes\n\
469 - Registry: {base}\n\n\
470 Nothing was published and nothing was changed.",
471 ));
472 }
473 Ok(())
474}
475
476fn find_filesystem_workspace_root() -> anyhow::Result<PathBuf> {
480 let cwd = std::env::current_dir().map_err(|e| {
481 CliError::new(
482 ExitKind::Generic,
483 crate::INTERNAL_CODE,
484 format!("read cwd: {e}"),
485 )
486 })?;
487 let mut current: &Path = &cwd;
488 loop {
489 if memstead_base::is_workspace_root(current) {
490 return Ok(current.to_path_buf());
491 }
492 match current.parent() {
493 Some(p) => current = p,
494 None => {
495 return Err(CliError::new(
496 ExitKind::NotFound,
497 "WORKSPACE_NOT_INITIALISED",
498 format!(
499 "no workspace found from {} or any ancestor (missing \
500 .memstead/workspace.toml) — run `memstead init` first, pass \
501 --workspace <path>, or supply an archive path",
502 cwd.display()
503 ),
504 )
505 .into());
506 }
507 }
508 }
509}
510
511fn resolve_workspace_root(root_override: Option<&Path>) -> anyhow::Result<PathBuf> {
514 match root_override {
518 Some(p) => Ok(p.to_path_buf()),
519 None => find_filesystem_workspace_root(),
520 }
521}
522
523fn stage_bytes_to_tempfile(bytes: &[u8]) -> anyhow::Result<(PathBuf, Option<NamedTempFile>)> {
527 let tempfile = NamedTempFile::new().map_err(|e| {
528 CliError::new(
529 ExitKind::Generic,
530 crate::INTERNAL_CODE,
531 format!("tempfile: {e}"),
532 )
533 })?;
534 std::fs::write(tempfile.path(), bytes).map_err(|e| {
535 CliError::new(
536 ExitKind::Generic,
537 crate::INTERNAL_CODE,
538 format!("write tempfile {}: {e}", tempfile.path().display()),
539 )
540 })?;
541 let path = tempfile.path().to_path_buf();
542 Ok((path, Some(tempfile)))
543}
544
545fn login_inline(client: &reqwest::blocking::Client, host: &str) -> anyhow::Result<String> {
546 println!("Not logged in — starting GitHub Device Flow…");
547 let outcome = device_flow::run(
548 client,
549 device_flow::MEMSTEAD_GITHUB_CLIENT_ID,
550 device_flow::MEMSTEAD_GITHUB_SCOPE,
551 |url| {
552 let _ = device_flow::open_browser(url);
553 },
554 )
555 .map_err(|e| {
556 CliError::new(
557 ExitKind::Generic,
558 "LOGIN_FAILED",
559 format!("login failed: {e}"),
560 )
561 })?;
562
563 let user_login = fetch_login(client, &outcome.access_token).unwrap_or_default();
565
566 let entry = credentials::Entry::new(
567 outcome.access_token.clone(),
568 user_login,
569 outcome.scopes.clone(),
570 );
571 credentials::save_for(host, entry)?;
572
573 Ok(outcome.access_token)
574}
575
576fn fetch_login(client: &reqwest::blocking::Client, token: &str) -> anyhow::Result<String> {
577 let base = std::env::var("MEMSTEAD_GITHUB_API_BASE")
578 .unwrap_or_else(|_| "https://api.github.com".to_string());
579 let url = format!("{}/user", base.trim_end_matches('/'));
580 let resp = client
581 .get(url)
582 .bearer_auth(token)
583 .header("accept", "application/vnd.github+json")
584 .send()?;
585 if !resp.status().is_success() {
586 anyhow::bail!("GitHub /user returned {}", resp.status());
587 }
588 #[derive(serde::Deserialize)]
589 struct User {
590 login: String,
591 }
592 let user: User = resp.json()?;
593 Ok(user.login)
594}
595
596fn emit_success(
597 ctx: &CliContext,
598 base: &str,
599 resp: ®istry::PublishResponse,
600) -> anyhow::Result<()> {
601 let full_url = format!("{}{}", base, resp.url);
602 let demoted = resp.current.as_deref().filter(|cur| *cur != resp.version);
607 if ctx.json {
608 print_json(&json!({
609 "ok": true,
610 "scope": resp.scope,
611 "name": resp.name,
612 "version": resp.version,
613 "current": resp.current,
614 "url": full_url,
615 }))?;
616 } else {
617 let mut block = format!(
618 "# Published {}/{} v{}\n\n- URL: {}",
619 resp.scope, resp.name, resp.version, full_url,
620 );
621 if let Some(cur) = demoted {
622 block.push_str(&format!(
623 "\n\n> Note: `current` stays at v{cur} — you published an older version, \
624 so it is retained and resolvable but is not the default users get.",
625 ));
626 }
627 print_markdown(&block);
628 }
629 Ok(())
630}
631
632fn map_publish_error(err: PublishError) -> CliError {
633 match err {
634 PublishError::Io(e) => CliError::new(
635 ExitKind::Generic,
636 "ARCHIVE_READ_FAILED",
637 format!("cannot read archive: {e}"),
638 ),
639 PublishError::Network(e) => CliError::new(
640 ExitKind::Generic,
641 "NETWORK_ERROR",
642 format!("network error: {e}"),
643 ),
644 PublishError::Malformed(e) => CliError::new(
645 ExitKind::Generic,
646 "REGISTRY_MALFORMED_RESPONSE",
647 format!("registry sent an unparseable success response: {e}"),
648 ),
649 PublishError::Raw { status, text } => CliError::new(
650 ExitKind::Generic,
651 "REGISTRY_ERROR",
652 format!("registry returned {status}: {text}"),
653 ),
654 PublishError::Api { status, envelope } => map_api_error(status, envelope),
655 }
656}
657
658fn map_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
659 let kind = match status.as_u16() {
660 400 => ExitKind::Validation,
661 401 | 403 => ExitKind::Generic,
662 404 => ExitKind::NotFound,
663 410 => ExitKind::Generic,
664 413 | 429 => ExitKind::Generic,
665 _ => ExitKind::Generic,
666 };
667 let code: &'static str = match status.as_u16() {
668 400 => "REGISTRY_VALIDATION_FAILED",
669 401 => "NOT_AUTHENTICATED",
670 403 => "FORBIDDEN",
671 404 => "REGISTRY_NOT_FOUND",
672 410 => "GONE",
673 413 => "ARCHIVE_TOO_LARGE",
674 429 => "RATE_LIMITED",
675 _ => "REGISTRY_ERROR",
676 };
677
678 let mut msg = match status.as_u16() {
679 400 => {
680 let variant = envelope
681 .variant
682 .clone()
683 .unwrap_or_else(|| "ValidationFailed".to_string());
684 let detail = envelope
685 .detail
686 .clone()
687 .unwrap_or_else(|| "validation failed (no detail)".to_string());
688 if let Some(path) = envelope.path.as_deref() {
689 format!("{variant} at {path}: {detail}")
690 } else {
691 format!("{variant}: {detail}")
692 }
693 }
694 401 => {
695 "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
696 }
697 403 => envelope
698 .detail
699 .clone()
700 .map(|d| format!("forbidden: {d}"))
701 .unwrap_or_else(|| "forbidden".to_string()),
702 404 => "registry returned 404 — is the URL correct?".to_string(),
703 410 => envelope
704 .detail
705 .clone()
706 .map(|d| format!("gone: {d}"))
707 .unwrap_or_else(|| "content is gone (taken down or deny-listed)".to_string()),
708 413 => "archive exceeds the 2 MB publisher cap".to_string(),
709 429 => {
710 let retry = envelope.retry_after_seconds.unwrap_or(0);
711 if retry > 0 {
712 format!("rate-limited — retry after {retry}s")
713 } else {
714 "rate-limited".to_string()
715 }
716 }
717 _ => envelope
718 .detail
719 .clone()
720 .unwrap_or_else(|| format!("registry returned {status}")),
721 };
722
723 if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
726 msg = format!("{msg} [{}]", envelope.error);
727 }
728
729 CliError::new(kind, code, msg)
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use memstead_base::filesystem::config::{WorkspaceConfig, write_workspace_config};
736 use memstead_schema::SchemaRef;
737 use tempfile::TempDir;
738
739 fn write_publishable_workspace(tmp: &TempDir, name: &str) {
740 let memstead_dir = tmp.path().join(".memstead");
743 std::fs::create_dir_all(&memstead_dir).unwrap();
744 std::fs::write(
745 memstead_dir.join("workspace.toml"),
746 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
747 )
748 .unwrap();
749 let pin: SchemaRef = "default@1.0.0".parse().unwrap();
754 let cfg = WorkspaceConfig::new(name, pin);
755 write_workspace_config(tmp.path(), &cfg).unwrap();
756 let cfg_path = tmp.path().join(".memstead").join("config.json");
760 let raw = std::fs::read_to_string(&cfg_path).unwrap();
761 let mut value: serde_json::Value = serde_json::from_str(&raw).unwrap();
762 value["version"] = serde_json::json!("0.1.0");
763 std::fs::write(&cfg_path, serde_json::to_string_pretty(&value).unwrap()).unwrap();
764 }
765
766 async fn spawn_fixture_publish_registry() -> (
771 String,
772 std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
773 tokio::task::JoinHandle<()>,
774 ) {
775 use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
776 use std::sync::{Arc, Mutex};
777
778 let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
779 let captured_clone = captured.clone();
780 let app: Router = Router::new()
781 .route(
782 "/api/publish",
783 post(
784 move |State(buf): State<Arc<Mutex<Vec<u8>>>>, body: axum::body::Bytes| async move {
785 *buf.lock().unwrap() = body.to_vec();
786 (
787 StatusCode::OK,
788 Json(serde_json::json!({
789 "ok": true,
790 "scope": "fixture",
791 "name": "demo",
792 "version": "0.1.0",
793 "url": "/v/fixture/demo",
794 })),
795 )
796 },
797 ),
798 )
799 .with_state(captured_clone);
800 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
801 let addr = listener.local_addr().unwrap();
802 let handle = tokio::spawn(async move {
803 axum::serve(listener, app).await.unwrap();
804 });
805 (format!("http://{addr}"), captured, handle)
806 }
807
808 #[tokio::test(flavor = "multi_thread")]
809 async fn publish_assembles_from_workspace_when_no_archive_arg() {
810 let tmp = TempDir::new().unwrap();
811 write_publishable_workspace(&tmp, "demo");
812
813 let (base, captured, handle) = spawn_fixture_publish_registry().await;
814
815 let workspace = tmp.path().to_path_buf();
816 let base_clone = base.clone();
817 let captured_clone = captured.clone();
818 let result = tokio::task::spawn_blocking(move || {
819 let ctx = CliContext {
820 json: false,
821 quiet: false,
822 role: Default::default(),
823 };
824 run_with_root(
825 &ctx,
826 Args {
827 archive: None,
828 mem: None,
829 scope: None,
830 version: None,
831 dry_run: false,
832 redact_anchors: false,
833 token: Some("fixture-token".to_string()),
834 registry: Some(base_clone),
835 },
836 Some(workspace),
837 )?;
838 let body = captured_clone.lock().unwrap().clone();
839 Ok::<Vec<u8>, anyhow::Error>(body)
840 })
841 .await
842 .unwrap();
843 handle.abort();
844 let body = result.unwrap();
845
846 assert!(body.len() > 4);
848 assert_eq!(&body[0..4], b"PK\x03\x04");
849 }
850
851 #[test]
858 fn publish_errors_when_root_lacks_workspace_shape() {
859 let tmp = TempDir::new().unwrap();
860 let ctx = CliContext {
862 json: false,
863 quiet: false,
864 role: Default::default(),
865 };
866 let err = run_with_root(
867 &ctx,
868 Args {
869 archive: None,
870 mem: None,
871 scope: None,
872 version: None,
873 dry_run: false,
874 redact_anchors: false,
875 token: Some("fixture-token".to_string()),
876 registry: Some("http://127.0.0.1:1".to_string()),
877 },
878 Some(tmp.path().to_path_buf()),
879 )
880 .unwrap_err();
881 let msg = err.to_string();
882 assert!(!msg.is_empty(), "marker-less root must error, got: {msg}");
883 }
884
885 #[test]
886 fn publish_mem_flag_routes_through_engine_and_maps_unknown_mem() {
887 let tmp = TempDir::new().unwrap();
897 write_publishable_workspace(&tmp, "demo");
898 let ctx = CliContext {
899 json: false,
900 quiet: false,
901 role: Default::default(),
902 };
903 let err = run_with_root(
904 &ctx,
905 Args {
906 archive: None,
907 mem: Some("nonexistent".to_string()),
908 scope: None,
909 version: None,
910 dry_run: false,
911 redact_anchors: false,
912 token: Some("fixture-token".to_string()),
913 registry: Some("http://127.0.0.1:1".to_string()),
914 },
915 Some(tmp.path().to_path_buf()),
916 )
917 .unwrap_err();
918 let msg = err.to_string();
919 assert!(
920 msg.contains("unknown mem") || msg.contains("nonexistent"),
921 "expected an engine UNKNOWN_MEM error from the --mem path, got: {msg}"
922 );
923 }
924
925 #[test]
926 fn publish_version_without_mem_is_rejected_before_any_io() {
927 let ctx = CliContext {
931 json: false,
932 quiet: false,
933 role: Default::default(),
934 };
935 let err = run(
936 &ctx,
937 Args {
938 archive: None,
939 mem: None,
940 scope: None,
941 version: Some("0.2.0".to_string()),
942 dry_run: false,
943 redact_anchors: false,
944 token: None,
945 registry: Some("http://127.0.0.1:1".to_string()),
946 },
947 )
948 .unwrap_err();
949 let msg = err.to_string();
950 assert!(
951 msg.contains("--version requires --mem"),
952 "expected a --version-requires-mem refusal, got: {msg}"
953 );
954 }
955
956 #[tokio::test(flavor = "multi_thread")]
957 async fn dry_run_posts_nothing() {
958 let tmp = TempDir::new().unwrap();
962 write_publishable_workspace(&tmp, "demo");
963
964 let (base, captured, handle) = spawn_fixture_publish_registry().await;
965
966 let workspace = tmp.path().to_path_buf();
967 let base_clone = base.clone();
968 let result = tokio::task::spawn_blocking(move || {
969 let ctx = CliContext {
970 json: false,
971 quiet: false,
972 role: Default::default(),
973 };
974 run_with_root(
975 &ctx,
976 Args {
977 archive: None,
978 mem: None,
979 scope: None,
980 version: None,
981 dry_run: true,
982 redact_anchors: false,
983 token: None,
984 registry: Some(base_clone),
985 },
986 Some(workspace),
987 )?;
988 Ok::<(), anyhow::Error>(())
989 })
990 .await
991 .unwrap();
992 handle.abort();
993 result.unwrap();
994
995 assert!(
996 captured.lock().unwrap().is_empty(),
997 "dry-run must not POST anything to the registry"
998 );
999 }
1000
1001 #[test]
1008 fn redact_anchors_refuses_prebuilt_archive_path() {
1009 let ctx = CliContext {
1010 json: false,
1011 quiet: false,
1012 role: Default::default(),
1013 };
1014 let err = run_with_root(
1015 &ctx,
1016 Args {
1017 archive: Some(PathBuf::from("/nonexistent/some.mem")),
1018 mem: None,
1019 scope: None,
1020 version: None,
1021 dry_run: false,
1022 redact_anchors: true,
1023 token: Some("fixture-token".to_string()),
1024 registry: Some("http://127.0.0.1:1".to_string()),
1025 },
1026 None,
1027 )
1028 .unwrap_err();
1029 let msg = err.to_string();
1030 assert!(
1031 msg.contains("--redact-anchors") && msg.contains("baked in"),
1032 "typed refusal names the flag and the alternative: {msg}"
1033 );
1034 }
1035
1036 #[tokio::test(flavor = "multi_thread")]
1040 async fn redacted_publish_posts_sentinel_sidecar() {
1041 let tmp = TempDir::new().unwrap();
1042 write_publishable_workspace(&tmp, "demo");
1043 std::fs::write(
1044 tmp.path().join("first.md"),
1045 "---\ntype: spec\n---\n# First\n",
1046 )
1047 .unwrap();
1048 let sidecar_path = tmp.path().join(".memstead").join("anchors.json");
1049 let local = br#"{"version":1,"entities":{"demo--first":[{"artifact":"src/private.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
1050 std::fs::write(&sidecar_path, local).unwrap();
1051
1052 let (base, captured, handle) = spawn_fixture_publish_registry().await;
1053 let workspace = tmp.path().to_path_buf();
1054 let base_clone = base.clone();
1055 let captured_clone = captured.clone();
1056 let body = tokio::task::spawn_blocking(move || {
1057 let ctx = CliContext {
1058 json: false,
1059 quiet: false,
1060 role: Default::default(),
1061 };
1062 run_with_root(
1063 &ctx,
1064 Args {
1065 archive: None,
1066 mem: None,
1067 scope: None,
1068 version: None,
1069 dry_run: false,
1070 redact_anchors: true,
1071 token: Some("fixture-token".to_string()),
1072 registry: Some(base_clone),
1073 },
1074 Some(workspace),
1075 )?;
1076 Ok::<Vec<u8>, anyhow::Error>(captured_clone.lock().unwrap().clone())
1077 })
1078 .await
1079 .unwrap()
1080 .unwrap();
1081 handle.abort();
1082
1083 let posted = String::from_utf8_lossy(&body).into_owned();
1084 assert!(
1085 posted.contains("[redacted]"),
1086 "posted package carries the sentinel"
1087 );
1088 assert!(
1089 !posted.contains("src/private.rs"),
1090 "posted package must not carry the artifact path"
1091 );
1092 let after = std::fs::read(&sidecar_path).unwrap();
1094 assert_eq!(after, local, "local sidecar bytes are byte-identical");
1095 }
1096}