Skip to main content

gor/cmd/
codespace.rs

1//! Implementation of the `gor codespace` subcommand.
2//!
3//! Provides codespace listing and creation.
4
5#![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
12/// Run the `gor codespace` subcommand.
13///
14/// # Errors
15///
16/// Returns an error if the command execution fails.
17pub 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    // Fetch SSH connection details
302    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
331/// Copy files between a local machine and a codespace.
332///
333/// The last path in `paths` is the destination, all others are sources.
334/// Use the `remote:` prefix for codespace paths.
335///
336/// # Errors
337///
338/// Returns an error if the codespace is not running, the paths are invalid,
339/// or the transfer fails.
340fn 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    // Fetch codespace details to verify it's running and get connection info.
361    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        // Upload: local → remote via SCP
383        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        // Download: remote → local via export API
432        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            // Start an export operation
440            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            // The export API returns a download URL in the `url` field
456            let download_url = export_result["url"]
457                .as_str()
458                .ok_or_else(|| anyhow::anyhow!("no download URL in export response"))?;
459
460            // Download the file
461            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            // Determine local path: if dest is a directory, use the filename
474            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        // Local to local copy — not supported by this command
495        anyhow::bail!(
496            "at least one path must use the 'remote:' prefix to specify a codespace path"
497        );
498    }
499
500    Ok(())
501}
502
503/// List forwarded ports for a codespace.
504///
505/// # Errors
506///
507/// Returns an error if the codespace is not running or the API request fails.
508fn 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
553/// Rebuild a codespace from its devcontainer configuration.
554///
555/// # Errors
556///
557/// Returns an error if the codespace is not running, the user cancels,
558/// or the API request fails.
559fn 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        // List all codespaces and stop each one
606        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}