use std::env;
use std::env::current_dir;
use std::path::PathBuf;
use anyhow::{anyhow, Result};
use cnctd_appstore::AppStoreConnectClient;
use cnctd_xcode::{
archive, export, upload, ArchiveOptions, ExportArchiveOptions, ExportOptionsPlistBuilder,
XcodeProject,
};
use colored::Colorize;
fn discover_project() -> Result<XcodeProject> {
let cwd = current_dir()?;
let base_path = cwd.to_string_lossy();
XcodeProject::discover(&base_path)
}
fn get_team_id() -> Result<String> {
env::var("APPLE_TEAM_ID").map_err(|_| anyhow!("APPLE_TEAM_ID env var not set"))
}
fn get_app_id(app_id_arg: Option<&str>) -> Result<String> {
match app_id_arg {
Some(id) => Ok(id.to_string()),
None => env::var("ASC_APP_ID").map_err(|_| {
anyhow!("App ID not provided. Use --app-id or set ASC_APP_ID env var")
}),
}
}
pub async fn archive_app(configuration: &str) -> Result<()> {
let project = discover_project()?;
println!(
"{} {} ({})",
"Archiving".blue(),
project.scheme,
configuration
);
let opts = ArchiveOptions::app_store(project).configuration(configuration);
let archive_path = archive(&opts).await?;
println!(
"{} Archive at {}",
"Done!".green(),
archive_path.display()
);
Ok(())
}
pub async fn export_app(method: &str) -> Result<()> {
let project = discover_project()?;
let team_id = get_team_id()?;
let archive_path = std::env::temp_dir().join(format!("{}.xcarchive", project.scheme));
if !archive_path.exists() {
return Err(anyhow!(
"No archive found at {}. Run `cnctd ios archive` first.",
archive_path.display()
));
}
println!("{} IPA (method: {})", "Exporting".blue(), method);
let plist_builder = match method {
"app-store" => ExportOptionsPlistBuilder::app_store(&team_id),
"ad-hoc" => ExportOptionsPlistBuilder::ad_hoc(&team_id),
"development" => ExportOptionsPlistBuilder::development(&team_id),
_ => return Err(anyhow!("Invalid export method '{}'. Use: app-store, ad-hoc, development", method)),
};
let plist_path = plist_builder.build()?;
let export_path = std::env::temp_dir().join(format!("{}-export", project.scheme));
let opts = ExportArchiveOptions {
archive_path,
export_path,
export_options_plist: plist_path,
};
let ipa_path = export(&opts).await?;
println!("{} IPA at {}", "Done!".green(), ipa_path.display());
Ok(())
}
pub async fn upload_app() -> Result<()> {
let project = discover_project()?;
let key_id = env::var("ASC_KEY_ID").map_err(|_| anyhow!("ASC_KEY_ID env var not set"))?;
let issuer_id =
env::var("ASC_ISSUER_ID").map_err(|_| anyhow!("ASC_ISSUER_ID env var not set"))?;
let export_dir = std::env::temp_dir().join(format!("{}-export", project.scheme));
if !export_dir.exists() {
return Err(anyhow!(
"No export directory found at {}. Run `cnctd ios export` first.",
export_dir.display()
));
}
let ipa_path = find_ipa_in_dir(&export_dir)?;
println!("{} {}", "Uploading".blue(), ipa_path.display());
upload(&ipa_path, &key_id, &issuer_id).await?;
println!("{} Uploaded to App Store Connect", "Done!".green());
Ok(())
}
pub async fn release(version_bump: Option<&str>) -> Result<()> {
let project = discover_project()?;
let team_id = get_team_id()?;
let key_id = env::var("ASC_KEY_ID").map_err(|_| anyhow!("ASC_KEY_ID env var not set"))?;
let issuer_id =
env::var("ASC_ISSUER_ID").map_err(|_| anyhow!("ASC_ISSUER_ID env var not set"))?;
let scheme_name = project.scheme.clone();
if let Some(part) = version_bump {
let old = project.get_version()?;
let new = project.bump_version(part)?;
println!(
"{} {} version: {} -> {}",
"1/5".dimmed(),
"Bumped".green(),
old.yellow(),
new.green()
);
}
let old_build = project.get_build_number()?;
let new_build = project.bump_build_number()?;
let step = if version_bump.is_some() { "2/5" } else { "1/4" };
println!(
"{} {} build: {} -> {}",
step.dimmed(),
"Bumped".green(),
old_build.yellow(),
new_build.green()
);
let total = if version_bump.is_some() { 5 } else { 4 };
let archive_step = if version_bump.is_some() { 3 } else { 2 };
println!(
"{} {} {}",
format!("{}/{}", archive_step, total).dimmed(),
"Archiving".blue(),
scheme_name
);
let archive_opts = ArchiveOptions::app_store(project);
let archive_path = archive(&archive_opts).await?;
let export_step = archive_step + 1;
println!(
"{} {} IPA",
format!("{}/{}", export_step, total).dimmed(),
"Exporting".blue()
);
let plist_path = ExportOptionsPlistBuilder::app_store(&team_id).build()?;
let export_path = std::env::temp_dir().join(format!("{}-export", scheme_name));
let export_opts = ExportArchiveOptions {
archive_path,
export_path: export_path.clone(),
export_options_plist: plist_path,
};
let ipa_path = export(&export_opts).await?;
let upload_step = export_step + 1;
println!(
"{} {} to App Store Connect",
format!("{}/{}", upload_step, total).dimmed(),
"Uploading".blue()
);
upload(&ipa_path, &key_id, &issuer_id).await?;
println!("\n{} Release complete!", "Done!".green().bold());
println!(" Build {} uploaded to App Store Connect", new_build.green());
println!(
" Check processing status: {}",
"cnctd ios status".yellow()
);
Ok(())
}
pub async fn check_status(app_id: Option<&str>) -> Result<()> {
let app_id = get_app_id(app_id)?;
let client = AppStoreConnectClient::new()?;
println!("{} builds for app {}", "Fetching".blue(), app_id);
let builds = client.list_builds(&app_id, Some(5)).await?;
if builds.is_empty() {
println!("No builds found");
return Ok(());
}
for build in &builds {
let version = build
.attributes
.version
.as_deref()
.unwrap_or("unknown");
let state = build
.attributes
.processing_state
.as_deref()
.unwrap_or("unknown");
let uploaded = build
.attributes
.uploaded_date
.as_deref()
.unwrap_or("unknown");
let state_colored = match state {
"VALID" => state.green().to_string(),
"PROCESSING" => state.yellow().to_string(),
"FAILED" | "INVALID" => state.red().to_string(),
_ => state.dimmed().to_string(),
};
println!(
" Build {} - {} (uploaded {})",
version.blue(),
state_colored,
uploaded.dimmed()
);
}
Ok(())
}
fn find_ipa_in_dir(dir: &PathBuf) -> Result<PathBuf> {
for entry in std::fs::read_dir(dir)?.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("ipa") {
return Ok(path);
}
}
Err(anyhow!("No .ipa file found in {}", dir.display()))
}