Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions cli/launcher/firstrun_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
30 changes: 30 additions & 0 deletions cli/launcher/firstrun_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 35 additions & 13 deletions cli/launcher/menu_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
//
Expand Down