Skip to content

Commit 578044e

Browse files
committed
feat(snapshot): add interactive scan progress and editor TUI
- Add CaptureWithProgress() with step-by-step callback for progress tracking - Add ScanProgress renderer with animated spinner and ANSI multi-line output - Add SnapshotEditorModel TUI with 3 tabs (Formulae, Casks, macOS Prefs) - Rewrite snapshot orchestration: scan → edit → confirm → upload → success - Add success screen with config URL, share command, and auto-open browser - Preserve --json (silent), --local (progress only), --dry-run flag behavior
1 parent 372c1e9 commit 578044e

4 files changed

Lines changed: 942 additions & 20 deletions

File tree

internal/cli/snapshot.go

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"net/http"
99
"os"
10+
"os/exec"
1011
"strings"
1112

1213
"github.com/charmbracelet/lipgloss"
@@ -54,23 +55,17 @@ func runSnapshot(cmd *cobra.Command) error {
5455
jsonFlag, _ := cmd.Flags().GetBool("json")
5556
dryRunFlag, _ := cmd.Flags().GetBool("dry-run")
5657

57-
fmt.Fprintln(os.Stderr)
58-
fmt.Fprintln(os.Stderr, snapTitleStyle.Render("=== Scanning your Mac... ==="))
59-
fmt.Fprintln(os.Stderr)
60-
61-
fmt.Fprintf(os.Stderr, " Capturing environment...\n")
62-
snap, err := snapshot.Capture()
63-
if err != nil {
64-
return fmt.Errorf("failed to capture snapshot: %w", err)
65-
}
66-
67-
catalogMatch := snapshot.MatchPackages(snap)
68-
snap.CatalogMatch = *catalogMatch
69-
snap.MatchedPreset = snapshot.DetectBestPreset(snap)
70-
71-
showSnapshotPreview(snap)
58+
// --- CAPTURE PHASE ---
7259

60+
// For --json: use silent Capture() (no progress UI, stdout must be clean)
7361
if jsonFlag {
62+
snap, err := snapshot.Capture()
63+
if err != nil {
64+
return err
65+
}
66+
catalogMatch := snapshot.MatchPackages(snap)
67+
snap.CatalogMatch = *catalogMatch
68+
snap.MatchedPreset = snapshot.DetectBestPreset(snap)
7469
data, err := json.MarshalIndent(snap, "", " ")
7570
if err != nil {
7671
return fmt.Errorf("failed to marshal snapshot: %w", err)
@@ -79,25 +74,95 @@ func runSnapshot(cmd *cobra.Command) error {
7974
return nil
8075
}
8176

77+
// Interactive/local/dry-run: use progress UI
78+
snap, err := captureWithUI()
79+
if err != nil {
80+
return err
81+
}
82+
83+
// Do matching
84+
catalogMatch := snapshot.MatchPackages(snap)
85+
snap.CatalogMatch = *catalogMatch
86+
snap.MatchedPreset = snapshot.DetectBestPreset(snap)
87+
8288
if localFlag {
8389
path, err := snapshot.SaveLocal(snap)
8490
if err != nil {
8591
return fmt.Errorf("failed to save snapshot: %w", err)
8692
}
8793
fmt.Fprintln(os.Stderr)
88-
fmt.Fprintln(os.Stderr, snapSuccessStyle.Render(fmt.Sprintf("✓ Snapshot saved to %s", path)))
94+
fmt.Fprintln(os.Stderr, snapSuccessStyle.Render("✓ Snapshot saved to "+path))
8995
fmt.Fprintln(os.Stderr)
9096
return nil
9197
}
9298

9399
if dryRunFlag {
100+
showSnapshotPreview(snap)
94101
fmt.Fprintln(os.Stderr)
95102
fmt.Fprintln(os.Stderr, snapMutedStyle.Render("Dry run — no changes made"))
96103
fmt.Fprintln(os.Stderr)
97104
return nil
98105
}
99106

100-
return uploadSnapshot(snap)
107+
// --- EDITOR PHASE ---
108+
edited, confirmed, err := ui.RunSnapshotEditor(snap)
109+
if err != nil {
110+
return err
111+
}
112+
if !confirmed {
113+
fmt.Fprintln(os.Stderr)
114+
fmt.Fprintln(os.Stderr, snapMutedStyle.Render("Snapshot cancelled."))
115+
fmt.Fprintln(os.Stderr)
116+
return nil
117+
}
118+
119+
// --- CONFIRM PHASE ---
120+
fmt.Fprintln(os.Stderr)
121+
upload, err := ui.Confirm("Upload this snapshot to openboot.dev?", true)
122+
if err != nil {
123+
return err
124+
}
125+
126+
if !upload {
127+
// Offer local save as fallback
128+
saveLocal, err := ui.Confirm("Save snapshot locally instead?", true)
129+
if err != nil {
130+
return err
131+
}
132+
if saveLocal {
133+
path, err := snapshot.SaveLocal(edited)
134+
if err != nil {
135+
return fmt.Errorf("failed to save snapshot: %w", err)
136+
}
137+
fmt.Fprintln(os.Stderr, snapSuccessStyle.Render("✓ Snapshot saved to "+path))
138+
} else {
139+
fmt.Fprintln(os.Stderr, snapMutedStyle.Render("Snapshot discarded."))
140+
}
141+
fmt.Fprintln(os.Stderr)
142+
return nil
143+
}
144+
145+
// --- UPLOAD PHASE ---
146+
return uploadSnapshot(edited)
147+
}
148+
149+
// captureWithUI runs CaptureWithProgress with the ScanProgress renderer.
150+
func captureWithUI() (*snapshot.Snapshot, error) {
151+
fmt.Fprintln(os.Stderr)
152+
153+
progress := ui.NewScanProgress(7)
154+
155+
snap, err := snapshot.CaptureWithProgress(func(step snapshot.ScanStep) {
156+
progress.Update(step)
157+
})
158+
159+
progress.Finish()
160+
161+
if err != nil {
162+
return nil, fmt.Errorf("failed to capture snapshot: %w", err)
163+
}
164+
165+
return snap, nil
101166
}
102167

103168
func uploadSnapshot(snap *snapshot.Snapshot) error {
@@ -166,10 +231,23 @@ func uploadSnapshot(snap *snapshot.Snapshot) error {
166231
return fmt.Errorf("failed to parse upload response: %w", err)
167232
}
168233

234+
// --- SUCCESS SCREEN ---
235+
configURL := fmt.Sprintf("%s/%s/%s", apiBase, stored.Username, result.Slug)
236+
installURL := fmt.Sprintf("curl -fsSL %s/%s/%s/install | bash", apiBase, stored.Username, result.Slug)
237+
238+
fmt.Fprintln(os.Stderr)
239+
fmt.Fprintln(os.Stderr, snapSuccessStyle.Render("✓ Config uploaded successfully!"))
169240
fmt.Fprintln(os.Stderr)
170-
fmt.Fprintln(os.Stderr, snapSuccessStyle.Render(
171-
fmt.Sprintf("✓ Config uploaded! View at: %s/%s/%s", apiBase, stored.Username, result.Slug),
172-
))
241+
fmt.Fprintln(os.Stderr, snapBoldStyle.Render(" View your config:"))
242+
fmt.Fprintf(os.Stderr, " %s\n", configURL)
243+
fmt.Fprintln(os.Stderr)
244+
fmt.Fprintln(os.Stderr, snapBoldStyle.Render(" Share with others:"))
245+
fmt.Fprintf(os.Stderr, " %s\n", installURL)
246+
fmt.Fprintln(os.Stderr)
247+
248+
// Auto-open browser
249+
exec.Command("open", configURL).Start()
250+
fmt.Fprintln(os.Stderr, snapMutedStyle.Render(" Opening in browser..."))
173251
fmt.Fprintln(os.Stderr)
174252

175253
return nil

internal/snapshot/capture.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,151 @@ func Capture() (*Snapshot, error) {
7575
}, nil
7676
}
7777

78+
// ScanStep represents progress information for a single capture step.
79+
type ScanStep struct {
80+
Name string `json:"name"` // e.g. "Homebrew Formulae"
81+
Index int `json:"index"` // 0-6
82+
Total int `json:"total"` // always 7
83+
Status string `json:"status"` // "scanning" | "done" | "error"
84+
Count int `json:"count"` // items found (only meaningful on "done")
85+
}
86+
87+
// CaptureWithProgress orchestrates a full environment snapshot with progress callbacks.
88+
// The callback is invoked before and after each capture step.
89+
// If callback is nil, it is not invoked.
90+
func CaptureWithProgress(callback func(step ScanStep)) (*Snapshot, error) {
91+
hostname, err := os.Hostname()
92+
if err != nil {
93+
hostname = "unknown"
94+
}
95+
96+
// Step 0: Homebrew Formulae
97+
if callback != nil {
98+
callback(ScanStep{Name: "Homebrew Formulae", Index: 0, Total: 7, Status: "scanning", Count: 0})
99+
}
100+
formulae, err := CaptureFormulae()
101+
if err != nil {
102+
if callback != nil {
103+
callback(ScanStep{Name: "Homebrew Formulae", Index: 0, Total: 7, Status: "error", Count: 0})
104+
}
105+
} else {
106+
if callback != nil {
107+
callback(ScanStep{Name: "Homebrew Formulae", Index: 0, Total: 7, Status: "done", Count: len(formulae)})
108+
}
109+
}
110+
111+
// Step 1: Homebrew Casks
112+
if callback != nil {
113+
callback(ScanStep{Name: "Homebrew Casks", Index: 1, Total: 7, Status: "scanning", Count: 0})
114+
}
115+
casks, err := CaptureCasks()
116+
if err != nil {
117+
if callback != nil {
118+
callback(ScanStep{Name: "Homebrew Casks", Index: 1, Total: 7, Status: "error", Count: 0})
119+
}
120+
} else {
121+
if callback != nil {
122+
callback(ScanStep{Name: "Homebrew Casks", Index: 1, Total: 7, Status: "done", Count: len(casks)})
123+
}
124+
}
125+
126+
// Step 2: Homebrew Taps
127+
if callback != nil {
128+
callback(ScanStep{Name: "Homebrew Taps", Index: 2, Total: 7, Status: "scanning", Count: 0})
129+
}
130+
taps, err := CaptureTaps()
131+
if err != nil {
132+
if callback != nil {
133+
callback(ScanStep{Name: "Homebrew Taps", Index: 2, Total: 7, Status: "error", Count: 0})
134+
}
135+
} else {
136+
if callback != nil {
137+
callback(ScanStep{Name: "Homebrew Taps", Index: 2, Total: 7, Status: "done", Count: len(taps)})
138+
}
139+
}
140+
141+
// Step 3: macOS Preferences
142+
if callback != nil {
143+
callback(ScanStep{Name: "macOS Preferences", Index: 3, Total: 7, Status: "scanning", Count: 0})
144+
}
145+
prefs, err := CaptureMacOSPrefs()
146+
if err != nil {
147+
if callback != nil {
148+
callback(ScanStep{Name: "macOS Preferences", Index: 3, Total: 7, Status: "error", Count: 0})
149+
}
150+
} else {
151+
if callback != nil {
152+
callback(ScanStep{Name: "macOS Preferences", Index: 3, Total: 7, Status: "done", Count: len(prefs)})
153+
}
154+
}
155+
156+
// Step 4: Shell Environment
157+
if callback != nil {
158+
callback(ScanStep{Name: "Shell Environment", Index: 4, Total: 7, Status: "scanning", Count: 0})
159+
}
160+
shellSnap, err := CaptureShell()
161+
if err != nil {
162+
if callback != nil {
163+
callback(ScanStep{Name: "Shell Environment", Index: 4, Total: 7, Status: "error", Count: 0})
164+
}
165+
} else {
166+
if callback != nil {
167+
callback(ScanStep{Name: "Shell Environment", Index: 4, Total: 7, Status: "done", Count: 1})
168+
}
169+
}
170+
171+
// Step 5: Git Configuration
172+
if callback != nil {
173+
callback(ScanStep{Name: "Git Configuration", Index: 5, Total: 7, Status: "scanning", Count: 0})
174+
}
175+
gitSnap, err := CaptureGit()
176+
if err != nil {
177+
if callback != nil {
178+
callback(ScanStep{Name: "Git Configuration", Index: 5, Total: 7, Status: "error", Count: 0})
179+
}
180+
} else {
181+
if callback != nil {
182+
callback(ScanStep{Name: "Git Configuration", Index: 5, Total: 7, Status: "done", Count: 1})
183+
}
184+
}
185+
186+
// Step 6: Dev Tools
187+
if callback != nil {
188+
callback(ScanStep{Name: "Dev Tools", Index: 6, Total: 7, Status: "scanning", Count: 0})
189+
}
190+
devTools, err := CaptureDevTools()
191+
if err != nil {
192+
if callback != nil {
193+
callback(ScanStep{Name: "Dev Tools", Index: 6, Total: 7, Status: "error", Count: 0})
194+
}
195+
} else {
196+
if callback != nil {
197+
callback(ScanStep{Name: "Dev Tools", Index: 6, Total: 7, Status: "done", Count: len(devTools)})
198+
}
199+
}
200+
201+
return &Snapshot{
202+
Version: 1,
203+
CapturedAt: time.Now(),
204+
Hostname: hostname,
205+
Packages: PackageSnapshot{
206+
Formulae: formulae,
207+
Casks: casks,
208+
Taps: taps,
209+
},
210+
MacOSPrefs: prefs,
211+
Shell: *shellSnap,
212+
Git: *gitSnap,
213+
DevTools: devTools,
214+
MatchedPreset: "",
215+
CatalogMatch: CatalogMatch{
216+
Matched: []string{},
217+
Unmatched: []string{},
218+
MatchRate: 0,
219+
},
220+
}, nil
221+
}
222+
78223
func isBrewInstalled() bool {
79224
_, err := exec.LookPath("brew")
80225
return err == nil

0 commit comments

Comments
 (0)