1use std::path::{Path, PathBuf};
38
39pub const BINARIES: &[&str] = &["choir", "choir-node", "choir-mcp", "choir-ssh"];
41
42#[derive(Debug, Clone, Eq, PartialEq)]
44pub enum Source {
45 Shelf(String),
47 Checkout(PathBuf),
49}
50
51#[derive(Debug, Clone, Eq, PartialEq)]
53pub struct Options {
54 pub source: Source,
56 pub into: Option<PathBuf>,
59 pub state: PathBuf,
61 pub dry_run: bool,
63}
64
65pub fn parse(rest: &[&str], default_state: PathBuf) -> Result<Options, String> {
72 let mut from: Option<String> = None;
73 let mut source: Option<PathBuf> = None;
74 let mut into: Option<PathBuf> = None;
75 let mut state: Option<PathBuf> = None;
76 let mut dry_run = false;
77 let mut i = 0;
78 while i < rest.len() {
79 let arg = rest[i];
80 let value = || -> Result<String, String> {
81 rest.get(i + 1)
82 .map(|v| (*v).to_string())
83 .ok_or_else(|| format!("{arg} needs a value"))
84 };
85 match arg {
86 "--dry-run" => {
87 dry_run = true;
88 i += 1;
89 continue;
90 }
91 "--from" => from = Some(value()?),
92 "--source" => source = Some(PathBuf::from(value()?)),
93 "--into" => into = Some(PathBuf::from(value()?)),
94 "--state" => state = Some(PathBuf::from(value()?)),
95 other => return Err(format!("unknown option {other:?}")),
96 }
97 i += 2;
98 }
99 let source = match (from, source) {
100 (Some(_), Some(_)) => {
101 return Err(
102 "--from and --source are two answers to one question. Give one.".to_string(),
103 )
104 }
105 (Some(url), None) => {
106 if !(url.starts_with("http://") || url.starts_with("https://")) {
107 return Err(format!(
108 "--from is a node URL, http:// or https://, not {url:?}"
109 ));
110 }
111 Source::Shelf(url.trim_end_matches('/').to_string())
112 }
113 (None, Some(dir)) => Source::Checkout(absolute(dir)),
114 (None, None) => {
115 return Err("where do the newer binaries come from?\n\n \
116 choir node upgrade --from https://<node> its shelf\n \
117 choir node upgrade --source <checkout> built there"
118 .to_string())
119 }
120 };
121 Ok(Options {
122 source,
123 into: into.map(absolute),
124 state: absolute(state.unwrap_or(default_state)),
125 dry_run,
126 })
127}
128
129fn absolute(path: PathBuf) -> PathBuf {
130 std::path::absolute(&path).unwrap_or(path)
131}
132
133#[must_use]
136pub fn stamp_of(version_line: &str) -> Option<String> {
137 let word = version_line
138 .split_whitespace()
139 .skip_while(|w| *w != "build")
140 .nth(1)?;
141 let word = word.trim_end_matches("+dirty");
142 (word.len() >= 7 && word.chars().all(|c| c.is_ascii_hexdigit())).then(|| word.to_string())
143}
144
145#[must_use]
147pub fn version_of(choir: &Path) -> Option<(String, Option<String>)> {
148 let out = std::process::Command::new(choir)
149 .arg("--version")
150 .output()
151 .ok()?;
152 if !out.status.success() {
153 return None;
154 }
155 let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
156 let stamp = stamp_of(&line);
157 Some((line, stamp))
158}
159
160pub fn cargo_home_for(into: &Path) -> Result<PathBuf, String> {
169 match (into.file_name(), into.parent()) {
170 (Some(name), Some(parent)) if name == "bin" => Ok(parent.to_path_buf()),
171 _ => Err(format!(
172 "the installer places binaries in a directory called `bin`, and this \
173 choir lives in {}\n\n \
174 build from a checkout instead: choir node upgrade --source <dir>\n \
175 or name a bin directory: choir node upgrade --from <node> --into <dir>/bin",
176 into.display()
177 )),
178 }
179}
180
181pub fn place(built: &Path, into: &Path) -> Result<Vec<String>, String> {
191 std::fs::create_dir_all(into).map_err(|e| format!("create {}: {e}", into.display()))?;
192 let mut placed = Vec::new();
193 for name in BINARIES {
194 let from = built.join(name);
195 if !from.is_file() {
196 continue;
197 }
198 let staged = into.join(format!(".{name}.new"));
199 std::fs::copy(&from, &staged)
200 .map_err(|e| format!("copy {} to {}: {e}", from.display(), staged.display()))?;
201 std::fs::rename(&staged, into.join(name))
202 .map_err(|e| format!("place {}: {e}", into.join(name).display()))?;
203 placed.push((*name).to_string());
204 }
205 if placed.is_empty() {
206 return Err(format!(
207 "nothing to place: none of {} is in {}",
208 BINARIES.join(", "),
209 built.display()
210 ));
211 }
212 Ok(placed)
213}
214
215pub fn build(checkout: &Path) -> Result<(PathBuf, String), String> {
227 let head = std::process::Command::new("git")
228 .args(["-C", &checkout.display().to_string(), "rev-parse", "HEAD"])
229 .output()
230 .map_err(|e| format!("could not run git: {e}"))?;
231 if !head.status.success() {
232 return Err(format!(
233 "{} is not a git checkout: {}",
234 checkout.display(),
235 String::from_utf8_lossy(&head.stderr).trim()
236 ));
237 }
238 let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
239 let status = std::process::Command::new("cargo")
240 .args(["build", "--release", "-p", "choir-node", "-p", "choir-cli"])
241 .current_dir(checkout)
242 .env("CHOIR_GIT_HEAD", &head)
243 .status()
244 .map_err(|e| format!("could not run cargo: {e}"))?;
245 if !status.success() {
246 return Err("cargo build failed; the old binaries are untouched".to_string());
247 }
248 Ok((checkout.join("target").join("release"), head))
249}
250
251pub fn install_from_shelf(node: &str, cargo_home: &Path) -> Result<String, String> {
260 let script = std::env::temp_dir().join(format!("choir-install-{}.sh", std::process::id()));
261 let fetched = std::process::Command::new("curl")
262 .args([
263 "-fsSL",
264 &format!("{node}/download/install.sh"),
265 "-o",
266 &script.display().to_string(),
267 ])
268 .output()
269 .map_err(|e| format!("could not run curl: {e}"))?;
270 if !fetched.status.success() {
271 return Err(format!(
272 "could not fetch {node}/download/install.sh: {}",
273 String::from_utf8_lossy(&fetched.stderr).trim()
274 ));
275 }
276 let out = std::process::Command::new("sh")
277 .arg(&script)
278 .args(["choir-cli", "choir-node"])
279 .env("CARGO_HOME", cargo_home)
280 .output()
281 .map_err(|e| format!("could not run sh: {e}"))?;
282 std::fs::remove_file(&script).ok();
283 let text = format!(
284 "{}{}",
285 String::from_utf8_lossy(&out.stdout),
286 String::from_utf8_lossy(&out.stderr)
287 );
288 if !out.status.success() {
289 return Err(format!("the installer refused:\n{}", text.trim()));
290 }
291 Ok(text.trim().to_string())
292}