Skip to content

Commit 22b5353

Browse files
feat(snapshot): add snapshot types and system capture functions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent f5963f5 commit 22b5353

2 files changed

Lines changed: 398 additions & 0 deletions

File tree

internal/snapshot/capture.go

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
package snapshot
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
"time"
10+
11+
"github.com/openbootdotdev/openboot/internal/macos"
12+
)
13+
14+
// Capture orchestrates a full environment snapshot.
15+
func Capture(includeVSCode bool) (*Snapshot, error) {
16+
hostname, err := os.Hostname()
17+
if err != nil {
18+
hostname = "unknown"
19+
}
20+
21+
formulae, err := CaptureFormulae()
22+
if err != nil {
23+
return nil, err
24+
}
25+
26+
casks, err := CaptureCasks()
27+
if err != nil {
28+
return nil, err
29+
}
30+
31+
taps, err := CaptureTaps()
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
prefs, err := CaptureMacOSPrefs()
37+
if err != nil {
38+
return nil, err
39+
}
40+
41+
shellSnap, err := CaptureShell()
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
gitSnap, err := CaptureGit()
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
devTools, err := CaptureDevTools()
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
vsExts := []string{}
57+
if includeVSCode {
58+
vsExts, err = CaptureVSCodeExtensions()
59+
if err != nil {
60+
return nil, err
61+
}
62+
}
63+
64+
return &Snapshot{
65+
Version: 1,
66+
CapturedAt: time.Now(),
67+
Hostname: hostname,
68+
Packages: PackageSnapshot{
69+
Formulae: formulae,
70+
Casks: casks,
71+
Taps: taps,
72+
},
73+
MacOSPrefs: prefs,
74+
Shell: *shellSnap,
75+
Git: *gitSnap,
76+
DevTools: devTools,
77+
VSCodeExts: vsExts,
78+
MatchedPreset: "",
79+
CatalogMatch: CatalogMatch{
80+
Matched: []string{},
81+
Unmatched: []string{},
82+
MatchRate: 0,
83+
},
84+
}, nil
85+
}
86+
87+
func isBrewInstalled() bool {
88+
_, err := exec.LookPath("brew")
89+
return err == nil
90+
}
91+
92+
// CaptureFormulae returns user-intentional formulae via `brew leaves`.
93+
func CaptureFormulae() ([]string, error) {
94+
if !isBrewInstalled() {
95+
return []string{}, nil
96+
}
97+
98+
cmd := exec.Command("brew", "leaves")
99+
output, err := cmd.Output()
100+
if err != nil {
101+
return []string{}, nil
102+
}
103+
104+
return parseLines(string(output)), nil
105+
}
106+
107+
// CaptureCasks returns installed casks via `brew list --cask`.
108+
func CaptureCasks() ([]string, error) {
109+
if !isBrewInstalled() {
110+
return []string{}, nil
111+
}
112+
113+
cmd := exec.Command("brew", "list", "--cask")
114+
output, err := cmd.Output()
115+
if err != nil {
116+
return []string{}, nil
117+
}
118+
119+
return parseLines(string(output)), nil
120+
}
121+
122+
// CaptureTaps returns active Homebrew taps.
123+
func CaptureTaps() ([]string, error) {
124+
if !isBrewInstalled() {
125+
return []string{}, nil
126+
}
127+
128+
cmd := exec.Command("brew", "tap")
129+
output, err := cmd.Output()
130+
if err != nil {
131+
return []string{}, nil
132+
}
133+
134+
return parseLines(string(output)), nil
135+
}
136+
137+
// CaptureMacOSPrefs reads the current values of whitelisted macOS preferences.
138+
func CaptureMacOSPrefs() ([]MacOSPref, error) {
139+
prefs := []MacOSPref{}
140+
141+
for _, p := range macos.DefaultPreferences {
142+
cmd := exec.Command("defaults", "read", p.Domain, p.Key)
143+
output, err := cmd.Output()
144+
if err != nil {
145+
continue
146+
}
147+
148+
prefs = append(prefs, MacOSPref{
149+
Domain: p.Domain,
150+
Key: p.Key,
151+
Value: strings.TrimSpace(string(output)),
152+
Desc: p.Desc,
153+
})
154+
}
155+
156+
return prefs, nil
157+
}
158+
159+
// CaptureShell detects the default shell, Oh-My-Zsh, plugins, and theme.
160+
func CaptureShell() (*ShellSnapshot, error) {
161+
snap := &ShellSnapshot{
162+
Default: os.Getenv("SHELL"),
163+
Plugins: []string{},
164+
}
165+
166+
home, err := os.UserHomeDir()
167+
if err != nil {
168+
return snap, nil
169+
}
170+
171+
omzDir := filepath.Join(home, ".oh-my-zsh")
172+
if _, err := os.Stat(omzDir); err == nil {
173+
snap.OhMyZsh = true
174+
}
175+
176+
zshrc := filepath.Join(home, ".zshrc")
177+
data, err := os.ReadFile(zshrc)
178+
if err != nil {
179+
return snap, nil
180+
}
181+
content := string(data)
182+
183+
pluginsRe := regexp.MustCompile(`plugins=\(([^)]*)\)`)
184+
if m := pluginsRe.FindStringSubmatch(content); len(m) > 1 {
185+
for _, p := range strings.Fields(m[1]) {
186+
p = strings.TrimSpace(p)
187+
if p != "" {
188+
snap.Plugins = append(snap.Plugins, p)
189+
}
190+
}
191+
}
192+
193+
themeRe := regexp.MustCompile(`ZSH_THEME="([^"]*)"`)
194+
if m := themeRe.FindStringSubmatch(content); len(m) > 1 {
195+
snap.Theme = m[1]
196+
}
197+
198+
return snap, nil
199+
}
200+
201+
// CaptureGit reads global git user.name and user.email.
202+
func CaptureGit() (*GitSnapshot, error) {
203+
snap := &GitSnapshot{}
204+
205+
if out, err := exec.Command("git", "config", "--global", "user.name").Output(); err == nil {
206+
snap.UserName = strings.TrimSpace(string(out))
207+
}
208+
209+
if out, err := exec.Command("git", "config", "--global", "user.email").Output(); err == nil {
210+
snap.UserEmail = strings.TrimSpace(string(out))
211+
}
212+
213+
return snap, nil
214+
}
215+
216+
var devToolCommands = []struct {
217+
name string
218+
args []string
219+
}{
220+
{"go", []string{"version"}},
221+
{"node", []string{"--version"}},
222+
{"python3", []string{"--version"}},
223+
{"rustc", []string{"--version"}},
224+
{"java", []string{"--version"}},
225+
{"ruby", []string{"--version"}},
226+
{"docker", []string{"--version"}},
227+
}
228+
229+
// CaptureDevTools detects installed development tools and their versions.
230+
func CaptureDevTools() ([]DevTool, error) {
231+
tools := []DevTool{}
232+
233+
for _, dt := range devToolCommands {
234+
if _, err := exec.LookPath(dt.name); err != nil {
235+
continue
236+
}
237+
238+
cmd := exec.Command(dt.name, dt.args...)
239+
output, err := cmd.Output()
240+
if err != nil {
241+
continue
242+
}
243+
244+
version := parseVersion(dt.name, strings.TrimSpace(string(output)))
245+
tools = append(tools, DevTool{
246+
Name: dt.name,
247+
Version: version,
248+
})
249+
}
250+
251+
return tools, nil
252+
}
253+
254+
// CaptureVSCodeExtensions lists installed VS Code extensions.
255+
func CaptureVSCodeExtensions() ([]string, error) {
256+
if _, err := exec.LookPath("code"); err != nil {
257+
return []string{}, nil
258+
}
259+
260+
cmd := exec.Command("code", "--list-extensions")
261+
output, err := cmd.Output()
262+
if err != nil {
263+
return []string{}, nil
264+
}
265+
266+
return parseLines(string(output)), nil
267+
}
268+
269+
func sanitizePath(path string) string {
270+
home, err := os.UserHomeDir()
271+
if err != nil {
272+
return path
273+
}
274+
if strings.HasPrefix(path, home+"/") {
275+
return "~/" + path[len(home)+1:]
276+
}
277+
if path == home {
278+
return "~"
279+
}
280+
return path
281+
}
282+
283+
func parseLines(output string) []string {
284+
lines := []string{}
285+
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
286+
line = strings.TrimSpace(line)
287+
if line != "" {
288+
lines = append(lines, line)
289+
}
290+
}
291+
return lines
292+
}
293+
294+
func parseVersion(toolName, output string) string {
295+
switch toolName {
296+
case "go":
297+
// "go version go1.22.0 darwin/arm64" -> "1.22.0"
298+
if strings.HasPrefix(output, "go version go") {
299+
parts := strings.Fields(output)
300+
if len(parts) >= 3 {
301+
return strings.TrimPrefix(parts[2], "go")
302+
}
303+
}
304+
case "node":
305+
// "v20.11.0" -> "20.11.0"
306+
return strings.TrimPrefix(output, "v")
307+
case "python3":
308+
// "Python 3.12.0" -> "3.12.0"
309+
return strings.TrimPrefix(output, "Python ")
310+
case "rustc":
311+
// "rustc 1.75.0 (82e1608df 2023-12-21)" -> "1.75.0"
312+
parts := strings.Fields(output)
313+
if len(parts) >= 2 {
314+
return parts[1]
315+
}
316+
case "java":
317+
// First line: 'openjdk 21.0.1 2023-10-17' or 'java 21.0.1 ...'
318+
firstLine := strings.Split(output, "\n")[0]
319+
parts := strings.Fields(firstLine)
320+
if len(parts) >= 2 {
321+
return parts[1]
322+
}
323+
case "ruby":
324+
// "ruby 3.2.2 (2023-03-30 revision e51014f9c0) ..." -> "3.2.2"
325+
parts := strings.Fields(output)
326+
if len(parts) >= 2 {
327+
return parts[1]
328+
}
329+
case "docker":
330+
// "Docker version 24.0.7, build afdd53b" -> "24.0.7"
331+
parts := strings.Fields(output)
332+
if len(parts) >= 3 {
333+
return strings.TrimSuffix(parts[2], ",")
334+
}
335+
}
336+
return output
337+
}

internal/snapshot/snapshot.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package snapshot
2+
3+
import "time"
4+
5+
// Snapshot represents a complete capture of the developer environment state.
6+
type Snapshot struct {
7+
Version int `json:"version"`
8+
CapturedAt time.Time `json:"captured_at"`
9+
Hostname string `json:"hostname"`
10+
Packages PackageSnapshot `json:"packages"`
11+
MacOSPrefs []MacOSPref `json:"macos_prefs"`
12+
Shell ShellSnapshot `json:"shell"`
13+
Git GitSnapshot `json:"git"`
14+
DevTools []DevTool `json:"dev_tools"`
15+
VSCodeExts []string `json:"vscode_exts"`
16+
MatchedPreset string `json:"matched_preset"`
17+
CatalogMatch CatalogMatch `json:"catalog_match"`
18+
}
19+
20+
// PackageSnapshot captures installed Homebrew packages.
21+
type PackageSnapshot struct {
22+
Formulae []string `json:"formulae"`
23+
Casks []string `json:"casks"`
24+
Taps []string `json:"taps"`
25+
}
26+
27+
// MacOSPref captures a single macOS defaults preference value.
28+
type MacOSPref struct {
29+
Domain string `json:"domain"`
30+
Key string `json:"key"`
31+
Value string `json:"value"`
32+
Desc string `json:"desc"`
33+
}
34+
35+
// ShellSnapshot captures the shell environment configuration.
36+
type ShellSnapshot struct {
37+
Default string `json:"default"`
38+
OhMyZsh bool `json:"oh_my_zsh"`
39+
Plugins []string `json:"plugins"`
40+
Theme string `json:"theme"`
41+
}
42+
43+
// GitSnapshot captures global git user configuration.
44+
type GitSnapshot struct {
45+
UserName string `json:"user_name"`
46+
UserEmail string `json:"user_email"`
47+
}
48+
49+
// DevTool captures a detected development tool and its version.
50+
type DevTool struct {
51+
Name string `json:"name"`
52+
Version string `json:"version"`
53+
}
54+
55+
// CatalogMatch records how well the snapshot matches a catalog.
56+
// Populated by the matching phase, not during capture.
57+
type CatalogMatch struct {
58+
Matched []string `json:"matched"`
59+
Unmatched []string `json:"unmatched"`
60+
MatchRate float64 `json:"match_rate"`
61+
}

0 commit comments

Comments
 (0)