1use std::{
  2    path::{Path, PathBuf},
  3    sync::Arc,
  4};
  5
  6use anyhow::{Context as _, Result};
  7use askpass::EncryptedPassword;
  8use auto_update::AutoUpdater;
  9use editor::Editor;
 10use extension_host::ExtensionStore;
 11use futures::channel::oneshot;
 12use gpui::{
 13    AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
 14    ParentElement as _, PromptLevel, Render, SemanticVersion, SharedString, Task,
 15    TextStyleRefinement, WeakEntity,
 16};
 17
 18use language::{CursorShape, Point};
 19use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 20use release_channel::ReleaseChannel;
 21use remote::{
 22    ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
 23    SshConnectionOptions,
 24};
 25pub use settings::SshConnection;
 26use settings::{ExtendingVec, Settings, WslConnection};
 27use theme::ThemeSettings;
 28use ui::{
 29    ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
 30    IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
 31};
 32use util::paths::PathWithPosition;
 33use workspace::{AppState, ModalView, Workspace};
 34
 35pub struct SshSettings {
 36    pub ssh_connections: ExtendingVec<SshConnection>,
 37    pub wsl_connections: ExtendingVec<WslConnection>,
 38    /// Whether to read ~/.ssh/config for ssh connection sources.
 39    pub read_ssh_config: bool,
 40}
 41
 42impl SshSettings {
 43    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
 44        self.ssh_connections.clone().0.into_iter()
 45    }
 46
 47    pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
 48        self.wsl_connections.clone().0.into_iter()
 49    }
 50
 51    pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
 52        for conn in self.ssh_connections() {
 53            if conn.host == options.host
 54                && conn.username == options.username
 55                && conn.port == options.port
 56            {
 57                options.nickname = conn.nickname;
 58                options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
 59                options.args = Some(conn.args);
 60                options.port_forwards = conn.port_forwards;
 61                break;
 62            }
 63        }
 64    }
 65
 66    pub fn connection_options_for(
 67        &self,
 68        host: String,
 69        port: Option<u16>,
 70        username: Option<String>,
 71    ) -> SshConnectionOptions {
 72        let mut options = SshConnectionOptions {
 73            host,
 74            port,
 75            username,
 76            ..Default::default()
 77        };
 78        self.fill_connection_options_from_settings(&mut options);
 79        options
 80    }
 81}
 82
 83#[derive(Clone, PartialEq)]
 84pub enum Connection {
 85    Ssh(SshConnection),
 86    Wsl(WslConnection),
 87}
 88
 89impl From<Connection> for RemoteConnectionOptions {
 90    fn from(val: Connection) -> Self {
 91        match val {
 92            Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
 93            Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
 94        }
 95    }
 96}
 97
 98impl From<SshConnection> for Connection {
 99    fn from(val: SshConnection) -> Self {
100        Connection::Ssh(val)
101    }
102}
103
104impl From<WslConnection> for Connection {
105    fn from(val: WslConnection) -> Self {
106        Connection::Wsl(val)
107    }
108}
109
110impl Settings for SshSettings {
111    fn from_settings(content: &settings::SettingsContent) -> Self {
112        let remote = &content.remote;
113        Self {
114            ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
115            wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
116            read_ssh_config: remote.read_ssh_config.unwrap(),
117        }
118    }
119}
120
121pub struct RemoteConnectionPrompt {
122    connection_string: SharedString,
123    nickname: Option<SharedString>,
124    is_wsl: bool,
125    status_message: Option<SharedString>,
126    prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
127    cancellation: Option<oneshot::Sender<()>>,
128    editor: Entity<Editor>,
129}
130
131impl Drop for RemoteConnectionPrompt {
132    fn drop(&mut self) {
133        if let Some(cancel) = self.cancellation.take() {
134            cancel.send(()).ok();
135        }
136    }
137}
138
139pub struct RemoteConnectionModal {
140    pub(crate) prompt: Entity<RemoteConnectionPrompt>,
141    paths: Vec<PathBuf>,
142    finished: bool,
143}
144
145impl RemoteConnectionPrompt {
146    pub(crate) fn new(
147        connection_string: String,
148        nickname: Option<String>,
149        is_wsl: bool,
150        window: &mut Window,
151        cx: &mut Context<Self>,
152    ) -> Self {
153        Self {
154            connection_string: connection_string.into(),
155            nickname: nickname.map(|nickname| nickname.into()),
156            is_wsl,
157            editor: cx.new(|cx| Editor::single_line(window, cx)),
158            status_message: None,
159            cancellation: None,
160            prompt: None,
161        }
162    }
163
164    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
165        self.cancellation = Some(tx);
166    }
167
168    fn set_prompt(
169        &mut self,
170        prompt: String,
171        tx: oneshot::Sender<EncryptedPassword>,
172        window: &mut Window,
173        cx: &mut Context<Self>,
174    ) {
175        let theme = ThemeSettings::get_global(cx);
176
177        let refinement = TextStyleRefinement {
178            font_family: Some(theme.buffer_font.family.clone()),
179            font_features: Some(FontFeatures::disable_ligatures()),
180            font_size: Some(theme.buffer_font_size(cx).into()),
181            color: Some(cx.theme().colors().editor_foreground),
182            background_color: Some(gpui::transparent_black()),
183            ..Default::default()
184        };
185
186        self.editor.update(cx, |editor, cx| {
187            if prompt.contains("yes/no") {
188                editor.set_masked(false, cx);
189            } else {
190                editor.set_masked(true, cx);
191            }
192            editor.set_text_style_refinement(refinement);
193            editor.set_cursor_shape(CursorShape::Block, cx);
194        });
195
196        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
197        self.prompt = Some((markdown, tx));
198        self.status_message.take();
199        window.focus(&self.editor.focus_handle(cx));
200        cx.notify();
201    }
202
203    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
204        self.status_message = status.map(|s| s.into());
205        cx.notify();
206    }
207
208    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
209        if let Some((_, tx)) = self.prompt.take() {
210            self.status_message = Some("Connecting".into());
211
212            self.editor.update(cx, |editor, cx| {
213                let pw = editor.text(cx);
214                if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
215                    tx.send(secure).ok();
216                }
217                editor.clear(window, cx);
218            });
219        }
220    }
221}
222
223impl Render for RemoteConnectionPrompt {
224    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
225        let theme = ThemeSettings::get_global(cx);
226
227        let mut text_style = window.text_style();
228        let refinement = TextStyleRefinement {
229            font_family: Some(theme.buffer_font.family.clone()),
230            font_features: Some(FontFeatures::disable_ligatures()),
231            font_size: Some(theme.buffer_font_size(cx).into()),
232            color: Some(cx.theme().colors().editor_foreground),
233            background_color: Some(gpui::transparent_black()),
234            ..Default::default()
235        };
236
237        text_style.refine(&refinement);
238        let markdown_style = MarkdownStyle {
239            base_text_style: text_style,
240            selection_background_color: cx.theme().colors().element_selection_background,
241            ..Default::default()
242        };
243
244        v_flex()
245            .key_context("PasswordPrompt")
246            .py_2()
247            .px_3()
248            .size_full()
249            .text_buffer(cx)
250            .when_some(self.status_message.clone(), |el, status_message| {
251                el.child(
252                    h_flex()
253                        .gap_1()
254                        .child(
255                            Icon::new(IconName::ArrowCircle)
256                                .size(IconSize::Medium)
257                                .with_rotate_animation(2),
258                        )
259                        .child(
260                            div()
261                                .text_ellipsis()
262                                .overflow_x_hidden()
263                                .child(format!("{}…", status_message)),
264                        ),
265                )
266            })
267            .when_some(self.prompt.as_ref(), |el, prompt| {
268                el.child(
269                    div()
270                        .size_full()
271                        .overflow_hidden()
272                        .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
273                        .child(self.editor.clone()),
274                )
275                .when(window.capslock().on, |el| {
276                    el.child(Label::new("⚠️ ⇪ is on"))
277                })
278            })
279    }
280}
281
282impl RemoteConnectionModal {
283    pub(crate) fn new(
284        connection_options: &RemoteConnectionOptions,
285        paths: Vec<PathBuf>,
286        window: &mut Window,
287        cx: &mut Context<Self>,
288    ) -> Self {
289        let (connection_string, nickname, is_wsl) = match connection_options {
290            RemoteConnectionOptions::Ssh(options) => {
291                (options.connection_string(), options.nickname.clone(), false)
292            }
293            RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
294        };
295        Self {
296            prompt: cx.new(|cx| {
297                RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
298            }),
299            finished: false,
300            paths,
301        }
302    }
303
304    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
305        self.prompt
306            .update(cx, |prompt, cx| prompt.confirm(window, cx))
307    }
308
309    pub fn finished(&mut self, cx: &mut Context<Self>) {
310        self.finished = true;
311        cx.emit(DismissEvent);
312    }
313
314    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
315        if let Some(tx) = self
316            .prompt
317            .update(cx, |prompt, _cx| prompt.cancellation.take())
318        {
319            tx.send(()).ok();
320        }
321        self.finished(cx);
322    }
323}
324
325pub(crate) struct SshConnectionHeader {
326    pub(crate) connection_string: SharedString,
327    pub(crate) paths: Vec<PathBuf>,
328    pub(crate) nickname: Option<SharedString>,
329    pub(crate) is_wsl: bool,
330}
331
332impl RenderOnce for SshConnectionHeader {
333    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
334        let theme = cx.theme();
335
336        let mut header_color = theme.colors().text;
337        header_color.fade_out(0.96);
338
339        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
340            (nickname, Some(format!("({})", self.connection_string)))
341        } else {
342            (self.connection_string, None)
343        };
344
345        let icon = match self.is_wsl {
346            true => IconName::Linux,
347            false => IconName::Server,
348        };
349
350        h_flex()
351            .px(DynamicSpacing::Base12.rems(cx))
352            .pt(DynamicSpacing::Base08.rems(cx))
353            .pb(DynamicSpacing::Base04.rems(cx))
354            .rounded_t_sm()
355            .w_full()
356            .gap_1p5()
357            .child(Icon::new(icon).size(IconSize::Small))
358            .child(
359                h_flex()
360                    .gap_1()
361                    .overflow_x_hidden()
362                    .child(
363                        div()
364                            .max_w_96()
365                            .overflow_x_hidden()
366                            .text_ellipsis()
367                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
368                    )
369                    .children(
370                        meta_label.map(|label| {
371                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
372                        }),
373                    )
374                    .child(div().overflow_x_hidden().text_ellipsis().children(
375                        self.paths.into_iter().map(|path| {
376                            Label::new(path.to_string_lossy().into_owned())
377                                .size(LabelSize::Small)
378                                .color(Color::Muted)
379                        }),
380                    )),
381            )
382    }
383}
384
385impl Render for RemoteConnectionModal {
386    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
387        let nickname = self.prompt.read(cx).nickname.clone();
388        let connection_string = self.prompt.read(cx).connection_string.clone();
389        let is_wsl = self.prompt.read(cx).is_wsl;
390
391        let theme = cx.theme().clone();
392        let body_color = theme.colors().editor_background;
393
394        v_flex()
395            .elevation_3(cx)
396            .w(rems(34.))
397            .border_1()
398            .border_color(theme.colors().border)
399            .key_context("SshConnectionModal")
400            .track_focus(&self.focus_handle(cx))
401            .on_action(cx.listener(Self::dismiss))
402            .on_action(cx.listener(Self::confirm))
403            .child(
404                SshConnectionHeader {
405                    paths: self.paths.clone(),
406                    connection_string,
407                    nickname,
408                    is_wsl,
409                }
410                .render(window, cx),
411            )
412            .child(
413                div()
414                    .w_full()
415                    .rounded_b_lg()
416                    .bg(body_color)
417                    .border_t_1()
418                    .border_color(theme.colors().border_variant)
419                    .child(self.prompt.clone()),
420            )
421    }
422}
423
424impl Focusable for RemoteConnectionModal {
425    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
426        self.prompt.read(cx).editor.focus_handle(cx)
427    }
428}
429
430impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
431
432impl ModalView for RemoteConnectionModal {
433    fn on_before_dismiss(
434        &mut self,
435        _window: &mut Window,
436        _: &mut Context<Self>,
437    ) -> workspace::DismissDecision {
438        workspace::DismissDecision::Dismiss(self.finished)
439    }
440
441    fn fade_out_background(&self) -> bool {
442        true
443    }
444}
445
446#[derive(Clone)]
447pub struct RemoteClientDelegate {
448    window: AnyWindowHandle,
449    ui: WeakEntity<RemoteConnectionPrompt>,
450    known_password: Option<EncryptedPassword>,
451}
452
453impl remote::RemoteClientDelegate for RemoteClientDelegate {
454    fn ask_password(
455        &self,
456        prompt: String,
457        tx: oneshot::Sender<EncryptedPassword>,
458        cx: &mut AsyncApp,
459    ) {
460        let mut known_password = self.known_password.clone();
461        if let Some(password) = known_password.take() {
462            tx.send(password).ok();
463        } else {
464            self.window
465                .update(cx, |_, window, cx| {
466                    self.ui.update(cx, |modal, cx| {
467                        modal.set_prompt(prompt, tx, window, cx);
468                    })
469                })
470                .ok();
471        }
472    }
473
474    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
475        self.update_status(status, cx)
476    }
477
478    fn download_server_binary_locally(
479        &self,
480        platform: RemotePlatform,
481        release_channel: ReleaseChannel,
482        version: Option<SemanticVersion>,
483        cx: &mut AsyncApp,
484    ) -> Task<anyhow::Result<PathBuf>> {
485        cx.spawn(async move |cx| {
486            AutoUpdater::download_remote_server_release(
487                platform.os,
488                platform.arch,
489                release_channel,
490                version,
491                cx,
492            )
493            .await
494            .with_context(|| {
495                format!(
496                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
497                    version
498                        .map(|v| format!("{}", v))
499                        .unwrap_or("unknown".to_string()),
500                    platform.os,
501                    platform.arch,
502                )
503            })
504        })
505    }
506
507    fn get_download_params(
508        &self,
509        platform: RemotePlatform,
510        release_channel: ReleaseChannel,
511        version: Option<SemanticVersion>,
512        cx: &mut AsyncApp,
513    ) -> Task<Result<Option<(String, String)>>> {
514        cx.spawn(async move |cx| {
515            AutoUpdater::get_remote_server_release_url(
516                platform.os,
517                platform.arch,
518                release_channel,
519                version,
520                cx,
521            )
522            .await
523        })
524    }
525}
526
527impl RemoteClientDelegate {
528    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
529        self.window
530            .update(cx, |_, _, cx| {
531                self.ui.update(cx, |modal, cx| {
532                    modal.set_status(status.map(|s| s.to_string()), cx);
533                })
534            })
535            .ok();
536    }
537}
538
539pub fn connect(
540    unique_identifier: ConnectionIdentifier,
541    connection_options: RemoteConnectionOptions,
542    ui: Entity<RemoteConnectionPrompt>,
543    window: &mut Window,
544    cx: &mut App,
545) -> Task<Result<Option<Entity<RemoteClient>>>> {
546    let window = window.window_handle();
547    let known_password = match &connection_options {
548        RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
549            .password
550            .as_deref()
551            .and_then(|pw| pw.try_into().ok()),
552        _ => None,
553    };
554    let (tx, rx) = oneshot::channel();
555    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
556
557    let delegate = Arc::new(RemoteClientDelegate {
558        window,
559        ui: ui.downgrade(),
560        known_password,
561    });
562
563    cx.spawn(async move |cx| {
564        let connection = remote::connect(connection_options, delegate.clone(), cx).await?;
565        cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))?
566            .await
567    })
568}
569
570pub async fn open_remote_project(
571    connection_options: RemoteConnectionOptions,
572    paths: Vec<PathBuf>,
573    app_state: Arc<AppState>,
574    open_options: workspace::OpenOptions,
575    cx: &mut AsyncApp,
576) -> Result<()> {
577    let window = if let Some(window) = open_options.replace_window {
578        window
579    } else {
580        let workspace_position = cx
581            .update(|cx| {
582                // todo: These paths are wrong they may have column and line information
583                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
584            })?
585            .await
586            .context("fetching ssh workspace position from db")?;
587
588        let mut options =
589            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
590        options.window_bounds = workspace_position.window_bounds;
591
592        cx.open_window(options, |window, cx| {
593            let project = project::Project::local(
594                app_state.client.clone(),
595                app_state.node_runtime.clone(),
596                app_state.user_store.clone(),
597                app_state.languages.clone(),
598                app_state.fs.clone(),
599                None,
600                cx,
601            );
602            cx.new(|cx| {
603                let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
604                workspace.centered_layout = workspace_position.centered_layout;
605                workspace
606            })
607        })?
608    };
609
610    loop {
611        let (cancel_tx, cancel_rx) = oneshot::channel();
612        let delegate = window.update(cx, {
613            let paths = paths.clone();
614            let connection_options = connection_options.clone();
615            move |workspace, window, cx| {
616                window.activate_window();
617                workspace.toggle_modal(window, cx, |window, cx| {
618                    RemoteConnectionModal::new(&connection_options, paths, window, cx)
619                });
620
621                let ui = workspace
622                    .active_modal::<RemoteConnectionModal>(cx)?
623                    .read(cx)
624                    .prompt
625                    .clone();
626
627                ui.update(cx, |ui, _cx| {
628                    ui.set_cancellation_tx(cancel_tx);
629                });
630
631                Some(Arc::new(RemoteClientDelegate {
632                    window: window.window_handle(),
633                    ui: ui.downgrade(),
634                    known_password: if let RemoteConnectionOptions::Ssh(options) =
635                        &connection_options
636                    {
637                        options
638                            .password
639                            .as_deref()
640                            .and_then(|pw| EncryptedPassword::try_from(pw).ok())
641                    } else {
642                        None
643                    },
644                }))
645            }
646        })?;
647
648        let Some(delegate) = delegate else { break };
649
650        let remote_connection =
651            remote::connect(connection_options.clone(), delegate.clone(), cx).await?;
652        let (paths, paths_with_positions) =
653            determine_paths_with_positions(&remote_connection, paths.clone()).await;
654
655        let opened_items = cx
656            .update(|cx| {
657                workspace::open_remote_project_with_new_connection(
658                    window,
659                    remote_connection,
660                    cancel_rx,
661                    delegate.clone(),
662                    app_state.clone(),
663                    paths.clone(),
664                    cx,
665                )
666            })?
667            .await;
668
669        window
670            .update(cx, |workspace, _, cx| {
671                if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
672                    ui.update(cx, |modal, cx| modal.finished(cx))
673                }
674            })
675            .ok();
676
677        match opened_items {
678            Err(e) => {
679                log::error!("Failed to open project: {e:?}");
680                let response = window
681                    .update(cx, |_, window, cx| {
682                        window.prompt(
683                            PromptLevel::Critical,
684                            match connection_options {
685                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
686                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
687                            },
688                            Some(&e.to_string()),
689                            &["Retry", "Ok"],
690                            cx,
691                        )
692                    })?
693                    .await;
694                if response == Ok(0) {
695                    continue;
696                }
697            }
698            Ok(items) => {
699                for (item, path) in items.into_iter().zip(paths_with_positions) {
700                    let Some(item) = item else {
701                        continue;
702                    };
703                    let Some(row) = path.row else {
704                        continue;
705                    };
706                    if let Some(active_editor) = item.downcast::<Editor>() {
707                        window
708                            .update(cx, |_, window, cx| {
709                                active_editor.update(cx, |editor, cx| {
710                                    let row = row.saturating_sub(1);
711                                    let col = path.column.unwrap_or(0).saturating_sub(1);
712                                    editor.go_to_singleton_buffer_point(
713                                        Point::new(row, col),
714                                        window,
715                                        cx,
716                                    );
717                                });
718                            })
719                            .ok();
720                    }
721                }
722            }
723        }
724
725        window
726            .update(cx, |workspace, _, cx| {
727                if let Some(client) = workspace.project().read(cx).remote_client() {
728                    ExtensionStore::global(cx)
729                        .update(cx, |store, cx| store.register_remote_client(client, cx));
730                }
731            })
732            .ok();
733
734        break;
735    }
736
737    // Already showed the error to the user
738    Ok(())
739}
740
741pub(crate) async fn determine_paths_with_positions(
742    remote_connection: &Arc<dyn RemoteConnection>,
743    mut paths: Vec<PathBuf>,
744) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
745    let mut paths_with_positions = Vec::<PathWithPosition>::new();
746    for path in &mut paths {
747        if let Some(path_str) = path.to_str() {
748            let path_with_position = PathWithPosition::parse_str(&path_str);
749            if path_with_position.row.is_some() {
750                if !path_exists(&remote_connection, &path).await {
751                    *path = path_with_position.path.clone();
752                    paths_with_positions.push(path_with_position);
753                    continue;
754                }
755            }
756        }
757        paths_with_positions.push(PathWithPosition::from_path(path.clone()))
758    }
759    (paths, paths_with_positions)
760}
761
762async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
763    let Ok(command) = connection.build_command(
764        Some("test".to_string()),
765        &["-e".to_owned(), path.to_string_lossy().to_string()],
766        &Default::default(),
767        None,
768        None,
769    ) else {
770        return false;
771    };
772    let Ok(mut child) = util::command::new_smol_command(command.program)
773        .args(command.args)
774        .envs(command.env)
775        .spawn()
776    else {
777        return false;
778    };
779    child.status().await.is_ok_and(|status| status.success())
780}