Skip to content

Commit db6adab

Browse files
committed
fix: accept typed object array packages in push config
openboot push failed with "cannot unmarshal object into Go struct field RemoteConfig.packages of type string" when the file contained packages in the API export format [{"name":"git","type":"formula"}] instead of flat string arrays ["git"]. Add UnmarshalRemoteConfigFlexible() that accepts both formats and use it in pushConfig and LoadRemoteConfigFromFile. Fixes #12
1 parent 6b41868 commit db6adab

File tree

3 files changed

+176
-6
lines changed

3 files changed

+176
-6
lines changed

internal/cli/push.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ func pushSnapshot(data []byte, slug, token, username, apiBase string) error {
111111
}
112112

113113
func pushConfig(data []byte, slug, token, username, apiBase string) error {
114-
var rc config.RemoteConfig
115-
if err := json.Unmarshal(data, &rc); err != nil {
114+
rc, err := config.UnmarshalRemoteConfigFlexible(data)
115+
if err != nil {
116116
return fmt.Errorf("parse config: %w", err)
117117
}
118118
if err := rc.Validate(); err != nil {
@@ -125,7 +125,7 @@ func pushConfig(data []byte, slug, token, username, apiBase string) error {
125125
}
126126

127127
// Convert RemoteConfig to API format: packages as [{name, type}]
128-
packages := remoteConfigToAPIPackages(&rc)
128+
packages := remoteConfigToAPIPackages(rc)
129129

130130
reqBody := map[string]interface{}{
131131
"name": name,

internal/config/config.go

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,89 @@ type RemoteMacOSPref struct {
9797
Desc string `json:"desc"`
9898
}
9999

100+
// typedPackage represents a package entry with name and type, as returned
101+
// by the openboot.dev API (e.g. {"name":"git","type":"formula"}).
102+
type typedPackage struct {
103+
Name string `json:"name"`
104+
Type string `json:"type"`
105+
}
106+
107+
// UnmarshalRemoteConfigFlexible parses JSON into a RemoteConfig, accepting
108+
// packages in either flat string array format (["git","curl"]) or typed
109+
// object array format ([{"name":"git","type":"formula"}]).
110+
func UnmarshalRemoteConfigFlexible(data []byte) (*RemoteConfig, error) {
111+
// Try direct unmarshal first (flat string arrays).
112+
var rc RemoteConfig
113+
if err := json.Unmarshal(data, &rc); err == nil {
114+
return &rc, nil
115+
}
116+
117+
// Extract packages as typed objects and convert to flat arrays.
118+
var raw map[string]json.RawMessage
119+
if err := json.Unmarshal(data, &raw); err != nil {
120+
return nil, err
121+
}
122+
123+
pkgData, ok := raw["packages"]
124+
if !ok {
125+
return nil, fmt.Errorf("missing packages field")
126+
}
127+
128+
var typed []typedPackage
129+
if err := json.Unmarshal(pkgData, &typed); err != nil {
130+
return nil, fmt.Errorf("packages must be a string array or typed object array: %w", err)
131+
}
132+
133+
var formulae, casks, taps, npm []string
134+
for _, p := range typed {
135+
switch p.Type {
136+
case "cask":
137+
casks = append(casks, p.Name)
138+
case "tap":
139+
taps = append(taps, p.Name)
140+
case "npm":
141+
npm = append(npm, p.Name)
142+
default:
143+
formulae = append(formulae, p.Name)
144+
}
145+
}
146+
147+
// Replace packages with flat arrays and re-unmarshal.
148+
converted := make(map[string]json.RawMessage, len(raw))
149+
for k, v := range raw {
150+
converted[k] = v
151+
}
152+
if f, err := json.Marshal(formulae); err == nil {
153+
converted["packages"] = f
154+
}
155+
if len(casks) > 0 {
156+
if c, err := json.Marshal(casks); err == nil {
157+
converted["casks"] = c
158+
}
159+
}
160+
if len(taps) > 0 {
161+
if t, err := json.Marshal(taps); err == nil {
162+
converted["taps"] = t
163+
}
164+
}
165+
if len(npm) > 0 {
166+
if n, err := json.Marshal(npm); err == nil {
167+
converted["npm"] = n
168+
}
169+
}
170+
171+
normalised, err := json.Marshal(converted)
172+
if err != nil {
173+
return nil, fmt.Errorf("normalise config: %w", err)
174+
}
175+
176+
var result RemoteConfig
177+
if err := json.Unmarshal(normalised, &result); err != nil {
178+
return nil, err
179+
}
180+
return &result, nil
181+
}
182+
100183
var (
101184
pkgNameRe = regexp.MustCompile(`^[a-zA-Z0-9@/_.-]+$`)
102185
tapNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$`)
@@ -256,14 +339,14 @@ func LoadRemoteConfigFromFile(path string) (*RemoteConfig, error) {
256339
return loadSnapshotAsRemoteConfig(data)
257340
}
258341

259-
var rc RemoteConfig
260-
if err := json.Unmarshal(data, &rc); err != nil {
342+
rc, err := UnmarshalRemoteConfigFlexible(data)
343+
if err != nil {
261344
return nil, fmt.Errorf("parse remote config: %w", err)
262345
}
263346
if err := rc.Validate(); err != nil {
264347
return nil, fmt.Errorf("invalid config: %w", err)
265348
}
266-
return &rc, nil
349+
return rc, nil
267350
}
268351

269352
// snapshotFile mirrors the subset of snapshot.Snapshot needed for conversion,

internal/config/config_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,93 @@ func TestFetchRemoteConfig_NoAliasNoDefault(t *testing.T) {
345345
assert.Contains(t, err.Error(), "config not found: testuser/default")
346346
}
347347

348+
func TestUnmarshalRemoteConfigFlexible_FlatStringArrays(t *testing.T) {
349+
data := []byte(`{
350+
"username": "testuser",
351+
"packages": ["git", "curl"],
352+
"casks": ["firefox"],
353+
"taps": ["homebrew/cask-fonts"],
354+
"npm": ["typescript"]
355+
}`)
356+
357+
rc, err := UnmarshalRemoteConfigFlexible(data)
358+
require.NoError(t, err)
359+
assert.Equal(t, []string{"git", "curl"}, rc.Packages)
360+
assert.Equal(t, []string{"firefox"}, rc.Casks)
361+
assert.Equal(t, []string{"homebrew/cask-fonts"}, rc.Taps)
362+
assert.Equal(t, []string{"typescript"}, rc.Npm)
363+
}
364+
365+
func TestUnmarshalRemoteConfigFlexible_TypedObjectArray(t *testing.T) {
366+
data := []byte(`{
367+
"username": "testuser",
368+
"name": "My Setup",
369+
"packages": [
370+
{"name": "git", "type": "formula"},
371+
{"name": "curl", "type": "formula"},
372+
{"name": "firefox", "type": "cask"},
373+
{"name": "homebrew/cask-fonts", "type": "tap"},
374+
{"name": "typescript", "type": "npm"}
375+
]
376+
}`)
377+
378+
rc, err := UnmarshalRemoteConfigFlexible(data)
379+
require.NoError(t, err)
380+
assert.Equal(t, "testuser", rc.Username)
381+
assert.Equal(t, "My Setup", rc.Name)
382+
assert.Equal(t, []string{"git", "curl"}, rc.Packages)
383+
assert.Equal(t, []string{"firefox"}, rc.Casks)
384+
assert.Equal(t, []string{"homebrew/cask-fonts"}, rc.Taps)
385+
assert.Equal(t, []string{"typescript"}, rc.Npm)
386+
}
387+
388+
func TestUnmarshalRemoteConfigFlexible_TypedObjectWithDesc(t *testing.T) {
389+
data := []byte(`{
390+
"packages": [
391+
{"name": "git", "type": "formula", "desc": "Version control"},
392+
{"name": "firefox", "type": "cask", "desc": "Web browser"}
393+
]
394+
}`)
395+
396+
rc, err := UnmarshalRemoteConfigFlexible(data)
397+
require.NoError(t, err)
398+
assert.Equal(t, []string{"git"}, rc.Packages)
399+
assert.Equal(t, []string{"firefox"}, rc.Casks)
400+
}
401+
402+
func TestUnmarshalRemoteConfigFlexible_PreservesOtherFields(t *testing.T) {
403+
data := []byte(`{
404+
"username": "testuser",
405+
"slug": "myconfig",
406+
"name": "My Config",
407+
"preset": "developer",
408+
"dotfiles_repo": "https://github.com/testuser/dotfiles",
409+
"packages": [
410+
{"name": "git", "type": "formula"}
411+
],
412+
"shell": {"oh_my_zsh": true, "theme": "robbyrussell", "plugins": ["git"]},
413+
"macos_prefs": [{"domain": "com.apple.dock", "key": "autohide", "type": "bool", "value": "true"}]
414+
}`)
415+
416+
rc, err := UnmarshalRemoteConfigFlexible(data)
417+
require.NoError(t, err)
418+
assert.Equal(t, "testuser", rc.Username)
419+
assert.Equal(t, "myconfig", rc.Slug)
420+
assert.Equal(t, "developer", rc.Preset)
421+
assert.Equal(t, "https://github.com/testuser/dotfiles", rc.DotfilesRepo)
422+
assert.Equal(t, []string{"git"}, rc.Packages)
423+
require.NotNil(t, rc.Shell)
424+
assert.True(t, rc.Shell.OhMyZsh)
425+
assert.Equal(t, "robbyrussell", rc.Shell.Theme)
426+
assert.Len(t, rc.MacOSPrefs, 1)
427+
}
428+
429+
func TestUnmarshalRemoteConfigFlexible_InvalidJSON(t *testing.T) {
430+
data := []byte(`not json`)
431+
_, err := UnmarshalRemoteConfigFlexible(data)
432+
assert.Error(t, err)
433+
}
434+
348435
func TestFetchRemoteConfig_ExplicitSlugNoFallback(t *testing.T) {
349436
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
350437
assert.Equal(t, "/testuser/default/config", r.URL.Path)

0 commit comments

Comments
 (0)