diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go index dcb41e8..a3eab1d 100644 --- a/cli/launcher/firstrun_darwin.go +++ b/cli/launcher/firstrun_darwin.go @@ -40,11 +40,13 @@ const bootstrapServerName = "local" // configuration this app should quietly repair; it is an installation that // needs setting up again, and saying so is the whole point. // -// Not covered: a database file that exists but holds no users — a truncated or -// hand-emptied one. Answering that needs to open SQLite, which would drag the -// driver into the launcher for a case far rarer than "I deleted my data -// directory". The server refuses to start in that state and says why in -// ~/.cix/logs/cix-server.err. +// Not covered here: a database file that exists but holds no users. That case +// is not rare at all — it is what a deleted data directory turns into on the +// very next start, because the server creates the file and runs migrations +// BEFORE it checks for an admin account, then refuses. Answering it from here +// would need the SQLite driver in the launcher; instead the refusal itself is +// recognised after the fact — see isBootstrapRefusal, and the Start handler +// that routes it back to setup. func needsFirstRun() bool { path, err := serverEnvPath() if err != nil { @@ -73,6 +75,23 @@ func needsFirstRun() bool { return false } +// isBootstrapRefusal recognises the server's no-admin-account refusal in a log +// tail. +// +// This closes the gap needsFirstRun leaves open. Delete ~/.cix/data and the +// first start attempt — a login-time autostart as easily as a click — recreates +// an empty cix.db before bootstrapAuth refuses, so from then on the file exists +// and needsFirstRun answers false. The one thing that still knows the database +// has no accounts is the server itself, and it says so in words this matches: +// "incomplete bootstrap configuration" (an email left in server.env after the +// password was retired) and "no users in database" (neither var set). Both mean +// exactly one thing — there is no admin account and the server will not invent +// one — and for both, running setup again is the fix. +func isBootstrapRefusal(logTail string) bool { + return strings.Contains(logTail, "incomplete bootstrap configuration") || + strings.Contains(logTail, "no users in database") +} + // retireBootstrapPassword drops CIX_BOOTSTRAP_ADMIN_PASSWORD from server.env // once there is a running server, and therefore an account, that no longer // needs it. @@ -118,7 +137,14 @@ func runFirstRun(u *updater) error { "It is the login for the cix dashboard on this Mac — nothing is sent anywhere. " + "A password is generated for you, and setup then downloads the server (about 40 MB)." - email, err := prompt("Set up cix", intro, "") + // On a re-run the previous admin's address is still in server.env, and the + // most likely answer is the same one — so offer it, editable. + priorEmail := "" + if prior, err := readServerEnv(); err == nil { + priorEmail = strings.TrimSpace(prior["CIX_BOOTSTRAP_ADMIN_EMAIL"]) + } + + email, err := prompt("Set up cix", intro, priorEmail) if err != nil { return err } diff --git a/cli/launcher/firstrun_darwin_test.go b/cli/launcher/firstrun_darwin_test.go index 0caa5c9..4be7ded 100644 --- a/cli/launcher/firstrun_darwin_test.go +++ b/cli/launcher/firstrun_darwin_test.go @@ -91,6 +91,36 @@ func TestNeedsFirstRun(t *testing.T) { }) } +// The two refusals bootstrapAuth emits on a user-less database must route to +// setup, and other startup failures must not — a port clash is not a reason to +// offer wiping anyone's configuration. The strings are copied from +// server/cmd/cix-server/bootstrap.go; if the server rewords them, this test is +// the tripwire. +func TestIsBootstrapRefusal(t *testing.T) { + refusals := []string{ + "cix-server: bootstrap auth: incomplete bootstrap configuration: " + + "CIX_BOOTSTRAP_ADMIN_EMAIL is set but CIX_BOOTSTRAP_ADMIN_PASSWORD is empty.", + "cix-server: bootstrap auth: no users in database and the bootstrap admin " + + "env vars are not set: refuse to start.", + } + for _, tail := range refusals { + if !isBootstrapRefusal(tail) { + t.Errorf("isBootstrapRefusal(%q) = false, want true", tail) + } + } + + others := []string{ + "listen tcp 127.0.0.1:21847: bind: address already in use", + "open database: unable to open database file", + "", // no log at all + } + for _, tail := range others { + if isBootstrapRefusal(tail) { + t.Errorf("isBootstrapRefusal(%q) = true, want false", tail) + } + } +} + func TestRetireBootstrapPassword(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go index cb3deed..da82bc9 100644 --- a/cli/launcher/menu_darwin.go +++ b/cli/launcher/menu_darwin.go @@ -460,19 +460,11 @@ func (m *menu) toggleServer() { // log nobody has open, so the button appears to do nothing at all. Offer // the thing that would actually help. if needsFirstRun() { - ok, err := confirm("Set cix up again?", - "There is no cix database. If you deleted it, the server cannot start until an "+ - "administrator account is created again.\n\n"+ - "Setting up again creates a new account and a new, empty index. Anything that "+ - "was indexed before is already gone with the database.", - "Set Up") - if err != nil || !ok { - return - } - if err := runFirstRun(m.updater); err != nil && !errors.Is(err, errCancelled) { - logf("re-running setup failed: %v", err) - _ = alert("Setup failed", fmt.Sprintf("cix could not set itself up again.\n\n%v", err)) - } + m.offerSetupAgain( + "There is no cix database. If you deleted it, the server cannot start until an " + + "administrator account is created again.\n\n" + + "Setting up again creates a new account and a new, empty index. Anything that " + + "was indexed before is already gone with the database.") m.poll.refresh() return } @@ -494,12 +486,42 @@ func (m *menu) toggleServer() { // a deleted database looked like a broken Start button. if detail := serverDiedOnStart(); detail != "" { logf("the server exited immediately after Start: %s", detail) + // One refusal deserves better than its log text: no admin account. The + // needsFirstRun check above cannot see this case, because the failed + // start itself recreated an empty cix.db — the file exists, the + // accounts do not. The server's own words are the reliable signal, so + // route them to setup instead of printing them. + if isBootstrapRefusal(detail) { + m.offerSetupAgain( + "The cix database exists but has no accounts — this is what deleting the data " + + "directory looks like after a start attempt recreates an empty database.\n\n" + + "The server will not start until an administrator account is created again. " + + "Setting up again creates a new account and a new, empty index.") + m.poll.refresh() + return + } _ = alert("The server stopped straight away", detail+ "\n\nThe full log is in ~/.cix/logs/cix-server.err.") } m.poll.refresh() } +// offerSetupAgain proposes re-running the setup wizard and runs it on consent. +// +// Shared by the two ways a gutted installation shows itself: the database file +// is missing outright (needsFirstRun), or it exists, freshly recreated and +// empty, and the server refused to start against it (isBootstrapRefusal). +func (m *menu) offerSetupAgain(message string) { + ok, err := confirm("Set cix up again?", message, "Set Up") + if err != nil || !ok { + return + } + if err := runFirstRun(m.updater); err != nil && !errors.Is(err, errCancelled) { + logf("re-running setup failed: %v", err) + _ = alert("Setup failed", fmt.Sprintf("cix could not set itself up again.\n\n%v", err)) + } +} + // toggleNetworkAccess switches CIX_BIND_ADDR between loopback and all // interfaces, then restarts the server so the change takes effect. //