#[cfg(unix)]
use std::os::unix::prelude::PermissionsExt;
use std::{
env::{args, current_dir},
fs::{File, create_dir_all, metadata, read_to_string, rename, set_permissions},
io::{Write, copy},
path::PathBuf,
process::{Command as OsCommand, Stdio, exit},
};
use anyhow::{Context, Result};
use clap::ArgMatches;
use reqwest::blocking::Client;
use termcolor::{ColorChoice, StandardStream, WriteColor};
use super::base_path::{find_nearest_manifest_from, find_nearest_manifest_root_unbounded};
use crate::prompt::{ArrayCompleter, prompt_for_confirmation};
#[derive(Debug)]
pub(crate) enum VersionCheckOutcome {
SkipNoManifest,
SkipWhitelisted,
Ok,
ReexecNotSupported,
}
fn current_cli_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn parse_required_cli_version(manifest_root: &PathBuf) -> Option<String> {
let manifest_path = manifest_root.join(".forklaunch").join("manifest.toml");
let content = read_to_string(&manifest_path).ok()?;
let value: toml::Value = toml::from_str(&content).ok()?;
value
.get("cli_version")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn platform_triple() -> Result<&'static str> {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
return Ok("darwin-aarch64");
}
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
{
return Ok("darwin-x86_64");
}
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
{
return Ok("linux-aarch64");
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
return Ok("linux-x86_64");
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
return Ok("windows-x86_64");
}
#[allow(unreachable_code)]
Err(anyhow::anyhow!("Unsupported platform"))
}
fn pinned_binary_path(required_version: &str) -> Result<PathBuf> {
let dir = dirs::home_dir()
.map(|p| p.join(".forklaunch").join("bin"))
.context("Failed to resolve install directory")?;
let name = if platform_triple()?.starts_with("windows") {
format!("forklaunch-{required_version}.exe")
} else {
format!("forklaunch-{required_version}")
};
Ok(dir.join(name))
}
fn download_binary(required_version: &str) -> Result<PathBuf> {
let platform = platform_triple()?;
let artifact_name = if platform.starts_with("windows") {
format!("forklaunch-{}.exe", platform)
} else {
format!("forklaunch-{}", platform)
};
let url = format!(
"https://github.com/forklaunch/forklaunch-js/releases/download/cli-v{}/{}",
required_version, artifact_name
);
let binary_path = pinned_binary_path(required_version)?;
if let Some(dir) = binary_path.parent() {
create_dir_all(dir).ok();
}
let client = Client::builder().build()?;
let mut resp = client.get(&url).send()?;
if !resp.status().is_success() {
anyhow::bail!(
"Failed to download forklaunch v{} ({}): HTTP {}",
required_version,
platform,
resp.status()
);
}
let tmp_path = binary_path.with_extension(format!("tmp{}", std::process::id()));
let mut file = File::create(&tmp_path)?;
copy(&mut resp, &mut file)?;
drop(file);
#[cfg(unix)]
{
let mut perms = metadata(&tmp_path)?.permissions();
perms.set_mode(0o755);
set_permissions(&tmp_path, perms)?;
}
rename(&tmp_path, &binary_path)?;
Ok(binary_path)
}
pub(crate) fn invocation_manifest_root(matches: &ArgMatches) -> Option<PathBuf> {
let mut current_matches = matches;
while let Some((_, sub_matches)) = current_matches.subcommand() {
current_matches = sub_matches;
}
let start_path: PathBuf = if current_matches.try_get_one::<String>("base_path").is_ok()
&& let Some(base_path) = current_matches.get_one::<String>("base_path")
{
PathBuf::from(base_path)
} else if current_matches.try_get_one::<String>("path").is_ok()
&& let Some(path) = current_matches.get_one::<String>("path")
{
PathBuf::from(path)
} else {
current_dir().unwrap_or_else(|_| PathBuf::from("."))
};
find_nearest_manifest_from(&start_path).or_else(|| find_nearest_manifest_root_unbounded())
}
fn supports_accounts(binary_path: &PathBuf) -> bool {
OsCommand::new(binary_path)
.args(["account", "--help"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn without_account_flag(args: impl Iterator<Item = String>) -> Vec<String> {
let mut kept = Vec::new();
let mut skip_value = false;
for arg in args {
if skip_value {
skip_value = false;
} else if arg == "--account" {
skip_value = true;
} else if !arg.starts_with("--account=") {
kept.push(arg);
}
}
kept
}
pub(crate) fn precheck_version(
matches: &ArgMatches,
subcommand: &str,
) -> Result<VersionCheckOutcome> {
if subcommand == "init" {
if let Some((child, _)) = matches.subcommand() {
if child == "application" {
return Ok(VersionCheckOutcome::SkipWhitelisted);
}
}
}
if matches!(subcommand, "account" | "login" | "logout") {
return Ok(VersionCheckOutcome::SkipWhitelisted);
}
let manifest_root = invocation_manifest_root(matches);
let Some(manifest_root) = manifest_root else {
return Ok(VersionCheckOutcome::SkipNoManifest);
};
let Some(required_version) = parse_required_cli_version(&manifest_root) else {
return Ok(VersionCheckOutcome::Ok);
};
let current = current_cli_version();
if current == required_version || current == "0.0.0" {
return Ok(VersionCheckOutcome::Ok);
}
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let cached = pinned_binary_path(&required_version)?;
let binary_path = if cached.exists() {
cached
} else {
log_warn!(
stdout,
"This project requires forklaunch CLI v{}, but you are running v{}.",
required_version,
current
);
let mut line_editor =
rustyline::Editor::<ArrayCompleter, rustyline::history::DefaultHistory>::new()?;
let confirm = prompt_for_confirmation(
&mut line_editor,
"Do you want to install the required version now? [y/n]: ",
)?;
if !confirm {
anyhow::bail!("Version mismatch. Aborting as per user choice.");
}
let platform = platform_triple()?;
log_info!(
stdout,
"Installing forklaunch CLI v{} for {}...",
required_version,
platform
);
let binary_path = download_binary(&required_version)?;
log_ok!(
stdout,
"Installed forklaunch CLI v{} at {}",
required_version,
binary_path.display()
);
binary_path
};
log_info!(
stdout,
"Re-executing your command with forklaunch v{}...",
required_version
);
let selection = super::accounts::selection()?;
let mut command = OsCommand::new(&binary_path);
let handoff = if supports_accounts(&binary_path) {
if selection.source != super::accounts::AccountSource::Default
&& let Some(name) = &selection.name
{
command.env(super::accounts::ACCOUNT_ENV, name);
}
command.args(args().skip(1));
None
} else {
command.args(without_account_flag(args().skip(1)));
command.env_remove(super::accounts::ACCOUNT_ENV);
super::accounts::handoff(&super::accounts::forklaunch_dir()?, &selection)?
};
let mut child = command
.current_dir(current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?;
let status = child.wait()?;
drop(handoff);
match status.code() {
Some(code) => {
exit(code);
}
None => {
log_warn!(
stdout,
"forklaunch v{} at {} was stopped by a signal. Please re-run your command.",
required_version,
binary_path.display()
);
Ok(VersionCheckOutcome::ReexecNotSupported)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn strip(list: &[&str]) -> Vec<String> {
without_account_flag(list.iter().map(|s| s.to_string()))
}
#[test]
fn the_account_flag_is_stripped_for_a_binary_that_would_reject_it() {
assert_eq!(
strip(&["--account", "pact", "release", "create", "--yes"]),
["release", "create", "--yes"]
);
assert_eq!(
strip(&["deploy", "create", "--account=pact", "--yes"]),
["deploy", "create", "--yes"]
);
assert_eq!(
strip(&["cloud-account", "link", "--account-id", "1"]),
["cloud-account", "link", "--account-id", "1"]
);
}
}