Skip to main content

zoi_cli/cmd/
system.rs

1use anyhow::{Result, anyhow};
2use clap::{Parser, Subcommand};
3use colored::*;
4use std::io::Read;
5use zoi_core::utils::is_zoios;
6use zoi_system::config::load_system_lua;
7
8#[cfg(unix)]
9use zoi_system::client::send_request;
10#[cfg(unix)]
11use zoi_system::protocol::{Request, Response};
12
13#[derive(Parser, Debug)]
14pub struct SystemCommand {
15    #[command(subcommand)]
16    pub command: SystemSubcommands,
17}
18
19#[derive(Subcommand, Debug)]
20pub enum SystemSubcommands {
21    /// Apply a declarative system configuration from system.lua
22    Apply {
23        /// Path to the system configuration file
24        #[arg(default_value = "/etc/zoi/system.lua")]
25        file: String,
26    },
27    /// List all system generations
28    List,
29    /// Show current system status and active generation
30    Status,
31    /// Rollback to a previous system generation
32    Rollback {
33        /// Generation ID to roll back to
34        id: u32,
35    },
36    /// Manage secrets (hashes and encrypted strings)
37    Secret {
38        #[command(subcommand)]
39        command: SecretSubcommands,
40    },
41    /// Commands for building and managing ZoiOS distributions
42    Distro {
43        #[command(subcommand)]
44        command: DistroSubcommands,
45    },
46}
47
48#[derive(Subcommand, Debug)]
49pub enum DistroSubcommands {
50    /// Build a new ZoiOS distribution image or install to a disk
51    Build {
52        /// The target device or image path (e.g. /dev/sdb)
53        #[arg(short, long)]
54        target: String,
55        /// Path to the system configuration to use for the build
56        #[arg(short, long)]
57        config: String,
58        /// Show the build plan without executing destructive commands
59        #[arg(long)]
60        dry_run: bool,
61    },
62    /// Enter a ZoiOS sysroot (chroot) with automatic device mounting
63    Chroot {
64        /// Path to the ZoiOS root directory
65        target: String,
66        /// Command to run inside the chroot (defaults to /bin/bash)
67        #[arg(short, long)]
68        run: Option<String>,
69        /// Show additional details
70        #[arg(long, short)]
71        verbose: bool,
72    },
73}
74
75#[derive(Subcommand, Debug)]
76pub enum SecretSubcommands {
77    /// Generate a one-way hash of a password for use in system.lua
78    Hash {
79        /// The password to hash
80        password: String,
81    },
82    /// Encrypt a sensitive string (like an API key) so only Zoi can decrypt it
83    Encrypt {
84        /// The plaintext string to encrypt
85        value: String,
86    },
87    /// Decrypt a ZOISEC string (only works on the same machine where it was encrypted)
88    Decrypt {
89        /// The encrypted ZOISEC string
90        secret: String,
91    },
92}
93
94pub fn run(args: SystemCommand, yes: bool) -> Result<()> {
95    let is_secret = matches!(args.command, SystemSubcommands::Secret { .. });
96    let is_distro = matches!(args.command, SystemSubcommands::Distro { .. });
97
98    if !is_secret && !is_distro && !is_zoios() {
99        return Err(anyhow!(
100            "OS management features are only available on ZoiOS systems."
101        ));
102    }
103
104    match args.command {
105        SystemSubcommands::Secret { command } => match command {
106            SecretSubcommands::Hash { password } => {
107                let hash = zoi_system::secret::hash_password(&password)?;
108                println!("Password hash generated successfully. Use this in your system.lua:");
109                println!("\n  {}", hash.green());
110            }
111            SecretSubcommands::Encrypt { value } => {
112                let encrypted = zoi_system::secret::encrypt_secret(&value)?;
113                println!("Value encrypted successfully. Use this in your system.lua or home.lua:");
114                println!("\n  {}", encrypted.yellow());
115                println!(
116                    "\n{}",
117                    "Note: This can only be decrypted by Zoi on this specific machine.".dimmed()
118                );
119            }
120            SecretSubcommands::Decrypt { secret } => {
121                let decrypted = zoi_system::secret::decrypt_secret(&secret)?;
122                if decrypted == secret {
123                    return Err(anyhow!("Input is not a valid Zoi secret string."));
124                }
125                println!("Secret decrypted successfully:");
126                println!("\n  {}", decrypted.green());
127            }
128        },
129        SystemSubcommands::Distro { command } => match command {
130            DistroSubcommands::Build {
131                target,
132                config,
133                dry_run,
134            } => {
135                let target_path = std::path::Path::new(&target);
136                let config = load_system_lua(&config)?;
137
138                // Pre-flight: Validate packages exist in registry
139                println!(
140                    "{} Validating {} packages...",
141                    "::".bold().blue(),
142                    config.packages.len().to_string().cyan()
143                );
144                for pkg_id in &config.packages {
145                    if let Err(e) = zoi_resolver::resolve::resolve_source(pkg_id, None, true, true)
146                    {
147                        return Err(anyhow!("Package validation failed for '{}': {}", pkg_id, e));
148                    }
149                }
150
151                print_build_summary(&target, &config, dry_run);
152
153                if !dry_run
154                    && !zoi_core::utils::ask_for_confirmation(
155                        "Are you sure you want to proceed with the build? This will install ZoiOS to the target device.",
156                        yes,
157                    )
158                {
159                    return Err(anyhow!("Build aborted by user."));
160                }
161
162                println!(
163                    "{} Orchestrating ZoiOS build on {}...",
164                    "::".bold().blue(),
165                    target.cyan()
166                );
167
168                // Marker
169                zoi_system::distro::initialize_zoios_marker(
170                    target_path,
171                    config.system.hostname.as_deref(),
172                    dry_run,
173                )?;
174
175                // Install packages into target sysroot
176                if dry_run {
177                    println!(
178                        "  {} Would install base packages: {}",
179                        "[DRY-RUN]".dimmed(),
180                        config.packages.join(", ")
181                    );
182                } else {
183                    println!(
184                        "{} Installing base packages to {}...",
185                        "::".bold().blue(),
186                        target.cyan()
187                    );
188
189                    // Use CLI's install engine
190                    crate::cmd::install::run(
191                        &config.packages,
192                        None,
193                        false,
194                        false,
195                        true,
196                        Some(crate::cli::InstallScope::System),
197                        false,
198                        false,
199                        false,
200                        None,
201                        false,
202                        None,
203                        false,
204                        false,
205                        false,
206                        false,
207                        3,
208                        false,
209                        false,
210                    )?;
211                }
212
213                // Finalize Generation
214                zoi_system::distro::finalize_first_generation(
215                    target_path,
216                    config.packages.clone(),
217                    dry_run,
218                )?;
219
220                let success_msg = if dry_run {
221                    "Dry-run complete."
222                } else {
223                    "ZoiOS build complete."
224                };
225                println!(
226                    "{} {} on {}.",
227                    "Success:".green(),
228                    success_msg,
229                    target.cyan()
230                );
231            }
232            DistroSubcommands::Chroot {
233                target,
234                run,
235                verbose,
236            } => {
237                let target_path = std::path::Path::new(&target);
238                if !target_path.exists() {
239                    return Err(anyhow!("Target path '{}' does not exist.", target));
240                }
241
242                let os_release = target_path.join("etc/os-release");
243                if !os_release.exists() {
244                    return Err(anyhow!(
245                        "Target path '{}' is not a valid ZoiOS root (missing /etc/os-release).",
246                        target
247                    ));
248                }
249
250                // --- BOOTSTRAP AUDIT ---
251
252                // Filesystem check
253                let fs_type = std::process::Command::new("stat")
254                    .arg("-f")
255                    .arg("-c")
256                    .arg("%T")
257                    .arg(&target)
258                    .output();
259                if let Ok(out) = fs_type {
260                    let t = String::from_utf8_lossy(&out.stdout).trim().to_string();
261                    if t == "msdos" || t == "vfat" {
262                        eprintln!(
263                            "\n{} CRITICAL: Target filesystem is '{}'. ZoiOS requires a Linux filesystem (ext4, btrfs, xfs) to support hard links and permissions. Your bootstrap will NOT work on FAT32.",
264                            "Error:".red().bold(),
265                            t.yellow()
266                        );
267                    } else if verbose {
268                        println!("{} Filesystem type: {}", "::".bold().blue(), t.green());
269                    }
270                }
271
272                // Merged-Usr Symlink Audit
273                let mut broken_layout = false;
274                for sym in &["bin", "sbin", "lib", "lib64"] {
275                    let p = target_path.join(sym);
276                    let meta = std::fs::symlink_metadata(&p);
277                    if let Ok(m) = meta {
278                        if !m.file_type().is_symlink() {
279                            eprintln!(
280                                "{} WARNING: '/{}' is a real directory, but ZoiOS expects a merged-usr symlink to 'usr/{}'.",
281                                "::".bold().yellow(),
282                                sym,
283                                sym
284                            );
285                            broken_layout = true;
286                        } else if let Ok(target) = std::fs::read_link(&p) {
287                            if target.is_absolute() {
288                                eprintln!(
289                                    "{} WARNING: '/{}' is an absolute symlink to '{}'. This WILL break inside the chroot. It should be relative (e.g. 'usr/{}').",
290                                    "::".bold().yellow(),
291                                    sym,
292                                    target.display(),
293                                    sym
294                                );
295                                broken_layout = true;
296                            } else {
297                                let abs_target = target_path.join(target);
298                                if !abs_target.exists() {
299                                    eprintln!(
300                                        "{} WARNING: Symlink '/{}' points to non-existent path '{}'.",
301                                        "::".bold().yellow(),
302                                        sym,
303                                        abs_target.display()
304                                    );
305                                    broken_layout = true;
306                                }
307                            }
308                        }
309                    } else if *sym != "sbin" {
310                        eprintln!(
311                            "{} WARNING: '/{}' is missing! Your binaries will likely fail to find their loader or shell.",
312                            "::".bold().yellow(),
313                            sym
314                        );
315                        broken_layout = true;
316                    }
317                }
318
319                // Dynamic Loader Validation (The common cause of 139)
320                let mut loader_found = false;
321                let loaders = [
322                    "usr/lib/ld-linux-x86-64.so.2",
323                    "lib64/ld-linux-x86-64.so.2",
324                    "lib/ld-linux-x86-64.so.2",
325                ];
326                for l in &loaders {
327                    let lp = target_path.join(l);
328                    if lp.exists() {
329                        loader_found = true;
330                        if let Ok(mut file) = std::fs::File::open(&lp) {
331                            let mut magic = [0u8; 4];
332                            if file.read_exact(&mut magic).is_ok() {
333                                if magic != [0x7f, b'E', b'L', b'F'] {
334                                    eprintln!(
335                                        "{} CRITICAL: Dynamic loader '{}' is NOT an ELF file! Your glibc installation is corrupted.",
336                                        "Error:".red().bold(),
337                                        l
338                                    );
339                                }
340                            } else {
341                                eprintln!(
342                                    "{} CRITICAL: Dynamic loader '{}' is 0 bytes or unreadable.",
343                                    "Error:".red().bold(),
344                                    l
345                                );
346                            }
347                        }
348                        break;
349                    }
350                }
351                if !loader_found {
352                    eprintln!(
353                        "{} Dynamic loader not found. Binaries WILL Segfault (139).",
354                        "::".bold().yellow()
355                    );
356                }
357
358                if broken_layout || !loader_found {
359                    println!(
360                        "{} Hint: Your ZoiOS bootstrap appears incomplete or corrupted. Please verify your base system packages.",
361                        "::".bold().blue()
362                    );
363                }
364
365                if verbose {
366                    println!(
367                        "{} Entering sysroot at {}...",
368                        "::".bold().blue(),
369                        target.cyan()
370                    );
371                }
372
373                let mut envs = std::collections::HashMap::new();
374                envs.insert(
375                    "PATH".to_string(),
376                    "/usr/bin:/bin:/usr/sbin:/sbin".to_string(),
377                );
378                envs.insert("SHELL".to_string(), "/usr/bin/bash".to_string());
379                envs.insert(
380                    "TERM".to_string(),
381                    std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()),
382                );
383
384                // Resolve shell path in guest (Prefer /usr/bin/bash)
385                let mut shell_bin = std::path::PathBuf::from("/usr/bin/bash");
386                if !target_path.join("usr/bin/bash").exists()
387                    && target_path.join("bin/bash").exists()
388                {
389                    shell_bin = std::path::PathBuf::from("/bin/bash");
390                }
391
392                if verbose {
393                    let cmd_display = if let Some(r) = &run {
394                        format!("{} -c '{}'", shell_bin.display(), r)
395                    } else {
396                        shell_bin.display().to_string()
397                    };
398                    println!(
399                        "{} Running inside chroot: {}",
400                        "::".bold().blue(),
401                        cmd_display.green()
402                    );
403                }
404
405                #[cfg(target_os = "linux")]
406                {
407                    let mut cmd = if let Some(run_cmd) = run {
408                        let args = vec!["-c".to_string(), run_cmd];
409                        crate::sandbox::wrap_command_in_root(
410                            target_path,
411                            &shell_bin,
412                            &args,
413                            &envs,
414                            &[],
415                            false,
416                        )?
417                    } else {
418                        crate::sandbox::wrap_command_in_root(
419                            target_path,
420                            &shell_bin,
421                            &[],
422                            &envs,
423                            &[],
424                            false,
425                        )?
426                    };
427
428                    if verbose {
429                        println!("{} Full command: {:?}", "::".bold().blue(), cmd);
430                    }
431
432                    let status = cmd.status()?;
433                    if !status.success() {
434                        let code = status.code().unwrap_or(1);
435                        eprintln!(
436                            "\n{} Chroot execution failed with exit code: {}",
437                            "Error:".red().bold(),
438                            code.to_string().yellow()
439                        );
440                        if code == 139 {
441                            println!(
442                                "{} Hint: Segfaults (139) often indicate an instruction set mismatch (e.g. x86-64-v3 binaries on older CPUs).",
443                                "::".bold().blue()
444                            );
445                        }
446                        std::process::exit(code);
447                    }
448                }
449
450                #[cfg(not(target_os = "linux"))]
451                return Err(anyhow!(
452                    "Distro chroot is only supported on Linux via Bubblewrap."
453                ));
454            }
455        },
456        SystemSubcommands::Apply { file } => {
457            #[cfg(unix)]
458            {
459                println!("Reading system configuration from {}...", file.cyan());
460                let config = load_system_lua(&file)?;
461                let response = send_request(Request::ApplySystemConfig(Box::new(config)))?;
462                handle_response(response)?;
463            }
464            #[cfg(not(unix))]
465            return Err(anyhow!(
466                "OS management daemon commands are only supported on Unix."
467            ));
468        }
469        SystemSubcommands::List => {
470            #[cfg(unix)]
471            {
472                let response = send_request(Request::ListGenerations)?;
473                match response {
474                    Response::Generations(gens) => {
475                        println!("{:<5} {:<25} {:<50}", "ID", "Created At", "Packages");
476                        println!("{:-<80}", "");
477                        for generation in gens {
478                            println!(
479                                "{:<5} {:<25} {:<50}",
480                                generation.id,
481                                generation.created_at.to_rfc3339(),
482                                generation.packages.join(", ")
483                            );
484                        }
485                    }
486                    _ => handle_response(response)?,
487                }
488            }
489            #[cfg(not(unix))]
490            return Err(anyhow!(
491                "OS management daemon commands are only supported on Unix."
492            ));
493        }
494        SystemSubcommands::Status => {
495            #[cfg(unix)]
496            {
497                let response = send_request(Request::GetStatus)?;
498                handle_response(response)?;
499            }
500            #[cfg(not(unix))]
501            return Err(anyhow!(
502                "OS management daemon commands are only supported on Unix."
503            ));
504        }
505        SystemSubcommands::Rollback { id } => {
506            #[cfg(unix)]
507            {
508                println!("Rolling back to generation {}...", id.to_string().yellow());
509                let response = send_request(Request::RollbackGeneration(id))?;
510                handle_response(response)?;
511            }
512            #[cfg(not(unix))]
513            let _ = id;
514            #[cfg(not(unix))]
515            return Err(anyhow!(
516                "OS management daemon commands are only supported on Unix."
517            ));
518        }
519    }
520
521    Ok(())
522}
523
524fn print_build_summary(target: &str, config: &zoi_system::config::SystemConfig, dry_run: bool) {
525    use comfy_table::modifiers::UTF8_ROUND_CORNERS;
526    use comfy_table::presets::UTF8_FULL_CONDENSED;
527    use comfy_table::{Cell, Color, Table};
528
529    println!("\n{}", " ZoiOS Build Plan ".bold().on_blue().white());
530    if dry_run {
531        println!(
532            "{}",
533            " [DRY-RUN MODE - NO CHANGES WILL BE MADE] "
534                .on_yellow()
535                .black()
536                .bold()
537        );
538    }
539    println!("{} {}\n", "Target Root:".bold(), target.cyan());
540
541    // Filesystems
542    let mut fs_table = Table::new();
543    fs_table
544        .load_preset(UTF8_FULL_CONDENSED)
545        .apply_modifier(UTF8_ROUND_CORNERS)
546        .set_header(vec![
547            Cell::new("Action").fg(Color::Yellow),
548            Cell::new("Device").fg(Color::Yellow),
549            Cell::new("FS Type").fg(Color::Yellow),
550            Cell::new("Mount Point").fg(Color::Yellow),
551            Cell::new("Options").fg(Color::Yellow),
552        ]);
553
554    for fs in &config.filesystems {
555        fs_table.add_row(vec![
556            Cell::new("Configure (fstab)").fg(Color::Blue),
557            Cell::new(&fs.device),
558            Cell::new(&fs.fs_type),
559            Cell::new(&fs.mount).fg(Color::Cyan),
560            Cell::new(fs.options.as_deref().unwrap_or("defaults")),
561        ]);
562    }
563    println!("{}", " 1. Filesystem & Partitioning ".bold().underline());
564    println!("{}\n", fs_table);
565
566    // System Info
567    let mut sys_table = Table::new();
568    sys_table
569        .load_preset(UTF8_FULL_CONDENSED)
570        .apply_modifier(UTF8_ROUND_CORNERS)
571        .set_header(vec![
572            Cell::new("Property").fg(Color::Yellow),
573            Cell::new("Value").fg(Color::Yellow),
574        ]);
575
576    sys_table.add_row(vec![
577        Cell::new("Hostname"),
578        Cell::new(config.system.hostname.as_deref().unwrap_or("zoios")).fg(Color::Cyan),
579    ]);
580    sys_table.add_row(vec![
581        Cell::new("Timezone"),
582        Cell::new(config.system.timezone.as_deref().unwrap_or("UTC")),
583    ]);
584    sys_table.add_row(vec![
585        Cell::new("Locale"),
586        Cell::new(config.system.locale.as_deref().unwrap_or("en_US.UTF-8")),
587    ]);
588
589    println!("{}", " 2. System Configuration ".bold().underline());
590    println!("{}\n", sys_table);
591
592    // Packages
593    println!("{}", " 3. Packages ".bold().underline());
594    println!(
595        "{} base packages will be installed from the registry.\n",
596        config.packages.len().to_string().green().bold()
597    );
598
599    let mut pkg_list = String::new();
600    for (i, pkg) in config.packages.iter().enumerate() {
601        pkg_list.push_str(&format!("{}", pkg.cyan()));
602        if i < config.packages.len() - 1 {
603            pkg_list.push_str(", ");
604        }
605        if (i + 1) % 5 == 0 {
606            pkg_list.push('\n');
607        }
608    }
609    println!("{}\n", pkg_list);
610}
611
612#[cfg(unix)]
613fn handle_response(response: Response) -> Result<()> {
614    match response {
615        Response::Ok => println!("{}", "Operation successful.".green()),
616        Response::Success(msg) => println!("{} {}", "Success:".green(), msg),
617        Response::Status(msg) => println!("Daemon status: {}", msg.cyan()),
618        Response::Error(err) => return Err(anyhow!("Daemon error: {}", err)),
619        _ => return Err(anyhow!("Unexpected response from daemon")),
620    }
621    Ok(())
622}