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
2 changes: 0 additions & 2 deletions cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ module github.com/dvcdsys/code-index/cli
go 1.25.12

require (
fyne.io/systray v1.12.2
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
Expand Down Expand Up @@ -34,7 +33,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
Expand Down
4 changes: 0 additions & 4 deletions cli/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
Expand Down Expand Up @@ -43,8 +41,6 @@ github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJ
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down
62 changes: 16 additions & 46 deletions cli/launcher/dialog_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import (
"time"
)

// osascript is the dialog mechanism for the whole launcher.
// osascript dialogs — the FALLBACK layer.
//
// The menu-bar library (Phase 2) has no dialog API, and pulling in a second GUI
// toolkit to draw three alerts would double the bundle for no gain. AppleScript
// alerts are native, need no linkage, and survive the app being LSUIElement.
// The app's dialogs live inside the panel now (paneldialog_darwin.go and
// panel.html's #dialog): alert/confirm/ask/prompt/alertWithSecret there route
// to the webview once the AppKit side is up. What remains here is the same
// primitives over osascript, used only in the window before the panel exists —
// a translocated bundle refusing to run, a version query gone wrong — where a
// native modal is the only surface available.
//
// Two rules, both load-bearing:
// - Every string that reaches AppleScript goes through quoteAS. Text here is
Expand All @@ -36,20 +39,17 @@ func quoteAS(s string) string {
return strings.Join(parts, " & return & ")
}

// dialogIcon is the POSIX path to the icon dialogs are drawn with. Set once at
// startup from the bundle; empty when the launcher runs outside a .app.
// dialogIcon is the POSIX path to the icon osascript dialogs are drawn with.
// Set once at startup from the bundle; empty when running outside a .app.
var dialogIcon string

// alert shows a modal informational dialog and blocks until it is dismissed.
// osaAlert shows a modal informational dialog and blocks until dismissed.
//
// `display dialog` rather than the more obvious `display alert`, for one
// reason: an alert is drawn with the icon of the process that ran the script,
// which here is osascript — so the app's own dialogs came up wearing a generic
// folder icon. `display dialog` takes an explicit icon. The cost is that the
// title is a window title instead of bold body text; the icon is worth more.
// It is also the primitive Phase 2 needs anyway, since only `display dialog`
// supports `default answer` for text input.
func alert(title, message string) error {
// folder icon. `display dialog` takes an explicit icon.
func osaAlert(title, message string) error {
var script string
if dialogIcon != "" {
script = fmt.Sprintf(
Expand All @@ -65,25 +65,6 @@ func alert(title, message string) error {
return runOsascript(2*time.Minute, script)
}

// alertWithSecret shows a credential and puts it on the clipboard.
//
// The copying happens before the window opens, not on a button, and the message
// says so. A button was tried and was worse: AppleScript cannot make a run of
// text clickable — `display dialog` is modal and returns only when it closes —
// so "copy" meant closing the window and opening it again, which on screen is
// a flash. Copying up front removes the flash and the click at once, and there
// is nothing to be coy about: this is a password the app generated seconds ago
// and is showing on purpose, and reaching for the clipboard is the next thing
// anyone does with it.
func alertWithSecret(title, message, secret, secretName string) error {
note := fmt.Sprintf("\n\nThe %s is on your clipboard.", secretName)
if err := copyToClipboard(secret); err != nil {
logf("could not copy the %s to the clipboard: %v", secretName, err)
note = fmt.Sprintf("\n\nThe %s could not be copied to your clipboard — select it above.", secretName)
}
return alert(title, message+note)
}

// copyToClipboard pipes a value to pbcopy.
//
// Not AppleScript's `set the clipboard to`: that would put the secret into a
Expand Down Expand Up @@ -111,8 +92,8 @@ func isUserCancelled(stderr string) bool {
return strings.Contains(stderr, "(-128)")
}

// prompt asks for one line of text. Returns errCancelled if the user cancels.
func prompt(title, message, defaultAnswer string) (string, error) {
// osaPrompt asks for one line of text. Returns errCancelled on cancel.
func osaPrompt(title, message, defaultAnswer string) (string, error) {
script := fmt.Sprintf(
`display dialog %s with title %s default answer %s %s buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel"`,
quoteAS(message), quoteAS(title), quoteAS(defaultAnswer), iconClause(),
Expand All @@ -131,20 +112,9 @@ func prompt(title, message, defaultAnswer string) (string, error) {
return strings.TrimSpace(answer), nil
}

// confirm shows a two-button question. Returns false when the user declines.
func confirm(title, message, okLabel string) (bool, error) {
return ask(title, message, okLabel, "Cancel")
}

// ask shows a two-button question with both labels spelled out.
//
// Separate from confirm because not every choice has a "cancel" side. "Take
// Over" versus "Leave It Alone" are two real options, and labelling the second
// one Cancel would imply it does nothing — when in fact it decides how the app
// behaves from then on.
//
// osaAsk shows a two-button question with both labels spelled out.
// yesLabel is the default button. Dismissing the dialog counts as no.
func ask(title, message, yesLabel, noLabel string) (bool, error) {
func osaAsk(title, message, yesLabel, noLabel string) (bool, error) {
script := fmt.Sprintf(
`display dialog %s with title %s %s buttons {%s, %s} default button %s cancel button %s`,
quoteAS(message), quoteAS(title), iconClause(),
Expand Down
107 changes: 0 additions & 107 deletions cli/launcher/dots_darwin.go

This file was deleted.

41 changes: 35 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 All @@ -138,9 +164,12 @@ func runFirstRun(u *updater) error {
// for reasons outside this machine, and a setup that wrote server.env and a
// launchd agent pointing at a server that was never downloaded would look
// complete and be broken.
showPanelBusy("Downloading the cix server…")
if err := ensureRuntime(u, logProgress); err != nil {
clearPanelBusy()
return fmt.Errorf("could not install the cix server: %w", err)
}
clearPanelBusy()

password, err := generatePassword()
if err != nil {
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
5 changes: 5 additions & 0 deletions cli/launcher/logging_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,8 @@ func logf(format string, args ...any) {

fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...))
}

// logProgress is the progress sink for slow work with no better surface —
// paths that run before the menu exists, or that never got a menu reference.
// The log is where anyone investigating a slow first launch looks anyway.
func logProgress(msg string) { logf("%s", msg) }
Loading