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 };
280
281 crate::cmd::install::run(
282 &config.packages,
283 None,
284 false, false, yes,
287 Some(crate::cli::InstallScope::System),
288 false,
289 false,
290 false,
291 false,
292 false,
293 None,
294 false,
295 None,
296 false,
297 false,
298 false,
299 false,
300 3,
301 false,
302 false,
303 Some(project_config)
304 )?;
305 }
306
307 zoi_system::distro::finalize_first_generation(
309 target_path,
310 config.packages.clone(),
311 dry_run
312 )?;
313
314 let success_msg = if dry_run {
315 "Dry-run complete."
316 } else {
317 "ZoiOS build complete."
318 };
319 println!(
320 "{} {} on {}.",
321 "Success:".green(),
322 success_msg,
323 target.cyan()
324 );
325 }
326 DistroSubcommands::Chroot {
327 target,
328 run,
329 verbose
330 } => {
331 let target_path = std::path::Path::new(&target);
332 if !target_path.exists() {
333 return Err(anyhow!(
334 "Target path '{target}' does not exist."
335 ));
336 }
337
338 let os_release = target_path.join("etc/os-release");
339 if !os_release.exists() {
340 return Err(anyhow!(
341 "Target path '{target}' is not a valid ZoiOS root \
342 (missing /etc/os-release)."
343 ));
344 }
345
346 let fs_type = std::process::Command::new("stat")
350 .arg("-f")
351 .arg("-c")
352 .arg("%T")
353 .arg(&target)
354 .output();
355 if let Ok(out) = fs_type {
356 let t =
357 String::from_utf8_lossy(&out.stdout).trim().to_string();
358 if t == "msdos" || t == "vfat" {
359 eprintln!(
360 "\n{} CRITICAL: Target filesystem is '{}'. ZoiOS \
361 requires a Linux filesystem (ext4, btrfs, xfs) \
362 to support hard links and permissions. Your \
363 bootstrap will NOT work on FAT32.",
364 "Error:".red().bold(),
365 t.yellow()
366 );
367 } else if verbose {
368 println!(
369 "{} Filesystem type: {}",
370 "::".bold().blue(),
371 t.green()
372 );
373 }
374 }
375
376 let mut broken_layout = false;
378 for sym in &["bin", "sbin", "lib", "lib64"] {
379 let p = target_path.join(sym);
380 let meta = std::fs::symlink_metadata(&p);
381 if let Ok(m) = meta {
382 if !m.file_type().is_symlink() {
383 eprintln!(
384 "{} WARNING: '/{}' is a real directory, but \
385 ZoiOS expects a merged-usr symlink to \
386 'usr/{}'.",
387 "::".bold().yellow(),
388 sym,
389 sym
390 );
391 broken_layout = true;
392 } else if let Ok(target) = std::fs::read_link(&p) {
393 if target.is_absolute() {
394 eprintln!(
395 "{} WARNING: '/{}' is an absolute symlink \
396 to '{}'. This WILL break inside the \
397 chroot. It should be relative (e.g. \
398 'usr/{}').",
399 "::".bold().yellow(),
400 sym,
401 target.display(),
402 sym
403 );
404 broken_layout = true;
405 } else {
406 let abs_target = target_path.join(target);
407 if !abs_target.exists() {
408 eprintln!(
409 "{} WARNING: Symlink '/{}' points to \
410 non-existent path '{}'.",
411 "::".bold().yellow(),
412 sym,
413 abs_target.display()
414 );
415 broken_layout = true;
416 }
417 }
418 }
419 } else if *sym != "sbin" {
420 eprintln!(
421 "{} WARNING: '/{}' is missing! Your binaries will \
422 likely fail to find their loader or shell.",
423 "::".bold().yellow(),
424 sym
425 );
426 broken_layout = true;
427 }
428 }
429
430 let mut loader_found = false;
432 let loaders = [
433 "usr/lib/ld-linux-x86-64.so.2",
434 "lib64/ld-linux-x86-64.so.2",
435 "lib/ld-linux-x86-64.so.2"
436 ];
437 for l in &loaders {
438 let lp = target_path.join(l);
439 if lp.exists() {
440 loader_found = true;
441 if let Ok(mut file) = std::fs::File::open(&lp) {
442 let mut magic = [0u8; 4];
443 if file.read_exact(&mut magic).is_ok() {
444 if magic != [0x7f, b'E', b'L', b'F'] {
445 eprintln!(
446 "{} CRITICAL: Dynamic loader '{}' is \
447 NOT an ELF file! Your glibc \
448 installation is corrupted.",
449 "Error:".red().bold(),
450 l
451 );
452 }
453 } else {
454 eprintln!(
455 "{} CRITICAL: Dynamic loader '{}' is 0 \
456 bytes or unreadable.",
457 "Error:".red().bold(),
458 l
459 );
460 }
461 }
462 break;
463 }
464 }
465 if !loader_found {
466 eprintln!(
467 "{} Dynamic loader not found. Binaries WILL Segfault \
468 (139).",
469 "::".bold().yellow()
470 );
471 }
472
473 if broken_layout || !loader_found {
474 println!(
475 "{} Hint: Your ZoiOS bootstrap appears incomplete or \
476 corrupted. Please verify your base system packages.",
477 "::".bold().blue()
478 );
479 }
480
481 if verbose {
482 println!(
483 "{} Entering sysroot at {}...",
484 "::".bold().blue(),
485 target.cyan()
486 );
487 }
488
489 let mut envs = std::collections::HashMap::new();
490 envs.insert(
491 "PATH".to_string(),
492 "/usr/bin:/bin:/usr/sbin:/sbin".to_string()
493 );
494 envs.insert("SHELL".to_string(), "/usr/bin/bash".to_string());
495 envs.insert(
496 "TERM".to_string(),
497 std::env::var("TERM")
498 .unwrap_or_else(|_| "xterm-256color".to_string())
499 );
500
501 let mut shell_bin = std::path::PathBuf::from("/usr/bin/bash");
503 if !target_path.join("usr/bin/bash").exists()
504 && target_path.join("bin/bash").exists()
505 {
506 shell_bin = std::path::PathBuf::from("/bin/bash");
507 }
508
509 if verbose {
510 let cmd_display = if let Some(r) = &run {
511 format!("{} -c '{}'", shell_bin.display(), r)
512 } else {
513 shell_bin.display().to_string()
514 };
515 println!(
516 "{} Running inside chroot: {}",
517 "::".bold().blue(),
518 cmd_display.green()
519 );
520 }
521
522 #[cfg(target_os = "linux")]
523 {
524 let mut cmd = if let Some(run_cmd) = run {
525 let args = vec!["-c".to_string(), run_cmd];
526 crate::sandbox::wrap_command_in_root(
527 target_path,
528 &shell_bin,
529 &args,
530 &envs,
531 &[],
532 false
533 )?
534 } else {
535 crate::sandbox::wrap_command_in_root(
536 target_path,
537 &shell_bin,
538 &[],
539 &envs,
540 &[],
541 false
542 )?
543 };
544
545 if verbose {
546 println!(
547 "{} Full command: {:?}",
548 "::".bold().blue(),
549 cmd
550 );
551 }
552
553 let status = cmd.status()?;
554 if !status.success() {
555 let code = status.code().unwrap_or(1);
556 eprintln!(
557 "\n{} Chroot execution failed with exit code: {}",
558 "Error:".red().bold(),
559 code.to_string().yellow()
560 );
561 if code == 139 {
562 println!(
563 "{} Hint: Segfaults (139) often indicate an \
564 instruction set mismatch (e.g. x86-64-v3 \
565 binaries on older CPUs).",
566 "::".bold().blue()
567 );
568 }
569 std::process::exit(code);
570 }
571 }
572
573 #[cfg(not(target_os = "linux"))]
574 return Err(anyhow!(
575 "Distro chroot is only supported on Linux via Bubblewrap."
576 ));
577 }
578 },
579 SystemSubcommands::Apply { file } => {
580 #[cfg(unix)]
581 {
582 println!(
583 "Reading system configuration from {}...",
584 file.cyan()
585 );
586 let config = load_system_lua(&file)?;
587 let response =
588 send_request(Request::ApplySystemConfig(Box::new(config)))?;
589 handle_response(response)?;
590 }
591 #[cfg(not(unix))]
592 {
593 let _ = file;
594 return Err(anyhow!(
595 "OS management daemon commands are only supported on Unix."
596 ));
597 }
598 }
599 SystemSubcommands::List => {
600 #[cfg(unix)]
601 {
602 let response = send_request(Request::ListGenerations)?;
603 match response {
604 Response::Generations(gens) => {
605 println!(
606 "{:<5} {:<25} {:<50}",
607 "ID", "Created At", "Packages"
608 );
609 println!("{:-<80}", "");
610 for generation in gens {
611 println!(
612 "{:<5} {:<25} {:<50}",
613 generation.id,
614 generation.created_at.to_rfc3339(),
615 generation.packages.join(", ")
616 );
617 }
618 }
619 _ => handle_response(response)?
620 }
621 }
622 #[cfg(not(unix))]
623 return Err(anyhow!(
624 "OS management daemon commands are only supported on Unix."
625 ));
626 }
627 SystemSubcommands::Status => {
628 #[cfg(unix)]
629 {
630 let response = send_request(Request::GetStatus)?;
631 handle_response(response)?;
632 }
633 #[cfg(not(unix))]
634 return Err(anyhow!(
635 "OS management daemon commands are only supported on Unix."
636 ));
637 }
638 SystemSubcommands::Rollback { id } => {
639 #[cfg(unix)]
640 {
641 println!(
642 "Rolling back to generation {}...",
643 id.to_string().yellow()
644 );
645 let response = send_request(Request::RollbackGeneration(id))?;
646 handle_response(response)?;
647 }
648 #[cfg(not(unix))]
649 let _ = id;
650 #[cfg(not(unix))]
651 return Err(anyhow!(
652 "OS management daemon commands are only supported on Unix."
653 ));
654 }
655 SystemSubcommands::Pin { id } => {
656 #[cfg(unix)]
657 {
658 let response = send_request(Request::PinGeneration(id, true))?;
659 handle_response(response)?;
660 }
661 #[cfg(not(unix))]
662 let _ = id;
663 #[cfg(not(unix))]
664 return Err(anyhow!(
665 "OS management daemon commands are only supported on Unix."
666 ));
667 }
668 SystemSubcommands::Unpin { id } => {
669 #[cfg(unix)]
670 {
671 let response = send_request(Request::PinGeneration(id, false))?;
672 handle_response(response)?;
673 }
674 #[cfg(not(unix))]
675 let _ = id;
676 #[cfg(not(unix))]
677 return Err(anyhow!(
678 "OS management daemon commands are only supported on Unix."
679 ));
680 }
681 }
682
683 Ok(())
684}
685
686fn print_build_summary(
688 target: &str,
689 config: &zoi_system::config::SystemConfig,
690 dry_run: bool
691) {
692 use comfy_table::presets::UTF8_FULL_CONDENSED;
693 use comfy_table::{Cell, Color, Table};
694
695 println!("\n{}", " ZoiOS Build Plan ".bold().on_blue().white());
696 if dry_run {
697 println!(
698 "{}",
699 " [DRY-RUN MODE - NO CHANGES WILL BE MADE] "
700 .on_yellow()
701 .black()
702 .bold()
703 );
704 }
705 println!("{} {}\n", "Target Root:".bold(), target.cyan());
706
707 let mut fs_table = Table::new();
709 fs_table
710 .load_style(UTF8_FULL_CONDENSED.with_rounded_corners())
711 .set_header(vec![
712 Cell::new("Action").fg(Color::Yellow),
713 Cell::new("Device").fg(Color::Yellow),
714 Cell::new("FS Type").fg(Color::Yellow),
715 Cell::new("Mount Point").fg(Color::Yellow),
716 Cell::new("Options").fg(Color::Yellow),
717 ]);
718
719 for fs in &config.filesystems {
720 fs_table.add_row(vec![
721 Cell::new("Configure (fstab)").fg(Color::Blue),
722 Cell::new(&fs.device),
723 Cell::new(&fs.fs_type),
724 Cell::new(&fs.mount).fg(Color::Cyan),
725 Cell::new(fs.options.as_deref().unwrap_or("defaults")),
726 ]);
727 }
728 println!("{}", " 1. Filesystem & Partitioning ".bold().underline());
729 println!("{fs_table}\n");
730
731 let mut sys_table = Table::new();
733 sys_table
734 .load_style(UTF8_FULL_CONDENSED.with_rounded_corners())
735 .set_header(vec![
736 Cell::new("Property").fg(Color::Yellow),
737 Cell::new("Value").fg(Color::Yellow),
738 ]);
739
740 sys_table.add_row(vec![
741 Cell::new("Hostname"),
742 Cell::new(config.system.hostname.as_deref().unwrap_or("zoios"))
743 .fg(Color::Cyan),
744 ]);
745 sys_table.add_row(vec![
746 Cell::new("Timezone"),
747 Cell::new(config.system.timezone.as_deref().unwrap_or("UTC")),
748 ]);
749 sys_table.add_row(vec![
750 Cell::new("Locale"),
751 Cell::new(config.system.locale.as_deref().unwrap_or("en_US.UTF-8")),
752 ]);
753
754 println!("{}", " 2. System Configuration ".bold().underline());
755 println!("{sys_table}\n");
756
757 println!("{}", " 3. Packages ".bold().underline());
759 println!(
760 "{} base packages will be installed from the registry.\n",
761 config.packages.len().to_string().green().bold()
762 );
763
764 let mut pkg_list = String::new();
765 for (i, pkg) in config.packages.iter().enumerate() {
766 let _ = write!(pkg_list, "{}", pkg.cyan());
767 if i < config.packages.len() - 1 {
768 pkg_list.push_str(", ");
769 }
770 if (i + 1) % 5 == 0 {
771 pkg_list.push('\n');
772 }
773 }
774 println!("{pkg_list}\n");
775}
776
777#[cfg(unix)]
779fn handle_response(response: Response) -> Result<()> {
780 match response {
781 Response::Ok => println!("{}", "Operation successful.".green()),
782 Response::Success(msg) => println!("{} {}", "Success:".green(), msg),
783 Response::Status(msg) => println!("Daemon status: {}", msg.cyan()),
784 Response::Error(err) => return Err(anyhow!("Daemon error: {err}")),
785 Response::Generations(_) => {
786 return Err(anyhow!("Unexpected response from daemon"));
787 }
788 }
789 Ok(())
790}