use std::path::Path;
use anyhow::{Result, bail};
use dialoguer::{Confirm, Input, Password, Select};
use crate::model::{App, Auth, Credential, Server, Target, parse_host, unique_target_name, validate_name, validate_remote_path};
use crate::{pick, secrets, store};
pub fn run(app_name: Option<String>, server_name: Option<String>) -> Result<()> {
pick::ensure_interactive("`deploy-setup` is a wizard; in scripts configure with `turnout app edit` and `turnout server edit`")?;
let mut apps = store::load_apps()?;
let mut servers = store::load_servers()?;
let mut credentials = store::load_credentials()?;
let mut paths = store::load_paths()?;
let mut targets = store::load_targets()?;
let state = store::load_state()?;
let app_name = match app_name {
Some(name) => name,
None => pick::app(&apps, &state, "Deploy which app")?,
};
let app_index = apps
.iter()
.position(|a| a.name == app_name)
.ok_or_else(|| anyhow::anyhow!("no app named '{app_name}' - see `turnout app list`"))?;
let server_name = match server_name {
Some(name) => name,
None => pick::server_for_app(&servers, &apps[app_index], "Deploy to")?,
};
let server_index = servers
.iter()
.position(|s| s.name == server_name)
.ok_or_else(|| anyhow::anyhow!("no server named '{server_name}' - see `turnout server list`"))?;
println!("Setting up deploy of '{app_name}' to '{server_name}'.");
let project = Path::new(&apps[app_index].path).to_path_buf();
let suggested = apps[app_index]
.dist_dir
.clone()
.or_else(|| {
["dist", "build", "out", "public"]
.iter()
.find(|d| project.join(d).is_dir())
.map(|d| d.to_string())
})
.unwrap_or_else(|| "dist".to_string());
let dist: String = Input::new()
.with_prompt("Artifact directory (relative to the project)")
.default(suggested)
.interact_text()?;
if !project.join(dist.trim()).is_dir() {
println!(
" Note: {} does not exist yet - the build should produce it.",
project.join(dist.trim()).display()
);
}
let current_host = servers[server_index]
.host
.as_ref()
.map(|h| format!("{h}:{}", servers[server_index].port))
.unwrap_or_default();
let host_spec: String = Input::new()
.with_prompt("SSH host[:port] (empty to use the URL's host)")
.default(current_host)
.allow_empty(true)
.interact_text()?;
let (host, port) = if host_spec.trim().is_empty() {
(None, 22)
} else {
let (host, port) = parse_host(host_spec.trim())?;
(Some(host), port)
};
let credential_name = choose_credential(&mut credentials, servers[server_index].credential.as_deref())?;
let credential_index = credentials.iter().position(|c| c.name == credential_name).expect("just chosen or created");
let existing = targets.iter().find(|t| t.app == app_name && t.server == server_name).cloned();
let path_name = choose_path(&mut paths, existing.as_ref().map(|t| t.path.as_str()))?;
let mut secret_to_save = None;
if credentials[credential_index].auth == Auth::Password {
let has_secret = secrets::get(&credential_name).is_ok();
let prompt = if has_secret {
format!("Replace the stored secret of '{credential_name}'?")
} else {
format!("Store a secret for '{credential_name}' in the OS keyring?")
};
if Confirm::new().with_prompt(prompt).default(!has_secret).interact()? {
let secret = Password::new()
.with_prompt("Secret")
.with_confirmation("Repeat to confirm", "Values do not match")
.interact()?;
if secret.is_empty() {
bail!("empty secret - nothing saved");
}
secret_to_save = Some(secret);
}
}
let allowed = apply(
&mut apps[app_index],
&mut servers[server_index],
Answers {
dist: dist.trim(),
host,
port,
credential: &credential_name,
},
);
if allowed {
println!(" Allowed '{server_name}' for '{app_name}'.");
}
let target_name = match existing {
Some(existing) => {
let entry = targets.iter_mut().find(|t| t.name == existing.name).expect("just found");
entry.credential = credential_name.clone();
entry.path = path_name.clone();
existing.name
}
None => {
let name = unique_target_name(&app_name, &server_name, &targets);
targets.push(Target {
name: name.clone(),
app: app_name.clone(),
server: server_name.clone(),
credential: credential_name.clone(),
path: path_name.clone(),
});
targets.sort_by(|a, b| a.name.cmp(&b.name));
name
}
};
store::save_apps(&apps)?;
store::save_servers(&servers)?;
store::save_credentials(&credentials)?;
store::save_paths(&paths)?;
store::save_targets(&targets)?;
if let Some(secret) = secret_to_save {
secrets::set(&credential_name, &secret)?;
}
println!("Done. Deploy with:");
println!(" turnout deploy {target_name}");
Ok(())
}
fn choose_credential(credentials: &mut Vec<Credential>, current: Option<&str>) -> Result<String> {
if !credentials.is_empty() {
let mut labels: Vec<String> = credentials.iter().map(|c| format!("{} ({}@, {})", c.name, c.user, c.auth)).collect();
labels.push("+ a new credential".to_string());
let default = current.and_then(|name| credentials.iter().position(|c| c.name == name)).unwrap_or(0);
let choice = Select::new().with_prompt("Logs in with").items(&labels).default(default).interact()?;
if choice < credentials.len() {
return Ok(credentials[choice].name.clone());
}
}
let name: String = Input::new()
.with_prompt("New credential name")
.validate_with(|s: &String| validate_name(s).map_err(|e| e.to_string()))
.interact_text()?;
if credentials.iter().any(|c| c.name == name) {
bail!("credential '{name}' already exists");
}
let user: String = Input::new().with_prompt("Logs in as (remote user)").interact_text()?;
if user.trim().is_empty() {
bail!("the remote user cannot be empty");
}
let auth = crate::commands::credential::AUTH_KINDS[Select::new()
.with_prompt("Authenticates with")
.items(crate::commands::credential::AUTH_LABELS)
.default(0)
.interact()?];
let key = if auth == Auth::Key {
let answer: String = Input::new().with_prompt("Private key file").interact_text()?;
Some(answer.trim().to_string())
} else {
None
};
credentials.push(Credential {
name: name.clone(),
user: user.trim().to_string(),
auth,
key,
});
credentials.sort_by(|a, b| a.name.cmp(&b.name));
Ok(name)
}
fn choose_path(paths: &mut Vec<crate::model::Path>, current: Option<&str>) -> Result<String> {
if !paths.is_empty() {
let mut labels: Vec<String> = paths.iter().map(|p| format!("{} ({})", p.name, p.dir)).collect();
labels.push("+ a new path".to_string());
let default = current.and_then(|name| paths.iter().position(|p| p.name == name)).unwrap_or(0);
let choice = Select::new().with_prompt("Files land in").items(&labels).default(default).interact()?;
if choice < paths.len() {
return Ok(paths[choice].name.clone());
}
}
let name: String = Input::new()
.with_prompt("New path name")
.validate_with(|s: &String| validate_name(s).map_err(|e| e.to_string()))
.interact_text()?;
if paths.iter().any(|p| p.name == name) {
bail!("path '{name}' already exists");
}
let dir: String = Input::new()
.with_prompt("Remote directory")
.validate_with(|s: &String| validate_remote_path(s).map_err(|e| e.to_string()))
.interact_text()?;
let restart: String = Input::new()
.with_prompt("Command to run after upload (empty for none)")
.allow_empty(true)
.interact_text()?;
paths.push(crate::model::Path {
name: name.clone(),
dir: crate::model::normalize_remote_path(dir.trim()),
restart: if restart.trim().is_empty() { None } else { Some(restart.trim().to_string()) },
});
paths.sort_by(|a, b| a.name.cmp(&b.name));
Ok(name)
}
struct Answers<'a> {
dist: &'a str,
host: Option<String>,
port: u16,
credential: &'a str,
}
fn apply(app: &mut App, server: &mut Server, answers: Answers) -> bool {
app.dist_dir = Some(answers.dist.to_string());
let allowed = !app.servers.is_empty() && !app.servers.contains(&server.name);
if allowed {
app.servers.push(server.name.clone());
}
server.host = answers.host;
server.port = answers.port;
server.credential = Some(answers.credential.to_string());
allowed
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn an_app(servers: &[&str]) -> App {
App {
name: "web".into(),
path: "/tmp/web".into(),
commands: BTreeMap::new(),
dist_dir: None,
gateway_port: None,
servers: servers.iter().map(|s| s.to_string()).collect(),
}
}
fn a_server() -> Server {
Server {
name: "prod".into(),
label: None,
url: "https://prod.example.com".into(),
host: None,
port: 22,
accept_invalid_certs: false,
credential: None,
shell: None,
}
}
fn answers() -> Answers<'static> {
Answers {
dist: "dist",
host: Some("prod.example.com".into()),
port: 2222,
credential: "deploy",
}
}
#[test]
fn writes_every_field_the_deploy_needs() {
let (mut app, mut server) = (an_app(&[]), a_server());
apply(&mut app, &mut server, answers());
assert_eq!(app.dist_dir.as_deref(), Some("dist"));
assert_eq!(server.credential.as_deref(), Some("deploy"));
assert_eq!(server.host.as_deref(), Some("prod.example.com"));
assert_eq!(server.port, 2222);
}
#[test]
fn an_empty_host_keeps_following_the_url() {
let (mut app, mut server) = (an_app(&[]), a_server());
apply(&mut app, &mut server, Answers { host: None, ..answers() });
assert!(server.host.is_none());
assert_eq!(server.ssh_host(), "prod.example.com", "it resolves through the URL");
}
#[test]
fn a_restricted_app_gains_the_target_server() {
let (mut app, mut server) = (an_app(&["staging"]), a_server());
assert!(apply(&mut app, &mut server, answers()));
assert!(app.servers.contains(&"prod".to_string()));
let (mut app, mut server) = (an_app(&[]), a_server());
assert!(!apply(&mut app, &mut server, answers()));
assert!(app.servers.is_empty());
}
}