mod binary;
mod github;
mod platform;
mod version;
#[cfg(test)]
mod tests;
use std::io::IsTerminal;
use anyhow::{bail, Context, Result};
use console::Style;
use dialoguer::Confirm;
use semver::Version;
use super::upgrade;
use binary::{detect_server_path, detect_server_version, update_cli_binary, update_target_binary};
use github::{available_asset_names, check_recent_releases, GitHubRelease};
use platform::{asset_name_for_binary, platform_asset_name};
use version::{
is_newer, latest_compatible_release, latest_known_release, latest_release_with_server_asset,
};
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const UPDATE_TARGET_ENV: &str = "TUITBOT_UPDATE_TARGET";
pub async fn execute(
non_interactive: bool,
check_only: bool,
config_only: bool,
config_path_str: &str,
out: crate::output::CliOutput,
) -> Result<()> {
let bold = Style::new().bold();
let dim = Style::new().dim();
let green = Style::new().green().bold();
let current = Version::parse(CURRENT_VERSION).context("Failed to parse current version")?;
let mut binary_updated = false;
if !config_only {
out.info(&format!("{}", bold.apply_to("Checking for updates...")));
out.info("");
let fetched_releases = check_recent_releases().await;
match &fetched_releases {
Ok(releases) => match latest_known_release(releases) {
Some((latest_release, latest)) if is_newer(&latest, ¤t) => {
out.info(&format!(
" {} {} → {}",
green.apply_to("New version available:"),
current,
latest
));
if check_only {
if out.is_json() {
return out.json(&serde_json::json!({
"update_available": true,
"current_version": current.to_string(),
"latest_version": latest.to_string(),
}));
}
out.info("");
out.info(&format!(
"{}",
dim.apply_to("Run 'tuitbot update' to install the update.")
));
return Ok(());
}
if !non_interactive && std::io::stdin().is_terminal() {
eprintln!();
let proceed = Confirm::new()
.with_prompt(format!("Update tuitbot to v{latest}?"))
.default(true)
.interact()?;
if !proceed {
out.info(&format!("{}", dim.apply_to("Update skipped.")));
out.info("");
return run_config_upgrade(
non_interactive,
config_path_str,
&bold,
&dim,
out,
)
.map(|_| ());
}
}
let asset_name = match platform_asset_name()
.context("Unsupported platform for binary self-update")
{
Ok(name) => name,
Err(e) => {
let reason = format!("{e}");
out.info("");
out.info(&format!(
" {} Binary update skipped: {e}",
Style::new().yellow().bold().apply_to("⚠"),
));
out.info(&format!(
" {}",
dim.apply_to(
"No prebuilt binary is published for this platform. Build from source or install manually."
)
));
out.info(&format!(
" {}",
dim.apply_to(
"Manual downloads: https://github.com/aramirez087/TuitBot/releases"
)
));
out.info("");
if non_interactive && out.is_json() {
out.json(&serde_json::json!({
"binary_updated": false,
"binary_skipped": true,
"reason": reason,
"current_version": current.to_string(),
"latest_version": latest.to_string(),
}))?;
std::process::exit(1);
}
if non_interactive {
bail!("Binary update skipped: {e}");
}
return run_config_upgrade(
non_interactive,
config_path_str,
&bold,
&dim,
out,
)
.map(|_| ());
}
};
let (release_for_update, release_version) = match latest_compatible_release(
releases,
¤t,
&asset_name,
) {
Some(found) => found,
None => {
let reason = format!("no compatible asset found for '{asset_name}'");
out.info("");
out.info(&format!(
" {} Binary update skipped: {reason}",
Style::new().yellow().bold().apply_to("⚠"),
));
out.info(&format!(
" {} Latest release checked: {}",
dim.apply_to("Tag:"),
latest_release.tag_name,
));
out.info(&format!(
" {} {}",
dim.apply_to("Available assets:"),
available_asset_names(latest_release),
));
out.info(&format!(
" {}",
dim.apply_to(
"Manual downloads: https://github.com/aramirez087/TuitBot/releases"
)
));
out.info("");
if non_interactive && out.is_json() {
out.json(&serde_json::json!({
"binary_updated": false,
"binary_skipped": true,
"reason": reason,
"current_version": current.to_string(),
"latest_version": latest.to_string(),
}))?;
std::process::exit(1);
}
if non_interactive {
bail!("Binary update skipped: {reason}");
}
return run_config_upgrade(
non_interactive,
config_path_str,
&bold,
&dim,
out,
)
.map(|_| ());
}
};
if release_version != latest {
out.info(&format!(
" {} Latest version v{} has no '{}' asset; installing newest compatible v{}.",
Style::new().yellow().bold().apply_to("⚠"),
latest,
asset_name,
release_version
));
}
match update_cli_binary(release_for_update).await {
Ok(()) => {
binary_updated = true;
out.info("");
out.info(&format!(
" {} Updated tuitbot to v{}",
green.apply_to("✓"),
release_version
));
}
Err(e) => {
out.info("");
out.info(&format!(
" {} CLI binary update failed: {e}",
Style::new().red().bold().apply_to("✗"),
));
out.info(&format!(
" {}",
dim.apply_to(
"You can download manually from: https://github.com/aramirez087/TuitBot/releases"
)
));
}
}
}
Some((_, latest)) => {
out.info(&format!(" Already up to date (v{current})."));
if latest != current {
out.info(&format!(
" {}",
dim.apply_to(format!("(latest release: v{latest})"))
));
}
if check_only {
if out.is_json() {
return out.json(&serde_json::json!({
"update_available": false,
"current_version": current.to_string(),
}));
}
return Ok(());
}
}
None => {
out.info(&format!(
" {} Could not find a parseable CLI release tag",
Style::new().yellow().bold().apply_to("⚠"),
));
if check_only {
if out.is_json() {
return out.json(&serde_json::json!({
"update_available": false,
"current_version": current.to_string(),
"warning": "Could not find a parseable CLI release tag",
}));
}
return Ok(());
}
}
},
Err(e) => {
out.info(&format!(
" {} Could not check for updates: {e}",
Style::new().yellow().bold().apply_to("⚠"),
));
out.info(&format!(
" {}",
dim.apply_to("Skipping binary update, continuing with config upgrade...")
));
}
}
if !check_only {
if let Ok(releases) = &fetched_releases {
check_and_update_server(releases, &green, &dim, out).await;
}
}
out.info("");
} else if check_only {
bail!("--check and --config-only cannot be used together.");
}
let config_up_to_date = run_config_upgrade(non_interactive, config_path_str, &bold, &dim, out)?;
if out.is_json() {
out.json(&serde_json::json!({
"current_version": current.to_string(),
"binary_updated": binary_updated,
"config_up_to_date": config_up_to_date,
}))?;
}
Ok(())
}
pub async fn check_before_run(config_path_str: &str) -> Result<()> {
let config_path = upgrade::expand_tilde(config_path_str);
if !config_path.exists() {
return Ok(());
}
let missing = upgrade::detect_missing_features(&config_path)?;
if missing.is_empty() {
return Ok(());
}
let bold = Style::new().bold();
let dim = Style::new().dim();
eprintln!();
eprintln!(
"{}",
bold.apply_to("New features available in your config:")
);
for group in &missing {
eprintln!(" • {} — {}", group.display_name(), group.description());
}
eprintln!();
let configure_now = Confirm::new()
.with_prompt("Configure new features now?")
.default(false)
.interact()?;
if !configure_now {
eprintln!(
"{}",
dim.apply_to("Tip: Run 'tuitbot update' any time to configure new features.")
);
eprintln!();
return Ok(());
}
upgrade::run_upgrade_wizard(&config_path, &missing)?;
Ok(())
}
async fn check_and_update_server(
releases: &[GitHubRelease],
green: &Style,
dim: &Style,
out: crate::output::CliOutput,
) {
let server_exe = match detect_server_path() {
Some(path) => path,
None => return, };
let server_asset = match asset_name_for_binary("tuitbot-server") {
Some(name) => name,
None => {
out.info(&format!(
" {} Server update skipped: unsupported platform",
dim.apply_to("ℹ"),
));
return;
}
};
let (release, release_version) = match latest_release_with_server_asset(releases, &server_asset)
{
Some(found) => found,
None => return, };
if let Some(server_version) = detect_server_version(&server_exe) {
if server_version >= release_version {
out.info(&format!(
" {} tuitbot-server is up to date (v{server_version}).",
dim.apply_to("ℹ"),
));
return;
}
out.info(&format!(
" {} tuitbot-server v{server_version} → v{release_version}",
green.apply_to("Server update available:"),
));
} else {
out.info(&format!(
" {} Could not detect server version; attempting update to v{release_version}.",
dim.apply_to("ℹ"),
));
}
match update_target_binary(release, "tuitbot-server", &server_asset, &server_exe).await {
Ok(()) => {
out.info(&format!(
" {} Updated tuitbot-server at {}",
green.apply_to("✓"),
server_exe.display()
));
out.info(&format!(
" {}",
dim.apply_to(
"Restart the server to use the new version (e.g., sudo systemctl restart tuitbot)."
)
));
}
Err(e) => {
out.info(&format!(
" {} Server update failed: {e}",
Style::new().yellow().bold().apply_to("⚠"),
));
let hint = if cfg!(unix) && server_exe.starts_with("/usr") {
"Hint: You may need to run with sudo to update the server binary."
} else {
"Hint: Make sure tuitbot-server is not running, then try again."
};
out.info(&format!(" {}", dim.apply_to(hint)));
}
}
}
fn run_config_upgrade(
non_interactive: bool,
config_path_str: &str,
bold: &Style,
dim: &Style,
out: crate::output::CliOutput,
) -> Result<bool> {
let config_path = upgrade::expand_tilde(config_path_str);
if !config_path.exists() {
out.info(&format!(
" {}",
dim.apply_to("No config file found — run 'tuitbot init' to create one.")
));
return Ok(true);
}
out.info(&format!("{}", bold.apply_to("Checking configuration...")));
let missing = upgrade::detect_missing_features(&config_path)?;
if missing.is_empty() {
out.info(" Config is up to date.");
return Ok(true);
}
out.info(" New feature groups to configure:");
for group in &missing {
out.info(&format!(
" • {} — {}",
group.display_name(),
group.description()
));
}
out.info("");
if non_interactive {
upgrade::apply_defaults(&config_path, &missing, out)?;
} else if std::io::stdin().is_terminal() {
upgrade::run_upgrade_wizard(&config_path, &missing)?;
} else {
out.info(&format!(
" {}",
dim.apply_to(
"Non-interactive terminal detected. Use --non-interactive to apply defaults."
)
));
}
Ok(false)
}