Skip to main content

memstead_cli/commands/
unpublish.rs

1//! `memstead unpublish <scope>/<name>` — remove a mem from the registry.
2//!
3//! Permitted to the original uploader and to admins. Hard delete:
4//! the same `<scope>/<name>` becomes immediately re-publishable.
5//! Auth via the same token ladder as `publish`, except first-use
6//! Device Flow is intentionally NOT triggered — unpublish is
7//! destructive, and silently opening a browser when the user hasn't
8//! logged in feels wrong. Tell them to `memstead login` first.
9
10use 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    /// `<scope>/<name>` of the mem to unpublish.
22    #[arg(value_name = "SCOPE/NAME")]
23    pub target: String,
24
25    /// Explicit token override. Takes precedence over `MEMSTEAD_TOKEN`
26    /// and stored credentials.
27    #[arg(long, value_name = "TOKEN")]
28    pub token: Option<String>,
29
30    /// Registry URL (overrides `MEMSTEAD_REGISTRY`; defaults to https://memstead.io).
31    #[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}