use crate::logger::*;
use crate::paths::*;
use std::io::{self, Read, Write};
fn read_ssh_key_silently() -> io::Result<String> {
let _ = std::process::Command::new("stty").arg("-echo").status();
let mut input = String::new();
let res = std::io::stdin().read_to_string(&mut input);
let _ = std::process::Command::new("stty").arg("echo").status();
println!();
res.map(|_| input)
}
pub fn run_setup(
repo_url: &str,
ssh_key_path: &str,
age_pass: &str,
force: bool,
) -> io::Result<()> {
let home = std::env::var("HOME").unwrap_or_default();
let chezmoi_source_dir = concat_paths(&home, ".local/share/chezmoi");
log_section("Bootstrapping dotfiles...");
if !force && std::path::Path::new(&chezmoi_source_dir).exists() {
log_warn("Chezmoi repository is already configured at ~/.local/share/chezmoi.");
log_warn("To prevent breaking your existing configuration, setup has been aborted.");
log_step(
"If you are sure you want to re-initialize your dotfiles repository, run: dotfiles-manager setup --force",
);
return Ok(());
}
let ssh_dir = concat_paths(&home, ".ssh");
let chezmoi_dir = concat_paths(&home, ".config/chezmoi");
std::fs::create_dir_all(&ssh_dir)?;
std::fs::create_dir_all(&chezmoi_dir)?;
log_section("Checking required tools...");
for cmd in &["age", "chezmoi"] {
if std::process::Command::new("which")
.arg(cmd)
.output()?
.status
.success()
{
log_success(&(cmd.to_string() + " is available."));
} else {
if std::process::Command::new("which")
.arg("brew")
.output()?
.status
.success()
{
log_warn(&(cmd.to_string() + " not found. Installing via Homebrew..."));
let status = std::process::Command::new("brew")
.args(&["install", cmd])
.status()?;
if status.success() {
log_success(&(cmd.to_string() + " installed."));
} else {
log_error(&("Failed to install ".to_owned() + cmd));
std::process::exit(1);
}
} else {
log_error(&(cmd.to_string() + " is not installed and Homebrew was not found."));
log_warn("Please install manually to continue.");
std::process::exit(1);
}
}
}
let ssh_path = std::path::Path::new(ssh_key_path);
let key_exists =
ssh_path.exists() && std::fs::metadata(&ssh_path).map(|m| m.len()).unwrap_or(0) > 0;
if !key_exists {
log_warn(&("SSH key not found or empty at ".to_owned() + ssh_key_path));
log_step(
"Paste your SSH private key. Input is hidden; press Enter, then Ctrl+D when finished.",
);
let key_content = read_ssh_key_silently()?;
let mut file = std::fs::File::create(ssh_key_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
file.write_all(key_content.as_bytes())?;
} else {
log_success(&("Using existing SSH key at ".to_owned() + ssh_key_path));
}
log_step("Generating public key from the SSH private key...");
let out = std::process::Command::new("ssh-keygen")
.args(&["-y", "-f", ssh_key_path])
.output()?;
if !out.status.success() {
log_error(
"Failed to generate public key from SSH private key. Please check that the private key is valid and not passphrase-protected.",
);
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ssh-keygen failed to generate public key from private key",
));
}
let pub_key_path = ssh_key_path.to_owned() + ".pub";
std::fs::write(&pub_key_path, out.stdout)?;
let domain = if repo_url.contains('@') {
repo_url
.split('@')
.nth(1)
.unwrap_or("")
.split(':')
.next()
.unwrap_or("gitlab.com")
} else {
"gitlab.com"
};
log_step(&("Scanning SSH host key for ".to_owned() + domain));
let keyscan_output = std::process::Command::new("ssh-keyscan")
.arg(domain)
.output()?;
if keyscan_output.status.success() {
let known_hosts_path = concat_paths(&home, ".ssh/known_hosts");
let existing_content = std::fs::read_to_string(&known_hosts_path).unwrap_or_default();
let new_keys = String::from_utf8_lossy(&keyscan_output.stdout);
let mut to_append = String::new();
for line in new_keys.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !existing_content.contains(trimmed) {
to_append.push_str(trimmed);
to_append.push('\n');
}
}
if !to_append.is_empty() {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&known_hosts_path)?;
file.write_all(to_append.as_bytes())?;
}
}
log_section("Initializing chezmoi repository...");
let git_ssh_command = "ssh -i ".to_owned() + ssh_key_path + " -o IdentitiesOnly=yes";
let status = std::process::Command::new("chezmoi")
.arg("init")
.arg(repo_url)
.env("GIT_SSH_COMMAND", &git_ssh_command)
.status()?;
let age_dec_path = concat_paths(&home, ".config/chezmoi/.age-private-key.txt");
if !status.success() {
log_warn("Failed to clone remote repository (it may be empty or not exist yet).");
log_step("Initializing a brand new dotfiles repository locally...");
let local_status = std::process::Command::new("chezmoi").arg("init").status()?;
if !local_status.success() {
log_error("Failed to initialize empty chezmoi repository locally.");
std::process::exit(1);
}
let mut public_key = String::new();
if !std::path::Path::new(&age_dec_path).exists() {
log_step("Generating brand new age encryption key pair...");
let gen_status = std::process::Command::new("age-keygen")
.arg("-o")
.arg(&age_dec_path)
.status()?;
if !gen_status.success() {
log_error("Failed to generate age keys via age-keygen.");
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(file) = std::fs::File::open(&age_dec_path) {
let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
}
}
}
}
if std::path::Path::new(&age_dec_path).exists() {
if let Ok(content) = std::fs::read_to_string(&age_dec_path) {
for line in content.lines() {
if line.starts_with("# public key: ") {
public_key = line["# public key: ".len()..].trim().to_string();
break;
}
}
}
}
let tmpl_path = concat_paths(&chezmoi_source_dir, ".chezmoi.toml.tmpl");
let tmpl_content = format!(
r#"encryption = "age"
[age]
identity = {{{{ joinPath .chezmoi.homeDir ".config/chezmoi/.age-private-key.txt" | quote }}}}
recipient = "{}"
[git]
autoCommit = true
autoPush = true
[add]
recursive = true
"#,
public_key
);
std::fs::write(&tmpl_path, tmpl_content)?;
log_success("Generated default .chezmoi.toml.tmpl in the dotfiles repository.");
let conf_path = concat_paths(&home, ".config/chezmoi/chezmoi.toml");
let conf_content = format!(
r#"encryption = "age"
[age]
identity = "{}"
recipient = "{}"
[git]
autoCommit = true
autoPush = true
[add]
recursive = true
"#,
age_dec_path, public_key
);
std::fs::write(&conf_path, conf_content)?;
log_success("Generated ~/.config/chezmoi/chezmoi.toml for immediate use.");
log_step("Configuring Git remote and repository settings...");
std::process::Command::new("chezmoi")
.args(&["git", "--", "remote", "add", "origin", repo_url])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "core.sshCommand", &git_ssh_command])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "gpg.format", "ssh"])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "user.signingkey", &pub_key_path])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "commit.gpgsign", "true"])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "push.autoSetupRemote", "true"])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "add", ".chezmoi.toml.tmpl"])
.status()?;
let commit_status = std::process::Command::new("chezmoi")
.args(&[
"git",
"--",
"commit",
"--quiet",
"-m",
"feat: initialize dotfiles repository",
])
.status()?;
if commit_status.success() {
log_success("Created initial commit in the local dotfiles repository.");
}
} else {
log_step("Configuring chezmoi Git settings...");
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "core.sshCommand", &git_ssh_command])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "gpg.format", "ssh"])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "user.signingkey", &pub_key_path])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "commit.gpgsign", "true"])
.status()?;
std::process::Command::new("chezmoi")
.args(&["git", "--", "config", "push.autoSetupRemote", "true"])
.status()?;
log_section("Decrypting age identity...");
let candidate_enc_paths = [
concat_paths(
&home,
".local/share/chezmoi/dot_config/dot_fp-dotfiles-manager/age-private-key.enc",
),
concat_paths(
&home,
".local/share/chezmoi/dot_config/dot_chezmoi/age-private-key.enc",
),
concat_paths(
&home,
".local/share/chezmoi/dot_bin/dot_age-private-key.enc",
),
concat_paths(&home, ".local/share/chezmoi/dot_age-private-key.enc"),
concat_paths(&home, ".config/fp-dotfiles-manager/age-private-key.enc"),
concat_paths(&home, ".config/chezmoi/age-private-key.enc"),
];
let age_enc_path = candidate_enc_paths
.iter()
.find(|p| std::path::Path::new(p).exists())
.cloned()
.unwrap_or_else(|| candidate_enc_paths[0].clone());
if std::path::Path::new(&age_enc_path).exists() {
if !age_pass.is_empty() {
if std::process::Command::new("which")
.arg("expect")
.output()?
.status
.success()
{
log_step("Using non-interactive passphrase entry with expect...");
let expect_cmd = format!(
"spawn age -d -o {} {}; expect \"Enter passphrase\"; send \"{}\\r\"; expect eof",
age_dec_path, age_enc_path, age_pass
);
std::process::Command::new("expect")
.args(&["-c", &expect_cmd])
.status()?;
} else {
log_warn("expect is unavailable; falling back to the interactive prompt.");
std::process::Command::new("age")
.args(&["-d", "-o", &age_dec_path, &age_enc_path])
.status()?;
}
} else {
log_step("Waiting for interactive decryption...");
std::process::Command::new("age")
.args(&["-d", "-o", &age_dec_path, &age_enc_path])
.status()?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(file) = std::fs::File::open(&age_dec_path) {
let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
}
}
} else {
log_warn(
&("Age-encrypted private key not found at ".to_owned()
+ &age_enc_path
+ ". Skipping decryption."),
);
}
}
log_section("Applying dotfiles...");
std::process::Command::new("chezmoi")
.args(&["apply", "--force"])
.status()?;
log_done("Setup complete.");
log_section("Running post-setup bootstrap hooks...");
super::bootstrap::run_bootstrap(
&concat_paths(&home, ".config/fp-dotfiles-manager/bootstrap.d"),
false,
)?;
log_section("Setup & Usage Quick Tutorial");
println!(
"To start managing your dotfiles with fp-dotfiles-manager, here is what you need to do:"
);
println!();
println!("1. Configuration File:");
println!(
" A configuration file should be created at ~/.config/fp-dotfiles-manager/config.yml"
);
println!(" You can initialize a default one by running: fp-dotfiles-manager init-config");
println!();
println!("2. Tracked Files Lists:");
println!(" By default, the lists of files to track are read from:");
println!(" - Standard files: ~/.config/fp-dotfiles-manager/tracked");
println!(" - Sensitive files: ~/.config/fp-dotfiles-manager/tracked_encrypted");
println!(
" Place these tracking lists (containing one relative path per line, e.g., .bashrc or .config/git/config)"
);
println!(
" at those paths, or use `fp-dotfiles-manager add` to automatically append new files to them."
);
println!();
println!("3. Post-Setup Bootstrap Hooks:");
println!(
" Any numerical/alphabetical shell scripts (e.g. 01-packages.sh, 02-symlinks.sh) placed inside:"
);
println!(" ~/.config/fp-dotfiles-manager/bootstrap.d/");
println!(
" will be executed automatically after setup completes. You can run them manually at any time with:"
);
println!(" fp-dotfiles-manager bootstrap");
println!();
println!("4. Sync & Backup:");
println!(
" - To sync modifications, track newly discovered files, and purge untracked files, run: fp-dotfiles-manager sync"
);
println!(
" - To safely archive your tracked dotfiles to a compressed backup, run: fp-dotfiles-manager backup [destination_path]"
);
println!();
println!("Happy dotfiles managing!");
Ok(())
}