fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
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...");

    // 1. Prevent accidental duplicate initialization of an existing configuration
    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);
            }
        }
    }

    // 2. Validate private key existence and size
    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));
    }

    // 3. Generate public key safely and check for errors
    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"
    };

    // 4. Safely check and scan SSH host key, avoiding duplicate entries
    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);
        }

        // Generate age key pair if it doesn't exist
        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;
                    }
                }
            }
        }

        // Generate .chezmoi.toml.tmpl inside chezmoi source directory
        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.");

        // Generate ~/.config/chezmoi/chezmoi.toml for instant use
        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.");

        // Configure git remote and git config inside source directory
        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()?;

        // Stage and commit the template
        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()?;

        // 5. Decrypt age identity safely, only if key file exists
        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(())
}