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