@@ -68,17 +68,86 @@ type SnapshotGitConfig struct {
6868 UserEmail string
6969}
7070
71+ // PackageEntry represents a package with an optional description.
72+ type PackageEntry struct {
73+ Name string `json:"name"`
74+ Desc string `json:"desc,omitempty"`
75+ }
76+
77+ // PackageEntryList is a list of PackageEntry that unmarshals from either
78+ // ["git","curl"] (flat strings) or [{"name":"git","desc":"..."}] (objects).
79+ type PackageEntryList []PackageEntry
80+
81+ // UnmarshalJSON handles both flat string arrays and object arrays.
82+ func (p * PackageEntryList ) UnmarshalJSON (data []byte ) error {
83+ // Try flat string array first (most common from server responses).
84+ var names []string
85+ if err := json .Unmarshal (data , & names ); err == nil {
86+ result := make ([]PackageEntry , len (names ))
87+ for i , n := range names {
88+ result [i ] = PackageEntry {Name : n }
89+ }
90+ * p = result
91+ return nil
92+ }
93+
94+ // Try object array [{name, desc}]. Reject if any entry has a "type"
95+ // field — those must be split by UnmarshalRemoteConfigFlexible instead.
96+ var raw []json.RawMessage
97+ if err := json .Unmarshal (data , & raw ); err != nil {
98+ return fmt .Errorf ("packages must be a string array or object array: %w" , err )
99+ }
100+
101+ entries := make ([]PackageEntry , 0 , len (raw ))
102+ for _ , item := range raw {
103+ var probe struct {
104+ Type string `json:"type"`
105+ }
106+ if err := json .Unmarshal (item , & probe ); err == nil && probe .Type != "" {
107+ // Has a "type" field — bail so the caller's typed-object path handles it.
108+ return fmt .Errorf ("object has type field; needs typed splitting" )
109+ }
110+ var entry PackageEntry
111+ if err := json .Unmarshal (item , & entry ); err != nil {
112+ return fmt .Errorf ("invalid package entry: %w" , err )
113+ }
114+ entries = append (entries , entry )
115+ }
116+ * p = entries
117+ return nil
118+ }
119+
120+ // Names returns a slice of just the package names.
121+ func (p PackageEntryList ) Names () []string {
122+ names := make ([]string , len (p ))
123+ for i , e := range p {
124+ names [i ] = e .Name
125+ }
126+ return names
127+ }
128+
129+ // DescMap returns a map of name → desc for entries that have descriptions.
130+ func (p PackageEntryList ) DescMap () map [string ]string {
131+ m := make (map [string ]string , len (p ))
132+ for _ , e := range p {
133+ if e .Desc != "" {
134+ m [e .Name ] = e .Desc
135+ }
136+ }
137+ return m
138+ }
139+
71140type RemoteConfig struct {
72- Username string `json:"username"`
73- Slug string `json:"slug"`
74- Name string `json:"name"`
75- Preset string `json:"preset"`
76- Packages [] string `json:"packages"`
77- Casks [] string `json:"casks"`
78- Taps []string `json:"taps"`
79- Npm [] string `json:"npm"`
80- DotfilesRepo string `json:"dotfiles_repo"`
81- PostInstall []string `json:"post_install"`
141+ Username string `json:"username"`
142+ Slug string `json:"slug"`
143+ Name string `json:"name"`
144+ Preset string `json:"preset"`
145+ Packages PackageEntryList `json:"packages"`
146+ Casks PackageEntryList `json:"casks"`
147+ Taps []string `json:"taps"`
148+ Npm PackageEntryList `json:"npm"`
149+ DotfilesRepo string `json:"dotfiles_repo"`
150+ PostInstall []string `json:"post_install"`
82151 Shell * RemoteShellConfig `json:"shell"`
83152 MacOSPrefs []RemoteMacOSPref `json:"macos_prefs"`
84153}
@@ -97,11 +166,12 @@ type RemoteMacOSPref struct {
97166 Desc string `json:"desc"`
98167}
99168
100- // typedPackage represents a package entry with name and type, as returned
101- // by the openboot.dev API (e.g. {"name":"git","type":"formula"}) .
169+ // typedPackage represents a package entry with name, type, and optional
170+ // description, as returned by the openboot.dev API.
102171type typedPackage struct {
103172 Name string `json:"name"`
104173 Type string `json:"type"`
174+ Desc string `json:"desc,omitempty"`
105175}
106176
107177// UnmarshalRemoteConfigFlexible parses JSON into a RemoteConfig, accepting
@@ -131,38 +201,42 @@ func UnmarshalRemoteConfigFlexible(data []byte) (*RemoteConfig, error) {
131201 return nil , fmt .Errorf ("packages must be a string array or typed object array: %w" , err )
132202 }
133203
134- var formulae , casks , taps , npm []string
204+ var formulae , casks , npm PackageEntryList
205+ var taps []string
135206 for _ , p := range typed {
207+ entry := PackageEntry {Name : p .Name , Desc : p .Desc }
136208 switch p .Type {
137209 case "cask" :
138- casks = append (casks , p . Name )
210+ casks = append (casks , entry )
139211 case "tap" :
140212 taps = append (taps , p .Name )
141213 case "npm" :
142- npm = append (npm , p . Name )
214+ npm = append (npm , entry )
143215 default :
144- formulae = append (formulae , p . Name )
216+ formulae = append (formulae , entry )
145217 }
146218 }
147219
148- // Replace packages with flat arrays and re-unmarshal.
220+ // Replace packages with typed arrays and re-unmarshal.
149221 converted := make (map [string ]json.RawMessage , len (raw ))
150222 for k , v := range raw {
151223 converted [k ] = v
152224 }
153- if f , err := json .Marshal (formulae ); err == nil {
154- converted ["packages" ] = f
155- }
156- marshalIfNonEmpty := func (key string , items []string ) {
157- if len (items ) > 0 {
158- if data , err := json .Marshal (items ); err == nil {
159- converted [key ] = data
160- }
225+ marshalInto := func (key string , items interface {}) {
226+ if data , err := json .Marshal (items ); err == nil {
227+ converted [key ] = data
161228 }
162229 }
163- marshalIfNonEmpty ("casks" , casks )
164- marshalIfNonEmpty ("taps" , taps )
165- marshalIfNonEmpty ("npm" , npm )
230+ marshalInto ("packages" , formulae )
231+ if len (casks ) > 0 {
232+ marshalInto ("casks" , casks )
233+ }
234+ if len (taps ) > 0 {
235+ marshalInto ("taps" , taps )
236+ }
237+ if len (npm ) > 0 {
238+ marshalInto ("npm" , npm )
239+ }
166240
167241 normalised , err := json .Marshal (converted )
168242 if err != nil {
@@ -250,27 +324,27 @@ func ValidateDotfilesURL(rawURL string) error {
250324
251325func (rc * RemoteConfig ) Validate () error {
252326 for _ , p := range rc .Packages {
253- if len (p ) > maxPackageNameLen {
254- return fmt .Errorf ("package name too long (%d chars, max %d): %q" , len (p ), maxPackageNameLen , p )
327+ if len (p . Name ) > maxPackageNameLen {
328+ return fmt .Errorf ("package name too long (%d chars, max %d): %q" , len (p . Name ), maxPackageNameLen , p . Name )
255329 }
256- if ! pkgNameRe .MatchString (p ) {
257- return fmt .Errorf ("invalid package name: %q" , p )
330+ if ! pkgNameRe .MatchString (p . Name ) {
331+ return fmt .Errorf ("invalid package name: %q" , p . Name )
258332 }
259333 }
260334 for _ , c := range rc .Casks {
261- if len (c ) > maxPackageNameLen {
262- return fmt .Errorf ("cask name too long (%d chars, max %d): %q" , len (c ), maxPackageNameLen , c )
335+ if len (c . Name ) > maxPackageNameLen {
336+ return fmt .Errorf ("cask name too long (%d chars, max %d): %q" , len (c . Name ), maxPackageNameLen , c . Name )
263337 }
264- if ! pkgNameRe .MatchString (c ) {
265- return fmt .Errorf ("invalid cask name: %q" , c )
338+ if ! pkgNameRe .MatchString (c . Name ) {
339+ return fmt .Errorf ("invalid cask name: %q" , c . Name )
266340 }
267341 }
268342 for _ , n := range rc .Npm {
269- if len (n ) > maxPackageNameLen {
270- return fmt .Errorf ("npm package name too long (%d chars, max %d): %q" , len (n ), maxPackageNameLen , n )
343+ if len (n . Name ) > maxPackageNameLen {
344+ return fmt .Errorf ("npm package name too long (%d chars, max %d): %q" , len (n . Name ), maxPackageNameLen , n . Name )
271345 }
272- if ! pkgNameRe .MatchString (n ) {
273- return fmt .Errorf ("invalid npm package name: %q" , n )
346+ if ! pkgNameRe .MatchString (n . Name ) {
347+ return fmt .Errorf ("invalid npm package name: %q" , n . Name )
274348 }
275349 }
276350 for _ , t := range rc .Taps {
@@ -426,10 +500,10 @@ func LoadRemoteConfigFromFile(path string) (*RemoteConfig, error) {
426500// avoiding an import cycle with the snapshot package.
427501type snapshotFile struct {
428502 Packages struct {
429- Formulae [] string `json:"formulae"`
430- Casks [] string `json:"casks"`
431- Taps []string `json:"taps"`
432- Npm [] string `json:"npm"`
503+ Formulae PackageEntryList `json:"formulae"`
504+ Casks PackageEntryList `json:"casks"`
505+ Taps []string `json:"taps"`
506+ Npm PackageEntryList `json:"npm"`
433507 } `json:"packages"`
434508 Shell struct {
435509 OhMyZsh bool `json:"oh_my_zsh"`
0 commit comments