devcontainer_api.rs

  1use std::{
  2    collections::{HashMap, HashSet},
  3    fmt::Display,
  4    path::{Path, PathBuf},
  5    sync::Arc,
  6};
  7
  8use gpui::AsyncWindowContext;
  9use node_runtime::NodeRuntime;
 10use serde::Deserialize;
 11use settings::{DevContainerConnection, Settings as _};
 12use smol::{fs, process::Command};
 13use workspace::Workspace;
 14
 15use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate};
 16
 17#[derive(Debug, Deserialize)]
 18#[serde(rename_all = "camelCase")]
 19struct DevContainerUp {
 20    _outcome: String,
 21    container_id: String,
 22    remote_user: String,
 23    remote_workspace_folder: String,
 24}
 25
 26#[derive(Debug, Deserialize)]
 27#[serde(rename_all = "camelCase")]
 28pub(crate) struct DevContainerApply {
 29    pub(crate) files: Vec<String>,
 30}
 31
 32#[derive(Debug, Deserialize)]
 33#[serde(rename_all = "camelCase")]
 34pub(crate) struct DevContainerConfiguration {
 35    name: Option<String>,
 36}
 37
 38#[derive(Debug, Deserialize)]
 39pub(crate) struct DevContainerConfigurationOutput {
 40    configuration: DevContainerConfiguration,
 41}
 42
 43#[derive(Debug, Clone, PartialEq, Eq)]
 44pub enum DevContainerError {
 45    DockerNotAvailable,
 46    DevContainerCliNotAvailable,
 47    DevContainerTemplateApplyFailed(String),
 48    DevContainerUpFailed(String),
 49    DevContainerNotFound,
 50    DevContainerParseFailed,
 51    NodeRuntimeNotAvailable,
 52    NotInValidProject,
 53}
 54
 55impl Display for DevContainerError {
 56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 57        write!(
 58            f,
 59            "{}",
 60            match self {
 61                DevContainerError::DockerNotAvailable =>
 62                    "Docker CLI not found on $PATH".to_string(),
 63                DevContainerError::DevContainerCliNotAvailable =>
 64                    "Docker not found on path".to_string(),
 65                DevContainerError::DevContainerUpFailed(message) => {
 66                    format!("DevContainer creation failed with error: {}", message)
 67                }
 68                DevContainerError::DevContainerTemplateApplyFailed(message) => {
 69                    format!("DevContainer template apply failed with error: {}", message)
 70                }
 71                DevContainerError::DevContainerNotFound =>
 72                    "No valid dev container definition found in project".to_string(),
 73                DevContainerError::DevContainerParseFailed =>
 74                    "Failed to parse file .devcontainer/devcontainer.json".to_string(),
 75                DevContainerError::NodeRuntimeNotAvailable =>
 76                    "Cannot find a valid node runtime".to_string(),
 77                DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
 78            }
 79        )
 80    }
 81}
 82
 83pub(crate) async fn read_devcontainer_configuration_for_project(
 84    cx: &mut AsyncWindowContext,
 85    node_runtime: &NodeRuntime,
 86) -> Result<DevContainerConfigurationOutput, DevContainerError> {
 87    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
 88
 89    let Some(directory) = project_directory(cx) else {
 90        return Err(DevContainerError::NotInValidProject);
 91    };
 92
 93    devcontainer_read_configuration(
 94        &path_to_devcontainer_cli,
 95        found_in_path,
 96        node_runtime,
 97        &directory,
 98        use_podman(cx),
 99    )
100    .await
101}
102
103pub(crate) async fn apply_dev_container_template(
104    template: &DevContainerTemplate,
105    options_selected: &HashMap<String, String>,
106    features_selected: &HashSet<DevContainerFeature>,
107    cx: &mut AsyncWindowContext,
108    node_runtime: &NodeRuntime,
109) -> Result<DevContainerApply, DevContainerError> {
110    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
111
112    let Some(directory) = project_directory(cx) else {
113        return Err(DevContainerError::NotInValidProject);
114    };
115
116    devcontainer_template_apply(
117        template,
118        options_selected,
119        features_selected,
120        &path_to_devcontainer_cli,
121        found_in_path,
122        node_runtime,
123        &directory,
124        false, // devcontainer template apply does not use --docker-path option
125    )
126    .await
127}
128
129fn use_podman(cx: &mut AsyncWindowContext) -> bool {
130    cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman)
131        .unwrap_or(false)
132}
133
134pub async fn start_dev_container(
135    cx: &mut AsyncWindowContext,
136    node_runtime: NodeRuntime,
137) -> Result<(DevContainerConnection, String), DevContainerError> {
138    let use_podman = use_podman(cx);
139    check_for_docker(use_podman).await?;
140
141    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
142
143    let Some(directory) = project_directory(cx) else {
144        return Err(DevContainerError::NotInValidProject);
145    };
146
147    match devcontainer_up(
148        &path_to_devcontainer_cli,
149        found_in_path,
150        &node_runtime,
151        directory.clone(),
152        use_podman,
153    )
154    .await
155    {
156        Ok(DevContainerUp {
157            container_id,
158            remote_workspace_folder,
159            remote_user,
160            ..
161        }) => {
162            let project_name = match devcontainer_read_configuration(
163                &path_to_devcontainer_cli,
164                found_in_path,
165                &node_runtime,
166                &directory,
167                use_podman,
168            )
169            .await
170            {
171                Ok(DevContainerConfigurationOutput {
172                    configuration:
173                        DevContainerConfiguration {
174                            name: Some(project_name),
175                        },
176                }) => project_name,
177                _ => get_backup_project_name(&remote_workspace_folder, &container_id),
178            };
179
180            let connection = DevContainerConnection {
181                name: project_name,
182                container_id: container_id,
183                use_podman,
184                remote_user,
185            };
186
187            Ok((connection, remote_workspace_folder))
188        }
189        Err(err) => {
190            let message = format!("Failed with nested error: {}", err);
191            Err(DevContainerError::DevContainerUpFailed(message))
192        }
193    }
194}
195
196#[cfg(not(target_os = "windows"))]
197fn dev_container_cli() -> String {
198    "devcontainer".to_string()
199}
200
201#[cfg(target_os = "windows")]
202fn dev_container_cli() -> String {
203    "devcontainer.cmd".to_string()
204}
205
206async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
207    let mut command = if use_podman {
208        util::command::new_smol_command("podman")
209    } else {
210        util::command::new_smol_command("docker")
211    };
212    command.arg("--version");
213
214    match command.output().await {
215        Ok(_) => Ok(()),
216        Err(e) => {
217            log::error!("Unable to find docker in $PATH: {:?}", e);
218            Err(DevContainerError::DockerNotAvailable)
219        }
220    }
221}
222
223async fn ensure_devcontainer_cli(
224    node_runtime: &NodeRuntime,
225) -> Result<(PathBuf, bool), DevContainerError> {
226    let mut command = util::command::new_smol_command(&dev_container_cli());
227    command.arg("--version");
228
229    if let Err(e) = command.output().await {
230        log::error!(
231            "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
232            e
233        );
234
235        let Ok(node_runtime_path) = node_runtime.binary_path().await else {
236            return Err(DevContainerError::NodeRuntimeNotAvailable);
237        };
238
239        let datadir_cli_path = paths::devcontainer_dir()
240            .join("node_modules")
241            .join("@devcontainers")
242            .join("cli")
243            .join(format!("{}.js", &dev_container_cli()));
244
245        log::debug!(
246            "devcontainer not found in path, using local location: ${}",
247            datadir_cli_path.display()
248        );
249
250        let mut command =
251            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
252        command.arg(datadir_cli_path.display().to_string());
253        command.arg("--version");
254
255        match command.output().await {
256            Err(e) => log::error!(
257                "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
258                e
259            ),
260            Ok(output) => {
261                if output.status.success() {
262                    log::info!("Found devcontainer CLI in Data dir");
263                    return Ok((datadir_cli_path.clone(), false));
264                } else {
265                    log::error!(
266                        "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
267                        output
268                    );
269                }
270            }
271        }
272
273        if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
274            log::error!("Unable to create devcontainer directory. Error: {:?}", e);
275            return Err(DevContainerError::DevContainerCliNotAvailable);
276        }
277
278        if let Err(e) = node_runtime
279            .npm_install_packages(
280                &paths::devcontainer_dir(),
281                &[("@devcontainers/cli", "latest")],
282            )
283            .await
284        {
285            log::error!(
286                "Unable to install devcontainer CLI to data directory. Error: {:?}",
287                e
288            );
289            return Err(DevContainerError::DevContainerCliNotAvailable);
290        };
291
292        let mut command =
293            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
294        command.arg(datadir_cli_path.display().to_string());
295        command.arg("--version");
296        if let Err(e) = command.output().await {
297            log::error!(
298                "Unable to find devcontainer cli after NPM install. Error: {:?}",
299                e
300            );
301            Err(DevContainerError::DevContainerCliNotAvailable)
302        } else {
303            Ok((datadir_cli_path, false))
304        }
305    } else {
306        log::info!("Found devcontainer cli on $PATH, using it");
307        Ok((PathBuf::from(&dev_container_cli()), true))
308    }
309}
310
311async fn devcontainer_up(
312    path_to_cli: &PathBuf,
313    found_in_path: bool,
314    node_runtime: &NodeRuntime,
315    path: Arc<Path>,
316    use_podman: bool,
317) -> Result<DevContainerUp, DevContainerError> {
318    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
319        log::error!("Unable to find node runtime path");
320        return Err(DevContainerError::NodeRuntimeNotAvailable);
321    };
322
323    let mut command =
324        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
325    command.arg("up");
326    command.arg("--workspace-folder");
327    command.arg(path.display().to_string());
328
329    log::info!("Running full devcontainer up command: {:?}", command);
330
331    match command.output().await {
332        Ok(output) => {
333            if output.status.success() {
334                let raw = String::from_utf8_lossy(&output.stdout);
335                parse_json_from_cli(&raw)
336            } else {
337                let message = format!(
338                    "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
339                    String::from_utf8_lossy(&output.stdout),
340                    String::from_utf8_lossy(&output.stderr)
341                );
342
343                log::error!("{}", &message);
344                Err(DevContainerError::DevContainerUpFailed(message))
345            }
346        }
347        Err(e) => {
348            let message = format!("Error running devcontainer up: {:?}", e);
349            log::error!("{}", &message);
350            Err(DevContainerError::DevContainerUpFailed(message))
351        }
352    }
353}
354
355async fn devcontainer_read_configuration(
356    path_to_cli: &PathBuf,
357    found_in_path: bool,
358    node_runtime: &NodeRuntime,
359    path: &Arc<Path>,
360    use_podman: bool,
361) -> Result<DevContainerConfigurationOutput, DevContainerError> {
362    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
363        log::error!("Unable to find node runtime path");
364        return Err(DevContainerError::NodeRuntimeNotAvailable);
365    };
366
367    let mut command =
368        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
369    command.arg("read-configuration");
370    command.arg("--workspace-folder");
371    command.arg(path.display().to_string());
372
373    match command.output().await {
374        Ok(output) => {
375            if output.status.success() {
376                let raw = String::from_utf8_lossy(&output.stdout);
377                parse_json_from_cli(&raw)
378            } else {
379                let message = format!(
380                    "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
381                    String::from_utf8_lossy(&output.stdout),
382                    String::from_utf8_lossy(&output.stderr)
383                );
384                log::error!("{}", &message);
385                Err(DevContainerError::DevContainerNotFound)
386            }
387        }
388        Err(e) => {
389            let message = format!("Error running devcontainer read-configuration: {:?}", e);
390            log::error!("{}", &message);
391            Err(DevContainerError::DevContainerNotFound)
392        }
393    }
394}
395
396async fn devcontainer_template_apply(
397    template: &DevContainerTemplate,
398    template_options: &HashMap<String, String>,
399    features_selected: &HashSet<DevContainerFeature>,
400    path_to_cli: &PathBuf,
401    found_in_path: bool,
402    node_runtime: &NodeRuntime,
403    path: &Arc<Path>,
404    use_podman: bool,
405) -> Result<DevContainerApply, DevContainerError> {
406    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
407        log::error!("Unable to find node runtime path");
408        return Err(DevContainerError::NodeRuntimeNotAvailable);
409    };
410
411    let mut command =
412        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
413
414    let Ok(serialized_options) = serde_json::to_string(template_options) else {
415        log::error!("Unable to serialize options for {:?}", template_options);
416        return Err(DevContainerError::DevContainerParseFailed);
417    };
418
419    command.arg("templates");
420    command.arg("apply");
421    command.arg("--workspace-folder");
422    command.arg(path.display().to_string());
423    command.arg("--template-id");
424    command.arg(format!(
425        "{}/{}",
426        template
427            .source_repository
428            .as_ref()
429            .unwrap_or(&String::from("")),
430        template.id
431    ));
432    command.arg("--template-args");
433    command.arg(serialized_options);
434    command.arg("--features");
435    command.arg(template_features_to_json(features_selected));
436
437    log::debug!("Running full devcontainer apply command: {:?}", command);
438
439    match command.output().await {
440        Ok(output) => {
441            if output.status.success() {
442                let raw = String::from_utf8_lossy(&output.stdout);
443                parse_json_from_cli(&raw)
444            } else {
445                let message = format!(
446                    "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
447                    String::from_utf8_lossy(&output.stdout),
448                    String::from_utf8_lossy(&output.stderr)
449                );
450
451                log::error!("{}", &message);
452                Err(DevContainerError::DevContainerTemplateApplyFailed(message))
453            }
454        }
455        Err(e) => {
456            let message = format!("Error running devcontainer templates apply: {:?}", e);
457            log::error!("{}", &message);
458            Err(DevContainerError::DevContainerTemplateApplyFailed(message))
459        }
460    }
461}
462// Try to parse directly first (newer versions output pure JSON)
463// If that fails, look for JSON start (older versions have plaintext prefix)
464fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, DevContainerError> {
465    serde_json::from_str::<T>(&raw)
466        .or_else(|e| {
467            log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e);
468            let json_start = raw
469                .find(|c| c == '{')
470                .ok_or_else(|| {
471                    log::error!("No JSON found in devcontainer up output");
472                    DevContainerError::DevContainerParseFailed
473                })?;
474
475            serde_json::from_str(&raw[json_start..]).map_err(|e| {
476                log::error!(
477                    "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}",
478                    json_start,
479                    e
480                );
481                DevContainerError::DevContainerParseFailed
482            })
483        })
484}
485
486fn devcontainer_cli_command(
487    path_to_cli: &PathBuf,
488    found_in_path: bool,
489    node_runtime_path: &PathBuf,
490    use_podman: bool,
491) -> Command {
492    let mut command = if found_in_path {
493        util::command::new_smol_command(path_to_cli.display().to_string())
494    } else {
495        let mut command =
496            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
497        command.arg(path_to_cli.display().to_string());
498        command
499    };
500
501    if use_podman {
502        command.arg("--docker-path");
503        command.arg("podman");
504    }
505    command
506}
507
508fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
509    Path::new(remote_workspace_folder)
510        .file_name()
511        .and_then(|name| name.to_str())
512        .map(|string| string.to_string())
513        .unwrap_or_else(|| container_id.to_string())
514}
515
516fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
517    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
518        return None;
519    };
520
521    match workspace.update(cx, |workspace, _, cx| {
522        workspace.project().read(cx).active_project_directory(cx)
523    }) {
524        Ok(dir) => dir,
525        Err(e) => {
526            log::error!("Error getting project directory from workspace: {:?}", e);
527            None
528        }
529    }
530}
531
532fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
533    let features_map = features_selected
534        .iter()
535        .map(|feature| {
536            let mut map = HashMap::new();
537            map.insert(
538                "id",
539                format!(
540                    "{}/{}:{}",
541                    feature
542                        .source_repository
543                        .as_ref()
544                        .unwrap_or(&String::from("")),
545                    feature.id,
546                    feature.major_version()
547                ),
548            );
549            map
550        })
551        .collect::<Vec<HashMap<&str, String>>>();
552    serde_json::to_string(&features_map).unwrap()
553}
554
555#[cfg(test)]
556mod tests {
557    use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
558
559    #[test]
560    fn should_parse_from_devcontainer_json() {
561        let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
562        let up: DevContainerUp = parse_json_from_cli(json).unwrap();
563        assert_eq!(up._outcome, "success");
564        assert_eq!(
565            up.container_id,
566            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
567        );
568        assert_eq!(up.remote_user, "vscode");
569        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
570
571        let json_in_plaintext = r#"[2026-01-22T16:19:08.802Z] @devcontainers/cli 0.80.1. Node.js v22.21.1. darwin 24.6.0 arm64.
572            {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
573        let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap();
574        assert_eq!(up._outcome, "success");
575        assert_eq!(
576            up.container_id,
577            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
578        );
579        assert_eq!(up.remote_user, "vscode");
580        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
581    }
582}