diff --git a/internal/api/client.go b/internal/api/client.go index 20070d8..34113dd 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -150,26 +150,29 @@ type TeamSoftware struct { } type TeamSoftwarePackage struct { - URL string `json:"url"` - HashSHA256 string `json:"hash_sha256"` - SelfService bool `json:"self_service"` - ReferencedYAMLPath string `json:"referenced_yaml_path"` + URL string `json:"url"` + HashSHA256 string `json:"hash_sha256"` + SelfService bool `json:"self_service"` + Categories []string `json:"categories"` + ReferencedYAMLPath string `json:"referenced_yaml_path"` } type TeamFleetApp struct { - Slug string `json:"slug"` - SelfService bool `json:"self_service"` - TitleID uint `json:"-"` // software title ID, for fetching detail - TeamID uint `json:"-"` - InstallScript string `json:"-"` // populated from title detail endpoint - UninstallScript string `json:"-"` - PreInstallQuery string `json:"-"` - PostInstallScript string `json:"-"` + Slug string `json:"slug"` + SelfService bool `json:"self_service"` + Categories []string `json:"categories"` + TitleID uint `json:"-"` // software title ID, for fetching detail + TeamID uint `json:"-"` + InstallScript string `json:"-"` // populated from title detail endpoint + UninstallScript string `json:"-"` + PreInstallQuery string `json:"-"` + PostInstallScript string `json:"-"` } type TeamAppStoreApp struct { - AppStoreID string `json:"app_store_id"` - SelfService bool `json:"self_service"` + AppStoreID string `json:"app_store_id"` + SelfService bool `json:"self_service"` + Categories []string `json:"categories"` } // FleetMaintainedApp is an entry from Fleet's maintained-app catalog. diff --git a/internal/diff/differ.go b/internal/diff/differ.go index 36819e7..cad3e48 100644 --- a/internal/diff/differ.go +++ b/internal/diff/differ.go @@ -70,8 +70,11 @@ type ResourceChange struct { // FieldDiff shows old vs new value for a single field. type FieldDiff struct { - Old string - New string + Old string + New string + OldSlice []string // non-nil for slice-typed fields (used by JSON renderer) + NewSlice []string // non-nil for slice-typed fields (used by JSON renderer) + IsSlice bool // true when OldSlice/NewSlice should be used for JSON output } // LabelValidation reports label cross-reference status. @@ -697,6 +700,40 @@ func diffQueries(current []api.Query, proposed []parser.ParsedQuery) ResourceDif return diff } +// categoriesEqual compares two category slices as sets (order-independent). +func categoriesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + set := make(map[string]struct{}, len(a)) + for _, v := range a { + set[v] = struct{}{} + } + for _, v := range b { + if _, ok := set[v]; !ok { + return false + } + } + return true +} + +// sortedCategories returns a sorted copy of the slice. +func sortedCategories(cats []string) []string { + cp := make([]string, len(cats)) + copy(cp, cats) + sort.Strings(cp) + return cp +} + +// formatCategories returns a sorted display string like "[Browsers, Communication]". +func formatCategories(cats []string) string { + if len(cats) == 0 { + return "[]" + } + sorted := sortedCategories(cats) + return "[" + strings.Join(sorted, ", ") + "]" +} + func diffSoftware(current api.TeamSoftware, proposed parser.ParsedSoftware) ResourceDiff { var rd ResourceDiff @@ -736,6 +773,12 @@ func diffSoftware(current api.TeamSoftware, proposed parser.ParsedSoftware) Reso if p.HashSHA256 != "" { fields["hash_sha256"] = FieldDiff{New: p.HashSHA256} } + if len(p.Categories) > 0 { + fields["categories"] = FieldDiff{ + New: formatCategories(p.Categories), IsSlice: true, + NewSlice: sortedCategories(p.Categories), + } + } rd.Added = append(rd.Added, ResourceChange{Name: parser.NormalizeSoftwarePath(key), Fields: fields}) continue } @@ -749,6 +792,12 @@ func diffSoftware(current api.TeamSoftware, proposed parser.ParsedSoftware) Reso if cur.SelfService != p.SelfService { fields["self_service"] = FieldDiff{Old: fmt.Sprint(cur.SelfService), New: fmt.Sprint(p.SelfService)} } + if !categoriesEqual(cur.Categories, p.Categories) { + fields["categories"] = FieldDiff{ + Old: formatCategories(cur.Categories), New: formatCategories(p.Categories), + IsSlice: true, OldSlice: sortedCategories(cur.Categories), NewSlice: sortedCategories(p.Categories), + } + } if len(fields) > 0 { rd.Modified = append(rd.Modified, ResourceChange{ Name: parser.NormalizeSoftwarePath(key), @@ -781,12 +830,19 @@ func diffSoftware(current api.TeamSoftware, proposed parser.ParsedSoftware) Reso for slug, a := range proposedFleet { cur, exists := currentFleet[slug] if !exists { + addedFields := map[string]FieldDiff{ + "slug": {New: a.Slug}, + "self_service": {New: fmt.Sprint(a.SelfService)}, + } + if len(a.Categories) > 0 { + addedFields["categories"] = FieldDiff{ + New: formatCategories(a.Categories), IsSlice: true, + NewSlice: sortedCategories(a.Categories), + } + } rd.Added = append(rd.Added, ResourceChange{ - Name: "fleet app " + slug, - Fields: map[string]FieldDiff{ - "slug": {New: a.Slug}, - "self_service": {New: fmt.Sprint(a.SelfService)}, - }, + Name: "fleet app " + slug, + Fields: addedFields, }) continue } @@ -797,6 +853,12 @@ func diffSoftware(current api.TeamSoftware, proposed parser.ParsedSoftware) Reso New: fmt.Sprint(a.SelfService), } } + if !categoriesEqual(cur.Categories, a.Categories) { + fields["categories"] = FieldDiff{ + Old: formatCategories(cur.Categories), New: formatCategories(a.Categories), + IsSlice: true, OldSlice: sortedCategories(cur.Categories), NewSlice: sortedCategories(a.Categories), + } + } for _, sc := range []struct { name string curVal string @@ -846,24 +908,39 @@ func diffSoftware(current api.TeamSoftware, proposed parser.ParsedSoftware) Reso for id, a := range proposedApps { cur, exists := currentApps[id] if !exists { + addedFields := map[string]FieldDiff{ + "app_store_id": {New: a.AppStoreID}, + "self_service": {New: fmt.Sprint(a.SelfService)}, + } + if len(a.Categories) > 0 { + addedFields["categories"] = FieldDiff{ + New: formatCategories(a.Categories), IsSlice: true, + NewSlice: sortedCategories(a.Categories), + } + } rd.Added = append(rd.Added, ResourceChange{ - Name: "app store app " + id, - Fields: map[string]FieldDiff{ - "app_store_id": {New: a.AppStoreID}, - "self_service": {New: fmt.Sprint(a.SelfService)}, - }, + Name: "app store app " + id, + Fields: addedFields, }) continue } + fields := make(map[string]FieldDiff) if cur.SelfService != a.SelfService { + fields["self_service"] = FieldDiff{ + Old: fmt.Sprint(cur.SelfService), + New: fmt.Sprint(a.SelfService), + } + } + if !categoriesEqual(cur.Categories, a.Categories) { + fields["categories"] = FieldDiff{ + Old: formatCategories(cur.Categories), New: formatCategories(a.Categories), + IsSlice: true, OldSlice: sortedCategories(cur.Categories), NewSlice: sortedCategories(a.Categories), + } + } + if len(fields) > 0 { rd.Modified = append(rd.Modified, ResourceChange{ - Name: "app store app " + id, - Fields: map[string]FieldDiff{ - "self_service": { - Old: fmt.Sprint(cur.SelfService), - New: fmt.Sprint(a.SelfService), - }, - }, + Name: "app store app " + id, + Fields: fields, }) } } diff --git a/internal/diff/differ_test.go b/internal/diff/differ_test.go index 487364d..df8f896 100644 --- a/internal/diff/differ_test.go +++ b/internal/diff/differ_test.go @@ -138,17 +138,27 @@ func TestDiffTestdataAgainstMockAPI(t *testing.T) { t.Errorf("Workstations: deleted query name: got %q", ws.Queries.Deleted[0].Name) } - // Software: slack modified (URL changed), example-app added, old-agent deleted - if len(ws.Software.Modified) != 1 { - t.Errorf("Workstations: expected 1 modified software, got %d", len(ws.Software.Modified)) + // Software: slack modified (URL + hash + categories changed), cursor modified (categories added), + // example-app added, app store app 803453959 added, old-agent deleted + if len(ws.Software.Modified) != 2 { + t.Errorf("Workstations: expected 2 modified software, got %d", len(ws.Software.Modified)) } - if len(ws.Software.Added) != 1 { - t.Errorf("Workstations: expected 1 added software, got %d", len(ws.Software.Added)) + if len(ws.Software.Added) != 2 { + t.Errorf("Workstations: expected 2 added software, got %d", len(ws.Software.Added)) } if len(ws.Software.Deleted) != 1 { t.Errorf("Workstations: expected 1 deleted software, got %d", len(ws.Software.Deleted)) } + // Verify categories appear in slack's modified fields + for _, mod := range ws.Software.Modified { + if strings.Contains(mod.Name, "slack") { + if _, ok := mod.Fields["categories"]; !ok { + t.Error("expected categories field in modified slack software") + } + } + } + // Profiles: fleet_orbit-allowfulldiskaccess matches by PayloadDisplayName → no diff if !ws.Profiles.IsEmpty() { t.Errorf("Workstations: expected no profile changes (name matches PayloadDisplayName), got added=%d modified=%d deleted=%d", @@ -1946,6 +1956,207 @@ func TestDiffChangedFileFilterIncludesProfilePath(t *testing.T) { } } +func TestDiffSoftwareCategories(t *testing.T) { + tests := []struct { + name string + current api.TeamSoftware + proposed parser.ParsedSoftware + wantAdded int + wantModified int + checkName string + checkHasCat bool // expect "categories" field in result + }{ + { + name: "FMA added with categories", + proposed: parser.ParsedSoftware{ + FleetMaintained: []parser.ParsedFleetApp{ + {Slug: "chrome/mac", SelfService: true, Categories: []string{"Browsers"}}, + }, + }, + wantAdded: 1, checkName: "fleet app chrome/mac", checkHasCat: true, + }, + { + name: "FMA added without categories", + proposed: parser.ParsedSoftware{ + FleetMaintained: []parser.ParsedFleetApp{ + {Slug: "chrome/mac", SelfService: true}, + }, + }, + wantAdded: 1, checkName: "fleet app chrome/mac", checkHasCat: false, + }, + { + name: "package modified set to different set", + current: api.TeamSoftware{ + Packages: []api.TeamSoftwarePackage{ + {ReferencedYAMLPath: "software/app.yml", URL: "https://x.com/a.pkg", Categories: []string{"Communication"}}, + }, + }, + proposed: parser.ParsedSoftware{ + Packages: []parser.ParsedSoftwarePackage{ + {RefPath: "software/app.yml", URL: "https://x.com/a.pkg", Categories: []string{"Productivity"}}, + }, + }, + wantModified: 1, checkName: "software/app.yml", checkHasCat: true, + }, + { + name: "package modified set to empty", + current: api.TeamSoftware{ + Packages: []api.TeamSoftwarePackage{ + {ReferencedYAMLPath: "software/app.yml", URL: "https://x.com/a.pkg", Categories: []string{"Communication"}}, + }, + }, + proposed: parser.ParsedSoftware{ + Packages: []parser.ParsedSoftwarePackage{ + {RefPath: "software/app.yml", URL: "https://x.com/a.pkg"}, + }, + }, + wantModified: 1, checkName: "software/app.yml", checkHasCat: true, + }, + { + name: "package modified empty to set", + current: api.TeamSoftware{ + Packages: []api.TeamSoftwarePackage{ + {ReferencedYAMLPath: "software/app.yml", URL: "https://x.com/a.pkg"}, + }, + }, + proposed: parser.ParsedSoftware{ + Packages: []parser.ParsedSoftwarePackage{ + {RefPath: "software/app.yml", URL: "https://x.com/a.pkg", Categories: []string{"Communication"}}, + }, + }, + wantModified: 1, checkName: "software/app.yml", checkHasCat: true, + }, + { + name: "package modified reorder only no change", + current: api.TeamSoftware{ + Packages: []api.TeamSoftwarePackage{ + {ReferencedYAMLPath: "software/app.yml", URL: "https://x.com/a.pkg", Categories: []string{"B", "A"}}, + }, + }, + proposed: parser.ParsedSoftware{ + Packages: []parser.ParsedSoftwarePackage{ + {RefPath: "software/app.yml", URL: "https://x.com/a.pkg", Categories: []string{"A", "B"}}, + }, + }, + wantModified: 0, + }, + { + name: "app store app added with categories", + proposed: parser.ParsedSoftware{ + AppStoreApps: []parser.ParsedAppStoreApp{ + {AppStoreID: "123456", SelfService: true, Categories: []string{"Productivity"}}, + }, + }, + wantAdded: 1, checkName: "app store app 123456", checkHasCat: true, + }, + { + name: "app store app modified categories", + current: api.TeamSoftware{ + AppStoreApps: []api.TeamAppStoreApp{ + {AppStoreID: "123456", SelfService: true, Categories: []string{"Communication"}}, + }, + }, + proposed: parser.ParsedSoftware{ + AppStoreApps: []parser.ParsedAppStoreApp{ + {AppStoreID: "123456", SelfService: true, Categories: []string{"Communication", "Productivity"}}, + }, + }, + wantModified: 1, checkName: "app store app 123456", checkHasCat: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current := &api.FleetState{ + Teams: []api.Team{{ID: 1, Name: "T", Software: tt.current}}, + } + proposed := &parser.ParsedRepo{ + Teams: []parser.ParsedTeam{{Name: "T", Software: tt.proposed}}, + } + + results := Diff(current, proposed, nil, nil) + r := results[0] + + if len(r.Software.Added) != tt.wantAdded { + t.Errorf("added: got %d, want %d", len(r.Software.Added), tt.wantAdded) + } + if len(r.Software.Modified) != tt.wantModified { + t.Errorf("modified: got %d, want %d", len(r.Software.Modified), tt.wantModified) + } + + if tt.checkName != "" { + var found *ResourceChange + for i := range r.Software.Added { + if r.Software.Added[i].Name == tt.checkName { + found = &r.Software.Added[i] + } + } + for i := range r.Software.Modified { + if r.Software.Modified[i].Name == tt.checkName { + found = &r.Software.Modified[i] + } + } + if found == nil { + t.Fatalf("expected resource %q not found", tt.checkName) + } + _, hasCat := found.Fields["categories"] + if tt.checkHasCat && !hasCat { + t.Error("expected categories field in diff") + } + if !tt.checkHasCat && hasCat { + t.Error("did not expect categories field in diff") + } + if hasCat { + fd := found.Fields["categories"] + if !fd.IsSlice { + t.Error("categories FieldDiff should have IsSlice=true") + } + } + } + }) + } +} + +func TestCategoriesEqual(t *testing.T) { + tests := []struct { + name string + a, b []string + want bool + }{ + {"both nil", nil, nil, true}, + {"both empty", []string{}, []string{}, true}, + {"same order", []string{"A", "B"}, []string{"A", "B"}, true}, + {"different order", []string{"B", "A"}, []string{"A", "B"}, true}, + {"different sets", []string{"A"}, []string{"B"}, false}, + {"different lengths", []string{"A", "B"}, []string{"A"}, false}, + {"nil vs empty", nil, []string{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := categoriesEqual(tt.a, tt.b); got != tt.want { + t.Errorf("categoriesEqual(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestFormatCategories(t *testing.T) { + tests := []struct { + input []string + want string + }{ + {nil, "[]"}, + {[]string{}, "[]"}, + {[]string{"Communication"}, "[Communication]"}, + {[]string{"Productivity", "Browsers"}, "[Browsers, Productivity]"}, + } + for _, tt := range tests { + if got := formatCategories(tt.input); got != tt.want { + t.Errorf("formatCategories(%v) = %q, want %q", tt.input, got, tt.want) + } + } +} + // findTeam locates a DiffResult by team name, failing the test if not found. func findTeam(t *testing.T, results []DiffResult, name string) *DiffResult { t.Helper() diff --git a/internal/output/json.go b/internal/output/json.go index 1158671..0489c8a 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -48,9 +48,10 @@ type JSONChange struct { } // JSONField is an old/new field value in JSON format. +// Old/New are string for scalar fields or []string for slice fields (e.g. categories). type JSONField struct { - Old string `json:"old"` - New string `json:"new"` + Old any `json:"old"` + New any `json:"new"` } // JSONLabelResult is label validation in JSON format. @@ -116,7 +117,18 @@ func convertChanges(changes []diff.ResourceChange) []JSONChange { if len(c.Fields) > 0 { jc.Fields = make(map[string]JSONField) for k, v := range c.Fields { - jc.Fields[k] = JSONField{Old: v.Old, New: v.New} + if v.IsSlice { + var old, new any + if v.OldSlice != nil { + old = v.OldSlice + } + if v.NewSlice != nil { + new = v.NewSlice + } + jc.Fields[k] = JSONField{Old: old, New: new} + } else { + jc.Fields[k] = JSONField{Old: v.Old, New: v.New} + } } } result = append(result, jc) diff --git a/internal/output/json_test.go b/internal/output/json_test.go index 8dd3b8e..7a16664 100644 --- a/internal/output/json_test.go +++ b/internal/output/json_test.go @@ -208,6 +208,104 @@ func TestRenderDiffJSONIsValidJSON(t *testing.T) { } } +func TestRenderDiffJSONCategories(t *testing.T) { + tests := []struct { + name string + field diff.FieldDiff + check func(t *testing.T, raw string) + }{ + { + name: "added categories renders as null old and array new", + field: diff.FieldDiff{ + New: "[Communication]", IsSlice: true, + NewSlice: []string{"Communication"}, + }, + check: func(t *testing.T, raw string) { + if !strings.Contains(raw, `"old": null`) { + t.Errorf("expected null old, got:\n%s", raw) + } + if !strings.Contains(raw, `"Communication"`) { + t.Errorf("expected Communication in new, got:\n%s", raw) + } + }, + }, + { + name: "modified categories renders arrays for both", + field: diff.FieldDiff{ + Old: "[Communication]", New: "[Communication, Productivity]", IsSlice: true, + OldSlice: []string{"Communication"}, NewSlice: []string{"Communication", "Productivity"}, + }, + check: func(t *testing.T, raw string) { + // Parse as generic JSON to check structure + var output JSONDiffOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + f := output.Teams[0].Software.Added[0].Fields["categories"] + // Old should be an array + if f.Old == nil { + t.Error("expected non-nil old") + } + if f.New == nil { + t.Error("expected non-nil new") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := []diff.DiffResult{{ + Team: "T", + Software: diff.ResourceDiff{ + Added: []diff.ResourceChange{{ + Name: "app", + Fields: map[string]diff.FieldDiff{"categories": tt.field}, + }}, + }, + }} + raw, err := RenderDiffJSON(results) + if err != nil { + t.Fatalf("RenderDiffJSON: %v", err) + } + if !json.Valid([]byte(raw)) { + t.Fatalf("invalid JSON:\n%s", raw) + } + tt.check(t, raw) + }) + } +} + +func TestRenderDiffJSONStringFieldsUnchanged(t *testing.T) { + // Verify that regular string fields still render as strings, not arrays. + results := []diff.DiffResult{{ + Team: "T", + Queries: diff.ResourceDiff{ + Modified: []diff.ResourceChange{{ + Name: "Q", + Fields: map[string]diff.FieldDiff{"interval": {Old: "3600", New: "7200"}}, + }}, + }, + }} + raw, err := RenderDiffJSON(results) + if err != nil { + t.Fatalf("RenderDiffJSON: %v", err) + } + var output JSONDiffOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + f := output.Teams[0].Queries.Modified[0].Fields["interval"] + // String fields should unmarshal as strings via any + oldStr, ok := f.Old.(string) + if !ok { + t.Errorf("expected string old, got %T", f.Old) + } + if oldStr != "3600" { + t.Errorf("old = %q, want 3600", oldStr) + } +} + func TestRenderDiffJSONSpecialCharsRoundtrip(t *testing.T) { results := []diff.DiffResult{{ Team: "Team with \"quotes\" and ", diff --git a/internal/output/markdown_test.go b/internal/output/markdown_test.go index 4f5e9ba..e55da87 100644 --- a/internal/output/markdown_test.go +++ b/internal/output/markdown_test.go @@ -324,6 +324,56 @@ func TestRenderDiffMarkdown(t *testing.T) { } } +func TestRenderDiffMarkdownCategories(t *testing.T) { + tests := []struct { + name string + results []diff.DiffResult + wantAll []string + }{ + { + name: "added software with categories in Details not shown (added only shows host count)", + results: []diff.DiffResult{{ + Team: "T", + Software: diff.ResourceDiff{ + Added: []diff.ResourceChange{{ + Name: "fleet app chrome/mac", + Fields: map[string]diff.FieldDiff{ + "slug": {New: "chrome/mac"}, + "categories": {New: "[Browsers]", IsSlice: true, NewSlice: []string{"Browsers"}}, + }, + }}, + }, + }}, + wantAll: []string{"ADDED", "fleet app chrome/mac"}, + }, + { + name: "modified software with categories change", + results: []diff.DiffResult{{ + Team: "T", + Software: diff.ResourceDiff{ + Modified: []diff.ResourceChange{{ + Name: "software/slack.yml", + Fields: map[string]diff.FieldDiff{ + "categories": { + Old: "[Communication]", New: "[Communication, Productivity]", + IsSlice: true, + }, + }, + }}, + }, + }}, + wantAll: []string{"MODIFIED", "software/slack.yml", "`categories`", "[Communication]", "[Communication, Productivity]"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := RenderDiffMarkdown(tt.results, MarkdownOptions{}) + assertOutputContains(t, out, tt.wantAll) + }) + } +} + func TestMdDiffContext(t *testing.T) { tests := []struct { name string diff --git a/internal/output/terminal_test.go b/internal/output/terminal_test.go index b6a964f..7fc6657 100644 --- a/internal/output/terminal_test.go +++ b/internal/output/terminal_test.go @@ -210,6 +210,47 @@ func TestRenderDiffTerminal(t *testing.T) { } } +func TestRenderDiffTerminalCategories(t *testing.T) { + // Added fields only show in verbose mode; modified fields always show. + results := []diff.DiffResult{{ + Team: "T", + Software: diff.ResourceDiff{ + Added: []diff.ResourceChange{{ + Name: "fleet app chrome/mac", + Fields: map[string]diff.FieldDiff{ + "slug": {New: "chrome/mac"}, + "self_service": {New: "true"}, + "categories": {New: "[Browsers]", IsSlice: true, NewSlice: []string{"Browsers"}}, + }, + }}, + Modified: []diff.ResourceChange{{ + Name: "software/slack.yml", + Fields: map[string]diff.FieldDiff{ + "categories": {Old: "[Communication]", New: "[Communication, Productivity]", IsSlice: true}, + }, + }}, + }, + }} + + out := RenderDiffTerminal(results, true) + plain := stripANSI(out) + + for _, want := range []string{"categories:", "[Browsers]", "[Communication]", "[Communication, Productivity]"} { + if !strings.Contains(plain, want) { + t.Errorf("expected %q in verbose output, got:\n%s", want, plain) + } + } + + // In default mode, modified categories should still show (may be truncated) + outDefault := RenderDiffTerminal(results, false) + plainDefault := stripANSI(outDefault) + for _, want := range []string{"categories:", "[Communication]", "Productivity]"} { + if !strings.Contains(plainDefault, want) { + t.Errorf("expected %q in default output, got:\n%s", want, plainDefault) + } + } +} + // ---------- renderFieldLines ---------- func TestRenderFieldLines(t *testing.T) { diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 8d0a77f..e35ce42 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -147,6 +147,7 @@ type ParsedSoftwarePackage struct { URL string `yaml:"url"` HashSHA256 string `yaml:"hash_sha256"` SelfService bool `yaml:"self_service"` + Categories []string `yaml:"categories"` SourceFile string `yaml:"-"` RefPath string `yaml:"-"` SourceFiles []string `yaml:"-"` // all referenced file paths (install/uninstall scripts, pre_install_query) @@ -156,6 +157,7 @@ type ParsedSoftwarePackage struct { type ParsedFleetApp struct { Slug string `yaml:"slug"` SelfService bool `yaml:"self_service"` + Categories []string `yaml:"categories"` InstallScript string `yaml:"-"` // resolved file content UninstallScript string `yaml:"-"` PreInstallQuery string `yaml:"-"` @@ -165,8 +167,9 @@ type ParsedFleetApp struct { // ParsedAppStoreApp represents an App Store app. type ParsedAppStoreApp struct { - AppStoreID string `yaml:"app_store_id"` - SelfService bool `yaml:"self_service"` + AppStoreID string `yaml:"app_store_id"` + SelfService bool `yaml:"self_service"` + Categories []string `yaml:"categories"` } // ParsedLabel represents a label from YAML. @@ -228,6 +231,7 @@ type rawSoftwareBlock struct { type rawFleetApp struct { Slug string `yaml:"slug"` SelfService bool `yaml:"self_service"` + Categories []string `yaml:"categories"` InstallScript *rawPathRef `yaml:"install_script"` UninstallScript *rawPathRef `yaml:"uninstall_script"` PreInstallQuery *rawPathRef `yaml:"pre_install_query"` @@ -235,8 +239,9 @@ type rawFleetApp struct { } type rawSoftwareRef struct { - Path string `yaml:"path"` - SelfService *bool `yaml:"self_service"` + Path string `yaml:"path"` + SelfService *bool `yaml:"self_service"` + Categories []string `yaml:"categories"` } // rawSoftwarePackage captures script path: refs inside a software package YAML file. @@ -244,6 +249,7 @@ type rawSoftwarePackage struct { URL string `yaml:"url"` HashSHA256 string `yaml:"hash_sha256"` SelfService bool `yaml:"self_service"` + Categories []string `yaml:"categories"` InstallScript *rawPathRef `yaml:"install_script"` UninstallScript *rawPathRef `yaml:"uninstall_script"` PreInstallQuery *rawPathRef `yaml:"pre_install_query"` @@ -408,6 +414,9 @@ func parseTeamFile(root, path string) (*ParsedTeam, []ParseError) { if ref.SelfService != nil { pkgs[i].SelfService = *ref.SelfService } + if ref.Categories != nil { + pkgs[i].Categories = ref.Categories + } // Guard against duplicate package refs in the same team YAML. if canonicalRef != "" { if seenSoftwareRefs[canonicalRef] { @@ -583,6 +592,7 @@ func resolveSoftwareRef(root, baseDir, refPath, parentFile string) ([]ParsedSoft URL: raw.URL, HashSHA256: raw.HashSHA256, SelfService: raw.SelfService, + Categories: raw.Categories, SourceFile: resolved, } @@ -612,6 +622,7 @@ func resolveFleetApp(root, baseDir string, raw rawFleetApp, parentFile string) ( fma := ParsedFleetApp{ Slug: raw.Slug, SelfService: raw.SelfService, + Categories: raw.Categories, } readScript := func(ref *rawPathRef, label string) string { diff --git a/testdata/software/mac/slack/slack.yml b/testdata/software/mac/slack/slack.yml index 1e9d160..42a9be8 100644 --- a/testdata/software/mac/slack/slack.yml +++ b/testdata/software/mac/slack/slack.yml @@ -1,3 +1,5 @@ url: https://downloads.slack-edge.com/desktop-releases/macos/4.42.1/Slack-4.42.1-macOS.dmg hash_sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 self_service: true +categories: + - Communication diff --git a/testdata/teams/workstations.yml b/testdata/teams/workstations.yml index cf84807..003fbd3 100644 --- a/testdata/teams/workstations.yml +++ b/testdata/teams/workstations.yml @@ -34,3 +34,10 @@ software: uninstall_script: path: ../software/windows/cursor/uninstall.ps1 self_service: true + categories: + - Browsers + app_store_apps: + - app_store_id: "803453959" + self_service: true + categories: + - Productivity