409 lines · 11093 bytes
1 use clap::{Parser, Subcommand};
2 use serde::Deserialize;
3 use std::{
4 env, fs,
5 io::{self, BufRead, Write},
6 path::PathBuf,
7 process::Command,
8 };
9
10 const DEFAULT_HOST: &str = "git.botnet.pub";
11
12 #[derive(Parser)]
13 #[command(name = "openhub", about = "CLI for openhub git hosting")]
14 struct Cli {
15 #[command(subcommand)]
16 command: Commands,
17 }
18
19 #[derive(Subcommand)]
20 enum Commands {
21 /// Check SSH key and set host
22 Init,
23 /// Create an account
24 Register {
25 /// Username to register
26 username: Option<String>,
27 /// Invite code
28 #[arg(long)]
29 invite: Option<String>,
30 },
31 /// Authenticate with SSH key
32 Login,
33 /// Repository management
34 Repo {
35 #[command(subcommand)]
36 action: RepoAction,
37 },
38 /// Show current user
39 Whoami,
40 /// Clone a repository
41 Clone {
42 /// Repository in user/repo format
43 repo: String,
44 },
45 }
46
47 #[derive(Subcommand)]
48 enum RepoAction {
49 /// Create a new repository
50 New { name: String },
51 /// List repositories
52 List {
53 /// User to list repos for (default: self)
54 user: Option<String>,
55 },
56 /// Delete a repository
57 Delete { name: String },
58 }
59
60 fn config_dir() -> PathBuf {
61 dirs::config_dir()
62 .unwrap_or_else(|| {
63 dirs::home_dir()
64 .unwrap_or_else(|| PathBuf::from("."))
65 .join(".config")
66 })
67 .join("openhub")
68 }
69
70 fn read_config(name: &str) -> Option<String> {
71 fs::read_to_string(config_dir().join(name))
72 .ok()
73 .map(|s| s.trim().to_string())
74 .filter(|s| !s.is_empty())
75 }
76
77 fn write_config(name: &str, value: &str) {
78 let dir = config_dir();
79 fs::create_dir_all(&dir).ok();
80 fs::write(dir.join(name), value).ok();
81 }
82
83 fn host() -> String {
84 env::var("OPENHUB_HOST")
85 .ok()
86 .or_else(|| read_config("host"))
87 .unwrap_or_else(|| DEFAULT_HOST.to_string())
88 }
89
90 fn base_url() -> String {
91 let h = host();
92 if h.starts_with("http://") || h.starts_with("https://") {
93 h
94 } else {
95 format!("https://{}", h)
96 }
97 }
98
99 fn ssh_key_path() -> PathBuf {
100 env::var("OPENHUB_SSH_KEY")
101 .map(PathBuf::from)
102 .unwrap_or_else(|_| {
103 dirs::home_dir()
104 .unwrap_or_else(|| PathBuf::from("."))
105 .join(".ssh/id_ed25519")
106 })
107 }
108
109 fn load_token() -> Result<String, String> {
110 read_config("token").ok_or_else(|| "not logged in — run 'openhub login' first".to_string())
111 }
112
113 fn load_username() -> Result<String, String> {
114 read_config("username").ok_or_else(|| "not logged in — run 'openhub login' first".to_string())
115 }
116
117 #[derive(Deserialize)]
118 struct ApiError {
119 error: ApiErrorInner,
120 }
121
122 #[derive(Deserialize)]
123 struct ApiErrorInner {
124 message: String,
125 }
126
127 fn api_error_message(body: &str) -> String {
128 serde_json::from_str::<ApiError>(body)
129 .map(|e| e.error.message)
130 .unwrap_or_else(|_| body.to_string())
131 }
132
133 fn prompt(msg: &str) -> String {
134 eprint!("{}", msg);
135 io::stderr().flush().ok();
136 let mut line = String::new();
137 io::stdin().lock().read_line(&mut line).unwrap_or(0);
138 line.trim().to_string()
139 }
140
141 fn main() {
142 let cli = Cli::parse();
143
144 let result = match cli.command {
145 Commands::Init => cmd_init(),
146 Commands::Register { username, invite } => cmd_register(username, invite),
147 Commands::Login => cmd_login(),
148 Commands::Repo { action } => match action {
149 RepoAction::New { name } => cmd_repo_new(&name),
150 RepoAction::List { user } => cmd_repo_list(user.as_deref()),
151 RepoAction::Delete { name } => cmd_repo_delete(&name),
152 },
153 Commands::Whoami => cmd_whoami(),
154 Commands::Clone { repo } => cmd_clone(&repo),
155 };
156
157 if let Err(e) = result {
158 eprintln!("error: {}", e);
159 std::process::exit(1);
160 }
161 }
162
163 fn cmd_init() -> Result<(), String> {
164 let key_path = ssh_key_path();
165 if key_path.exists() {
166 eprintln!("SSH key found: {}", key_path.display());
167 } else {
168 eprintln!("Warning: No SSH key found at {}", key_path.display());
169 eprintln!("Generate one with: ssh-keygen -t ed25519");
170 }
171
172 let current_host = read_config("host");
173 let default = current_host.as_deref().unwrap_or(DEFAULT_HOST);
174 let input = prompt(&format!("Host [{}]: ", default));
175 let h = if input.is_empty() {
176 default.to_string()
177 } else {
178 input
179 };
180 write_config("host", &h);
181
182 eprintln!("Configuration saved to {}", config_dir().display());
183 eprintln!();
184 eprintln!("Next steps:");
185 eprintln!(" openhub register # create your account");
186 eprintln!(" openhub login # authenticate");
187 Ok(())
188 }
189
190 fn cmd_register(username: Option<String>, invite: Option<String>) -> Result<(), String> {
191 let username = match username {
192 Some(u) => u,
193 None => {
194 let u = prompt("Username: ");
195 if u.is_empty() {
196 return Err("username required".into());
197 }
198 u
199 }
200 };
201
202 let invite = match invite {
203 Some(i) => i,
204 None => {
205 let i = prompt("Invite code: ");
206 if i.is_empty() {
207 return Err("invite code required".into());
208 }
209 i
210 }
211 };
212
213 let key_path = ssh_key_path();
214 let pub_key_path = PathBuf::from(format!("{}.pub", key_path.display()));
215 let public_key = fs::read_to_string(&pub_key_path)
216 .map_err(|_| format!("could not read public key from {}", pub_key_path.display()))?
217 .trim()
218 .to_string();
219
220 let client = reqwest::blocking::Client::new();
221 let resp = client
222 .post(format!("{}/api/signup", base_url()))
223 .json(&serde_json::json!({
224 "username": username,
225 "public_key": public_key,
226 "invite_code": invite,
227 }))
228 .send()
229 .map_err(|e| format!("request failed: {}", e))?;
230
231 if resp.status().is_success() {
232 println!("Account created! Welcome, {}.", username);
233 Ok(())
234 } else {
235 let body = resp.text().unwrap_or_default();
236 Err(api_error_message(&body))
237 }
238 }
239
240 fn cmd_login() -> Result<(), String> {
241 let key_path = ssh_key_path();
242 let key = ssh_key::PrivateKey::read_openssh_file(&key_path)
243 .map_err(|e| format!("could not read SSH key from {}: {}", key_path.display(), e))?;
244
245 let username = match read_config("username") {
246 Some(u) => u,
247 None => {
248 let u = prompt("Username: ");
249 if u.is_empty() {
250 return Err("username required".into());
251 }
252 u
253 }
254 };
255
256 let client = reqwest::blocking::Client::new();
257
258 // Step 1: Get challenge nonce
259 let resp = client
260 .post(format!("{}/api/login/challenge", base_url()))
261 .json(&serde_json::json!({"username": username}))
262 .send()
263 .map_err(|e| format!("challenge request failed: {}", e))?;
264
265 if !resp.status().is_success() {
266 let body = resp.text().unwrap_or_default();
267 return Err(api_error_message(&body));
268 }
269
270 #[derive(Deserialize)]
271 struct ChallengeResp {
272 nonce: String,
273 }
274 let challenge: ChallengeResp = resp.json().map_err(|e| format!("invalid response: {}", e))?;
275
276 // Step 2: Sign nonce with SSH key
277 let sig = key
278 .sign("openhub", ssh_key::HashAlg::Sha512, challenge.nonce.as_bytes())
279 .map_err(|e| format!("signing failed: {}", e))?;
280 let sig_pem = sig
281 .to_pem(ssh_key::LineEnding::LF)
282 .map_err(|e| format!("PEM encoding failed: {}", e))?;
283
284 // Step 3: Verify signature and get token
285 let resp = client
286 .post(format!("{}/api/login/verify", base_url()))
287 .json(&serde_json::json!({
288 "username": username,
289 "nonce": challenge.nonce,
290 "signature": sig_pem,
291 }))
292 .send()
293 .map_err(|e| format!("verify request failed: {}", e))?;
294
295 if !resp.status().is_success() {
296 let body = resp.text().unwrap_or_default();
297 return Err(api_error_message(&body));
298 }
299
300 #[derive(Deserialize)]
301 struct VerifyResp {
302 token: String,
303 }
304 let verify: VerifyResp = resp.json().map_err(|e| format!("invalid response: {}", e))?;
305
306 write_config("token", &verify.token);
307 write_config("username", &username);
308
309 println!("Logged in as {}.", username);
310 Ok(())
311 }
312
313 fn cmd_repo_new(name: &str) -> Result<(), String> {
314 let token = load_token()?;
315 let username = load_username()?;
316
317 let client = reqwest::blocking::Client::new();
318 let resp = client
319 .post(format!("{}/api/repos", base_url()))
320 .bearer_auth(&token)
321 .json(&serde_json::json!({"name": name}))
322 .send()
323 .map_err(|e| format!("request failed: {}", e))?;
324
325 if resp.status().is_success() {
326 println!("Created {}/{}", username, name);
327 Ok(())
328 } else {
329 let body = resp.text().unwrap_or_default();
330 Err(api_error_message(&body))
331 }
332 }
333
334 fn cmd_repo_list(user: Option<&str>) -> Result<(), String> {
335 let username = match user {
336 Some(u) => u.to_string(),
337 None => load_username()?,
338 };
339
340 let client = reqwest::blocking::Client::new();
341 let resp = client
342 .get(format!("{}/api/repos/{}", base_url(), username))
343 .send()
344 .map_err(|e| format!("request failed: {}", e))?;
345
346 if !resp.status().is_success() {
347 let body = resp.text().unwrap_or_default();
348 return Err(api_error_message(&body));
349 }
350
351 #[derive(Deserialize)]
352 struct ListResp {
353 repos: Vec<RepoInfo>,
354 }
355 #[derive(Deserialize)]
356 struct RepoInfo {
357 owner: String,
358 name: String,
359 }
360
361 let list: ListResp = resp.json().map_err(|e| format!("invalid response: {}", e))?;
362 for r in list.repos {
363 println!("{}/{}", r.owner, r.name);
364 }
365 Ok(())
366 }
367
368 fn cmd_repo_delete(name: &str) -> Result<(), String> {
369 let token = load_token()?;
370 let username = load_username()?;
371
372 let client = reqwest::blocking::Client::new();
373 let resp = client
374 .delete(format!("{}/api/repos/{}/{}", base_url(), username, name))
375 .bearer_auth(&token)
376 .send()
377 .map_err(|e| format!("request failed: {}", e))?;
378
379 if resp.status().is_success() {
380 println!("Deleted {}/{}", username, name);
381 Ok(())
382 } else {
383 let body = resp.text().unwrap_or_default();
384 Err(api_error_message(&body))
385 }
386 }
387
388 fn cmd_whoami() -> Result<(), String> {
389 let username = load_username()?;
390 println!("{}", username);
391 Ok(())
392 }
393
394 fn cmd_clone(repo: &str) -> Result<(), String> {
395 let h = host();
396 let url = format!("git@{}:{}.git", h, repo);
397
398 let status = Command::new("git")
399 .arg("clone")
400 .arg(&url)
401 .status()
402 .map_err(|e| format!("failed to run git: {}", e))?;
403
404 if status.success() {
405 Ok(())
406 } else {
407 Err("git clone failed".into())
408 }
409 }