1use std::fmt::Write as _;
8use std::io::Read;
9
10use anyhow::{Result, anyhow};
11use clap::{Parser, Subcommand};
12use colored::Colorize;
13use zoi_core::utils::is_zoios;
14#[cfg(unix)]
15use zoi_system::client::send_request;
16use zoi_system::config::load_system_lua;
17#[cfg(unix)]
18use zoi_system::protocol::{Request, Response};
19
20#[derive(Parser, Debug)]
22pub struct SystemCommand {
23 #[command(subcommand)]
25 pub command: SystemSubcommands
26}
27
28#[derive(Subcommand, Debug)]
30pub enum SystemSubcommands {
31 Apply {
33 #[arg(default_value = "/etc/zoi/system.lua")]
35 file: String
36 },
37 List,
39 Status,
41 Rollback {
43 id: u32
45 },
46 Pin {
48 id: u32
50 },
51 Unpin {
53 id: u32
55 },
56 Secret {
58 #[command(subcommand)]
60 command: SecretSubcommands
61 },
62 Distro {
64 #[command(subcommand)]
66 command: DistroSubcommands
67 }
68}
69
70#[derive(Subcommand, Debug)]
72pub enum DistroSubcommands {
73 Build {
75 #[arg(short, long)]
77 target: String,
78 #[arg(short, long)]
80 config: String,
81 #[arg(long)]
83 dry_run: bool
84 },
85 Chroot {
87 target: String,
89 #[arg(short, long)]
91 run: Option<String>,
92 #[arg(long, short)]
94 verbose: bool
95 }
96}
97
98#[derive(Subcommand, Debug)]
100pub enum SecretSubcommands {
101 Hash {
103 password: String
105 },
106 Encrypt {
108 value: String
110 },
111 Decrypt {
114 secret: String
116 },
117 ExportKey,
119 ImportKey {
121 key: String
123 }
124}
125
126pub fn run(args: SystemCommand, yes: bool) -> Result<()> {
140 let is_secret = matches!(args.command, SystemSubcommands::Secret { .. });
141 let is_distro = matches!(args.command, SystemSubcommands::Distro { .. });
142
143 if !is_secret && !is_distro && !is_zoios() {
144 return Err(anyhow!(
145 "OS management features are only available on ZoiOS systems."
146 ));
147 }
148
149 match args.command {
150 SystemSubcommands::Secret { command } => match command {
151 SecretSubcommands::Hash { password } => {
152 let hash = zoi_system::secret::hash_password(&password)?;
153 println!(
154 "Password hash generated successfully. Use this in your \
155 system.lua:"
156 );
157 println!("\n {}", hash.green());
158 }
159 SecretSubcommands::Encrypt { value } => {
160 let encrypted = zoi_system::secret::encrypt_secret(&value)?;
161 println!(
162 "Value encrypted successfully. Use this in your \
163 system.lua or home.lua:"
164 );
165 println!("\n {}", encrypted.yellow());
166 println!(
167 "\n{}",
168 "Note: This can only be decrypted by Zoi on this specific \
169 machine."
170 .dimmed()
171 );
172 }
173 SecretSubcommands::Decrypt { secret } => {
174 let decrypted = zoi_system::secret::decrypt_secret(&secret)?;
175 if decrypted == secret {
176 return Err(anyhow!(
177 "Input is not a valid Zoi secret string."
178 ));
179 }
180 println!("Secret decrypted successfully:");
181 println!("\n {}", decrypted.green());
182 }
183 SecretSubcommands::ExportKey => {
184 let key = zoi_system::secret::export_master_key()?;
185 println!("ZoiSEC Master Key (base64):");
186 println!("\n {}", key.yellow());
187 println!(
188 "\n{}",
189 "Keep this key safe! Anyone with this key can decrypt \
190 your ZoiSEC secrets."
191 .red()
192 .bold()
193 );
194 }
195 SecretSubcommands::ImportKey { key } => {
196 zoi_system::secret::import_master_key(&key)?;
197 println!(
198 "{} ZoiSEC master key imported successfully.",
199 "Success:".green()
200 );
201 }
202 },
203 SystemSubcommands::Distro { command } => match command {
204 DistroSubcommands::Build {
205 target,
206 config,
207 dry_run
208 } => {
209 let target_path = std::path::Path::new(&target);
210 let config = load_system_lua(&config)?;
211
212 println!(
214 "{} Validating {} packages...",
215 "::".bold().blue(),
216 config.packages.len().to_string().cyan()
217 );
218 for pkg_id in &config.packages {
219 if let Err(e) = zoi_resolver::resolve::resolve_source(
220 pkg_id, None, true, true
221 ) {
222 return Err(anyhow!(
223 "Package validation failed for '{pkg_id}': {e}"
224 ));
225 }
226 }
227
228 print_build_summary(&target, &config, dry_run);
229
230 if !dry_run
231 && !zoi_core::utils::ask_for_confirmation(
232 "Are you sure you want to proceed with the build? \
233 This will install ZoiOS to the target device.",
234 yes
235 )
236 {
237 return Err(anyhow!("Build aborted by user."));
238 }
239
240 println!(
241 "{} Orchestrating ZoiOS build on {}...",
242 "::".bold().blue(),
243 target.cyan()
244 );
245
246 zoi_system::distro::initialize_zoios_marker(
248 target_path,
249 config.system.hostname.as_deref(),
250 dry_run
251 )?;
252
253 if dry_run {
255 println!(
256 " {} Would install base packages: {}",
257 "[DRY-RUN]".dimmed(),
258 config.packages.join(", ")
259 );
260 } else {
261 println!(
262 "{} Installing base packages to {}...",
263 "::".bold().blue(),
264 target.cyan()
265 );
266
267 let project_config = zoi_project::config::ProjectConfig {
269 name: "system".to_string(),
270 registries: std::collections::HashMap::new(),
271 packages: Vec::new(),
272 pkgs: config.packages.clone(),
273 pkgs_v2: config.packages_v2.clone(),
274 config:
275 zoi_project::config::ProjectLocalConfig::default(),
276 commands: Vec::new(),
277 environments: Vec::new(),
278 shell: Some(zoi_project::config::ShellSpec::default()),
279 imports: std::collections::BTreeMap::default(),
280 package_export: None
281 };
282
283 crate::cmd::install::run(
284 &config.packages,
285 None,
286 false, false, yes,
289 Some(crate::cli::InstallScope::System),
290 false,
291 false,
292 false,
293 false,
294 false,
295 None,
296 false,
297 None,
298 false,
299 false,
300 false,
301 false,
302 3,
303 false,
304 false,
305 Some(project_config)
306 )?;
307 }
308
309 println!("{} Configuring accounts...", "::".bold().blue());
313 match zoi_system::account::apply_from_config(&config, dry_run) {
314 Ok(report) if !dry_run => {
315 zoi_system::account::print_report(&report);
316 }
317 Ok(_) => {}
318 Err(e) => {
319 return Err(anyhow!(
320 "Failed to configure users/groups: {e}"
321 ));
322 }
323 }
324
325 zoi_system::distro::finalize_first_generation(
327 target_path,
328 config.packages.clone(),
329 dry_run
330 )?;
331
332 let success_msg = if dry_run {
333 "Dry-run complete."
334 } else {
335 "ZoiOS build complete."
336 };
337 println!(
338 "{} {} on {}.",
339 "Success:".green(),
340 success_msg,
341 target.cyan()
342 );
343 }
344 DistroSubcommands::Chroot {
345 target,
346 run,
347 verbose
348 } => {
349 let target_path = std::path::Path::new(&target);
350 if !target_path.exists() {
351 return Err(anyhow!(
352 "Target path '{target}' does not exist."
353 ));
354 }
355
356 let os_release = target_path.join("etc/os-release");
357 if !os_release.exists() {
358 return Err(anyhow!(
359 "Target path '{target}' is not a valid ZoiOS root \
360 (missing /etc/os-release)."
361 ));
362 }
363
364 let fs_type = std::process::Command::new("stat")
368 .arg("-f")
369 .arg("-c")
370 .arg("%T")
371 .arg(&target)
372 .output();
373 if let Ok(out) = fs_type {
374 let t =
375 String::from_utf8_lossy(&out.stdout).trim().to_string();
376 if t == "msdos" || t == "vfat" {
377 eprintln!(
378 "\n{} CRITICAL: Target filesystem is '{}'. ZoiOS \
379 requires a Linux filesystem (ext4, btrfs, xfs) \
380 to support hard links and permissions. Your \
381 bootstrap will NOT work on FAT32.",
382 "Error:".red().bold(),
383 t.yellow()
384 );
385 } else if verbose {
386 println!(
387 "{} Filesystem type: {}",
388 "::".bold().blue(),
389 t.green()
390 );
391 }
392 }
393
394 let mut broken_layout = false;
396 for sym in &["bin", "sbin", "lib", "lib64"] {
397 let p = target_path.join(sym);
398 let meta = std::fs::symlink_metadata(&p);
399 if let Ok(m) = meta {
400 if !m.file_type().is_symlink() {
401 eprintln!(
402 "{} WARNING: '/{}' is a real directory, but \
403 ZoiOS expects a merged-usr symlink to \
404 'usr/{}'.",
405 "::".bold().yellow(),
406 sym,
407 sym
408 );
409 broken_layout = true;
410 } else if let Ok(target) = std::fs::read_link(&p) {
411 if target.is_absolute() {
412 eprintln!(
413 "{} WARNING: '/{}' is an absolute symlink \
414 to '{}'. This WILL break inside the \
415 chroot. It should be relative (e.g. \
416 'usr/{}').",
417 "::".bold().yellow(),
418 sym,
419 target.display(),
420 sym
421 );
422 broken_layout = true;
423 } else {
424 let abs_target = target_path.join(target);
425 if !abs_target.exists() {
426 eprintln!(
427 "{} WARNING: Symlink '/{}' points to \
428 non-existent path '{}'.",
429 "::".bold().yellow(),
430 sym,
431 abs_target.display()
432 );
433 broken_layout = true;
434 }
435 }
436 }
437 } else if *sym != "sbin" {
438 eprintln!(
439 "{} WARNING: '/{}' is missing! Your binaries will \
440 likely fail to find their loader or shell.",
441 "::".bold().yellow(),
442 sym
443 );
444 broken_layout = true;
445 }
446 }
447
448 let mut loader_found = false;
450 let loaders = [
451 "usr/lib/ld-linux-x86-64.so.2",
452 "lib64/ld-linux-x86-64.so.2",
453 "lib/ld-linux-x86-64.so.2"
454 ];
455 for l in &loaders {
456 let lp = target_path.join(l);
457 if lp.exists() {
458 loader_found = true;
459 if let Ok(mut file) = std::fs::File::open(&lp) {
460 let mut magic = [0u8; 4];
461 if file.read_exact(&mut magic).is_ok() {
462 if magic != [0x7f, b'E', b'L', b'F'] {
463 eprintln!(
464 "{} CRITICAL: Dynamic loader '{}' is \
465 NOT an ELF file! Your glibc \
466 installation is corrupted.",
467 "Error:".red().bold(),
468 l
469 );
470 }
471 } else {
472 eprintln!(
473 "{} CRITICAL: Dynamic loader '{}' is 0 \
474 bytes or unreadable.",
475 "Error:".red().bold(),
476 l
477 );
478 }
479 }
480 break;
481 }
482 }
483 if !loader_found {
484 eprintln!(
485 "{} Dynamic loader not found. Binaries WILL Segfault \
486 (139).",
487 "::".bold().yellow()
488 );
489 }
490
491 if broken_layout || !loader_found {
492 println!(
493 "{} Hint: Your ZoiOS bootstrap appears incomplete or \
494 corrupted. Please verify your base system packages.",
495 "::".bold().blue()
496 );
497 }
498
499 if verbose {
500 println!(
501 "{} Entering sysroot at {}...",
502 "::".bold().blue(),
503 target.cyan()
504 );
505 }
506
507 let mut envs = std::collections::HashMap::new();
508 envs.insert(
509 "PATH".to_string(),
510 "/usr/bin:/bin:/usr/sbin:/sbin".to_string()
511 );
512 envs.insert("SHELL".to_string(), "/usr/bin/bash".to_string());
513 envs.insert(
514 "TERM".to_string(),
515 std::env::var("TERM")
516 .unwrap_or_else(|_| "xterm-256color".to_string())
517 );
518
519 let mut shell_bin = std::path::PathBuf::from("/usr/bin/bash");
521 if !target_path.join("usr/bin/bash").exists()
522 && target_path.join("bin/bash").exists()
523 {
524 shell_bin = std::path::PathBuf::from("/bin/bash");
525 }
526
527 if verbose {
528 let cmd_display = if let Some(r) = &run {
529 format!("{} -c '{}'", shell_bin.display(), r)
530 } else {
531 shell_bin.display().to_string()
532 };
533 println!(
534 "{} Running inside chroot: {}",
535 "::".bold().blue(),
536 cmd_display.green()
537 );
538 }
539
540 #[cfg(target_os = "linux")]
541 {
542 let mut cmd = if let Some(run_cmd) = run {
543 let args = vec!["-c".to_string(), run_cmd];
544 crate::sandbox::wrap_command_in_root(
545 target_path,
546 &shell_bin,
547 &args,
548 &envs,
549 &[],
550 false
551 )?
552 } else {
553 crate::sandbox::wrap_command_in_root(
554 target_path,
555 &shell_bin,
556 &[],
557 &envs,
558 &[],
559 false
560 )?
561 };
562
563 if verbose {
564 println!(
565 "{} Full command: {:?}",
566 "::".bold().blue(),
567 cmd
568 );
569 }
570
571 let status = cmd.status()?;
572 if !status.success() {
573 let code = status.code().unwrap_or(1);
574 eprintln!(
575 "\n{} Chroot execution failed with exit code: {}",
576 "Error:".red().bold(),
577 code.to_string().yellow()
578 );
579 if code == 139 {
580 println!(
581 "{} Hint: Segfaults (139) often indicate an \
582 instruction set mismatch (e.g. x86-64-v3 \
583 binaries on older CPUs).",
584 "::".bold().blue()
585 );
586 }
587 std::process::exit(code);
588 }
589 }
590
591 #[cfg(not(target_os = "linux"))]
592 return Err(anyhow!(
593 "Distro chroot is only supported on Linux via Bubblewrap."
594 ));
595 }
596 },
597 SystemSubcommands::Apply { file } => {
598 #[cfg(unix)]
599 {
600 println!(
601 "Reading system configuration from {}...",
602 file.cyan()
603 );
604 let config = load_system_lua(&file)?;
605 let response =
606 send_request(Request::ApplySystemConfig(Box::new(config)))?;
607 handle_response(response)?;
608 }
609 #[cfg(not(unix))]
610 {
611 let _ = file;
612 return Err(anyhow!(
613 "OS management daemon commands are only supported on Unix."
614 ));
615 }
616 }
617 SystemSubcommands::List => {
618 #[cfg(unix)]
619 {
620 let response = send_request(Request::ListGenerations)?;
621 match response {
622 Response::Generations(gens) => {
623 println!(
624 "{:<5} {:<25} {:<50}",
625 "ID", "Created At", "Packages"
626 );
627 println!("{:-<80}", "");
628 for generation in gens {
629 println!(
630 "{:<5} {:<25} {:<50}",
631 generation.id,
632 generation.created_at.to_rfc3339(),
633 generation.packages.join(", ")
634 );
635 }
636 }
637 _ => handle_response(response)?
638 }
639 }
640 #[cfg(not(unix))]
641 return Err(anyhow!(
642 "OS management daemon commands are only supported on Unix."
643 ));
644 }
645 SystemSubcommands::Status => {
646 #[cfg(unix)]
647 {
648 let response = send_request(Request::GetStatus)?;
649 handle_response(response)?;
650 }
651 #[cfg(not(unix))]
652 return Err(anyhow!(
653 "OS management daemon commands are only supported on Unix."
654 ));
655 }
656 SystemSubcommands::Rollback { id } => {
657 #[cfg(unix)]
658 {
659 println!(
660 "Rolling back to generation {}...",
661 id.to_string().yellow()
662 );
663 let response = send_request(Request::RollbackGeneration(id))?;
664 handle_response(response)?;
665 }
666 #[cfg(not(unix))]
667 let _ = id;
668 #[cfg(not(unix))]
669 return Err(anyhow!(
670 "OS management daemon commands are only supported on Unix."
671 ));
672 }
673 SystemSubcommands::Pin { id } => {
674 #[cfg(unix)]
675 {
676 let response = send_request(Request::PinGeneration(id, true))?;
677 handle_response(response)?;
678 }
679 #[cfg(not(unix))]
680 let _ = id;
681 #[cfg(not(unix))]
682 return Err(anyhow!(
683 "OS management daemon commands are only supported on Unix."
684 ));
685 }
686 SystemSubcommands::Unpin { id } => {
687 #[cfg(unix)]
688 {
689 let response = send_request(Request::PinGeneration(id, false))?;
690 handle_response(response)?;
691 }
692 #[cfg(not(unix))]
693 let _ = id;
694 #[cfg(not(unix))]
695 return Err(anyhow!(
696 "OS management daemon commands are only supported on Unix."
697 ));
698 }
699 }
700
701 Ok(())
702}
703
704fn print_build_summary(
706 target: &str,
707 config: &zoi_system::config::SystemConfig,
708 dry_run: bool
709) {
710 use comfy_table::presets::UTF8_FULL_CONDENSED;
711 use comfy_table::{Cell, Color, Table};
712
713 println!("\n{}", " ZoiOS Build Plan ".bold().on_blue().white());
714 if dry_run {
715 println!(
716 "{}",
717 " [DRY-RUN MODE - NO CHANGES WILL BE MADE] "
718 .on_yellow()
719 .black()
720 .bold()
721 );
722 }
723 println!("{} {}\n", "Target Root:".bold(), target.cyan());
724
725 let mut fs_table = Table::new();
727 fs_table
728 .load_style(UTF8_FULL_CONDENSED.with_rounded_corners())
729 .set_header(vec![
730 Cell::new("Action").fg(Color::Yellow),
731 Cell::new("Device").fg(Color::Yellow),
732 Cell::new("FS Type").fg(Color::Yellow),
733 Cell::new("Mount Point").fg(Color::Yellow),
734 Cell::new("Options").fg(Color::Yellow),
735 ]);
736
737 for fs in &config.filesystems {
738 fs_table.add_row(vec![
739 Cell::new("Configure (fstab)").fg(Color::Blue),
740 Cell::new(&fs.device),
741 Cell::new(&fs.fs_type),
742 Cell::new(&fs.mount).fg(Color::Cyan),
743 Cell::new(fs.options.as_deref().unwrap_or("defaults")),
744 ]);
745 }
746 println!("{}", " 1. Filesystem & Partitioning ".bold().underline());
747 println!("{fs_table}\n");
748
749 let mut sys_table = Table::new();
751 sys_table
752 .load_style(UTF8_FULL_CONDENSED.with_rounded_corners())
753 .set_header(vec![
754 Cell::new("Property").fg(Color::Yellow),
755 Cell::new("Value").fg(Color::Yellow),
756 ]);
757
758 sys_table.add_row(vec![
759 Cell::new("Hostname"),
760 Cell::new(config.system.hostname.as_deref().unwrap_or("zoios"))
761 .fg(Color::Cyan),
762 ]);
763 sys_table.add_row(vec![
764 Cell::new("Timezone"),
765 Cell::new(config.system.timezone.as_deref().unwrap_or("UTC")),
766 ]);
767 sys_table.add_row(vec![
768 Cell::new("Locale"),
769 Cell::new(config.system.locale.as_deref().unwrap_or("en_US.UTF-8")),
770 ]);
771
772 println!("{}", " 2. System Configuration ".bold().underline());
773 println!("{sys_table}\n");
774
775 println!("{}", " 3. Packages ".bold().underline());
777 println!(
778 "{} base packages will be installed from the registry.\n",
779 config.packages.len().to_string().green().bold()
780 );
781
782 let mut pkg_list = String::new();
783 for (i, pkg) in config.packages.iter().enumerate() {
784 let _ = write!(pkg_list, "{}", pkg.cyan());
785 if i < config.packages.len() - 1 {
786 pkg_list.push_str(", ");
787 }
788 if (i + 1) % 5 == 0 {
789 pkg_list.push('\n');
790 }
791 }
792 println!("{pkg_list}\n");
793}
794
795#[cfg(unix)]
797fn handle_response(response: Response) -> Result<()> {
798 match response {
799 Response::Ok => println!("{}", "Operation successful.".green()),
800 Response::Success(msg) => println!("{} {}", "Success:".green(), msg),
801 Response::Status(msg) => println!("Daemon status: {}", msg.cyan()),
802 Response::Error(err) => return Err(anyhow!("Daemon error: {err}")),
803 Response::Generations(_) => {
804 return Err(anyhow!("Unexpected response from daemon"));
805 }
806 }
807 Ok(())
808}