memstead_cli/commands/
unpublish.rs1use clap::Parser;
11use serde_json::json;
12
13use crate::CliError;
14use crate::auth::resolve_token;
15use crate::output::{ExitKind, print_json, print_markdown};
16use crate::registry::{self, ApiErrorBody, PublishError, UnpublishResponse};
17use crate::setup::CliContext;
18
19#[derive(Parser, Debug)]
20pub struct Args {
21 #[arg(value_name = "SCOPE/NAME")]
23 pub target: String,
24
25 #[arg(long, value_name = "TOKEN")]
28 pub token: Option<String>,
29
30 #[arg(long, value_name = "URL")]
32 pub registry: Option<String>,
33}
34
35pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
36 let (scope, name) = registry::parse_ref(&args.target).ok_or_else(|| {
37 CliError::new(
38 ExitKind::Generic,
39 "INVALID_INPUT",
40 format!("expected `<scope>/<name>`, got `{}`", args.target),
41 )
42 })?;
43
44 let base = registry::registry_base(args.registry.as_deref());
45 let host = registry::registry_host(&base);
46 let client = registry::build_http()?;
47
48 let token = match resolve_token(&host, args.token.as_deref())? {
49 Some(r) => r.token,
50 None => {
51 return Err(CliError::new(
52 ExitKind::Generic,
53 "NOT_AUTHENTICATED",
54 "not logged in — run `memstead login` or set MEMSTEAD_TOKEN \
55 (unpublish does not auto-trigger Device Flow)",
56 )
57 .into());
58 }
59 };
60
61 match registry::unpublish(&client, &base, &scope, &name, &token) {
62 Ok(resp) => emit_success(ctx, &resp),
63 Err(e) => Err(map_unpublish_error(e).into()),
64 }
65}
66
67fn emit_success(ctx: &CliContext, resp: &UnpublishResponse) -> anyhow::Result<()> {
68 if ctx.json {
69 print_json(&json!({
70 "ok": true,
71 "scope": resp.scope,
72 "name": resp.name,
73 }))?;
74 } else {
75 print_markdown(&format!(
76 "# Unpublished {}/{}\n\n- The same {}/{} can be re-published immediately.",
77 resp.scope, resp.name, resp.scope, resp.name,
78 ));
79 }
80 Ok(())
81}
82
83fn map_unpublish_error(err: PublishError) -> CliError {
84 match err {
85 PublishError::Io(e) => {
86 CliError::new(ExitKind::Generic, crate::INTERNAL_CODE, format!("io: {e}"))
87 }
88 PublishError::Network(e) => CliError::new(
89 ExitKind::Generic,
90 "NETWORK_ERROR",
91 format!("network error: {e}"),
92 ),
93 PublishError::Malformed(e) => CliError::new(
94 ExitKind::Generic,
95 "REGISTRY_MALFORMED_RESPONSE",
96 format!("registry sent an unparseable success response: {e}"),
97 ),
98 PublishError::Raw { status, text } => CliError::new(
99 ExitKind::Generic,
100 "REGISTRY_ERROR",
101 format!("registry returned {status}: {text}"),
102 ),
103 PublishError::Api { status, envelope } => map_api_error(status, envelope),
104 }
105}
106
107fn map_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
108 let kind = match status.as_u16() {
109 401 | 403 => ExitKind::Generic,
110 404 => ExitKind::NotFound,
111 _ => ExitKind::Generic,
112 };
113 let code: &'static str = match status.as_u16() {
114 401 => "NOT_AUTHENTICATED",
115 403 => "FORBIDDEN",
116 404 => "REGISTRY_NOT_FOUND",
117 _ => "REGISTRY_ERROR",
118 };
119
120 let mut msg = match status.as_u16() {
121 401 => {
122 "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
123 }
124 403 => envelope
125 .detail
126 .clone()
127 .map(|d| format!("forbidden: {d}"))
128 .unwrap_or_else(|| {
129 "forbidden — only the uploader or an admin can unpublish a mem".to_string()
130 }),
131 404 => "no such mem on the registry".to_string(),
132 _ => envelope
133 .detail
134 .clone()
135 .unwrap_or_else(|| format!("registry returned {status}")),
136 };
137
138 if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
139 msg = format!("{msg} [{}]", envelope.error);
140 }
141
142 CliError::new(kind, code, msg)
143}