1#![allow(clippy::print_stdout, clippy::option_if_let_else)]
6
7use crate::cli::CodespaceCommand;
8use crate::client::Client;
9use crate::output::print_json;
10use anyhow::Context;
11
12pub fn run(cmd: CodespaceCommand) -> anyhow::Result<()> {
18 match cmd {
19 CodespaceCommand::List {
20 repo,
21 json,
22 hostname,
23 } => list(repo.as_deref(), json, hostname.as_deref()),
24 CodespaceCommand::Create {
25 repo,
26 branch,
27 machine,
28 hostname,
29 } => create(
30 &repo,
31 branch.as_deref(),
32 machine.as_deref(),
33 hostname.as_deref(),
34 ),
35 CodespaceCommand::Delete {
36 name,
37 repo,
38 yes,
39 hostname,
40 } => delete(&name, repo.as_deref(), yes, hostname.as_deref()),
41 CodespaceCommand::Logs {
42 name,
43 repo,
44 json,
45 follow,
46 hostname,
47 } => logs(&name, repo.as_deref(), json, follow, hostname.as_deref()),
48 CodespaceCommand::Ssh {
49 name,
50 repo,
51 profile,
52 config,
53 hostname,
54 } => ssh(
55 &name,
56 repo.as_deref(),
57 profile.as_deref(),
58 config,
59 hostname.as_deref(),
60 ),
61 CodespaceCommand::Stop {
62 name,
63 repo,
64 all,
65 hostname,
66 } => stop(name.as_deref(), repo.as_deref(), all, hostname.as_deref()),
67 CodespaceCommand::Cp {
68 name,
69 paths,
70 recursive,
71 hostname,
72 } => cp(&name, &paths, recursive, hostname.as_deref()),
73 CodespaceCommand::Ports {
74 name,
75 json,
76 hostname,
77 } => ports(&name, json, hostname.as_deref()),
78 CodespaceCommand::Rebuild {
79 name,
80 yes,
81 hostname,
82 } => rebuild(&name, yes, hostname.as_deref()),
83 }
84}
85
86fn list(
87 repo: Option<&str>,
88 json: Option<Vec<String>>,
89 hostname: Option<&str>,
90) -> anyhow::Result<()> {
91 let host = hostname.unwrap_or("github.com");
92 let client = Client::new(host).context("failed to create HTTP client")?;
93
94 let path = if let Some(r) = repo {
95 format!("/user/codespaces?repository_id={r}")
96 } else {
97 "/user/codespaces".to_string()
98 };
99
100 let response = client.get(&path).context("failed to fetch codespaces")?;
101 let status = response.status();
102 if !status.is_success() {
103 anyhow::bail!("failed to list codespaces: HTTP {status}");
104 }
105
106 let result: serde_json::Value = response.json().context("failed to parse response")?;
107 let spaces: Vec<serde_json::Value> = result["codespaces"]
108 .as_array()
109 .map_or_else(Vec::new, Clone::clone);
110
111 if let Some(fields) = json {
112 let fields_ref: Option<&[String]> = if fields.is_empty() {
113 None
114 } else {
115 Some(&fields)
116 };
117 print_json(&spaces, fields_ref);
118 return Ok(());
119 }
120
121 if spaces.is_empty() {
122 println!("No codespaces found.");
123 return Ok(());
124 }
125
126 println!(
127 "{:<24} {:<20} {:<15} BRANCH",
128 "NAME", "REPOSITORY", "STATE"
129 );
130 for s in &spaces {
131 let name = s["name"].as_str().unwrap_or("—");
132 let repo_name = s["repository"]["full_name"].as_str().unwrap_or("—");
133 let state = s["state"].as_str().unwrap_or("—");
134 let branch = s["git_status"]["branch"].as_str().unwrap_or("—");
135 let name_truncated = crate::cmd::util::truncate(name, 24);
136 let repo_truncated = crate::cmd::util::truncate(repo_name, 20);
137 println!("{name_truncated:<24} {repo_truncated:<20} {state:<15} {branch}");
138 }
139
140 Ok(())
141}
142
143fn delete(name: &str, repo: Option<&str>, yes: bool, hostname: Option<&str>) -> anyhow::Result<()> {
144 let host = hostname.unwrap_or("github.com");
145 let client = Client::new(host).context("failed to create HTTP client")?;
146
147 if !yes {
148 use std::io::Write;
149 let prompt = if let Some(r) = repo {
150 format!("Are you sure you want to delete codespace '{name}' in repo '{r}'?")
151 } else {
152 format!("Are you sure you want to delete codespace '{name}'?")
153 };
154 print!("{prompt} [y/N] ");
155 std::io::stdout().flush().ok();
156
157 let mut input = String::new();
158 std::io::stdin()
159 .read_line(&mut input)
160 .context("failed to read input")?;
161 let input = input.trim().to_lowercase();
162 if input != "y" && input != "yes" {
163 println!("Cancelled.");
164 return Ok(());
165 }
166 }
167
168 let path = format!("/user/codespaces/{name}");
169
170 let response = client
171 .request("DELETE", &path, &[], None)
172 .context("failed to delete codespace")?;
173
174 let status = response.status();
175 if !status.is_success() {
176 anyhow::bail!("failed to delete codespace '{name}': HTTP {status}");
177 }
178
179 println!("Codespace '{name}' deleted.");
180 Ok(())
181}
182
183fn create(
184 repo: &str,
185 branch: Option<&str>,
186 machine: 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 mut body_map = serde_json::Map::new();
193 body_map.insert(
194 "repository_id".to_string(),
195 serde_json::Value::String(repo.to_string()),
196 );
197
198 if let Some(b) = branch {
199 body_map.insert("git_status".to_string(), serde_json::json!({"branch": b}));
200 }
201
202 if let Some(m) = machine {
203 body_map.insert(
204 "machine".to_string(),
205 serde_json::Value::String(m.to_string()),
206 );
207 }
208
209 let body_value = serde_json::Value::Object(body_map);
210 let body_bytes = serde_json::to_vec(&body_value).context("failed to serialize body")?;
211
212 let response = client
213 .request("POST", "/user/codespaces", &[], Some(body_bytes))
214 .context("failed to create codespace")?;
215
216 let status = response.status();
217 if !status.is_success() {
218 let err_body: serde_json::Value = response.json().unwrap_or_default();
219 let msg = err_body["message"].as_str().unwrap_or("create failed");
220 anyhow::bail!("failed to create codespace: {msg}");
221 }
222
223 let result: serde_json::Value = response.json().context("failed to parse response")?;
224 let name = result["name"].as_str().unwrap_or("—");
225 println!("Codespace '{name}' created.");
226 Ok(())
227}
228
229fn logs(
230 name: &str,
231 _repo: Option<&str>,
232 json: Option<Vec<String>>,
233 follow: bool,
234 hostname: Option<&str>,
235) -> anyhow::Result<()> {
236 let host = hostname.unwrap_or("github.com");
237 let client = Client::new(host).context("failed to create HTTP client")?;
238
239 let path = format!("/user/codespaces/{name}/logs");
240
241 let response = client
242 .get(&path)
243 .context("failed to fetch codespace logs")?;
244 let status = response.status();
245 if !status.is_success() {
246 anyhow::bail!("failed to fetch logs for '{name}': HTTP {status}");
247 }
248
249 let body = response.text().context("failed to read response")?;
250
251 if let Some(fields) = json {
252 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
253 let fields_ref: Option<&[String]> = if fields.is_empty() {
254 None
255 } else {
256 Some(&fields)
257 };
258 print_json(&parsed, fields_ref);
259 return Ok(());
260 }
261
262 if follow {
263 println!("Logs for codespace '{name}':");
264 }
265 println!("{body}");
266
267 if follow {
268 tracing::warn!("log following is not yet implemented");
269 }
270
271 Ok(())
272}
273
274fn ssh(
275 name: &str,
276 _repo: Option<&str>,
277 _profile: Option<&str>,
278 config: bool,
279 hostname: Option<&str>,
280) -> anyhow::Result<()> {
281 let host = hostname.unwrap_or("github.com");
282 let client = Client::new(host).context("failed to create HTTP client")?;
283
284 let path = format!("/user/codespaces/{name}");
285
286 let response = client
287 .get(&path)
288 .context("failed to fetch codespace details")?;
289 let status = response.status();
290 if !status.is_success() {
291 anyhow::bail!("failed to fetch codespace '{name}': HTTP {status}");
292 }
293
294 let cs: serde_json::Value = response.json().context("failed to parse response")?;
295 let state = cs["state"].as_str().unwrap_or("unknown");
296
297 if state != "Available" {
298 anyhow::bail!("codespace '{name}' is not available (state: {state})");
299 }
300
301 let ssh_path = format!("/user/codespaces/{name}");
303 let ssh_response = client
304 .get(&ssh_path)
305 .context("failed to fetch SSH details")?;
306 let ssh_details: serde_json::Value =
307 ssh_response.json().context("failed to parse SSH details")?;
308
309 if config {
310 let connection = ssh_details.get("connection").and_then(|c| c.as_object());
311 if let Some(conn) = connection {
312 println!("Host {name}");
313 if let Some(hostname) = conn.get("host").and_then(|v| v.as_str()) {
314 println!(" HostName {hostname}");
315 }
316 if let Some(port) = conn.get("port") {
317 println!(" Port {port}");
318 }
319 if let Some(user) = conn.get("user").and_then(|v| v.as_str()) {
320 println!(" User {user}");
321 }
322 } else {
323 println!("# No SSH connection details available for '{name}'");
324 }
325 return Ok(());
326 }
327
328 anyhow::bail!("SSH connection is not yet implemented; use --config to view connection details");
329}
330
331fn cp(name: &str, paths: &[String], recursive: bool, hostname: Option<&str>) -> anyhow::Result<()> {
341 let host = hostname.unwrap_or("github.com");
342 let client = Client::new(host).context("failed to create HTTP client")?;
343
344 if paths.len() < 2 {
345 anyhow::bail!("at least two paths are required: source and destination");
346 }
347
348 let dest = paths
349 .last()
350 .ok_or_else(|| anyhow::anyhow!("paths is empty"))?;
351 let sources = &paths[..paths.len() - 1];
352
353 let dest_is_remote = dest.starts_with("remote:");
354 let any_source_remote = sources.iter().any(|p| p.starts_with("remote:"));
355
356 if dest_is_remote && any_source_remote {
357 anyhow::bail!("copying between two remote paths is not supported");
358 }
359
360 let cs_path = format!("/user/codespaces/{name}");
362 let response = client
363 .get(&cs_path)
364 .context("failed to fetch codespace details")?;
365 let status = response.status();
366 if !status.is_success() {
367 anyhow::bail!("failed to fetch codespace '{name}': HTTP {status}");
368 }
369
370 let cs: serde_json::Value = response
371 .json()
372 .context("failed to parse codespace response")?;
373 let state = cs["state"].as_str().unwrap_or("unknown");
374
375 if state != "Available" {
376 anyhow::bail!(
377 "codespace '{name}' is not available (state: {state}). Start the codespace first."
378 );
379 }
380
381 if dest_is_remote {
382 let connection = cs["connection"]
384 .as_object()
385 .ok_or_else(|| anyhow::anyhow!("no SSH connection details available for '{name}'"))?;
386
387 let ssh_host = connection["host"]
388 .as_str()
389 .ok_or_else(|| anyhow::anyhow!("missing SSH host"))?;
390 let ssh_port = connection["port"]
391 .as_u64()
392 .ok_or_else(|| anyhow::anyhow!("missing SSH port"))?;
393 let ssh_user = connection["user"]
394 .as_str()
395 .ok_or_else(|| anyhow::anyhow!("missing SSH user"))?;
396
397 let remote_path = dest
398 .strip_prefix("remote:")
399 .ok_or_else(|| anyhow::anyhow!("invalid remote path"))?;
400
401 for source in sources {
402 if source.starts_with("remote:") {
403 anyhow::bail!("cannot mix remote sources with remote destination");
404 }
405
406 tracing::info!("Uploading {source} to {name}:{remote_path}...");
407
408 let mut cmd = std::process::Command::new("scp");
409 cmd.arg("-P")
410 .arg(ssh_port.to_string())
411 .arg("-o")
412 .arg("LogLevel=ERROR");
413 if recursive {
414 cmd.arg("-r");
415 }
416 cmd.arg(source);
417 cmd.arg(format!("{ssh_user}@{ssh_host}:{remote_path}"));
418
419 let output = cmd
420 .output()
421 .context("failed to run scp; is it installed?")?;
422
423 if !output.status.success() {
424 let stderr = String::from_utf8_lossy(&output.stderr);
425 anyhow::bail!("scp failed for '{source}': {stderr}");
426 }
427
428 tracing::info!("✓ Uploaded {source}");
429 }
430 } else if any_source_remote {
431 for source in sources {
433 let remote_path = source
434 .strip_prefix("remote:")
435 .ok_or_else(|| anyhow::anyhow!("invalid remote path: {source}"))?;
436
437 tracing::info!("Downloading {remote_path} from {name}...");
438
439 let export_body = serde_json::json!({"path": remote_path});
441 let export_response = client
442 .post(&format!("/user/codespaces/{name}/exports"), &export_body)
443 .context("failed to start export")?;
444
445 if !export_response.status().is_success() {
446 let err_body: serde_json::Value = export_response.json().unwrap_or_default();
447 let msg = err_body["message"].as_str().unwrap_or("export failed");
448 anyhow::bail!("failed to export '{remote_path}': {msg}");
449 }
450
451 let export_result: serde_json::Value = export_response
452 .json()
453 .context("failed to parse export response")?;
454
455 let download_url = export_result["url"]
457 .as_str()
458 .ok_or_else(|| anyhow::anyhow!("no download URL in export response"))?;
459
460 let file_response = client
462 .get_absolute(download_url)
463 .context("failed to download exported file")?;
464
465 if !file_response.status().is_success() {
466 anyhow::bail!("failed to download file: HTTP {}", file_response.status());
467 }
468
469 let bytes = file_response
470 .bytes()
471 .context("failed to read downloaded file")?;
472
473 let local_path = if dest.ends_with('/') || dest.ends_with(std::path::MAIN_SEPARATOR_STR)
475 {
476 let filename = std::path::Path::new(remote_path)
477 .file_name()
478 .and_then(|n| n.to_str())
479 .unwrap_or("file");
480 std::path::Path::new(dest).join(filename)
481 } else {
482 std::path::PathBuf::from(dest)
483 };
484
485 if let Some(parent) = local_path.parent() {
486 std::fs::create_dir_all(parent).context("failed to create parent directory")?;
487 }
488
489 std::fs::write(&local_path, &bytes).context("failed to write file")?;
490
491 tracing::info!("✓ Downloaded to {}", local_path.display());
492 }
493 } else {
494 anyhow::bail!(
496 "at least one path must use the 'remote:' prefix to specify a codespace path"
497 );
498 }
499
500 Ok(())
501}
502
503fn ports(name: &str, json: Option<Vec<String>>, hostname: Option<&str>) -> anyhow::Result<()> {
509 let host = hostname.unwrap_or("github.com");
510 let client = Client::new(host).context("failed to create HTTP client")?;
511
512 let path = format!("/user/codespaces/{name}/ports");
513 let response = client
514 .get(&path)
515 .context("failed to fetch codespace ports")?;
516 let status = response.status();
517 if !status.is_success() {
518 anyhow::bail!("failed to fetch ports for '{name}': HTTP {status}");
519 }
520
521 let ports: Vec<serde_json::Value> =
522 response.json().context("failed to parse ports response")?;
523
524 if let Some(fields) = json {
525 let fields_ref: Option<&[String]> = if fields.is_empty() {
526 None
527 } else {
528 Some(&fields)
529 };
530 print_json(&ports, fields_ref);
531 return Ok(());
532 }
533
534 if ports.is_empty() {
535 println!("No forwarded ports for codespace '{name}'.");
536 return Ok(());
537 }
538
539 println!("{:<24} {:<12} {:<12}", "LABEL", "PORT", "VISIBILITY");
540 for p in &ports {
541 let label = p["label"].as_str().unwrap_or("—");
542 let source_port = p["source_port"]
543 .as_u64()
544 .map_or_else(|| "—".to_string(), |v| v.to_string());
545 let visibility = p["visibility"].as_str().unwrap_or("—");
546 let label_truncated = crate::cmd::util::truncate(label, 24);
547 println!("{label_truncated:<24} {source_port:<12} {visibility:<12}");
548 }
549
550 Ok(())
551}
552
553fn rebuild(name: &str, yes: bool, hostname: Option<&str>) -> anyhow::Result<()> {
560 let host = hostname.unwrap_or("github.com");
561 let client = Client::new(host).context("failed to create HTTP client")?;
562
563 if !yes {
564 use std::io::Write;
565 print!("Are you sure you want to rebuild codespace '{name}'? [y/N] ");
566 std::io::stdout().flush().ok();
567
568 let mut input = String::new();
569 std::io::stdin()
570 .read_line(&mut input)
571 .context("failed to read input")?;
572 let input = input.trim().to_lowercase();
573 if input != "y" && input != "yes" {
574 println!("Cancelled.");
575 return Ok(());
576 }
577 }
578
579 let path = format!("/user/codespaces/{name}/rebuild");
580 let response = client
581 .request("POST", &path, &[], None)
582 .context("failed to trigger rebuild")?;
583
584 let status = response.status();
585 if !status.is_success() {
586 let err_body: serde_json::Value = response.json().unwrap_or_default();
587 let msg = err_body["message"].as_str().unwrap_or("rebuild failed");
588 anyhow::bail!("failed to rebuild codespace '{name}': {msg}");
589 }
590
591 println!("Codespace '{name}' rebuild started.");
592 Ok(())
593}
594
595fn stop(
596 name: Option<&str>,
597 _repo: Option<&str>,
598 all: bool,
599 hostname: Option<&str>,
600) -> anyhow::Result<()> {
601 let host = hostname.unwrap_or("github.com");
602 let client = Client::new(host).context("failed to create HTTP client")?;
603
604 if all {
605 let response = client
607 .get("/user/codespaces")
608 .context("failed to list codespaces")?;
609 let result: serde_json::Value = response.json().context("failed to parse response")?;
610 let spaces: Vec<serde_json::Value> = result["codespaces"]
611 .as_array()
612 .map_or_else(Vec::new, Clone::clone);
613
614 let mut stopped = 0u32;
615 for s in &spaces {
616 let cs_name = s["name"].as_str().unwrap_or("");
617 let cs_state = s["state"].as_str().unwrap_or("");
618 if cs_state == "Shutdown" || cs_state == "Deleted" {
619 continue;
620 }
621 let stop_path = format!("/user/codespaces/{cs_name}/stop");
622 let resp = client
623 .request("POST", &stop_path, &[], None)
624 .context("failed to stop codespace")?;
625 if resp.status().is_success() {
626 stopped += 1;
627 }
628 }
629 println!("Stopped {stopped} codespace(s).");
630 return Ok(());
631 }
632
633 let cs_name = name.ok_or_else(|| anyhow::anyhow!("codespace name is required"))?;
634 let path = format!("/user/codespaces/{cs_name}/stop");
635
636 let response = client
637 .request("POST", &path, &[], None)
638 .context("failed to stop codespace")?;
639
640 let status = response.status();
641 if !status.is_success() {
642 anyhow::bail!("failed to stop codespace '{cs_name}': HTTP {status}");
643 }
644
645 println!("Codespace '{cs_name}' stopped.");
646 Ok(())
647}