upstream_rs/services/builder/
scripts.rs1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use crate::{output, output::pager};
5use anyhow::{Context, Result, bail};
6
7use super::profiles::run_command_with_line_callback;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum BuildScriptAction {
11 Install,
12 Upgrade,
13}
14
15impl BuildScriptAction {
16 fn primary_names(&self) -> &'static [&'static str] {
17 match self {
18 BuildScriptAction::Install => &["install.sh", "install.bash", "install.ps1"],
19 BuildScriptAction::Upgrade => &["upgrade.sh", "upgrade.bash", "upgrade.ps1"],
20 }
21 }
22
23 fn label(&self) -> &'static str {
24 match self {
25 BuildScriptAction::Install => "install",
26 BuildScriptAction::Upgrade => "upgrade",
27 }
28 }
29}
30
31fn fallback_names(action: BuildScriptAction) -> &'static [&'static str] {
32 match action {
33 BuildScriptAction::Install => &[],
34 BuildScriptAction::Upgrade => &["install.sh", "install.bash", "install.ps1"],
35 }
36}
37
38fn resolve_script_from_names(workspace_root: &Path, names: &[&str]) -> Option<PathBuf> {
39 for dir in [workspace_root.to_path_buf(), workspace_root.join("scripts")] {
40 for name in names {
41 let path = dir.join(name);
42 if path.is_file() {
43 return Some(path);
44 }
45 }
46 }
47
48 None
49}
50
51pub fn script_for(action: BuildScriptAction, workspace_root: &Path) -> Option<PathBuf> {
52 resolve_script_from_names(workspace_root, action.primary_names())
53 .or_else(|| resolve_script_from_names(workspace_root, fallback_names(action)))
54}
55
56fn is_ps1(path: &Path) -> bool {
57 path.extension()
58 .and_then(|extension| extension.to_str())
59 .is_some_and(|extension| extension.eq_ignore_ascii_case("ps1"))
60}
61
62fn validate_script(path: &Path) -> Result<()> {
63 if is_ps1(path) {
64 return Ok(());
65 }
66
67 let content = std::fs::read(path)
68 .with_context(|| format!("Failed to read build script '{}'", path.display()))?;
69 if content.starts_with(b"#!") {
70 return Ok(());
71 }
72
73 bail!(
74 "Build script '{}' is missing a shebang. Add '#!' so the OS can resolve the interpreter.",
75 path.display()
76 );
77}
78
79fn command_preview(path: &Path) -> String {
80 if is_ps1(path) {
81 return format!("pwsh -File {}", path.display());
82 }
83
84 path.display().to_string()
85}
86
87fn command_for(path: &Path) -> Result<Command> {
88 if is_ps1(path) {
89 let mut command = Command::new("pwsh");
90 command.arg("-File").arg(path);
91 return Ok(command);
92 }
93
94 Ok(Command::new(path))
95}
96
97fn review_script(path: &Path) -> Result<()> {
98 let content = std::fs::read_to_string(path)
99 .with_context(|| format!("Failed to read build script '{}'", path.display()))?;
100 let mut preview = String::new();
101 for line in content.lines() {
102 preview.push_str(" ");
103 preview.push_str(line);
104 preview.push('\n');
105 }
106 preview.push('\n');
107 preview.push_str(&format!(" Command: {}\n", command_preview(path)));
108
109 pager::page_text(
110 Some(&format!("Reviewing script: {}", path.display())),
111 &preview,
112 )?;
113 Ok(())
114}
115
116pub fn run_build_script(
117 action: BuildScriptAction,
118 workspace_root: &Path,
119 line_callback: Option<&mut dyn FnMut(&str)>,
120) -> Result<()> {
121 let Some(path) = script_for(action, workspace_root) else {
122 return Ok(());
123 };
124
125 validate_script(&path)?;
126 review_script(&path)?;
127 output::confirm_or_cancel(
128 format!(
129 "Run {} script '{}' from '{}' ?",
130 action.label(),
131 path.file_name()
132 .and_then(|value| value.to_str())
133 .unwrap_or("script"),
134 path.parent()
135 .and_then(|value| value.file_name())
136 .and_then(|value| value.to_str())
137 .unwrap_or("scripts"),
138 ),
139 true,
140 )?;
141
142 let mut status_callback = line_callback;
143
144 let mut command = command_for(&path)?;
145 command.current_dir(workspace_root);
146
147 let context = format!(
148 "Failed to run build script '{}'. Check the script shebang, executable bit, and interpreter availability.",
149 path.display()
150 );
151 let status =
152 run_command_with_line_callback(&mut command, context.as_str(), &mut status_callback)
153 .with_context(|| format!("Build script execution failed: '{}'", path.display()))?;
154
155 if !status.success() {
156 bail!(
157 "Script '{}' exited with non-zero status ({})",
158 path.display(),
159 status.code().unwrap_or(-1)
160 );
161 }
162
163 Ok(())
164}
165
166#[cfg(test)]
167mod tests {
168 use super::{
169 BuildScriptAction, command_for, command_preview, is_ps1, script_for, validate_script,
170 };
171 use std::time::{SystemTime, UNIX_EPOCH};
172 use std::{fs, path::PathBuf};
173
174 fn temp_root(name: &str) -> PathBuf {
175 let nanos = SystemTime::now()
176 .duration_since(UNIX_EPOCH)
177 .map(|d| d.as_nanos())
178 .unwrap_or(0);
179 std::env::temp_dir().join(format!("upstream-builder-script-test-{name}-{nanos}"))
180 }
181
182 #[test]
183 fn install_prefers_root_bash_over_scripts_sh() {
184 let root = temp_root("install-prefers-sh");
185 fs::create_dir_all(root.join("scripts")).expect("create scripts dir");
186 fs::write(
187 root.join("install.bash"),
188 "#!/usr/bin/env bash\necho bash\n",
189 )
190 .expect("write root install.bash");
191 fs::write(
192 root.join("scripts").join("install.sh"),
193 "#!/bin/sh\necho sh\n",
194 )
195 .expect("write scripts install.sh");
196 let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
197 assert_eq!(path, root.join("install.bash"));
198 let _ = fs::remove_dir_all(&root);
199 }
200
201 #[test]
202 fn upgrade_prefers_upgrade_script_over_install() {
203 let root = temp_root("upgrade-priority");
204 fs::create_dir_all(&root).expect("create root");
205 fs::write(root.join("install.sh"), "#!/bin/sh\necho install\n").expect("write install");
206 fs::write(root.join("upgrade.bash"), "#!/bin/bash\necho upgrade\n").expect("write upgrade");
207 let path = script_for(BuildScriptAction::Upgrade, &root).expect("detect script");
208 assert_eq!(path, root.join("upgrade.bash"));
209 let _ = fs::remove_dir_all(&root);
210 }
211
212 #[test]
213 fn supports_scripts_directory_fallback() {
214 let root = temp_root("scripts-fallback");
215 fs::create_dir_all(root.join("scripts")).expect("create scripts dir");
216 fs::write(
217 root.join("scripts").join("install.bash"),
218 "#!/bin/bash\necho scripts\n",
219 )
220 .expect("write scripts install.bash");
221 let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
222 assert_eq!(path, root.join("scripts").join("install.bash"));
223 let _ = fs::remove_dir_all(&root);
224 }
225
226 #[test]
227 fn detects_ps1_candidates() {
228 let root = temp_root("ps1-candidate");
229 fs::create_dir_all(&root).expect("create root");
230 fs::write(root.join("install.ps1"), "Write-Output install\n").expect("write ps1");
231 let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
232 assert_eq!(path, root.join("install.ps1"));
233 assert!(is_ps1(&path));
234 let _ = fs::remove_dir_all(&root);
235 }
236
237 #[test]
238 fn ps1_script_uses_pwsh_on_all_platforms() {
239 let root = temp_root("ps1-command");
240 fs::create_dir_all(&root).expect("create root");
241 let script = root.join("install.ps1");
242 fs::write(&script, "Write-Output install\n").expect("write ps1");
243
244 validate_script(&script).expect("ps1 script is valid");
245 assert_eq!(
246 command_preview(&script),
247 format!("pwsh -File {}", script.display())
248 );
249
250 let command = command_for(&script).expect("build command");
251 assert_eq!(command.get_program().to_string_lossy(), "pwsh");
252 assert_eq!(
253 command
254 .get_args()
255 .map(|arg| arg.to_string_lossy().to_string())
256 .collect::<Vec<_>>(),
257 vec!["-File".to_string(), script.to_string_lossy().to_string()]
258 );
259
260 let _ = fs::remove_dir_all(&root);
261 }
262
263 #[test]
264 fn non_ps1_script_requires_shebang() {
265 let root = temp_root("requires-shebang");
266 fs::create_dir_all(&root).expect("create root");
267 let script = root.join("install.sh");
268 fs::write(&script, "echo no shebang\n").expect("write script");
269 let err = validate_script(&script).expect_err("must reject missing shebang");
270 assert!(err.to_string().contains("missing a shebang"));
271 let _ = fs::remove_dir_all(&root);
272 }
273
274 #[test]
275 fn non_ps1_script_with_shebang_is_valid() {
276 let root = temp_root("with-shebang");
277 fs::create_dir_all(&root).expect("create root");
278 let script = root.join("install.sh");
279 fs::write(&script, "#!/bin/sh\necho ok\n").expect("write script");
280 validate_script(&script).expect("valid shebang script");
281 let _ = fs::remove_dir_all(&root);
282 }
283}