Skip to content

Commit d13aa4d

Browse files
committed
feat: preserve package descriptions through import/export lifecycle
Introduce PackageEntry{Name, Desc} and PackageEntryList type to replace []string for Packages, Casks, and Npm fields in RemoteConfig. Descriptions from imported JSON configs are now preserved through parsing, TUI display, push, and export. Taps remain []string (no descriptions). PackageEntryList.UnmarshalJSON handles both flat string arrays and {name, desc} object arrays. Objects with a "type" field are rejected so UnmarshalRemoteConfigFlexible can split them by type correctly. Closes #14
1 parent 08848f9 commit d13aa4d

File tree

15 files changed

+322
-128
lines changed

15 files changed

+322
-128
lines changed

internal/cli/clean.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func cleanFromRemote(userSlug string) (*cleaner.CleanResult, error) {
130130
return nil, fmt.Errorf("fetch remote config: %w", err)
131131
}
132132

133-
return cleaner.DiffFromLists(rc.Packages, rc.Casks, rc.Npm, rc.Taps)
133+
return cleaner.DiffFromLists(rc.Packages.Names(), rc.Casks.Names(), rc.Npm.Names(), rc.Taps)
134134
}
135135

136136
func cleanFromLocalSnapshot() (*cleaner.CleanResult, error) {

internal/cli/push.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,20 +163,23 @@ func pushConfig(data []byte, slug, token, username, apiBase string) error {
163163
type apiPackage struct {
164164
Name string `json:"name"`
165165
Type string `json:"type"`
166+
Desc string `json:"desc,omitempty"`
166167
}
167168

168169
func remoteConfigToAPIPackages(rc *config.RemoteConfig) []apiPackage {
169170
totalCap := len(rc.Packages) + len(rc.Casks) + len(rc.Npm) + len(rc.Taps)
170171
pkgs := make([]apiPackage, 0, totalCap)
171-
appendTyped := func(items []string, typeName string) {
172-
for _, item := range items {
173-
pkgs = append(pkgs, apiPackage{Name: item, Type: typeName})
172+
appendEntries := func(entries config.PackageEntryList, typeName string) {
173+
for _, e := range entries {
174+
pkgs = append(pkgs, apiPackage{Name: e.Name, Type: typeName, Desc: e.Desc})
174175
}
175176
}
176-
appendTyped(rc.Packages, "formula")
177-
appendTyped(rc.Casks, "cask")
178-
appendTyped(rc.Npm, "npm")
179-
appendTyped(rc.Taps, "tap")
177+
appendEntries(rc.Packages, "formula")
178+
appendEntries(rc.Casks, "cask")
179+
appendEntries(rc.Npm, "npm")
180+
for _, t := range rc.Taps {
181+
pkgs = append(pkgs, apiPackage{Name: t, Type: "tap"})
182+
}
180183
return pkgs
181184
}
182185

internal/cli/push_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func TestRemoteConfigToAPIPackages(t *testing.T) {
2121
{
2222
name: "formulae only",
2323
rc: &config.RemoteConfig{
24-
Packages: []string{"git", "go"},
24+
Packages: config.PackageEntryList{{Name: "git"}, {Name: "go"}},
2525
},
2626
expected: []apiPackage{
2727
{Name: "git", Type: "formula"},
@@ -31,9 +31,9 @@ func TestRemoteConfigToAPIPackages(t *testing.T) {
3131
{
3232
name: "all types including taps",
3333
rc: &config.RemoteConfig{
34-
Packages: []string{"git"},
35-
Casks: []string{"docker"},
36-
Npm: []string{"typescript"},
34+
Packages: config.PackageEntryList{{Name: "git"}},
35+
Casks: config.PackageEntryList{{Name: "docker"}},
36+
Npm: config.PackageEntryList{{Name: "typescript"}},
3737
Taps: []string{"homebrew/cask-fonts", "hashicorp/tap"},
3838
},
3939
expected: []apiPackage{
@@ -69,11 +69,11 @@ func TestRemoteConfigToAPIPackages(t *testing.T) {
6969

7070
func TestRemoteConfigToAPIPackagesImmutability(t *testing.T) {
7171
rc := &config.RemoteConfig{
72-
Packages: []string{"git"},
72+
Packages: config.PackageEntryList{{Name: "git"}},
7373
Taps: []string{"homebrew/core"},
7474
}
7575

76-
originalPackages := make([]string, len(rc.Packages))
76+
originalPackages := make(config.PackageEntryList, len(rc.Packages))
7777
copy(originalPackages, rc.Packages)
7878
originalTaps := make([]string, len(rc.Taps))
7979
copy(originalTaps, rc.Taps)
@@ -91,7 +91,7 @@ func TestTapsNotInRequestBodyAsTopLevelField(t *testing.T) {
9191
// reqBody without a "taps" key — this test documents that contract
9292
// by checking remoteConfigToAPIPackages includes taps.
9393
rc := &config.RemoteConfig{
94-
Packages: []string{"git"},
94+
Packages: config.PackageEntryList{{Name: "git"}},
9595
Taps: []string{"hashicorp/tap"},
9696
}
9797

internal/cli/root_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ func TestPersistentPreRunE_UserFetchesRemoteConfig(t *testing.T) {
3232
Username: "testuser",
3333
Slug: "default",
3434
Preset: "developer",
35-
Packages: []string{"git"},
36-
Casks: []string{"firefox"},
37-
Npm: []string{"typescript"},
35+
Packages: config.PackageEntryList{{Name: "git"}},
36+
Casks: config.PackageEntryList{{Name: "firefox"}},
37+
Npm: config.PackageEntryList{{Name: "typescript"}},
3838
}
3939
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4040
switch r.URL.Path {

internal/config/config.go

Lines changed: 118 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
71140
type 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.
102171
type 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

251325
func (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.
427501
type 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

Comments
 (0)