Skip to main content

max / alloy

install: create the account, then review and confirm The last two questions, and the gate in front of the disk write. The account step is the first form in the console: three fields on a FocusRing, passwords masked at render time rather than in TextField. A widget that knows how to hide itself would have to be trusted to hide itself everywhere; this is the only place that draws one, so the masking lives here and the field stays a plain buffer. Enter walks the fields and only submits from the last. Submitting from the first is how the second half of a form ends up empty. A mismatch focuses the confirm rather than the password, because a mismatch is far more often a typo in the second one and landing there means the fix is to retype the field already under the caret. No minimum password length. Every length rule is a policy invented by whoever wrote the installer, and the owner of the machine choosing a short password on their own laptop is their call. What is checked is that the pair agrees — a typo in a value nobody can see is the one mistake here that is both easy to make and unrecoverable after reboot. `root` is refused up front, since it exists already and useradd would otherwise fail *after* the disk was written. The summary shows the answers and then the actual argv of every command about to run. docs/CONSOLE.md commits the console to teaching its own primitives, and the strongest form of that is showing the commands before they run rather than after — especially the one carrying --wipe. Two gates of different kinds, which was the design decision. The summary is the one you read: it has room for the disk with its size and model, the hostname, the account and four command lines. The modal is the one you mean: short, destructive-accented, Enter or Esc. A single modal doing both jobs would have to fit all that into seven lines, and a summary alone would let a stray Enter wipe a disk. The plan is the systemd-firstboot shape decided this session: bootc install to-disk --wipe, then firstboot, useradd and chpasswd against the deployed root. firstboot needs --force because etc/hostname ships with "alloy" in it and firstboot skips settings already present — without the flag the user's answer is silently discarded, which has a test. useradd gets --create-home and wheel, since sysusers-style entries make no home directory and an install whose only account cannot escalate has no way to administer itself. chpasswd takes `user:password` on stdin through Secret, so no line of the plan carries the password; that also has a test, because the summary renders these lines on screen. Two things are NOT done and are marked in the code, not just here. DEPLOYED_ROOT is a guess. bootc install to-disk partitions, deploys and unmounts, so something must mount the target root again before firstboot and useradd can be pointed at it. Whether bootc leaves a conventional path, and what the layout is called, needs a real bootc system. There is none on this box and the command is destructive besides. The constant is the single place that answer lands. confirmed() runs the plan synchronously and stops at the first failure. bootc takes minutes and this freezes the frame for all of them, with the log pane's last line the only sign of life. Everything after bootc is sub-second, so bootc is the whole problem, and the streaming run screen is the fix. Pressing on after a failed deploy would mean useradd writing into a tree that is not there, so it stops instead. Nothing here has run against real bootc, real useradd or a real disk. The tests cover the wizard, the validation and the shape of the plan; they prove nothing about whether the plan works. 209 tests pass, 7 ignored, clippy clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 00:51 UTC
Signed with PGP, not checked
Commit: 95b309a9d49a9ca3a216c3827da3155c203951ab
Parent: 0476d9f
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