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