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
8 changes: 8 additions & 0 deletions cli/launcher/env_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,11 @@ func dashboardURL(vars map[string]string) string {
func localBaseURL(vars map[string]string) string {
return fmt.Sprintf("http://localhost:%d", serverPort(vars))
}

// setDefault fills a key only when it has no value yet, so re-running setup
// keeps whatever the user has chosen since the first time.
func setDefault(vars map[string]string, key, value string) {
if strings.TrimSpace(vars[key]) == "" {
vars[key] = value
}
}
128 changes: 106 additions & 22 deletions cli/launcher/firstrun_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,80 @@ import (

const bootstrapServerName = "local"

// needsFirstRun reports whether ~/.cix/server.env is missing.
// needsFirstRun reports whether this installation still has to be set up.
//
// Two conditions, and the second one matters more than it looks: no
// ~/.cix/server.env, or no database at the path that file names.
//
// Keying on the config file alone was wrong. Delete ~/.cix/data and the app
// carried on as a configured install — the server then recreated the directory
// itself and silently minted a fresh admin from the bootstrap credentials still
// sitting in server.env, with the original generated password rather than
// whichever one the user had since set. A missing database is not a
// 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.
func needsFirstRun() bool {
path, err := serverEnvPath()
if err != nil {
return false
}
_, err = os.Stat(path)
return errors.Is(err, os.ErrNotExist)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return true
}

vars, err := readServerEnv()
if err != nil {
// Present but unreadable. Running the wizard would overwrite a file we
// cannot even parse, so leave it alone and let the failure surface.
logf("could not read %s: %v", path, err)
return false
}
db := strings.TrimSpace(vars["CIX_SQLITE_PATH"])
if db == "" {
// Hand-edited beyond what we wrote. Not ours to second-guess.
return false
}
if _, err := os.Stat(db); errors.Is(err, os.ErrNotExist) {
logf("configured database %s is missing — treating this as an unconfigured install", db)
return true
}
return false
}

// retireBootstrapPassword drops CIX_BOOTSTRAP_ADMIN_PASSWORD from server.env
// once there is a running server, and therefore an account, that no longer
// needs it.
//
// The password seeds the very first admin and has no purpose afterwards, but it
// used to stay on disk forever — and stay authoritative. Wipe the database and
// the account came back with THAT password, not the one the user had set in the
// dashboard; a credential outliving the account it created, silently.
//
// The email stays: it is harmless, and the password-reset dialog offers it as
// the address to reset.
//
// Called only when the server is confirmed up. Bootstrap runs before the HTTP
// listener opens, so an answering /health is proof the account exists.
func retireBootstrapPassword() {
vars, err := readServerEnv()
if err != nil {
return
}
if _, ok := vars["CIX_BOOTSTRAP_ADMIN_PASSWORD"]; !ok {
return
}
delete(vars, "CIX_BOOTSTRAP_ADMIN_PASSWORD")
if err := writeServerEnv(vars); err != nil {
logf("could not remove the bootstrap password from server.env: %v", err)
return
}
logf("removed CIX_BOOTSTRAP_ADMIN_PASSWORD from server.env — the admin account exists")
}

// runFirstRun walks the user through creating the admin account, writes
Expand Down Expand Up @@ -94,26 +160,40 @@ func runFirstRun(u *updater) error {
return err
}

vars := map[string]string{
"CIX_BOOTSTRAP_ADMIN_EMAIL": email,
"CIX_BOOTSTRAP_ADMIN_PASSWORD": password,
// Imported as an "env-bootstrap" legacy key when the admin is created
// on a fresh DB (bootstrap.go), so the CLI works from the first boot
// without anyone visiting the dashboard to mint one.
"CIX_API_KEY": apiKey,
"CIX_PORT": strconv.Itoa(defaultServerPort),
"CIX_DATA_DIR": dataDir,
"CIX_SQLITE_PATH": filepath.Join(dataDir, "cix.db"),
// Loopback by default, unlike the server's own all-interfaces default.
// A container has to be reachable from outside itself; a desktop app
// does not, and exposing a code index to the local network is a choice
// someone should make on purpose. The menu has a toggle for it.
"CIX_BIND_ADDR": bindLocalOnly,
// The .app owns updating itself. Leaving the server's own check on
// would mean two different components offering the user two different
// "update available" prompts for two different tag streams.
"CIX_VERSION_CHECK_ENABLED": "false",
// Start from whatever is already configured. The wizard runs a second time
// when the database has gone missing, and on that path server.env exists and
// holds decisions the user made since — the port, and whether the server is
// reachable from the network. Rebuilding the file from defaults would revert
// them without saying so, which is a worse surprise than the one this whole
// change exists to remove.
vars, err := readServerEnv()
if err != nil {
vars = map[string]string{}
}

// Always fresh: the account and its key are being created now. On a repeat
// run the previous key died with the database it was imported into.
vars["CIX_BOOTSTRAP_ADMIN_EMAIL"] = email
vars["CIX_BOOTSTRAP_ADMIN_PASSWORD"] = password
// Imported as an "env-bootstrap" legacy key when the admin is created on a
// fresh DB (bootstrap.go), so the CLI works from the first boot without
// anyone visiting the dashboard to mint one.
vars["CIX_API_KEY"] = apiKey

// Defaults only where nothing has been chosen.
setDefault(vars, "CIX_PORT", strconv.Itoa(defaultServerPort))
setDefault(vars, "CIX_DATA_DIR", dataDir)
setDefault(vars, "CIX_SQLITE_PATH", filepath.Join(dataDir, "cix.db"))
// Loopback by default, unlike the server's own all-interfaces default. A
// container has to be reachable from outside itself; a desktop app does not,
// and exposing a code index to the local network is a choice someone should
// make on purpose. The menu has a toggle for it.
setDefault(vars, "CIX_BIND_ADDR", bindLocalOnly)
// The .app owns updating itself. Leaving the server's own check on would
// mean two different components offering the user two different "update
// available" prompts for two different tag streams.
setDefault(vars, "CIX_VERSION_CHECK_ENABLED", "false")

if err := writeServerEnv(vars); err != nil {
return fmt.Errorf("write server.env: %w", err)
}
Expand All @@ -140,6 +220,10 @@ func runFirstRun(u *updater) error {
// minutes, in silence. Saying so beats a spinner that gives up.
waitNote = "\n\nThe server is still starting. First boot downloads and loads the embedding " +
"model, which can take a few minutes; the menu bar will show it as running when it is ready."
} else {
// The account exists — bootstrap runs before the listener opens — so the
// password that seeded it has done its job and stops being kept.
retireBootstrapPassword()
}

return alertWithCopy("cix is set up", fmt.Sprintf(
Expand Down
160 changes: 160 additions & 0 deletions cli/launcher/firstrun_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package main

import (
"os"
"path/filepath"
"testing"
)

// writeTestEnv lays down a server.env like the wizard's, pointing at dbPath.
func writeTestEnv(t *testing.T, home, dbPath string, extra map[string]string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(home, ".cix"), 0o700); err != nil {
t.Fatal(err)
}
vars := map[string]string{
"CIX_BOOTSTRAP_ADMIN_EMAIL": "someone@example.com",
"CIX_BOOTSTRAP_ADMIN_PASSWORD": "generated-once",
"CIX_API_KEY": "cix_testkey",
"CIX_PORT": "21847",
"CIX_SQLITE_PATH": dbPath,
"CIX_BIND_ADDR": bindLocalOnly,
}
for k, v := range extra {
if v == "" {
delete(vars, k)
continue
}
vars[k] = v
}
if err := writeServerEnv(vars); err != nil {
t.Fatal(err)
}
}

func touch(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("not really sqlite"), 0o600); err != nil {
t.Fatal(err)
}
}

// Setup is needed when the database is gone, not only when the config is.
//
// Keying on server.env alone meant that deleting ~/.cix/data left the app
// believing it was configured — and the server then minted a fresh admin from
// the bootstrap credentials still in that file, using the original generated
// password rather than whatever the user had set since.
func TestNeedsFirstRun(t *testing.T) {
t.Run("no server.env", func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if !needsFirstRun() {
t.Error("needsFirstRun() = false with no server.env")
}
})

t.Run("server.env but no database", func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
writeTestEnv(t, home, filepath.Join(home, ".cix", "data", "cix.db"), nil)
if !needsFirstRun() {
t.Error("needsFirstRun() = false with a configured database that does not exist")
}
})

t.Run("both present", func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
db := filepath.Join(home, ".cix", "data", "cix.db")
writeTestEnv(t, home, db, nil)
touch(t, db)
if needsFirstRun() {
t.Error("needsFirstRun() = true on a complete installation")
}
})

t.Run("hand-edited env with no database path", func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
writeTestEnv(t, home, "", map[string]string{"CIX_SQLITE_PATH": ""})
// Nothing to check against, so nothing to conclude. Running the wizard
// here would overwrite a file somebody edited on purpose.
if needsFirstRun() {
t.Error("needsFirstRun() = true on an env file with no CIX_SQLITE_PATH")
}
})
}

func TestRetireBootstrapPassword(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
db := filepath.Join(home, ".cix", "data", "cix.db")
writeTestEnv(t, home, db, map[string]string{"CIX_BIND_ADDR": bindAllInterfaces})
touch(t, db)

retireBootstrapPassword()

vars, err := readServerEnv()
if err != nil {
t.Fatal(err)
}
if _, ok := vars["CIX_BOOTSTRAP_ADMIN_PASSWORD"]; ok {
t.Error("the bootstrap password survived")
}
// The email is what the reset-password dialog offers as a default, and it
// is not a credential.
if vars["CIX_BOOTSTRAP_ADMIN_EMAIL"] != "someone@example.com" {
t.Errorf("email = %q, want it kept", vars["CIX_BOOTSTRAP_ADMIN_EMAIL"])
}
// Nothing else may be lost on the way through: the API key is what the CLI
// authenticates with, and the bind address is a choice the user made.
if vars["CIX_API_KEY"] != "cix_testkey" {
t.Errorf("api key = %q, want it kept", vars["CIX_API_KEY"])
}
if vars["CIX_BIND_ADDR"] != bindAllInterfaces {
t.Errorf("bind addr = %q, want it kept", vars["CIX_BIND_ADDR"])
}

// The file still holds an API key, so the mode still matters.
path, _ := serverEnvPath()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != envFileMode {
t.Errorf("server.env mode = %v, want %v", perm, os.FileMode(envFileMode))
}

// Idempotent: the menu calls this whenever it first sees a running server.
retireBootstrapPassword()
if vars, err := readServerEnv(); err != nil || vars["CIX_API_KEY"] != "cix_testkey" {
t.Errorf("second call damaged the file: %v %v", vars, err)
}
}

// The wizard runs again after a database is deleted, and on that path it must
// not quietly revert settings chosen since the first run — the network toggle
// most of all, because reverting it changes what the machine exposes.
func TestSetDefaultKeepsExistingChoices(t *testing.T) {
vars := map[string]string{
"CIX_BIND_ADDR": bindAllInterfaces,
"CIX_PORT": " ", // whitespace is not a choice
}
setDefault(vars, "CIX_BIND_ADDR", bindLocalOnly)
setDefault(vars, "CIX_PORT", "21847")
setDefault(vars, "CIX_VERSION_CHECK_ENABLED", "false")

if vars["CIX_BIND_ADDR"] != bindAllInterfaces {
t.Errorf("bind addr = %q, want the existing choice kept", vars["CIX_BIND_ADDR"])
}
if vars["CIX_PORT"] != "21847" {
t.Errorf("port = %q, want the blank value replaced", vars["CIX_PORT"])
}
if vars["CIX_VERSION_CHECK_ENABLED"] != "false" {
t.Errorf("version check = %q, want the default filled in", vars["CIX_VERSION_CHECK_ENABLED"])
}
}
Loading