Skip to content

Commit 0431eee

Browse files
committed
feat: fetch package catalog from server, unify snapshot format, add contract CI
- CLI fetches package metadata from /api/packages at startup with 24h cache in ~/.openboot/packages-cache.json, falls back to embedded YAML - Snapshot MarshalJSON always outputs canonical plain string arrays - PackageEntryList handles new {name, desc} object array format from server - CI validates against openboot-contract JSON schemas - CI triggers on contract-updated repository_dispatch events - Add comprehensive tests for remote package fetch, cache, and merge logic
1 parent 7e2404d commit 0431eee

8 files changed

Lines changed: 654 additions & 70 deletions

File tree

.github/workflows/test.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ on:
99
branches:
1010
- main
1111
- master
12+
repository_dispatch:
13+
types: [contract-updated]
1214
workflow_dispatch:
1315
inputs:
1416
run_destructive:
@@ -45,6 +47,33 @@ jobs:
4547
if: ${{ inputs.run_destructive == true }}
4648
run: make test-destructive
4749

50+
- name: Contract schema validation
51+
run: |
52+
git clone --depth 1 https://github.com/openbootdotdev/openboot-contract.git /tmp/contract
53+
pip3 install jsonschema
54+
55+
python3 -c "
56+
import json, jsonschema, sys
57+
58+
checks = [
59+
('/tmp/contract/schemas/remote-config.json', '/tmp/contract/fixtures/config-v1.json'),
60+
('/tmp/contract/schemas/snapshot.json', '/tmp/contract/fixtures/snapshot-v1.json'),
61+
]
62+
63+
failed = 0
64+
for schema_path, fixture_path in checks:
65+
schema = json.load(open(schema_path))
66+
data = json.load(open(fixture_path))
67+
try:
68+
jsonschema.validate(data, schema)
69+
print(f' ✓ {fixture_path.split(\"/\")[-1]} matches {schema_path.split(\"/\")[-1]}')
70+
except jsonschema.ValidationError as e:
71+
print(f' ✗ {fixture_path.split(\"/\")[-1]}: {e.message}')
72+
failed += 1
73+
74+
sys.exit(1 if failed else 0)
75+
"
76+
4877
- name: Upload coverage to Codecov
4978
uses: codecov/codecov-action@v4
5079
with:

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ openboot/
4646
│ ├── brew/ # Homebrew ops, parallel install (4 workers), retry logic, uninstall
4747
│ ├── cleaner/ # Diff current system vs desired state, remove extra packages
4848
│ ├── cli/ # Cobra commands: root, snapshot, doctor, clean, diff, sync, update, version
49-
│ ├── config/ # Embedded YAML (packages + presets), remote config fetch
50-
│ │ └── data/ # packages.yaml (9 categories), presets.yaml (3 presets)
49+
│ ├── config/ # Package catalog, presets, remote config fetch
50+
│ │ └── data/ # packages.yaml (embedded fallback), presets.yaml (3 presets)
5151
│ ├── diff/ # Read-only system vs config/snapshot comparison (pure logic)
5252
│ ├── dotfiles/ # Clone + stow/symlink with .openboot.bak backup
5353
│ ├── installer/ # Main orchestrator: 7-step wizard + snapshot restore
@@ -93,7 +93,7 @@ cli (root)
9393
| Task | Location | Notes |
9494
|------|----------|-------|
9595
| Add CLI command | `internal/cli/` | Register in root.go init(), follow cobra pattern |
96-
| Add package category | `internal/config/data/packages.yaml` | Rebuild after changing embedded YAML |
96+
| Add package category | `openboot.dev/src/lib/package-metadata.ts` | Server is the source of truth; CLI fetches via `/api/packages` at startup and caches 24h in `~/.openboot/packages-cache.json`. Embedded `packages.yaml` is fallback only |
9797
| Change install flow | `internal/installer/installer.go` | 7 steps: homebrew → git → preset → packages → shell → macos → dotfiles |
9898
| Change clean/uninstall | `internal/cleaner/cleaner.go` | Diffs current vs desired, calls brew/npm Uninstall |
9999
| Add TUI component | `internal/ui/` | Use bubbletea Model pattern, lipgloss styling |

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ shell configuration, and macOS preferences.`,
4040
openboot snapshot --json > my-setup.json`,
4141
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
4242
updater.AutoUpgrade(version)
43+
config.RefreshPackagesFromRemote()
4344

4445
if cfg.Silent {
4546
if name := os.Getenv("OPENBOOT_GIT_NAME"); name != "" {

internal/config/config_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,68 @@ func TestUnmarshalRemoteConfigFlexible_TopLevelMacOSPrefsNotOverridden(t *testin
472472
assert.Equal(t, "com.apple.dock", rc.MacOSPrefs[0].Domain, "top-level macos_prefs should take precedence")
473473
}
474474

475+
func TestUnmarshalRemoteConfigFlexible_ObjectArrayWithDesc(t *testing.T) {
476+
// New canonical format: server returns separate arrays with {name, desc} objects.
477+
data := []byte(`{
478+
"username": "testuser",
479+
"slug": "myconfig",
480+
"name": "My Setup",
481+
"preset": "developer",
482+
"packages": [{"name": "git", "desc": "Distributed version control"}, {"name": "curl", "desc": "Transfer data with URLs"}],
483+
"casks": [{"name": "firefox", "desc": "Privacy-focused browser"}],
484+
"taps": ["homebrew/cask-fonts"],
485+
"npm": [{"name": "typescript", "desc": "Typed superset of JavaScript"}],
486+
"dotfiles_repo": "https://github.com/testuser/dotfiles"
487+
}`)
488+
489+
rc, err := UnmarshalRemoteConfigFlexible(data)
490+
require.NoError(t, err)
491+
assert.Equal(t, "testuser", rc.Username)
492+
assert.Equal(t, "myconfig", rc.Slug)
493+
assert.Equal(t, PackageEntryList{{Name: "git", Desc: "Distributed version control"}, {Name: "curl", Desc: "Transfer data with URLs"}}, rc.Packages)
494+
assert.Equal(t, PackageEntryList{{Name: "firefox", Desc: "Privacy-focused browser"}}, rc.Casks)
495+
assert.Equal(t, []string{"homebrew/cask-fonts"}, rc.Taps)
496+
assert.Equal(t, PackageEntryList{{Name: "typescript", Desc: "Typed superset of JavaScript"}}, rc.Npm)
497+
}
498+
499+
func TestFetchRemoteConfig_ObjectArrayFormat(t *testing.T) {
500+
// Server now returns {name, desc} objects instead of flat strings.
501+
mockConfig := map[string]interface{}{
502+
"username": "testuser",
503+
"slug": "myconfig",
504+
"name": "Test Config",
505+
"preset": "developer",
506+
"packages": []map[string]string{{"name": "git", "desc": "Version control"}, {"name": "curl", "desc": "Transfer data"}},
507+
"casks": []map[string]string{{"name": "firefox", "desc": "Browser"}},
508+
"taps": []string{"homebrew/cask-fonts"},
509+
"npm": []map[string]string{{"name": "typescript", "desc": "Typed JS"}},
510+
"dotfiles_repo": "https://github.com/testuser/dotfiles",
511+
}
512+
513+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
514+
w.WriteHeader(http.StatusOK)
515+
json.NewEncoder(w).Encode(mockConfig)
516+
}))
517+
defer server.Close()
518+
519+
originalClient := remoteHTTPClient
520+
remoteHTTPClient = server.Client()
521+
defer func() { remoteHTTPClient = originalClient }()
522+
523+
t.Setenv("OPENBOOT_API_URL", server.URL)
524+
525+
result, err := FetchRemoteConfig("testuser/myconfig", "")
526+
require.NoError(t, err)
527+
assert.Equal(t, "testuser", result.Username)
528+
assert.Len(t, result.Packages, 2)
529+
assert.Equal(t, "git", result.Packages[0].Name)
530+
assert.Equal(t, "Version control", result.Packages[0].Desc)
531+
assert.Len(t, result.Casks, 1)
532+
assert.Equal(t, "Browser", result.Casks[0].Desc)
533+
assert.Len(t, result.Npm, 1)
534+
assert.Equal(t, "Typed JS", result.Npm[0].Desc)
535+
}
536+
475537
func TestUnmarshalRemoteConfigFlexible_InvalidJSON(t *testing.T) {
476538
data := []byte(`not json`)
477539
_, err := UnmarshalRemoteConfigFlexible(data)

internal/config/packages_remote.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"time"
11+
)
12+
13+
// remotePackage matches the JSON returned by GET /api/packages.
14+
type remotePackage struct {
15+
Name string `json:"name"`
16+
Desc string `json:"desc"`
17+
Category string `json:"category"`
18+
Type string `json:"type"`
19+
Installer string `json:"installer"` // "formula", "cask", or "npm"
20+
}
21+
22+
type remotePackagesResponse struct {
23+
Packages []remotePackage `json:"packages"`
24+
}
25+
26+
const (
27+
packagesCacheFile = "packages-cache.json"
28+
packagesCacheTTL = 24 * time.Hour
29+
)
30+
31+
// packagesCacheEntry is the on-disk cache format.
32+
type packagesCacheEntry struct {
33+
FetchedAt time.Time `json:"fetched_at"`
34+
Packages []remotePackage `json:"packages"`
35+
}
36+
37+
// RefreshPackagesFromRemote fetches packages from the server and merges them
38+
// into the global Categories slice. Safe to call multiple times; it is a no-op
39+
// if the cache is fresh. Falls back to the embedded packages.yaml silently.
40+
func RefreshPackagesFromRemote() {
41+
pkgs, err := loadRemotePackages()
42+
if err != nil || len(pkgs) == 0 {
43+
return // keep embedded fallback
44+
}
45+
mergeRemotePackages(pkgs)
46+
}
47+
48+
func loadRemotePackages() ([]remotePackage, error) {
49+
// Try disk cache first.
50+
if pkgs, err := readPackagesCache(); err == nil {
51+
return pkgs, nil
52+
}
53+
54+
// Fetch from server.
55+
pkgs, err := fetchRemotePackages()
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
// Write cache (best-effort).
61+
_ = writePackagesCache(pkgs)
62+
return pkgs, nil
63+
}
64+
65+
func fetchRemotePackages() ([]remotePackage, error) {
66+
apiURL := getAPIBase() + "/api/packages"
67+
client := &http.Client{Timeout: 8 * time.Second}
68+
69+
resp, err := client.Get(apiURL)
70+
if err != nil {
71+
return nil, fmt.Errorf("fetch packages: %w", err)
72+
}
73+
defer resp.Body.Close()
74+
75+
if resp.StatusCode != 200 {
76+
return nil, fmt.Errorf("fetch packages: status %d", resp.StatusCode)
77+
}
78+
79+
var result remotePackagesResponse
80+
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
81+
return nil, fmt.Errorf("parse packages: %w", err)
82+
}
83+
84+
return result.Packages, nil
85+
}
86+
87+
// cacheDir returns the directory for cache files. It is a variable so tests
88+
// can replace it with a temp directory.
89+
var cacheDir = func() string {
90+
home, _ := os.UserHomeDir()
91+
return filepath.Join(home, ".openboot")
92+
}
93+
94+
func readPackagesCache() ([]remotePackage, error) {
95+
data, err := os.ReadFile(filepath.Join(cacheDir(), packagesCacheFile))
96+
if err != nil {
97+
return nil, err
98+
}
99+
100+
var entry packagesCacheEntry
101+
if err := json.Unmarshal(data, &entry); err != nil {
102+
return nil, err
103+
}
104+
105+
if time.Since(entry.FetchedAt) > packagesCacheTTL {
106+
return nil, fmt.Errorf("cache expired")
107+
}
108+
109+
return entry.Packages, nil
110+
}
111+
112+
func writePackagesCache(pkgs []remotePackage) error {
113+
dir := cacheDir()
114+
if err := os.MkdirAll(dir, 0700); err != nil {
115+
return err
116+
}
117+
118+
entry := packagesCacheEntry{
119+
FetchedAt: time.Now(),
120+
Packages: pkgs,
121+
}
122+
data, err := json.Marshal(entry)
123+
if err != nil {
124+
return err
125+
}
126+
127+
return os.WriteFile(filepath.Join(dir, packagesCacheFile), data, 0600)
128+
}
129+
130+
// categoryMap maps server category names to display info.
131+
var categoryMap = map[string]struct {
132+
Name string
133+
Icon string
134+
}{
135+
"essential": {Name: "Essential", Icon: "⚡"},
136+
"development": {Name: "Development", Icon: "🛠"},
137+
"productivity": {Name: "Productivity", Icon: "🚀"},
138+
"optional": {Name: "Optional", Icon: "📦"},
139+
}
140+
141+
// mergeRemotePackages converts remote packages into Categories format and
142+
// merges them with the embedded data. Remote packages take precedence.
143+
func mergeRemotePackages(pkgs []remotePackage) {
144+
// Build a set of existing package names from embedded data.
145+
existing := make(map[string]bool)
146+
for _, cat := range Categories {
147+
for _, pkg := range cat.Packages {
148+
existing[pkg.Name] = true
149+
}
150+
}
151+
152+
// Group new remote packages by category.
153+
byCat := make(map[string][]Package)
154+
for _, rp := range pkgs {
155+
if existing[rp.Name] {
156+
// Update description in existing categories if remote has a better one.
157+
updateDescription(rp.Name, rp.Desc)
158+
// Update installer type flags.
159+
updateInstallerFlags(rp.Name, rp.Installer)
160+
continue
161+
}
162+
pkg := Package{
163+
Name: rp.Name,
164+
Description: rp.Desc,
165+
IsCask: rp.Installer == "cask",
166+
IsNpm: rp.Installer == "npm",
167+
}
168+
byCat[rp.Category] = append(byCat[rp.Category], pkg)
169+
}
170+
171+
// Append new packages to existing categories or create new ones.
172+
catIndex := make(map[string]int)
173+
for i, cat := range Categories {
174+
// Map existing category names to server categories.
175+
switch cat.Name {
176+
case "Essential":
177+
catIndex["essential"] = i
178+
case "Development", "Git & GitHub", "DevOps", "Database":
179+
catIndex["development"] = i
180+
case "Productivity", "Browsers":
181+
catIndex["productivity"] = i
182+
case "NPM Global":
183+
catIndex["development"] = i // npm goes with development
184+
}
185+
}
186+
187+
for serverCat, newPkgs := range byCat {
188+
if idx, ok := catIndex[serverCat]; ok {
189+
Categories[idx].Packages = append(Categories[idx].Packages, newPkgs...)
190+
} else {
191+
info := categoryMap[serverCat]
192+
if info.Name == "" {
193+
info = struct {
194+
Name string
195+
Icon string
196+
}{Name: serverCat, Icon: "📦"}
197+
}
198+
Categories = append(Categories, Category{
199+
Name: info.Name,
200+
Icon: info.Icon,
201+
Packages: newPkgs,
202+
})
203+
}
204+
}
205+
}
206+
207+
func updateDescription(name, desc string) {
208+
if desc == "" {
209+
return
210+
}
211+
for i := range Categories {
212+
for j := range Categories[i].Packages {
213+
if Categories[i].Packages[j].Name == name && Categories[i].Packages[j].Description == "" {
214+
Categories[i].Packages[j].Description = desc
215+
}
216+
}
217+
}
218+
}
219+
220+
func updateInstallerFlags(name, installer string) {
221+
for i := range Categories {
222+
for j := range Categories[i].Packages {
223+
if Categories[i].Packages[j].Name == name {
224+
switch installer {
225+
case "cask":
226+
Categories[i].Packages[j].IsCask = true
227+
case "npm":
228+
Categories[i].Packages[j].IsNpm = true
229+
}
230+
}
231+
}
232+
}
233+
}

0 commit comments

Comments
 (0)