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                    let project_config = zoi_project::config::ProjectConfig {
191                        name: "system".to_string(),
192                        registries: std::collections::HashMap::new(),
193                        packages: Vec::new(),
194                        pkgs: config.packages.clone(),
195                        pkgs_v2: config.packages_v2.clone(),
196                        config: zoi_project::config::ProjectLocalConfig::default(),
197                        commands: Vec::new(),
198                        environments: Vec::new(),
199                        shell: Some(zoi_project::config::ShellSpec::default()),
200                    };
201
202                    crate::cmd::install::run(
203                        &config.packages,
204                        None,
205                        false, // force
206                        false, // all_optional
207                        yes,
208                        Some(crate::cli::InstallScope::System),
209                        false,
210                        false,
211                        false,
212                        None,
213                        false,
214                        None,
215                        false,
216                        false,
217                        false,
218                        false,
219                        3,
220                        false,
221                        false,
222                        Some(project_config),
223                    )?;
224                }
225
226                // Finalize Generation
227                zoi_system::distro::finalize_first_generation(
228                    target_path,
229                    config.packages.clone(),
230                    dry_run,
231                )?;
232
233                let success_msg = if dry_run {
234                    "Dry-run complete."
235                } else {
236                    "ZoiOS build complete."
237                };
238                println!(
239                    "{} {} on {}.",
240                    "Success:".green(),
241                    success_msg,
242                    target.cyan()
243                );
244            }
245            DistroSubcommands::Chroot {
246                target,
247                run,
248                verbose,
249            } => {
250                let target_path = std::path::Path::new(&target);
251                if !target_path.exists() {
252                    return Err(anyhow!("Target path '{}' does not exist.", target));
253                }
254
255                let os_release = target_path.join("etc/os-release");
256                if !os_release.exists() {
257                    return Err(anyhow!(
258                        "Target path '{}' is not a valid ZoiOS root (missing /etc/os-release).",
259                        target
260                    ));
261                }
262
263                // --- BOOTSTRAP AUDIT ---
264
265                // Filesystem check
266                let fs_type = std::process::Command::new("stat")
267                    .arg("-f")
268                    .arg("-c")
269                    .arg("%T")
270                    .arg(&target)
271                    .output();
272                if let Ok(out) = fs_type {
273                    let t = String::from_utf8_lossy(&out.stdout).trim().to_string();
274                    if t == "msdos" || t == "vfat" {
275                        eprintln!(
276                            "\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.",
277                            "Error:".red().bold(),
278                            t.yellow()
279                        );
280                    } else if verbose {
281                        println!("{} Filesystem type: {}", "::".bold().blue(), t.green());
282                    }
283                }
284
285                // Merged-Usr Symlink Audit
286                let mut broken_layout = false;
287                for sym in &["bin", "sbin", "lib", "lib64"] {
288                    let p = target_path.join(sym);
289                    let meta = std::fs::symlink_metadata(&p);
290                    if let Ok(m) = meta {
291                        if !m.file_type().is_symlink() {
292                            eprintln!(
293                                "{} WARNING: '/{}' is a real directory, but ZoiOS expects a merged-usr symlink to 'usr/{}'.",
294                                "::".bold().yellow(),
295                                sym,
296                                sym
297                            );
298                            broken_layout = true;
299                        } else if let Ok(target) = std::fs::read_link(&p) {
300                            if target.is_absolute() {
301                                eprintln!(
302                                    "{} WARNING: '/{}' is an absolute symlink to '{}'. This WILL break inside the chroot. It should be relative (e.g. 'usr/{}').",
303                                    "::".bold().yellow(),
304                                    sym,
305                                    target.display(),
306                                    sym
307                                );
308                                broken_layout = true;
309                            } else {
310                                let abs_target = target_path.join(target);
311                                if !abs_target.exists() {
312                                    eprintln!(
313                                        "{} WARNING: Symlink '/{}' points to non-existent path '{}'.",
314                                        "::".bold().yellow(),
315                                        sym,
316                                        abs_target.display()
317                                    );
318                                    broken_layout = true;
319                                }
320                            }
321                        }
322                    } else if *sym != "sbin" {
323                        eprintln!(
324                            "{} WARNING: '/{}' is missing! Your binaries will likely fail to find their loader or shell.",
325                            "::".bold().yellow(),
326                            sym
327                        );
328                        broken_layout = true;
329                    }
330                }
331
332                // Dynamic Loader Validation (The common cause of 139)
333                let mut loader_found = false;
334                let loaders = [
335                    "usr/lib/ld-linux-x86-64.so.2",
336                    "lib64/ld-linux-x86-64.so.2",
337                    "lib/ld-linux-x86-64.so.2",
338                ];
339                for l in &loaders {
340                    let lp = target_path.join(l);
341                    if lp.exists() {
342                        loader_found = true;
343                        if let Ok(mut file) = std::fs::File::open(&lp) {
344                            let mut magic = [0u8; 4];
345                            if file.read_exact(&mut magic).is_ok() {
346                                if magic != [0x7f, b'E', b'L', b'F'] {
347                                    eprintln!(
348                                        "{} CRITICAL: Dynamic loader '{}' is NOT an ELF file! Your glibc installation is corrupted.",
349                                        "Error:".red().bold(),
350                                        l
351                                    );
352                                }
353                            } else {
354                                eprintln!(
355                                    "{} CRITICAL: Dynamic loader '{}' is 0 bytes or unreadable.",
356                                    "Error:".red().bold(),
357                                    l
358                                );
359                            }
360                        }
361                        break;
362                    }
363                }
364                if !loader_found {
365                    eprintln!(
366                        "{} Dynamic loader not found. Binaries WILL Segfault (139).",
367                        "::".bold().yellow()
368                    );
369                }
370
371                if broken_layout || !loader_found {
372                    println!(
373                        "{} Hint: Your ZoiOS bootstrap appears incomplete or corrupted. Please verify your base system packages.",
374                        "::".bold().blue()
375                    );
376                }
377
378                if verbose {
379                    println!(
380                        "{} Entering sysroot at {}...",
381                        "::".bold().blue(),
382                        target.cyan()
383                    );
384                }
385
386                let mut envs = std::collections::HashMap::new();
387                envs.insert(
388                    "PATH".to_string(),
389                    "/usr/bin:/bin:/usr/sbin:/sbin".to_string(),
390                );
391                envs.insert("SHELL".to_string(), "/usr/bin/bash".to_string());
392                envs.insert(
393                    "TERM".to_string(),
394                    std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()),
395                );
396
397                // Resolve shell path in guest (Prefer /usr/bin/bash)
398                let mut shell_bin = std::path::PathBuf::from("/usr/bin/bash");
399                if !target_path.join("usr/bin/bash").exists()
400                    && target_path.join("bin/bash").exists()
401                {
402                    shell_bin = std::path::PathBuf::from("/bin/bash");
403                }
404
405                if verbose {
406                    let cmd_display = if let Some(r) = &run {
407                        format!("{} -c '{}'", shell_bin.display(), r)
408                    } else {
409                        shell_bin.display().to_string()
410                    };
411                    println!(
412                        "{} Running inside chroot: {}",
413                        "::".bold().blue(),
414                        cmd_display.green()
415                    );
416                }
417
418                #[cfg(target_os = "linux")]
419                {
420                    let mut cmd = if let Some(run_cmd) = run {
421                        let args = vec!["-c".to_string(), run_cmd];
422                        crate::sandbox::wrap_command_in_root(
423                            target_path,
424                            &shell_bin,
425                            &args,
426                            &envs,
427                            &[],
428                            false,
429                        )?
430                    } else {
431                        crate::sandbox::wrap_command_in_root(
432                            target_path,
433                            &shell_bin,
434                            &[],
435                            &envs,
436                            &[],
437                            false,
438                        )?
439                    };
440
441                    if verbose {
442                        println!("{} Full command: {:?}", "::".bold().blue(), cmd);
443                    }
444
445                    let status = cmd.status()?;
446                    if !status.success() {
447                        let code = status.code().unwrap_or(1);
448                        eprintln!(
449                            "\n{} Chroot execution failed with exit code: {}",
450                            "Error:".red().bold(),
451                            code.to_string().yellow()
452                        );
453                        if code == 139 {
454                            println!(
455                                "{} Hint: Segfaults (139) often indicate an instruction set mismatch (e.g. x86-64-v3 binaries on older CPUs).",
456                                "::".bold().blue()
457                            );
458                        }
459                        std::process::exit(code);
460                    }
461                }
462
463                #[cfg(not(target_os = "linux"))]
464                return Err(anyhow!(
465                    "Distro chroot is only supported on Linux via Bubblewrap."
466                ));
467            }
468        },
469        SystemSubcommands::Apply { file } => {
470            #[cfg(unix)]
471            {
472                println!("Reading system configuration from {}...", file.cyan());
473                let config = load_system_lua(&file)?;
474                let response = send_request(Request::ApplySystemConfig(Box::new(config)))?;
475                handle_response(response)?;
476            }
477            #[cfg(not(unix))]
478            return Err(anyhow!(
479                "OS management daemon commands are only supported on Unix."
480            ));
481        }
482        SystemSubcommands::List => {
483            #[cfg(unix)]
484            {
485                let response = send_request(Request::ListGenerations)?;
486                match response {
487                    Response::Generations(gens) => {
488                        println!("{:<5} {:<25} {:<50}", "ID", "Created At", "Packages");
489                        println!("{:-<80}", "");
490                        for generation in gens {
491                            println!(
492                                "{:<5} {:<25} {:<50}",
493                                generation.id,
494                                generation.created_at.to_rfc3339(),
495                                generation.packages.join(", ")
496                            );
497                        }
498                    }
499                    _ => handle_response(response)?,
500                }
501            }
502            #[cfg(not(unix))]
503            return Err(anyhow!(
504                "OS management daemon commands are only supported on Unix."
505            ));
506        }
507        SystemSubcommands::Status => {
508            #[cfg(unix)]
509            {
510                let response = send_request(Request::GetStatus)?;
511                handle_response(response)?;
512            }
513            #[cfg(not(unix))]
514            return Err(anyhow!(
515                "OS management daemon commands are only supported on Unix."
516            ));
517        }
518        SystemSubcommands::Rollback { id } => {
519            #[cfg(unix)]
520            {
521                println!("Rolling back to generation {}...", id.to_string().yellow());
522                let response = send_request(Request::RollbackGeneration(id))?;
523                handle_response(response)?;
524            }
525            #[cfg(not(unix))]
526            let _ = id;
527            #[cfg(not(unix))]
528            return Err(anyhow!(
529                "OS management daemon commands are only supported on Unix."
530            ));
531        }
532    }
533
534    Ok(())
535}
536
537fn print_build_summary(target: &str, config: &zoi_system::config::SystemConfig, dry_run: bool) {
538    use comfy_table::modifiers::UTF8_ROUND_CORNERS;
539    use comfy_table::presets::UTF8_FULL_CONDENSED;
540    use comfy_table::{Cell, Color, Table};
541
542    println!("\n{}", " ZoiOS Build Plan ".bold().on_blue().white());
543    if dry_run {
544        println!(
545            "{}",
546            " [DRY-RUN MODE - NO CHANGES WILL BE MADE] "
547                .on_yellow()
548                .black()
549                .bold()
550        );
551    }
552    println!("{} {}\n", "Target Root:".bold(), target.cyan());
553
554    // Filesystems
555    let mut fs_table = Table::new();
556    fs_table
557        .load_preset(UTF8_FULL_CONDENSED)
558        .apply_modifier(UTF8_ROUND_CORNERS)
559        .set_header(vec![
560            Cell::new("Action").fg(Color::Yellow),
561            Cell::new("Device").fg(Color::Yellow),
562            Cell::new("FS Type").fg(Color::Yellow),
563            Cell::new("Mount Point").fg(Color::Yellow),
564            Cell::new("Options").fg(Color::Yellow),
565        ]);
566
567    for fs in &config.filesystems {
568        fs_table.add_row(vec![
569            Cell::new("Configure (fstab)").fg(Color::Blue),
570            Cell::new(&fs.device),
571            Cell::new(&fs.fs_type),
572            Cell::new(&fs.mount).fg(Color::Cyan),
573            Cell::new(fs.options.as_deref().unwrap_or("defaults")),
574        ]);
575    }
576    println!("{}", " 1. Filesystem & Partitioning ".bold().underline());
577    println!("{}\n", fs_table);
578
579    // System Info
580    let mut sys_table = Table::new();
581    sys_table
582        .load_preset(UTF8_FULL_CONDENSED)
583        .apply_modifier(UTF8_ROUND_CORNERS)
584        .set_header(vec![
585            Cell::new("Property").fg(Color::Yellow),
586            Cell::new("Value").fg(Color::Yellow),
587        ]);
588
589    sys_table.add_row(vec![
590        Cell::new("Hostname"),
591        Cell::new(config.system.hostname.as_deref().unwrap_or("zoios")).fg(Color::Cyan),
592    ]);
593    sys_table.add_row(vec![
594        Cell::new("Timezone"),
595        Cell::new(config.system.timezone.as_deref().unwrap_or("UTC")),
596    ]);
597    sys_table.add_row(vec![
598        Cell::new("Locale"),
599        Cell::new(config.system.locale.as_deref().unwrap_or("en_US.UTF-8")),
600    ]);
601
602    println!("{}", " 2. System Configuration ".bold().underline());
603    println!("{}\n", sys_table);
604
605    // Packages
606    println!("{}", " 3. Packages ".bold().underline());
607    println!(
608        "{} base packages will be installed from the registry.\n",
609        config.packages.len().to_string().green().bold()
610    );
611
612    let mut pkg_list = String::new();
613    for (i, pkg) in config.packages.iter().enumerate() {
614        pkg_list.push_str(&format!("{}", pkg.cyan()));
615        if i < config.packages.len() - 1 {
616            pkg_list.push_str(", ");
617        }
618        if (i + 1) % 5 == 0 {
619            pkg_list.push('\n');
620        }
621    }
622    println!("{}\n", pkg_list);
623}
624
625#[cfg(unix)]
626fn handle_response(response: Response) -> Result<()> {
627    match response {
628        Response::Ok => println!("{}", "Operation successful.".green()),
629        Response::Success(msg) => println!("{} {}", "Success:".green(), msg),
630        Response::Status(msg) => println!("Daemon status: {}", msg.cyan()),
631        Response::Error(err) => return Err(anyhow!("Daemon error: {}", err)),
632        _ => return Err(anyhow!("Unexpected response from daemon")),
633    }
634    Ok(())
635}