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)]
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
91fn 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 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 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 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 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 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
239fn 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#[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#[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
323fn 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
368fn 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
404fn resolve_workspace_root(root_override: Option<&Path>) -> anyhow::Result<PathBuf> {
407 match root_override {
411 Some(p) => Ok(p.to_path_buf()),
412 None => find_filesystem_workspace_root(),
413 }
414}
415
416fn 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 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: ®istry::PublishResponse,
493) -> anyhow::Result<()> {
494 let full_url = format!("{}{}", base, resp.url);
495 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 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 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 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 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 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 assert!(body.len() > 4);
740 assert_eq!(&body[0..4], b"PK\x03\x04");
741 }
742
743 #[test]
750 fn publish_errors_when_root_lacks_workspace_shape() {
751 let tmp = TempDir::new().unwrap();
752 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 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 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 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}