Skip to content

Commit 4306297

Browse files
authored
Merge pull request #6 from ananthb/main
Fix gh auth logout flag and surface missing codespace scope
2 parents f18d83e + 6583c1e commit 4306297

14 files changed

Lines changed: 1423 additions & 29 deletions

File tree

doctor_cmd.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/linuskendall/cosmonaut/internal/codespace"
10+
"github.com/linuskendall/cosmonaut/internal/doctor"
11+
)
12+
13+
func doctorCmd() *cobra.Command {
14+
var fix bool
15+
cmd := &cobra.Command{
16+
Use: "doctor",
17+
Short: "Diagnose and (optionally) fix problems blocking cosmonaut",
18+
Long: `Run a battery of checks (gh OAuth scopes, ~/.ssh/config sanity, etc.)
19+
and report which pass and which need attention.
20+
21+
With --fix, programmatic fixes are applied directly. Fixes that need a
22+
TTY (such as gh auth refresh) are printed as commands you can copy and
23+
run yourself.`,
24+
RunE: func(cmd *cobra.Command, args []string) error {
25+
return runDoctor(fix)
26+
},
27+
}
28+
cmd.Flags().BoolVar(&fix, "fix", false, "apply fixes for failing checks")
29+
return cmd
30+
}
31+
32+
func runDoctor(applyFixes bool) error {
33+
if err := codespace.RequireCommand("gh"); err != nil {
34+
return err
35+
}
36+
runner := codespace.DefaultGHRunner{}
37+
38+
// Lazy: only call gh codespace list if a check actually needs it.
39+
var (
40+
listErrCalled bool
41+
listErrCache error
42+
)
43+
listErr := func() error {
44+
if !listErrCalled {
45+
_, listErrCache = codespace.ListAllCodespaces(runner)
46+
listErrCalled = true
47+
}
48+
return listErrCache
49+
}
50+
51+
checks := doctor.Catalog(listErr)
52+
out := os.Stdout
53+
failures := 0
54+
55+
for _, c := range checks {
56+
issue := c.Status()
57+
if issue == nil {
58+
fmt.Fprintf(out, " ok %s\n", c.Title)
59+
continue
60+
}
61+
failures++
62+
fmt.Fprintf(out, " fail %s\n", c.Title)
63+
fmt.Fprintf(out, " %s\n", issue.Summary)
64+
65+
if !applyFixes {
66+
if c.HasInProcessFix() {
67+
fmt.Fprintln(out, " rerun with --fix to apply automatically")
68+
} else if c.HasTerminalFix() {
69+
fmt.Fprintf(out, " run: %s\n", c.FixCommand())
70+
}
71+
continue
72+
}
73+
74+
switch {
75+
case c.HasInProcessFix():
76+
if err := c.Fix(); err != nil {
77+
fmt.Fprintf(out, " fix failed: %v\n", err)
78+
} else {
79+
fmt.Fprintln(out, " fix applied")
80+
}
81+
case c.HasTerminalFix():
82+
fmt.Fprintf(out, " run: %s\n", c.FixCommand())
83+
default:
84+
fmt.Fprintln(out, " no automatic fix available")
85+
}
86+
}
87+
88+
if failures == 0 {
89+
fmt.Fprintln(out, "\nAll checks passed.")
90+
return nil
91+
}
92+
fmt.Fprintf(out, "\n%d check(s) need attention.\n", failures)
93+
return nil
94+
}

internal/daemon/daemon.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,57 @@ type Daemon struct {
2727

2828
mu sync.Mutex
2929
codespaces []codespace.Codespace
30+
listErr error
3031
stopCh chan struct{}
3132
sessions *SessionTracker
33+
34+
dismissMu sync.Mutex
35+
dismissed map[string]bool
36+
37+
uwMu sync.Mutex
38+
activeUW *unifiedWindow
39+
}
40+
41+
// setActiveUnifiedWindow records the currently-open main window so other
42+
// surfaces (e.g. the Settings page) can trigger its banner to refresh
43+
// when a check passes or dismissal state changes.
44+
func (d *Daemon) setActiveUnifiedWindow(uw *unifiedWindow) {
45+
d.uwMu.Lock()
46+
defer d.uwMu.Unlock()
47+
d.activeUW = uw
48+
}
49+
50+
// activeUnifiedWindow returns the currently-tracked main window, or nil.
51+
func (d *Daemon) activeUnifiedWindow() *unifiedWindow {
52+
d.uwMu.Lock()
53+
defer d.uwMu.Unlock()
54+
return d.activeUW
55+
}
56+
57+
// DismissCheck marks a doctor check ID as dismissed for the current
58+
// session. Banners hide it; the Settings page health section still
59+
// shows it so the user can come back to it.
60+
func (d *Daemon) DismissCheck(id string) {
61+
d.dismissMu.Lock()
62+
defer d.dismissMu.Unlock()
63+
if d.dismissed == nil {
64+
d.dismissed = map[string]bool{}
65+
}
66+
d.dismissed[id] = true
67+
}
68+
69+
// UndismissCheck clears a previous dismissal so the banner can show again.
70+
func (d *Daemon) UndismissCheck(id string) {
71+
d.dismissMu.Lock()
72+
defer d.dismissMu.Unlock()
73+
delete(d.dismissed, id)
74+
}
75+
76+
// IsDismissed reports whether the given check ID has been dismissed.
77+
func (d *Daemon) IsDismissed(id string) bool {
78+
d.dismissMu.Lock()
79+
defer d.dismissMu.Unlock()
80+
return d.dismissed[id]
3281
}
3382

3483
// New creates a new Daemon with the given config.
@@ -118,3 +167,18 @@ func (d *Daemon) SetCodespaces(cs []codespace.Codespace) {
118167
defer d.mu.Unlock()
119168
d.codespaces = cs
120169
}
170+
171+
// ListErr returns the error from the most recent codespace list attempt,
172+
// or nil on success.
173+
func (d *Daemon) ListErr() error {
174+
d.mu.Lock()
175+
defer d.mu.Unlock()
176+
return d.listErr
177+
}
178+
179+
// SetListErr stores the error from the most recent codespace list attempt.
180+
func (d *Daemon) SetListErr(err error) {
181+
d.mu.Lock()
182+
defer d.mu.Unlock()
183+
d.listErr = err
184+
}

internal/daemon/gui_flow.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,13 @@ func (d *Daemon) runCreateAndLaunch(win fyne.Window, target config.Target, resol
110110
go func() {
111111
cs, err := codespace.CreateCodespace(d.Runner, target)
112112
if err != nil {
113+
progress.stop()
113114
showFlowError(win, fmt.Errorf("creating codespace: %w", err))
114115
return
115116
}
117+
// runLaunchFlow installs its own progress screen; stop ours so the
118+
// first animation goroutine doesn't leak.
119+
progress.stop()
116120
d.runLaunchFlow(win, target, resolvedName, cs)
117121
}()
118122
}
@@ -124,6 +128,7 @@ func (d *Daemon) runLaunchFlow(win fyne.Window, target config.Target, resolvedNa
124128
fyne.Do(func() { win.SetContent(progress.canvas) })
125129

126130
go func() {
131+
defer progress.stop()
127132
setStatus := func(msg string) {
128133
fyne.Do(func() { progress.setStatus(msg) })
129134
}

internal/daemon/gui_preferences.go

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ package daemon
22

33
import (
44
"fmt"
5+
"image/color"
56
"log"
67

78
"fyne.io/fyne/v2"
9+
"fyne.io/fyne/v2/canvas"
810
"fyne.io/fyne/v2/container"
911
"fyne.io/fyne/v2/dialog"
1012
"fyne.io/fyne/v2/layout"
1113
"fyne.io/fyne/v2/widget"
1214

1315
"github.com/linuskendall/cosmonaut/internal/codespace"
1416
"github.com/linuskendall/cosmonaut/internal/config"
17+
"github.com/linuskendall/cosmonaut/internal/doctor"
1518
)
1619

1720
// buildSettingsPanel builds the settings content panel for the unified window.
@@ -22,6 +25,12 @@ func (d *Daemon) buildSettingsPanel(win fyne.Window) fyne.CanvasObject {
2225
heading.TextStyle = fyne.TextStyle{Bold: true}
2326
items = append(items, heading)
2427

28+
// Health checks: doctor catalog with per-check status and fix
29+
// buttons. Mirrors what the main-window banner shows, but stays
30+
// visible even if the user dismissed banners earlier.
31+
items = append(items, d.buildHealthSection(win))
32+
items = append(items, widget.NewSeparator())
33+
2534
// GitHub auth section.
2635
items = append(items, d.buildAuthSection(win))
2736
items = append(items, widget.NewSeparator())
@@ -71,6 +80,125 @@ func (d *Daemon) showPreferences() {
7180
})
7281
}
7382

83+
// buildHealthSection lists every doctor check with its current status
84+
// and a Fix button when applicable. Even if a user dismissed the main
85+
// window banner, the same fix is reachable here.
86+
func (d *Daemon) buildHealthSection(win fyne.Window) fyne.CanvasObject {
87+
heading := widget.NewLabel("Health checks")
88+
heading.TextStyle = fyne.TextStyle{Bold: true}
89+
90+
rebuild := func() {
91+
if win != nil {
92+
win.SetContent(d.buildSettingsPanel(win))
93+
}
94+
// Also refresh the main window banner if it's open.
95+
d.refreshMainWindowBanner()
96+
}
97+
98+
rows := []fyne.CanvasObject{heading}
99+
for _, c := range doctor.Catalog(d.ListErr) {
100+
rows = append(rows, d.buildHealthRow(c, win, rebuild))
101+
}
102+
return container.NewVBox(rows...)
103+
}
104+
105+
func (d *Daemon) buildHealthRow(c doctor.Check, win fyne.Window, rebuild func()) fyne.CanvasObject {
106+
issue := c.Status()
107+
108+
var dotColor color.Color
109+
var statusText string
110+
switch {
111+
case issue == nil:
112+
dotColor = cLime
113+
statusText = "OK"
114+
case issue.Severity == doctor.SeverityError:
115+
dotColor = cRed
116+
statusText = "Error"
117+
default:
118+
dotColor = cOrange
119+
statusText = "Warning"
120+
}
121+
dot := canvas.NewCircle(dotColor)
122+
dot.StrokeWidth = 0
123+
dot.Resize(fyne.NewSize(8, 8))
124+
125+
title := widget.NewLabel(c.Title)
126+
title.TextStyle = fyne.TextStyle{Bold: true}
127+
128+
status := canvas.NewText(statusText, dotColor)
129+
status.TextSize = 11
130+
status.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
131+
132+
header := container.NewHBox(container.NewCenter(dot), title, layout.NewSpacer(), status)
133+
134+
var detail fyne.CanvasObject
135+
if issue != nil {
136+
lbl := widget.NewLabel(issue.Summary)
137+
lbl.Wrapping = fyne.TextWrapWord
138+
detail = lbl
139+
} else {
140+
// When passing, show the description so the user understands
141+
// what was checked.
142+
lbl := widget.NewLabel(c.Description)
143+
lbl.Wrapping = fyne.TextWrapWord
144+
detail = lbl
145+
}
146+
147+
var actions fyne.CanvasObject
148+
if issue != nil {
149+
var btn *widget.Button
150+
switch {
151+
case c.HasInProcessFix():
152+
btn = primaryButton("Fix", func() {
153+
go func() {
154+
if err := c.Fix(); err != nil {
155+
fyne.Do(func() {
156+
dialog.ShowError(fmt.Errorf("fix %s: %w", c.ID, err), win)
157+
})
158+
return
159+
}
160+
fyne.Do(rebuild)
161+
}()
162+
})
163+
case c.HasTerminalFix():
164+
btn = primaryButton("Fix in terminal", func() {
165+
cmd := c.FixCommand() + `; echo; echo "Press enter to close"; read _`
166+
go openCommandInTerminal(cmd)
167+
})
168+
}
169+
recheckBtn := widget.NewButton("Re-check", func() { rebuild() })
170+
row := container.NewHBox(layout.NewSpacer())
171+
if btn != nil {
172+
row.Add(btn)
173+
}
174+
row.Add(recheckBtn)
175+
// If the user previously dismissed the banner, surface a way
176+
// to bring it back.
177+
if d.IsDismissed(c.ID) {
178+
restoreBtn := widget.NewButton("Show banner again", func() {
179+
d.UndismissCheck(c.ID)
180+
rebuild()
181+
})
182+
row.Add(restoreBtn)
183+
}
184+
actions = row
185+
}
186+
187+
if actions == nil {
188+
return container.NewPadded(container.NewVBox(header, detail))
189+
}
190+
return container.NewPadded(container.NewVBox(header, detail, actions))
191+
}
192+
193+
// refreshMainWindowBanner re-renders the top banner of an open main
194+
// window if there is one, so a fix applied (or banner restored) from
195+
// the Settings page is reflected immediately.
196+
func (d *Daemon) refreshMainWindowBanner() {
197+
if uw := d.activeUnifiedWindow(); uw != nil {
198+
uw.refreshBanner()
199+
}
200+
}
201+
74202
func (d *Daemon) buildAuthSection(win fyne.Window) fyne.CanvasObject {
75203
authed := codespace.EnsureGHAuth(d.Runner) == nil
76204

@@ -99,7 +227,7 @@ func (d *Daemon) buildAuthSection(win fyne.Window) fyne.CanvasObject {
99227
actionBtn = widget.NewButton("Remove auth", func() {
100228
actionBtn.Disable()
101229
go func() {
102-
_, err := d.Runner.Run([]string{"auth", "logout", "--hostname", "github.com", "--yes"})
230+
_, err := d.Runner.Run([]string{"auth", "logout", "--hostname", "github.com"})
103231
fyne.Do(func() {
104232
if err != nil {
105233
log.Printf("auth logout: %v", err)

0 commit comments

Comments
 (0)