Skip to main content

gor/cmd/
secret.rs

1//! Implementation of the `gor secret` subcommand.
2//!
3//! Provides secret listing, creation, and deletion for GitHub Actions.
4
5#![allow(clippy::print_stdout)]
6
7use crate::cli::SecretCommand;
8use crate::client::Client;
9use crate::output::print_json;
10use crate::repository::detect_remote;
11use anyhow::Context;
12
13/// Run the `gor secret` subcommand.
14///
15/// # Errors
16///
17/// Returns an error if the command execution fails.
18pub fn run(cmd: SecretCommand) -> anyhow::Result<()> {
19    match cmd {
20        SecretCommand::List {
21            org,
22            env,
23            json,
24            hostname,
25        } => list(org.as_deref(), env.as_deref(), json, hostname.as_deref()),
26        SecretCommand::Set {
27            name,
28            body,
29            file,
30            org,
31            env,
32            hostname,
33        } => set(
34            &name,
35            body.as_deref(),
36            file.as_deref(),
37            org.as_deref(),
38            env.as_deref(),
39            hostname.as_deref(),
40        ),
41        SecretCommand::Delete {
42            name,
43            org,
44            env,
45            hostname,
46        } => delete(&name, org.as_deref(), env.as_deref(), hostname.as_deref()),
47    }
48}
49
50fn list(
51    org: Option<&str>,
52    env: Option<&str>,
53    json: Option<Vec<String>>,
54    hostname: Option<&str>,
55) -> anyhow::Result<()> {
56    let host = hostname.unwrap_or("github.com");
57    let client = Client::new(host).context("failed to create HTTP client")?;
58
59    let path = if let Some(o) = org {
60        format!("/orgs/{o}/actions/secrets?per_page=100")
61    } else if let Some(e) = env {
62        let spec = detect_remote().ok_or_else(|| {
63            anyhow::anyhow!(
64                "could not detect repository; specify --repo or run from a repo directory"
65            )
66        })?;
67        format!(
68            "/repos/{}/{}/environments/{e}/secrets?per_page=100",
69            spec.owner, spec.repo
70        )
71    } else {
72        let spec = detect_remote().ok_or_else(|| {
73            anyhow::anyhow!(
74                "could not detect repository; specify --org or run from a repo directory"
75            )
76        })?;
77        format!(
78            "/repos/{}/{}/actions/secrets?per_page=100",
79            spec.owner, spec.repo
80        )
81    };
82
83    let response = client.get(&path).context("failed to fetch secrets")?;
84    let status = response.status();
85    if !status.is_success() {
86        anyhow::bail!("failed to list secrets: HTTP {status}");
87    }
88
89    let result: serde_json::Value = response.json().context("failed to parse response")?;
90    let secrets: Vec<serde_json::Value> = result["secrets"]
91        .as_array()
92        .map_or_else(Vec::new, Clone::clone);
93
94    if let Some(fields) = json {
95        let fields_ref: Option<&[String]> = if fields.is_empty() {
96            None
97        } else {
98            Some(&fields)
99        };
100        print_json(&secrets, fields_ref);
101        return Ok(());
102    }
103
104    if secrets.is_empty() {
105        println!("No secrets found.");
106        return Ok(());
107    }
108
109    println!("{:<30}  UPDATED", "NAME");
110    for s in &secrets {
111        let name = s["name"].as_str().unwrap_or("—");
112        let updated = s["updated_at"].as_str().map_or("—", |d| d);
113        let name_truncated = crate::cmd::util::truncate(name, 30);
114        println!("{name_truncated:<30}  {updated}");
115    }
116
117    Ok(())
118}
119
120fn set(
121    name: &str,
122    body: Option<&str>,
123    file: Option<&str>,
124    org: Option<&str>,
125    env: Option<&str>,
126    hostname: Option<&str>,
127) -> anyhow::Result<()> {
128    let host = hostname.unwrap_or("github.com");
129    let client = Client::new(host).context("failed to create HTTP client")?;
130
131    let value = if let Some(b) = body {
132        b.to_string()
133    } else if let Some(f) = file {
134        std::fs::read_to_string(f)
135            .with_context(|| format!("failed to read file: {f}"))?
136            .trim()
137            .to_string()
138    } else {
139        anyhow::bail!("no secret value provided (use --body or --file)");
140    };
141
142    let body_value = serde_json::json!({"encrypted_value": value});
143
144    let path = if let Some(o) = org {
145        format!("/orgs/{o}/actions/secrets/{name}")
146    } else if let Some(e) = env {
147        let spec = detect_remote().ok_or_else(|| {
148            anyhow::anyhow!(
149                "could not detect repository; specify --repo or run from a repo directory"
150            )
151        })?;
152        format!(
153            "/repos/{}/{}/environments/{e}/secrets/{name}",
154            spec.owner, spec.repo
155        )
156    } else {
157        let spec = detect_remote().ok_or_else(|| {
158            anyhow::anyhow!(
159                "could not detect repository; specify --org or run from a repo directory"
160            )
161        })?;
162        format!("/repos/{}/{}/actions/secrets/{name}", spec.owner, spec.repo)
163    };
164
165    let response = client
166        .request(
167            "PUT",
168            &path,
169            &[],
170            Some(serde_json::to_vec(&body_value).context("serialize")?),
171        )
172        .context("failed to set secret")?;
173
174    let status = response.status();
175    if !status.is_success() {
176        anyhow::bail!("failed to set secret '{name}': HTTP {status}");
177    }
178
179    println!("Secret '{name}' set.");
180    Ok(())
181}
182
183fn delete(
184    name: &str,
185    org: Option<&str>,
186    env: Option<&str>,
187    hostname: Option<&str>,
188) -> anyhow::Result<()> {
189    let host = hostname.unwrap_or("github.com");
190    let client = Client::new(host).context("failed to create HTTP client")?;
191
192    let path = if let Some(o) = org {
193        format!("/orgs/{o}/actions/secrets/{name}")
194    } else if let Some(e) = env {
195        let spec = detect_remote().ok_or_else(|| {
196            anyhow::anyhow!(
197                "could not detect repository; specify --repo or run from a repo directory"
198            )
199        })?;
200        format!(
201            "/repos/{}/{}/environments/{e}/secrets/{name}",
202            spec.owner, spec.repo
203        )
204    } else {
205        let spec = detect_remote().ok_or_else(|| {
206            anyhow::anyhow!(
207                "could not detect repository; specify --org or run from a repo directory"
208            )
209        })?;
210        format!("/repos/{}/{}/actions/secrets/{name}", spec.owner, spec.repo)
211    };
212
213    let response = client
214        .request("DELETE", &path, &[], None)
215        .context("failed to delete secret")?;
216
217    let status = response.status();
218    if status == 404 {
219        anyhow::bail!("secret '{name}' not found");
220    }
221    if !status.is_success() {
222        anyhow::bail!("failed to delete secret '{name}': HTTP {status}");
223    }
224
225    println!("Secret '{name}' deleted.");
226    Ok(())
227}