max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+399 insertions,
-13 deletions
| @@ -5,9 +5,14 @@ | |||
| 5 | 5 | //! command, not a reimplementation of Anaconda. See docs/CONSOLE.md and the | |
| 6 | 6 | //! wiki note `alloy-console`. | |
| 7 | 7 | //! | |
| 8 | - | //! The disk step is the first of them and the one with the consequences, so it | |
| 9 | - | //! lands first. Everything a later step needs — text fields, the streaming run | |
| 10 | - | //! screen — is absent here on purpose. | |
| 8 | + | //! Four questions: which disk, what to call the machine, who logs in, and a | |
| 9 | + | //! summary that shows the exact commands before running any of them. | |
| 10 | + | //! | |
| 11 | + | //! Two things are deliberately unfinished, both marked where they matter. | |
| 12 | + | //! [`DEPLOYED_ROOT`] is a guess until a real bootc system can say where the | |
| 13 | + | //! installed root ends up mounted, and [`InstallView::confirmed`] runs the plan | |
| 14 | + | //! synchronously, which freezes the frame for the minutes `bootc` takes. The | |
| 15 | + | //! streaming run screen is what fixes the second. | |
| 11 | 16 | //! | |
| 12 | 17 | //! <!-- wiki: alloy-console --> | |
| 13 | 18 | ||
| @@ -21,11 +26,11 @@ | |||
| 21 | 26 | use ratatui::widgets::Paragraph; | |
| 22 | 27 | use serde::Deserialize; | |
| 23 | 28 | ||
| 24 | - | use alloy_tui::Cursor; | |
| 29 | + | use alloy_tui::{Cursor, FocusRing}; | |
| 25 | 30 | ||
| 26 | - | use crate::cli::{CommandLog, Invocation}; | |
| 31 | + | use crate::cli::{CommandLog, Invocation, Secret}; | |
| 27 | 32 | use crate::field::TextField; | |
| 28 | - | use crate::shell::{Flow, View, block_title}; | |
| 33 | + | use crate::shell::{Confirm, Flow, View, block_title}; | |
| 29 | 34 | use crate::wizard::Steps; | |
| 30 | 35 | ||
| 31 | 36 | /// The questions, in the order they are asked. | |
| @@ -33,7 +38,7 @@ | |||
| 33 | 38 | /// The disk comes first because it is the one that can be wrong in a way | |
| 34 | 39 | /// nothing later recovers from, and because a user who cannot see their disk in | |
| 35 | 40 | /// the list should find that out before typing anything. | |
| 36 | - | const STEPS: [Step; 2] = [Step::Disk, Step::Hostname]; | |
| 41 | + | const STEPS: [Step; 4] = [Step::Disk, Step::Hostname, Step::Account, Step::Summary]; | |
| 37 | 42 | ||
| 38 | 43 | /// Which question the wizard is on. | |
| 39 | 44 | /// | |
| @@ -44,6 +49,8 @@ | |||
| 44 | 49 | enum Step { | |
| 45 | 50 | Disk, | |
| 46 | 51 | Hostname, | |
| 52 | + | Account, | |
| 53 | + | Summary, | |
| 47 | 54 | } | |
| 48 | 55 | ||
| 49 | 56 | impl Step { | |
| @@ -51,8 +58,78 @@ | |||
| 51 | 58 | match self { | |
| 52 | 59 | Self::Disk => "select a disk", | |
| 53 | 60 | Self::Hostname => "name this machine", | |
| 61 | + | Self::Account => "create your account", | |
| 62 | + | Self::Summary => "review and install", | |
| 54 | 63 | } | |
| 55 | 64 | } | |
| 65 | + | ||
| 66 | + | /// Whether this step takes typing, which decides if the shell keeps | |
| 67 | + | /// claiming `q` as quit while it is on screen. | |
| 68 | + | const fn types(self) -> bool { | |
| 69 | + | matches!(self, Self::Hostname | Self::Account) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Which field of the account step has focus. | |
| 74 | + | /// | |
| 75 | + | /// Indices into a [`FocusRing`], named so the render and key paths agree about | |
| 76 | + | /// what slot 2 is. | |
| 77 | + | const FIELD_USERNAME: usize = 0; | |
| 78 | + | const FIELD_PASSWORD: usize = 1; | |
| 79 | + | const FIELD_CONFIRM: usize = 2; | |
| 80 | + | const ACCOUNT_FIELDS: usize = 3; | |
| 81 | + | ||
| 82 | + | /// Longest username `useradd` accepts. | |
| 83 | + | const USERNAME_MAX: usize = 32; | |
| 84 | + | ||
| 85 | + | /// Check a username against what `useradd` will accept. | |
| 86 | + | /// | |
| 87 | + | /// Rules are the portable ones NAME_REGEX enforces on Fedora: start with a | |
| 88 | + | /// lowercase letter or underscore, then lowercase letters, digits, underscores | |
| 89 | + | /// or hyphens. Uppercase is rejected rather than folded, because a name that | |
| 90 | + | /// silently becomes something else is worse than one refused with a reason. | |
| 91 | + | /// | |
| 92 | + | /// `root` is refused separately: it exists already, so `useradd` would fail | |
| 93 | + | /// after the disk had been written, which is the worst possible moment to find | |
| 94 | + | /// out. | |
| 95 | + | fn validate_username(name: &str) -> Result<(), String> { | |
| 96 | + | if name.is_empty() { | |
| 97 | + | return Err("a username is required".into()); | |
| 98 | + | } | |
| 99 | + | if name == "root" { | |
| 100 | + | return Err("root already exists; pick another name".into()); | |
| 101 | + | } | |
| 102 | + | if name.chars().count() > USERNAME_MAX { | |
| 103 | + | return Err(format!("a username is at most {USERNAME_MAX} characters")); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | let first = name.chars().next().unwrap_or_default(); | |
| 107 | + | if !first.is_ascii_lowercase() && first != '_' { | |
| 108 | + | return Err("a username starts with a lowercase letter".into()); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | let allowed = |c: &char| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '-'; | |
| 112 | + | if let Some(bad) = name.chars().find(|c| !allowed(c)) { | |
| 113 | + | return Err(format!("'{bad}' is not allowed here; use a-z, 0-9, _ or -")); | |
| 114 | + | } | |
| 115 | + | Ok(()) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Check the password pair. | |
| 119 | + | /// | |
| 120 | + | /// No minimum length, deliberately. Every length rule is a policy invented by | |
| 121 | + | /// whoever wrote the installer, and a machine's owner choosing a short password | |
| 122 | + | /// on their own laptop is their call to make. What is checked is the pair | |
| 123 | + | /// agreeing, because a typo in a password nobody can see is the one mistake | |
| 124 | + | /// here that is both easy to make and impossible to recover from after reboot. | |
| 125 | + | fn validate_password(password: &str, confirm: &str) -> Result<(), String> { | |
| 126 | + | if password.is_empty() { | |
| 127 | + | return Err("a password is required".into()); | |
| 128 | + | } | |
| 129 | + | if password != confirm { | |
| 130 | + | return Err("the passwords do not match".into()); | |
| 131 | + | } | |
| 132 | + | Ok(()) | |
| 56 | 133 | } | |
| 57 | 134 | ||
| 58 | 135 | /// The hostname an install gets if the user does not change it. | |
| @@ -107,6 +184,75 @@ | |||
| 107 | 184 | pub disk: Option<String>, | |
| 108 | 185 | /// Written to `/etc/hostname` on the installed system. | |
| 109 | 186 | pub hostname: Option<String>, | |
| 187 | + | /// The account created on the installed system. | |
| 188 | + | /// | |
| 189 | + | /// The password is deliberately not here. It stays in its | |
| 190 | + | /// [`TextField`] until the plan is built, which keeps the number of | |
| 191 | + | /// places holding a readable copy to one. See [`Secret`]. | |
| 192 | + | pub username: Option<String>, | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// Where the installed system's root is mounted while it is configured. | |
| 196 | + | /// | |
| 197 | + | /// **Unverified.** `bootc install to-disk` partitions, deploys and unmounts, so | |
| 198 | + | /// something has to mount the target root again before `systemd-firstboot` and | |
| 199 | + | /// `useradd` can be pointed at it with `--root`. Whether bootc leaves a | |
| 200 | + | /// conventional path behind, and what the partition layout is called, are | |
| 201 | + | /// questions that need a real bootc system to answer — there is none on the | |
| 202 | + | /// development box, and the command is destructive besides. | |
| 203 | + | /// | |
| 204 | + | /// This constant is the single place that answer lands when the QEMU image can | |
| 205 | + | /// be booted. Everything downstream takes it as a parameter, so settling it is | |
| 206 | + | /// an edit here rather than a hunt. | |
| 207 | + | const DEPLOYED_ROOT: &str = "/mnt/alloy-target"; | |
| 208 | + | ||
| 209 | + | /// The commands an install runs, in order. | |
| 210 | + | /// | |
| 211 | + | /// Built as a list rather than executed inline for the reason the backends | |
| 212 | + | /// return [`Invocation`]s: it keeps the sequence testable on a machine with | |
| 213 | + | /// neither bootc nor a spare disk, and it lets the summary step show the user | |
| 214 | + | /// exactly what is about to run. The console's promise is that every action | |
| 215 | + | /// shows its invocation; here the invocations are shown *before* the action, | |
| 216 | + | /// which is the strongest form of it the installer can offer. | |
| 217 | + | /// | |
| 218 | + | /// `chpasswd` takes the password on stdin rather than as an argument. See | |
| 219 | + | /// [`Secret`] for why argv is not an option. | |
| 220 | + | fn install_plan( | |
| 221 | + | disk: &str, | |
| 222 | + | hostname: &str, | |
| 223 | + | username: &str, | |
| 224 | + | password: &str, | |
| 225 | + | root: &str, | |
| 226 | + | ) -> Vec<Invocation> { | |
| 227 | + | vec![ | |
| 228 | + | // --wipe is explicit rather than implied by the confirm the user just | |
| 229 | + | // answered: the flag that destroys the disk should be visible on the | |
| 230 | + | // line the summary displays, not hidden in a default. | |
| 231 | + | Invocation::new("bootc") | |
| 232 | + | .args(["install", "to-disk", "--wipe"]) | |
| 233 | + | .arg(disk), | |
| 234 | + | Invocation::new("systemd-firstboot") | |
| 235 | + | .arg(format!("--root={root}")) | |
| 236 | + | .arg(format!("--hostname={hostname}")) | |
| 237 | + | // Without --force firstboot skips any setting already present in | |
| 238 | + | // the image, and etc/hostname ships with "alloy" in it. The user's | |
| 239 | + | // answer would be silently discarded. | |
| 240 | + | .arg("--force"), | |
| 241 | + | // -m creates the home directory; sysusers-style entries do not, which | |
| 242 | + | // is why this is useradd. wheel is what sudo grants on Fedora, and an | |
| 243 | + | // install whose only account cannot escalate is one with no way to | |
| 244 | + | // administer itself. | |
| 245 | + | Invocation::new("useradd") | |
| 246 | + | .args(["--root", root, "--create-home", "--groups", "wheel"]) | |
| 247 | + | .arg(username), | |
| 248 | + | // `chpasswd` reads `user:password` lines, so the username is part of the | |
| 249 | + | // secret rather than an argument. Built here so exactly one place knows | |
| 250 | + | // the wire format, and with a trailing newline because chpasswd parses | |
| 251 | + | // lines and a final one without it is silently ignored by some builds. | |
| 252 | + | Invocation::new("chpasswd") | |
| 253 | + | .args(["--root", root]) | |
| 254 | + | .stdin(Secret::new(format!("{username}:{password}\n"))), | |
| 255 | + | ] | |
| 110 | 256 | } | |
| 111 | 257 | ||
| 112 | 258 | // ---- disks ---- | |
| @@ -178,6 +324,11 @@ | |||
| 178 | 324 | } | |
| 179 | 325 | } | |
| 180 | 326 | ||
| 327 | + | /// The model, or a dash where lsblk reported none. | |
| 328 | + | fn model_or_dash(&self) -> &str { | |
| 329 | + | self.model.as_deref().unwrap_or("-") | |
| 330 | + | } | |
| 331 | + | ||
| 181 | 332 | /// How the disk is attached, for the row. Removable is worth surfacing on | |
| 182 | 333 | /// its own: on a live install the USB stick and the target look alike | |
| 183 | 334 | /// until one of them says "usb, removable". | |
| @@ -402,6 +553,11 @@ | |||
| 402 | 553 | disks: Vec<Disk>, | |
| 403 | 554 | cursor: Cursor, | |
| 404 | 555 | hostname: TextField, | |
| 556 | + | username: TextField, | |
| 557 | + | password: TextField, | |
| 558 | + | confirm: TextField, | |
| 559 | + | /// Which of the three account fields has focus. | |
| 560 | + | fields: FocusRing, | |
| 405 | 561 | answers: Answers, | |
| 406 | 562 | error: Option<String>, | |
| 407 | 563 | } | |
| @@ -419,6 +575,10 @@ | |||
| 419 | 575 | disks: Vec::new(), | |
| 420 | 576 | cursor: Cursor::new(), | |
| 421 | 577 | hostname, | |
| 578 | + | username: TextField::new(), | |
| 579 | + | password: TextField::new(), | |
| 580 | + | confirm: TextField::new(), | |
| 581 | + | fields: FocusRing::new(ACCOUNT_FIELDS), | |
| 422 | 582 | answers: Answers::default(), | |
| 423 | 583 | error: None, | |
| 424 | 584 | }; | |
| @@ -507,6 +667,229 @@ | |||
| 507 | 667 | Flow::Continue | |
| 508 | 668 | } | |
| 509 | 669 | ||
| 670 | + | /// The field the account step's focus ring is currently on. | |
| 671 | + | fn focused_field(&mut self) -> &mut TextField { | |
| 672 | + | match self.fields.current() { | |
| 673 | + | FIELD_PASSWORD => &mut self.password, | |
| 674 | + | FIELD_CONFIRM => &mut self.confirm, | |
| 675 | + | _ => &mut self.username, | |
| 676 | + | } | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | /// Take the account, if both halves are answerable. | |
| 680 | + | /// | |
| 681 | + | /// The username is checked before the passwords so the first complaint is | |
| 682 | + | /// about the field the user is most likely still looking at. | |
| 683 | + | fn create_account(&mut self) -> Flow { | |
| 684 | + | if let Err(message) = validate_username(self.username.value()) { | |
| 685 | + | self.error = Some(message); | |
| 686 | + | self.fields.focus(FIELD_USERNAME); | |
| 687 | + | return Flow::Continue; | |
| 688 | + | } | |
| 689 | + | if let Err(message) = validate_password(self.password.value(), self.confirm.value()) { | |
| 690 | + | self.error = Some(message); | |
| 691 | + | // Focus the confirm rather than the password: a mismatch is far | |
| 692 | + | // more often a typo in the second one, and landing there means the | |
| 693 | + | // fix is to retype the field already under the caret. | |
| 694 | + | self.fields.focus(FIELD_CONFIRM); | |
| 695 | + | return Flow::Continue; | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | self.answers.username = Some(self.username.value().to_string()); | |
| 699 | + | self.error = None; | |
| 700 | + | self.steps.advance(); | |
| 701 | + | Flow::Continue | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | /// Keys for the account step's three fields. | |
| 705 | + | /// | |
| 706 | + | /// Enter advances the focus ring rather than submitting, until the last | |
| 707 | + | /// field. Submitting from the middle of a form is a way to have the second | |
| 708 | + | /// half silently empty. | |
| 709 | + | fn edit_account(&mut self, key: KeyEvent) -> Flow { | |
| 710 | + | match key.code { | |
| 711 | + | KeyCode::Tab | KeyCode::Down => self.fields.next(), | |
| 712 | + | KeyCode::BackTab | KeyCode::Up => self.fields.prev(), | |
| 713 | + | KeyCode::Enter => { | |
| 714 | + | if self.fields.current() == FIELD_CONFIRM { | |
| 715 | + | return self.create_account(); | |
| 716 | + | } | |
| 717 | + | self.fields.next(); | |
| 718 | + | } | |
| 719 | + | KeyCode::Char(c) => self.focused_field().insert(c), | |
| 720 | + | KeyCode::Backspace => self.focused_field().backspace(), | |
| 721 | + | KeyCode::Delete => self.focused_field().delete(), | |
| 722 | + | KeyCode::Left => self.focused_field().left(), | |
| 723 | + | KeyCode::Right => self.focused_field().right(), | |
| 724 | + | KeyCode::Home => self.focused_field().home(), | |
| 725 | + | KeyCode::End => self.focused_field().end(), | |
| 726 | + | _ => {} | |
| 727 | + | } | |
| 728 | + | Flow::Continue | |
| 729 | + | } | |
| 730 | + | ||
| 731 | + | /// One labelled field line, masked if it holds a password. | |
| 732 | + | /// | |
| 733 | + | /// Masking happens here rather than in [`TextField`] so the field stays a | |
| 734 | + | /// plain text buffer: a widget that knows how to hide itself would have to | |
| 735 | + | /// be trusted to hide itself everywhere, and this is the only place that | |
| 736 | + | /// draws one. | |
| 737 | + | fn field_line<'a>( | |
| 738 | + | &self, | |
| 739 | + | theme: &Theme, | |
| 740 | + | label: &'a str, | |
| 741 | + | field: &TextField, | |
| 742 | + | slot: usize, | |
| 743 | + | masked: bool, | |
| 744 | + | ) -> Line<'a> { | |
| 745 | + | let focused = self.fields.is_focused(slot); | |
| 746 | + | let (before, under, after) = field.split(); | |
| 747 | + | ||
| 748 | + | let (before, under, after) = if masked { | |
| 749 | + | ( | |
| 750 | + | "•".repeat(before.chars().count()), | |
| 751 | + | under.map(|_| '•'), | |
| 752 | + | "•".repeat(after.chars().count()), | |
| 753 | + | ) | |
| 754 | + | } else { | |
| 755 | + | (before.to_string(), under, after.to_string()) | |
| 756 | + | }; | |
| 757 | + | ||
| 758 | + | let mut spans = vec![ | |
| 759 | + | if focused { | |
| 760 | + | text::bold(theme, format!("{label:>10} ")) | |
| 761 | + | } else { | |
| 762 | + | text::muted(theme, format!("{label:>10} ")) | |
| 763 | + | }, | |
| 764 | + | text::primary(theme, before), | |
| 765 | + | ]; | |
| 766 | + | ||
| 767 | + | // Only the focused field draws a caret. Three block cursors on screen | |
| 768 | + | // at once says nothing about where typing will land. | |
| 769 | + | if focused { | |
| 770 | + | spans.push(Span::styled( | |
| 771 | + | under.unwrap_or(' ').to_string(), | |
| 772 | + | Style::default().add_modifier(Modifier::REVERSED), | |
| 773 | + | )); | |
| 774 | + | } else if let Some(c) = under { | |
| 775 | + | spans.push(text::primary(theme, c.to_string())); | |
| 776 | + | } | |
| 777 | + | spans.push(text::primary(theme, after)); | |
| 778 | + | ||
| 779 | + | Line::from(spans) | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | /// The account pane: three fields, the passwords masked. | |
| 783 | + | fn render_account(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 784 | + | let lines = vec![ | |
| 785 | + | Line::from(text::muted( | |
| 786 | + | theme, | |
| 787 | + | "The account you will log in with. It can use sudo.", | |
| 788 | + | )), | |
| 789 | + | Line::default(), | |
| 790 | + | self.field_line(theme, "username", &self.username, FIELD_USERNAME, false), | |
| 791 | + | self.field_line(theme, "password", &self.password, FIELD_PASSWORD, true), | |
| 792 | + | self.field_line(theme, "confirm", &self.confirm, FIELD_CONFIRM, true), | |
| 793 | + | ]; | |
| 794 | + | frame.render_widget(Paragraph::new(lines), area); | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | /// The summary pane: the answers, then the commands they produce. | |
| 798 | + | /// | |
| 799 | + | /// Showing the actual argv is the point. docs/CONSOLE.md commits the | |
| 800 | + | /// console to teaching its own primitives, and the strongest version of | |
| 801 | + | /// that promise is showing the commands before running them rather than | |
| 802 | + | /// after — especially the one carrying `--wipe`. | |
| 803 | + | fn render_summary(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 804 | + | /// One `label value note` row, so the three answers line up. | |
| 805 | + | fn row<'a>(theme: &Theme, label: &str, value: &str, note: String) -> Line<'a> { | |
| 806 | + | Line::from(vec![ | |
| 807 | + | text::secondary(theme, format!("{label:>10} ")), | |
| 808 | + | text::primary(theme, value.to_string()), | |
| 809 | + | text::muted(theme, note), | |
| 810 | + | ]) | |
| 811 | + | } | |
| 812 | + | ||
| 813 | + | let disk = self.answers.disk.as_deref().unwrap_or("-"); | |
| 814 | + | let detail = match self.disks.iter().find(|d| d.path == disk) { | |
| 815 | + | Some(d) => format!(" {} {}", format_size(d.size), d.model_or_dash()), | |
| 816 | + | None => String::new(), | |
| 817 | + | }; | |
| 818 | + | ||
| 819 | + | let mut lines = vec![ | |
| 820 | + | row(theme, "disk", disk, detail), | |
| 821 | + | row( | |
| 822 | + | theme, | |
| 823 | + | "hostname", | |
| 824 | + | self.answers.hostname.as_deref().unwrap_or("-"), | |
| 825 | + | String::new(), | |
| 826 | + | ), | |
| 827 | + | row( | |
| 828 | + | theme, | |
| 829 | + | "account", | |
| 830 | + | self.answers.username.as_deref().unwrap_or("-"), | |
| 831 | + | String::new(), | |
| 832 | + | ), | |
| 833 | + | Line::default(), | |
| 834 | + | Line::from(Span::styled( | |
| 835 | + | format!("Everything on {disk} will be erased."), | |
| 836 | + | Severity::Error.style(theme), | |
| 837 | + | )), | |
| 838 | + | Line::default(), | |
| 839 | + | Line::from(text::muted(theme, "This runs:")), | |
| 840 | + | ]; | |
| 841 | + | ||
| 842 | + | for invocation in self.plan() { | |
| 843 | + | let shown = format!(" {}", invocation.display()); | |
| 844 | + | lines.push(Line::from(text::secondary(theme, shown))); | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | frame.render_widget(Paragraph::new(lines), area); | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | /// Raise the wipe confirmation. | |
| 851 | + | /// | |
| 852 | + | /// Two gates of different kinds, on purpose. The summary is the one you | |
| 853 | + | /// read: it has room for the disk, its size and model, the hostname, the | |
| 854 | + | /// account and every command about to run. The modal is the one you mean: | |
| 855 | + | /// short, destructive-accented, and answerable only with Enter or Esc. A | |
| 856 | + | /// single modal doing both jobs would have to fit the summary into seven | |
| 857 | + | /// lines, and a summary alone would let a stray Enter wipe a disk. | |
| 858 | + | fn confirm_install(&mut self) -> Flow { | |
| 859 | + | let Some(disk) = self.answers.disk.clone() else { | |
| 860 | + | self.error = Some("no disk selected".into()); | |
| 861 | + | return Flow::Continue; | |
| 862 | + | }; | |
| 863 | + | ||
| 864 | + | Flow::Confirm(Confirm::destructive( | |
| 865 | + | "erase disk", | |
| 866 | + | format!("Erase {disk} and install Alloy? This cannot be undone."), | |
| 867 | + | )) | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | /// The commands this install will run, from the answers collected. | |
| 871 | + | /// | |
| 872 | + | /// Empty if an answer is missing, which cannot happen from the summary step | |
| 873 | + | /// — every earlier step gates on its own validation — but returning nothing | |
| 874 | + | /// beats rendering a command line with a hole in it. | |
| 875 | + | fn plan(&self) -> Vec<Invocation> { | |
| 876 | + | let (Some(disk), Some(hostname), Some(username)) = ( | |
| 877 | + | self.answers.disk.as_deref(), | |
| 878 | + | self.answers.hostname.as_deref(), | |
| 879 | + | self.answers.username.as_deref(), | |
| 880 | + | ) else { | |
| 881 | + | return Vec::new(); | |
| 882 | + | }; | |
| 883 | + | ||
| 884 | + | install_plan( | |
| 885 | + | disk, | |
| 886 | + | hostname, | |
| 887 | + | username, | |
| 888 | + | self.password.value(), | |
| 889 | + | DEPLOYED_ROOT, | |
| 890 | + | ) | |
| 891 | + | } | |
| 892 | + | ||
| 510 | 893 | /// The hostname pane: a prompt, the field with its caret, and what the | |
| 511 | 894 | /// name is for. | |
| 512 | 895 | fn render_hostname(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| @@ -536,7 +919,7 @@ | |||
| 536 | 919 | None => ("", Severity::Info), | |
| 537 | 920 | }; | |
| 538 | 921 | ||
| 539 | - | let model = disk.model.as_deref().unwrap_or("-"); | |
| 922 | + | let model = disk.model_or_dash(); | |
| 540 | 923 | ||
| 541 | 924 | Line::from(vec![ | |
| 542 | 925 | text::bold(theme, format!("{:<14}", disk.name)), | |
| @@ -569,6 +952,8 @@ | |||
| 569 | 952 | hint("r", "refresh"), | |
| 570 | 953 | ], | |
| 571 | 954 | Step::Hostname => vec![hint("enter", "confirm")], | |
| 955 | + | Step::Account => vec![hint("tab", "field"), hint("enter", "next")], | |
| 956 | + | Step::Summary => vec![hint("enter", "install")], | |
| 572 | 957 | }; | |
| 573 | 958 | if !self.steps.is_first() { | |
| 574 | 959 | hints.push(hint("esc", "back")); | |
| @@ -594,9 +979,11 @@ | |||
| 594 | 979 | let inner = block.inner(area); | |
| 595 | 980 | frame.render_widget(block, area); | |
| 596 | 981 | ||
| 597 | - | if self.step() == Step::Hostname { | |
| 598 | - | self.render_hostname(frame, inner, theme); | |
| 599 | - | return; | |
| 982 | + | match self.step() { | |
| 983 | + | Step::Hostname => return self.render_hostname(frame, inner, theme), | |
| 984 | + | Step::Account => return self.render_account(frame, inner, theme), | |
| 985 | + | Step::Summary => return self.render_summary(frame, inner, theme), | |
| 986 | + | Step::Disk => {} | |
| 600 | 987 | } | |
| 601 | 988 | ||
| 602 | 989 | if self.disks.is_empty() { | |
| @@ -618,12 +1005,20 @@ | |||
| 618 | 1005 | /// The hostname step types, so the shell must stop reading `q` as quit | |
| 619 | 1006 | /// while it is on screen. | |
| 620 | 1007 | fn text_entry(&self) -> bool { | |
| 621 | - | self.step() == Step::Hostname | |
| 1008 | + | self.step().types() | |
| 622 | 1009 | } | |
| 623 | 1010 | ||
| 624 | 1011 | fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow { | |
| 625 | - | if self.step() == Step::Hostname { |
Lines truncated