Skip to main content

flowey_lib_common/
install_dotnet_cli.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Install or locate the `dotnet` CLI.
5//!
6//! On ADO, uses the `UseDotNet@2` task to install the .NET SDK.
7//! On GitHub, expects `dotnet` to be pre-installed on the runner.
8//! Locally, first checks if `dotnet` is already on PATH. If
9//! `AutoInstall` is enabled and dotnet is not found, downloads and
10//! runs the official `dotnet-install` script to install the SDK to a
11//! persistent directory.
12
13use flowey::node::prelude::*;
14
15flowey_config! {
16    /// Config for the install_dotnet_cli node.
17    pub struct Config {
18        /// Specify the .NET SDK *channel* to install (e.g. "8.0", "9.0").
19        /// This is passed to `dotnet-install` as `--channel`, not as an exact
20        /// SDK version.
21        pub version: Option<String>,
22        /// Automatically install the .NET SDK if not found on PATH.
23        ///
24        /// Must be set to true/false when running locally.
25        pub auto_install: Option<bool>,
26    }
27}
28
29flowey_request! {
30    pub enum Request {
31        /// Get the path to the `dotnet` binary.
32        DotnetBin(WriteVar<PathBuf>),
33    }
34}
35
36new_flow_node_with_config!(struct Node);
37
38impl FlowNodeWithConfig for Node {
39    type Request = Request;
40    type Config = Config;
41
42    fn imports(_ctx: &mut ImportCtx<'_>) {}
43
44    fn emit(
45        config: Config,
46        requests: Vec<Self::Request>,
47        ctx: &mut NodeCtx<'_>,
48    ) -> anyhow::Result<()> {
49        let mut broadcast_dotnet_bin = Vec::new();
50
51        for req in requests {
52            match req {
53                Request::DotnetBin(outvar) => broadcast_dotnet_bin.push(outvar),
54            }
55        }
56
57        if broadcast_dotnet_bin.is_empty() {
58            return Ok(());
59        }
60
61        let version = config
62            .version
63            .ok_or(anyhow::anyhow!("missing config: version"))?;
64        let auto_install = config.auto_install;
65
66        // -- end of req processing -- //
67
68        match ctx.backend() {
69            FlowBackend::Ado => Self::emit_ado(ctx, broadcast_dotnet_bin, version),
70            FlowBackend::Local => {
71                let auto_install = auto_install
72                    .ok_or(anyhow::anyhow!("Missing essential request: AutoInstall"))?;
73                Self::emit_local(ctx, broadcast_dotnet_bin, version, auto_install)
74            }
75            FlowBackend::Github => Self::emit_github(ctx, broadcast_dotnet_bin),
76        }
77    }
78}
79
80impl Node {
81    fn emit_ado(
82        ctx: &mut NodeCtx<'_>,
83        broadcast_dotnet_bin: Vec<WriteVar<PathBuf>>,
84        version: String,
85    ) -> anyhow::Result<()> {
86        // UseDotNet@2 requires version in the format "major.minor.x" (e.g.
87        // "8.0.x"), not just "8.0".
88        let ado_version = if version.matches('.').count() < 2 {
89            format!("{version}.x")
90        } else {
91            version
92        };
93
94        let (dotnet_installed, claim_dotnet_installed) = ctx.new_var::<SideEffect>();
95        ctx.emit_ado_step("Install .NET SDK", move |ctx| {
96            claim_dotnet_installed.claim(ctx);
97            move |_| {
98                format!(
99                    r#"
100                    - task: UseDotNet@2
101                      inputs:
102                        packageType: sdk
103                        version: '{ado_version}'
104                "#
105                )
106            }
107        });
108
109        ctx.emit_rust_step("report dotnet install", move |ctx| {
110            dotnet_installed.claim(ctx);
111            let broadcast_dotnet_bin = broadcast_dotnet_bin.claim(ctx);
112            move |rt| {
113                let dotnet_bin = which::which(rt.platform().binary("dotnet")).map_err(|_| {
114                    anyhow::anyhow!("dotnet not found on PATH after UseDotNet task")
115                })?;
116                rt.write_all(broadcast_dotnet_bin, &dotnet_bin);
117                Ok(())
118            }
119        });
120
121        Ok(())
122    }
123
124    fn emit_local(
125        ctx: &mut NodeCtx<'_>,
126        broadcast_dotnet_bin: Vec<WriteVar<PathBuf>>,
127        version: String,
128        auto_install: bool,
129    ) -> anyhow::Result<()> {
130        if auto_install {
131            let persistent_dir = ctx.persistent_dir();
132
133            ctx.emit_rust_step("install dotnet", |ctx| {
134                let persistent_dir = persistent_dir.clone().claim(ctx);
135                let broadcast_dotnet_bin = broadcast_dotnet_bin.claim(ctx);
136                move |rt| {
137                    if let Some(existing_dotnet) = find_dotnet_on_path(rt) {
138                        log::info!("found existing dotnet at {}", existing_dotnet.display());
139                        rt.write_all(broadcast_dotnet_bin, &existing_dotnet);
140                        return Ok(());
141                    }
142
143                    // Not on PATH — install via the official dotnet-install script
144                    let install_dir = rt
145                        .read(persistent_dir)
146                        .ok_or(anyhow::anyhow!(
147                            "dotnet is not on PATH and no persistent directory is configured. \
148                             Please install the .NET SDK manually: \
149                             https://dotnet.microsoft.com/download"
150                        ))?
151                        .join("dotnet");
152
153                    let dotnet_bin_name = rt.platform().binary("dotnet");
154                    let dotnet_bin_path = install_dir.join(&dotnet_bin_name);
155
156                    if !dotnet_bin_path.exists() {
157                        log::info!(
158                            "dotnet not found on PATH or at {}, installing...",
159                            dotnet_bin_path.display()
160                        );
161
162                        fs_err::create_dir_all(&install_dir)?;
163
164                        match rt.platform() {
165                            FlowPlatform::Windows => {
166                                let install_script_url = "https://dot.net/v1/dotnet-install.ps1";
167                                let install_script_path = install_dir
168                                    .parent()
169                                    .unwrap_or(&install_dir)
170                                    .join("dotnet-install.ps1");
171
172                                flowey::shell_cmd!(
173                                    rt,
174                                    "curl --fail -sSL -o {install_script_path} {install_script_url}"
175                                )
176                                .run()?;
177
178                                flowey::shell_cmd!(
179                                    rt,
180                                    "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {install_script_path}
181                                        -Channel {version}
182                                        -InstallDir {install_dir}
183                                        -NoPath
184                                    "
185                                )
186                                .run()?;
187                            }
188                            FlowPlatform::Linux(_) | FlowPlatform::MacOs => {
189                                let install_script_url = "https://dot.net/v1/dotnet-install.sh";
190                                let install_script_path = install_dir
191                                    .parent()
192                                    .unwrap_or(&install_dir)
193                                    .join("dotnet-install.sh");
194
195                                flowey::shell_cmd!(
196                                    rt,
197                                    "curl --fail -sSL -o {install_script_path} {install_script_url}"
198                                )
199                                .run()?;
200
201                                flowey::shell_cmd!(rt, "chmod +x {install_script_path}").run()?;
202
203                                flowey::shell_cmd!(
204                                    rt,
205                                    "{install_script_path}
206                                        --channel {version}
207                                        --install-dir {install_dir}
208                                        --no-path
209                                    "
210                                )
211                                .run()?;
212                            }
213                            platform => {
214                                anyhow::bail!("unsupported platform for dotnet install: {platform}")
215                            }
216                        }
217
218                        if !dotnet_bin_path.exists() {
219                            anyhow::bail!(
220                                "dotnet installation completed but binary not found at {}",
221                                dotnet_bin_path.display()
222                            );
223                        }
224                    }
225
226                    log::info!("using dotnet at {}", dotnet_bin_path.display());
227                    rt.write_all(broadcast_dotnet_bin, &dotnet_bin_path);
228                    Ok(())
229                }
230            });
231        } else {
232            // auto_install is false — just check PATH
233            ctx.emit_rust_step("ensure dotnet is installed", |ctx| {
234                let broadcast_dotnet_bin = broadcast_dotnet_bin.claim(ctx);
235                move |rt| {
236                    let dotnet_bin = find_dotnet_on_path(rt).ok_or_else(|| {
237                        anyhow::anyhow!(
238                            "dotnet is not installed. Please install the .NET SDK: \
239                             https://dotnet.microsoft.com/download"
240                        )
241                    })?;
242                    rt.write_all(broadcast_dotnet_bin, &dotnet_bin);
243                    Ok(())
244                }
245            });
246        }
247
248        Ok(())
249    }
250
251    fn emit_github(
252        ctx: &mut NodeCtx<'_>,
253        broadcast_dotnet_bin: Vec<WriteVar<PathBuf>>,
254    ) -> anyhow::Result<()> {
255        // On GitHub Actions, dotnet is typically pre-installed.
256        // Just locate it on PATH.
257        ctx.emit_rust_step("resolve dotnet", |ctx| {
258            let broadcast_dotnet_bin = broadcast_dotnet_bin.claim(ctx);
259            move |rt| {
260                let dotnet_bin = which::which(rt.platform().binary("dotnet")).map_err(|_| {
261                    anyhow::anyhow!(
262                        "dotnet not found on PATH. \
263                         Add a `uses: actions/setup-dotnet` step to your workflow."
264                    )
265                })?;
266                rt.write_all(broadcast_dotnet_bin, &dotnet_bin);
267                Ok(())
268            }
269        });
270
271        Ok(())
272    }
273}
274
275/// Find `dotnet` on PATH, filtering out Windows `dotnet.exe` binaries
276/// when running under WSL2 (since they cannot handle Linux paths).
277fn find_dotnet_on_path(rt: &mut RustRuntimeServices<'_>) -> Option<PathBuf> {
278    let path = which::which("dotnet").ok()?;
279    if crate::_util::running_in_wsl(rt) {
280        let is_windows_exe = path
281            .extension()
282            .and_then(|ext| ext.to_str())
283            .map(|ext| ext.eq_ignore_ascii_case("exe"))
284            .unwrap_or(false);
285        if is_windows_exe {
286            log::warn!(
287                "ignoring Windows dotnet.exe at {} on WSL; \
288                 a native Linux dotnet is required",
289                path.display()
290            );
291            return None;
292        }
293    }
294    Some(path)
295}